diff --git a/qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp b/qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp index 87781756589..60601887280 100644 --- a/qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp @@ -9,6 +9,9 @@ #include "object/object.h" #include "mission/missiongrid.h" +#include +#include + #include namespace fso::fred::dialogs { @@ -51,6 +54,27 @@ void copyBriefingData(briefing& dst, const briefing& src) copyStageData(dst.stages[i], src.stages[i]); } } + +// Returns the value that `get` yields for every icon in `sel`, or `sentinel` if they diverge (or the +// selection is empty). Used to blank a field in the UI when a multi-icon selection disagrees on it. +template +T common_icon_value(const brief_stage& s, const SCP_vector& sel, Fn get, T sentinel) +{ + bool first = true; + T common = sentinel; + for (int idx : sel) { + if (idx < 0 || idx >= s.num_icons) + continue; + T v = get(s.icons[idx]); + if (first) { + common = v; + first = false; + } else if (!(v == common)) { + return sentinel; + } + } + return common; +} } // namespace BriefingEditorDialogModel::BriefingEditorDialogModel(QObject* parent, EditorViewport* viewport) @@ -772,6 +796,63 @@ void BriefingEditorDialogModel::setIconPosition(const vec3d& pos) applyToSelectedIconsCurrentAndForward([&](brief_icon& ic) { modify(ic.pos, pos); }); } +void BriefingEditorDialogModel::nudgeSelectedIcons(const vec3d& worldDelta) +{ + applyToSelectedIconsCurrentAndForward([&](brief_icon& ic) { + vec3d p = ic.pos; + vm_vec_add2(&p, &worldDelta); + modify(ic.pos, p); + }); +} + +void BriefingEditorDialogModel::beginIconDrag() +{ + _iconDragBaseline.clear(); + + auto& briefing = _wipBriefings[_currentTeam]; + if (briefing.num_stages <= 0 || _currentStage < 0 || _currentStage >= briefing.num_stages) + return; + + auto& stage = briefing.stages[_currentStage]; + for (int idx : getEffectiveSelection(stage)) { + if (valid_icon_index(stage, idx)) { + _iconDragBaseline.push_back({idx, stage.icons[idx].id, stage.icons[idx].pos}); + } + } +} + +void BriefingEditorDialogModel::dragSelectedIconsBy(const vec3d& worldDelta) +{ + auto& briefing = _wipBriefings[_currentTeam]; + if (briefing.num_stages <= 0 || _currentStage < 0 || _currentStage >= briefing.num_stages) + return; + + auto& stage = briefing.stages[_currentStage]; + for (const auto& base : _iconDragBaseline) { + if (!valid_icon_index(stage, base.index)) + continue; + + vec3d target = base.pos; + vm_vec_add2(&target, &worldDelta); + modify(stage.icons[base.index].pos, target); + + // Match setIconPosition()'s forward propagation: unless editing locally, place same-id icons in + // later stages at the same position so the icon stays put across the briefing. + if (!_changeLocally) { + for (int st = _currentStage + 1; st < briefing.num_stages; ++st) { + auto& stg = briefing.stages[st]; + for (int i = 0; i < stg.num_icons; ++i) { + if (stg.icons[i].id == base.id) { + modify(stg.icons[i].pos, target); + } + } + } + } + } + + set_modified(); +} + int BriefingEditorDialogModel::getIconId() const { const auto& b = _wipBriefings[_currentTeam]; @@ -856,10 +937,9 @@ SCP_string BriefingEditorDialogModel::getIconLabel() const return {}; const auto& s = b.stages[_currentStage]; - if (_currentIcon < 0 || _currentIcon >= s.num_icons) - return {}; - - return s.icons[_currentIcon].label; + // Blank when the selection diverges on the label. + return common_icon_value( + s, getEffectiveSelection(s), [](const brief_icon& ic) { return SCP_string(ic.label); }, {}); } void BriefingEditorDialogModel::setIconLabel(const SCP_string& text) @@ -878,10 +958,9 @@ SCP_string BriefingEditorDialogModel::getIconCloseupLabel() const return {}; const auto& s = b.stages[_currentStage]; - if (_currentIcon < 0 || _currentIcon >= s.num_icons) - return {}; - - return s.icons[_currentIcon].closeup_label; + // Blank when the selection diverges on the closeup label. + return common_icon_value( + s, getEffectiveSelection(s), [](const brief_icon& ic) { return SCP_string(ic.closeup_label); }, {}); } void BriefingEditorDialogModel::setIconCloseupLabel(const SCP_string& text) @@ -899,9 +978,8 @@ int BriefingEditorDialogModel::getIconTypeIndex() const if (b.num_stages <= 0 || _currentStage < 0 || _currentStage >= b.num_stages) return -1; const auto& s = b.stages[_currentStage]; - if (_currentIcon < 0 || _currentIcon >= s.num_icons) - return -1; - return s.icons[_currentIcon].type; // 0..MIN_BRIEF_ICONS-1 + // -1 (blank combo) when the selection diverges. type is 0..MIN_BRIEF_ICONS-1, so -1 is unambiguous. + return common_icon_value(s, getEffectiveSelection(s), [](const brief_icon& ic) { return ic.type; }, -1); } void BriefingEditorDialogModel::setIconTypeIndex(int idx) @@ -915,9 +993,9 @@ int BriefingEditorDialogModel::getIconShipTypeIndex() const if (b.num_stages <= 0 || _currentStage < 0 || _currentStage >= b.num_stages) return -1; const auto& s = b.stages[_currentStage]; - if (_currentIcon < 0 || _currentIcon >= s.num_icons) - return -1; - return s.icons[_currentIcon].ship_class; // may be -1 for unset depending on icon type + // -1 (blank combo) when the selection diverges; ship_class is also -1 when a single icon is unset, + // which likewise blanks the combo, so both cases collapse to the same display. + return common_icon_value(s, getEffectiveSelection(s), [](const brief_icon& ic) { return ic.ship_class; }, -1); } void BriefingEditorDialogModel::setIconShipTypeIndex(int idx) @@ -931,9 +1009,8 @@ int BriefingEditorDialogModel::getIconTeamIndex() const if (b.num_stages <= 0 || _currentStage < 0 || _currentStage >= b.num_stages) return -1; const auto& s = b.stages[_currentStage]; - if (_currentIcon < 0 || _currentIcon >= s.num_icons) - return -1; - return s.icons[_currentIcon].team; + // -1 (blank combo) when the selection diverges on the IFF team. + return common_icon_value(s, getEffectiveSelection(s), [](const brief_icon& ic) { return ic.team; }, -1); } void BriefingEditorDialogModel::setIconTeamIndex(int idx) @@ -947,10 +1024,11 @@ float BriefingEditorDialogModel::getIconScaleFactor() const if (b.num_stages <= 0 || _currentStage < 0 || _currentStage >= b.num_stages) return 1.0f; const auto& s = b.stages[_currentStage]; - if (_currentIcon < 0 || _currentIcon >= s.num_icons) + const auto sel = getEffectiveSelection(s); + if (sel.empty()) return 1.0f; - - return s.icons[_currentIcon].scale_factor; + // Negative sentinel (scale is always positive) tells the UI to blank the field on divergence. + return common_icon_value(s, sel, [](const brief_icon& ic) { return ic.scale_factor; }, -1.0f); } void BriefingEditorDialogModel::setIconScaleFactor(float factor) @@ -1295,22 +1373,17 @@ void BriefingEditorDialogModel::makeIcon(const SCP_string& label, int typeIndex, set_modified(); } -void BriefingEditorDialogModel::deleteCurrentIcon() +namespace { +// Remove one icon from a stage: drop any lines referencing it, reindex the remaining line endpoints, +// and shift the icons down to fill the gap. +void remove_briefing_icon(brief_stage& s, int del) { - auto& briefing = _wipBriefings[_currentTeam]; - if (briefing.num_stages <= 0 || _currentStage < 0 || _currentStage >= briefing.num_stages) - return; - - auto& s = briefing.stages[_currentStage]; - const int del = _currentIcon; if (del < 0 || del >= s.num_icons) return; // Remove any lines that reference the icon being deleted for (int i = s.num_lines - 1; i >= 0; --i) { - const int a = s.lines[i].start_icon; - const int b = s.lines[i].end_icon; - if (a == del || b == del) { + if (s.lines[i].start_icon == del || s.lines[i].end_icon == del) { for (int k = i; k + 1 < s.num_lines; ++k) { s.lines[k] = s.lines[k + 1]; } @@ -1331,12 +1404,35 @@ void BriefingEditorDialogModel::deleteCurrentIcon() s.icons[i] = s.icons[i + 1]; } --s.num_icons; +} +} // namespace + +void BriefingEditorDialogModel::deleteSelectedIcons() +{ + auto& briefing = _wipBriefings[_currentTeam]; + if (briefing.num_stages <= 0 || _currentStage < 0 || _currentStage >= briefing.num_stages) + return; + + auto& s = briefing.stages[_currentStage]; + + // Delete in descending index order so each removal doesn't shift the indices still to be deleted. + SCP_vector indices(_lineSelection.begin(), _lineSelection.end()); + std::sort(indices.begin(), indices.end(), std::greater<>()); + indices.erase(std::unique(indices.begin(), indices.end()), indices.end()); + + bool removedAny = false; + for (int idx : indices) { + if (idx >= 0 && idx < s.num_icons) { + remove_briefing_icon(s, idx); + removedAny = true; + } + } - // Update selection _lineSelection.clear(); _currentIcon = -1; - - set_modified(); + if (removedAny) { + set_modified(); + } } void BriefingEditorDialogModel::propagateCurrentIconForward() diff --git a/qtfred/src/mission/dialogs/BriefingEditorDialogModel.h b/qtfred/src/mission/dialogs/BriefingEditorDialogModel.h index c57e99cbdaa..c5d06bc2bad 100644 --- a/qtfred/src/mission/dialogs/BriefingEditorDialogModel.h +++ b/qtfred/src/mission/dialogs/BriefingEditorDialogModel.h @@ -73,6 +73,11 @@ class BriefingEditorDialogModel : public AbstractDialogModel { void setCurrentIconIndex(int idx); vec3d getIconPosition() const; void setIconPosition(const vec3d& pos); + void nudgeSelectedIcons(const vec3d& worldDelta); // moves the selected icon(s) by a world offset + // Relative multi-drag: snapshot the selected icons' positions, then move each by a world offset from + // its snapshot so a multi-selection keeps its relative layout instead of collapsing onto the cursor. + void beginIconDrag(); + void dragSelectedIconsBy(const vec3d& worldDelta); int getIconId() const; // returns false if the requested id was rejected (e.g. it collides with another icon) bool setIconId(int id); @@ -109,7 +114,7 @@ class BriefingEditorDialogModel : public AbstractDialogModel { void setIconUseCargo(bool enabled); void makeIcon(const SCP_string& label, int typeIndex, int teamIndex, int shipClassIndex); - void deleteCurrentIcon(); + void deleteSelectedIcons(); // deletes every icon in the line selection on the current stage void propagateCurrentIconForward(); int getBriefingMusicIndex() const; @@ -165,6 +170,14 @@ class BriefingEditorDialogModel : public AbstractDialogModel { SCP_vector _lineSelection; bool _changeLocally = false; + + // Snapshot taken at drag start so a relative multi-drag can offset each icon from its own origin. + struct IconDragEntry { + int index; + int id; + vec3d pos; + }; + SCP_vector _iconDragBaseline; }; } // namespace fso::fred::dialogs diff --git a/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp b/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp index 08b5c878021..825871c0935 100644 --- a/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp +++ b/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp @@ -5,6 +5,7 @@ #include "ui/Theme.h" #include #include "ui/widgets/BriefingMapWidget.h" +#include "ui/widgets/data_list_menu.h" #include "BriefingEditor/CameraCoordinatesDialog.h" #include "BriefingEditor/IconFromShipDialog.h" #include "BriefingEditor/IconCoordinatesDialog.h" @@ -18,9 +19,12 @@ #include #include +#include #include #include #include +#include +#include #include #include @@ -182,6 +186,47 @@ void BriefingEditorDialog::setupMapWidget() updateUi(); }); + // Ctrl+click on the map creates a new icon at the cursor. + connect(_mapWidget, &fso::fred::BriefingMapWidget::iconCreateRequested, this, [this](vec3d worldPos) { + createIconAt(worldPos); + }); + + // Shift+Ctrl+click opens Make Icon From Ship and places the resulting icon at the cursor. + connect(_mapWidget, &fso::fred::BriefingMapWidget::iconFromShipCreateRequested, this, [this](vec3d worldPos) { + createIconFromShipDialog(worldPos); + }); + + // Right-click context menus: on empty map vs. on an icon. + connect(_mapWidget, &fso::fred::BriefingMapWidget::mapContextMenuRequested, this, [this](QPoint globalPos, vec3d worldPos) { + showMapContextMenu(globalPos, worldPos); + }); + connect(_mapWidget, &fso::fred::BriefingMapWidget::iconContextMenuRequested, this, [this](QPoint globalPos) { + showIconContextMenu(globalPos); + }); + + // Delete key removes the selected icon(s), after a confirmation prompt. + connect(_mapWidget, &fso::fred::BriefingMapWidget::deleteSelectedIconsRequested, this, + &BriefingEditorDialog::deleteSelectedIconsWithConfirm); + + // Arrow keys nudge the selected icon(s). + connect(_mapWidget, &fso::fred::BriefingMapWidget::nudgeIconsRequested, this, [this](vec3d worldDelta) { + _model->nudgeSelectedIcons(worldDelta); + updateUi(); + }); + + // Drag-box selection: replace (or, when additive, extend) the selection with the enclosed icons. + connect(_mapWidget, &fso::fred::BriefingMapWidget::iconsSelectedInBox, this, [this](const SCP_vector& indices, bool additive) { + SCP_vector selection = additive ? _model->getLineSelection() : SCP_vector(); + for (int iconIdx : indices) { + if (std::find(selection.begin(), selection.end(), iconIdx) == selection.end()) { + selection.push_back(iconIdx); + } + } + _model->setLineSelection(selection); + _model->setCurrentIconIndex(selection.empty() ? -1 : selection.back()); + updateUi(); + }); + // Set the initial stage if (_model->getTotalStages() > 0) { _mapWidget->setStage(_model->getCurrentStage()); @@ -195,6 +240,9 @@ void BriefingEditorDialog::initializeUi() util::SignalBlockers blockers(this); ui->drawLinesCheckBox->setTristate(true); + // The four icon-flag checkboxes are tristate so PartiallyChecked can display a divergent multi- + // selection. Their stateChanged() slots convert a user click that lands on the partial state into a + // definite checked/unchecked, so a click never rests on partial (see on_*CheckBox_stateChanged). ui->highlightCheckBox->setTristate(true); ui->flipIconCheckBox->setTristate(true); ui->useWingIconCheckBox->setTristate(true); @@ -283,36 +331,63 @@ void BriefingEditorDialog::updateUi() const bool stage_exists = _model->getTotalStages() > 0 && _model->getCurrentStage() >= 0; const auto& lineSelection = _model->getLineSelection(); const bool icon_selected = stage_exists && !lineSelection.empty(); + const bool single_icon = stage_exists && lineSelection.size() == 1; const bool enoughForLines = stage_exists && lineSelection.size() >= 2; + + // Sets a checkbox to the given tri-state for display. PartiallyChecked shows a divergent multi- + // selection; the checkbox's stateChanged() slot keeps a user click from resting on that state. + const auto setFlagCheckBox = [](QCheckBox* box, TriStateBool state) { + switch (state) { + case TriStateBool::TRUE_: + box->setCheckState(Qt::Checked); + break; + case TriStateBool::UNKNOWN_: + box->setCheckState(Qt::PartiallyChecked); + break; + case TriStateBool::FALSE_: + default: + box->setCheckState(Qt::Unchecked); + break; + } + }; + if (icon_selected) { - ui->iconIdSpinBox->setValue(_model->getIconId()); + // The ID applies to a single icon (ids are unique within a stage), so it is only shown/editable + // for a single selection; otherwise blank it with a dash. + if (single_icon) { + ui->iconIdSpinBox->setSpecialValueText(QString()); + ui->iconIdSpinBox->setValue(_model->getIconId()); + } else { + ui->iconIdSpinBox->setSpecialValueText("-"); + ui->iconIdSpinBox->setValue(ui->iconIdSpinBox->minimum()); + } + + // The remaining fields blank themselves on divergence: the model returns an empty string / -1 / + // negative scale when the selection disagrees, which clears the widget. ui->iconLabelLineEdit->setText(QString::fromStdString(_model->getIconLabel())); ui->iconCloseupLabelLineEdit->setText(QString::fromStdString(_model->getIconCloseupLabel())); ui->iconImageComboBox->setCurrentIndex(_model->getIconTypeIndex()); ui->iconShipTypeComboBox->setCurrentIndex(_model->getIconShipTypeIndex()); ui->iconTeamComboBox->setCurrentIndex(_model->getIconTeamIndex()); - ui->scaleDoubleSpinBox->setValue(_model->getIconScaleFactor()); - const auto toQtCheckState = [](TriStateBool state) { - switch (state) { - case TriStateBool::TRUE_: - return Qt::Checked; - case TriStateBool::UNKNOWN_: - return Qt::PartiallyChecked; - case TriStateBool::FALSE_: - default: - return Qt::Unchecked; - } - }; - ui->highlightCheckBox->setCheckState(toQtCheckState(_model->getIconHighlightedState())); - ui->flipIconCheckBox->setCheckState(toQtCheckState(_model->getIconFlippedState())); - ui->useWingIconCheckBox->setCheckState(toQtCheckState(_model->getIconUseWingState())); - ui->useCargoIconCheckBox->setCheckState(toQtCheckState(_model->getIconUseCargoState())); - } - if (!icon_selected) { - ui->highlightCheckBox->setCheckState(Qt::Unchecked); - ui->flipIconCheckBox->setCheckState(Qt::Unchecked); - ui->useWingIconCheckBox->setCheckState(Qt::Unchecked); - ui->useCargoIconCheckBox->setCheckState(Qt::Unchecked); + + const float scale = _model->getIconScaleFactor(); + if (scale < 0.0f) { + ui->scaleDoubleSpinBox->setSpecialValueText("-"); + ui->scaleDoubleSpinBox->setValue(ui->scaleDoubleSpinBox->minimum()); + } else { + ui->scaleDoubleSpinBox->setSpecialValueText(QString()); + ui->scaleDoubleSpinBox->setValue(scale); + } + + setFlagCheckBox(ui->highlightCheckBox, _model->getIconHighlightedState()); + setFlagCheckBox(ui->flipIconCheckBox, _model->getIconFlippedState()); + setFlagCheckBox(ui->useWingIconCheckBox, _model->getIconUseWingState()); + setFlagCheckBox(ui->useCargoIconCheckBox, _model->getIconUseCargoState()); + } else { + setFlagCheckBox(ui->highlightCheckBox, TriStateBool::FALSE_); + setFlagCheckBox(ui->flipIconCheckBox, TriStateBool::FALSE_); + setFlagCheckBox(ui->useWingIconCheckBox, TriStateBool::FALSE_); + setFlagCheckBox(ui->useCargoIconCheckBox, TriStateBool::FALSE_); } switch (_model->getDrawLinesState()) { @@ -358,6 +433,10 @@ void BriefingEditorDialog::enableDisableControls() ui->makeIconButton->setEnabled(stage_exists); ui->makeIconFromShipButton->setEnabled(stage_exists); + // Change Locally is an editing-mode toggle (whether icon edits propagate forward), not a per-icon + // property, so it is available whenever a stage exists rather than gated on an icon being selected. + ui->changeLocallyCheckBox->setEnabled(stage_exists); + ui->teamComboBox->setEnabled(_model->getMissionIsMultiTeam()); ui->copyToOtherTeamsButton->setEnabled(_model->getMissionIsMultiTeam()); @@ -370,8 +449,9 @@ void BriefingEditorDialog::enableDisableControls() const bool icon_selected = stage_exists && !_model->getLineSelection().empty(); const bool single_icon_selected = stage_exists && _model->getLineSelection().size() == 1; ui->currentIconInfoGroupBox->setEnabled(icon_selected); + ui->iconIdSpinBox->setEnabled(single_icon_selected); // ids are unique per stage, so single-icon only ui->iconCoordinatesButton->setEnabled(single_icon_selected); - ui->deleteIconButton->setEnabled(single_icon_selected); + ui->deleteIconButton->setEnabled(icon_selected); // deletes the whole selection ui->propagateIconButton->setEnabled(single_icon_selected); } @@ -591,39 +671,156 @@ void BriefingEditorDialog::on_changeLocallyCheckBox_toggled(bool checked) _mapWidget->notifyIconVisualsChanged(); } -void BriefingEditorDialog::on_flipIconCheckBox_toggled(bool checked) +void BriefingEditorDialog::on_flipIconCheckBox_stateChanged(int state) { - _model->setIconFlipped(checked); + if (state == Qt::PartiallyChecked) { + ui->flipIconCheckBox->setCheckState(Qt::Checked); // re-enters with Checked and applies + return; + } + _model->setIconFlipped(state == Qt::Checked); _mapWidget->notifyIconVisualsChanged(); } -void BriefingEditorDialog::on_highlightCheckBox_toggled(bool checked) +void BriefingEditorDialog::on_highlightCheckBox_stateChanged(int state) { - _model->setIconHighlighted(checked); + if (state == Qt::PartiallyChecked) { + ui->highlightCheckBox->setCheckState(Qt::Checked); // re-enters with Checked and applies + return; + } + _model->setIconHighlighted(state == Qt::Checked); _mapWidget->notifyIconVisualsChanged(); } -void BriefingEditorDialog::on_useWingIconCheckBox_toggled(bool checked) +void BriefingEditorDialog::on_useWingIconCheckBox_stateChanged(int state) { - _model->setIconUseWing(checked); + if (state == Qt::PartiallyChecked) { + ui->useWingIconCheckBox->setCheckState(Qt::Checked); // re-enters with Checked and applies + return; + } + _model->setIconUseWing(state == Qt::Checked); _mapWidget->notifyIconVisualsChanged(); } -void BriefingEditorDialog::on_useCargoIconCheckBox_toggled(bool checked) +void BriefingEditorDialog::on_useCargoIconCheckBox_stateChanged(int state) { - _model->setIconUseCargo(checked); + if (state == Qt::PartiallyChecked) { + ui->useCargoIconCheckBox->setCheckState(Qt::Checked); // re-enters with Checked and applies + return; + } + _model->setIconUseCargo(state == Qt::Checked); _mapWidget->notifyIconVisualsChanged(); } void BriefingEditorDialog::on_makeIconButton_clicked() +{ + createIconAt(getNewIconPlacement()); +} + +void BriefingEditorDialog::createIconAt(const vec3d& worldPos) { _model->makeIcon("New Icon", 0, 0, 0); _model->setLineSelection({_model->getCurrentIconIndex()}); - _model->setIconPosition(getNewIconPlacement()); + _model->setIconPosition(worldPos); updateUi(); } +void BriefingEditorDialog::showMapContextMenu(const QPoint& globalPos, const vec3d& worldPos) +{ + if (_model->getTotalStages() <= 0 || _model->getCurrentStage() < 0) { + return; + } + + QMenu menu(this); + + QAction* gridAction = menu.addAction(tr("Disable Grid Rendering")); + gridAction->setCheckable(true); + gridAction->setChecked(_model->getDisableGrid()); + connect(gridAction, &QAction::toggled, this, [this](bool checked) { + _model->setDisableGrid(checked); + updateUi(); + }); + + menu.addSeparator(); + + connect(menu.addAction(tr("Make Icon")), &QAction::triggered, this, [this, worldPos] { createIconAt(worldPos); }); + connect(menu.addAction(tr("Make Icon From Ship...")), &QAction::triggered, this, [this, worldPos] { + createIconFromShipDialog(worldPos); + }); + + menu.exec(globalPos); +} + +void BriefingEditorDialog::showIconContextMenu(const QPoint& globalPos) +{ + const auto& selection = _model->getLineSelection(); + if (selection.empty()) { + return; + } + const bool single = selection.size() == 1; + + QMenu menu(this); + + connect(menu.addAction(single ? tr("Delete Icon") : tr("Delete Icons")), &QAction::triggered, this, + [this] { deleteSelectedIconsWithConfirm(); }); + + QAction* coords = menu.addAction(tr("Icon Coordinates...")); + coords->setEnabled(single); // coordinates edit a single icon + connect(coords, &QAction::triggered, this, [this] { on_iconCoordinatesButton_clicked(); }); + + menu.addSeparator(); + + // Checkable flags: checked when the whole selection shares the flag; a click applies the new state to + // every selected icon. + const auto addFlag = [&](const QString& label, TriStateBool state, const std::function& setter) { + QAction* action = menu.addAction(label); + action->setCheckable(true); + action->setChecked(state == TriStateBool::TRUE_); + connect(action, &QAction::toggled, this, [this, setter](bool checked) { + setter(checked); + _mapWidget->notifyIconVisualsChanged(); + updateUi(); + }); + }; + addFlag(tr("Flip Icon"), _model->getIconFlippedState(), [this](bool c) { _model->setIconFlipped(c); }); + addFlag(tr("Highlight"), _model->getIconHighlightedState(), [this](bool c) { _model->setIconHighlighted(c); }); + addFlag(tr("Use Cargo Icon"), _model->getIconUseCargoState(), [this](bool c) { _model->setIconUseCargo(c); }); + addFlag(tr("Use Wing Icon"), _model->getIconUseWingState(), [this](bool c) { _model->setIconUseWing(c); }); + + menu.addSeparator(); + + // Attribute submenus, styled like the viewport's create-ship menu. currentId bolds the shared value + // (or nothing, when a multi-selection diverges). + const auto addAttributeMenu = [&](const QString& label, + const SCP_vector>& items, + int currentId, + const std::function& setter) { + QMenu* sub = menu.addMenu(label); + std::vector entries; + entries.reserve(items.size()); + for (const auto& item : items) { + entries.push_back({QString::fromStdString(item.second), item.first}); + } + fso::fred::populateDataListMenu(sub, entries, _viewport->Data_menu_style, [this, setter](int id) { + setter(id); + updateUi(); + }, 0, currentId); + }; + addAttributeMenu(tr("Icon Image"), _model->getIconList(), _model->getIconTypeIndex(), + [this](int id) { _model->setIconTypeIndex(id); }); + addAttributeMenu(tr("Ship Type"), _model->getShipList(), _model->getIconShipTypeIndex(), + [this](int id) { _model->setIconShipTypeIndex(id); }); + addAttributeMenu(tr("Team"), _model->getIffList(), _model->getIconTeamIndex(), + [this](int id) { _model->setIconTeamIndex(id); }); + + menu.exec(globalPos); +} + void BriefingEditorDialog::on_makeIconFromShipButton_clicked() +{ + createIconFromShipDialog(getNewIconPlacement()); +} + +void BriefingEditorDialog::createIconFromShipDialog(const vec3d& placement) { IconFromShipDialog dlg(this, _model.get()); if (dlg.exec() == QDialog::Accepted) { @@ -635,7 +832,7 @@ void BriefingEditorDialog::on_makeIconFromShipButton_clicked() return; } _model->setLineSelection({_model->getCurrentIconIndex()}); - _model->setIconPosition(getNewIconPlacement()); + _model->setIconPosition(placement); updateUi(); } } @@ -648,8 +845,22 @@ void BriefingEditorDialog::on_iconCoordinatesButton_clicked() void BriefingEditorDialog::on_deleteIconButton_clicked() { - _model->deleteCurrentIcon(); - _model->setLineSelection({_model->getCurrentIconIndex()}); + deleteSelectedIconsWithConfirm(); +} + +void BriefingEditorDialog::deleteSelectedIconsWithConfirm() +{ + const auto selection = _model->getLineSelection(); + if (selection.empty()) { + return; + } + const int count = static_cast(selection.size()); + const QString message = (count == 1) ? tr("Delete the selected icon?") + : tr("Delete the %1 selected icons?").arg(count); + if (QMessageBox::question(this, tr("Delete Icons"), message) != QMessageBox::Yes) { + return; + } + _model->deleteSelectedIcons(); updateUi(); } diff --git a/qtfred/src/ui/dialogs/BriefingEditorDialog.h b/qtfred/src/ui/dialogs/BriefingEditorDialog.h index f302ad38ce1..d84cb81dc08 100644 --- a/qtfred/src/ui/dialogs/BriefingEditorDialog.h +++ b/qtfred/src/ui/dialogs/BriefingEditorDialog.h @@ -67,10 +67,12 @@ class BriefingEditorDialog : public QDialog, public SexpTreeEditorInterface { void on_drawLinesCheckBox_stateChanged(int state); void on_changeLocallyCheckBox_toggled(bool checked); - void on_flipIconCheckBox_toggled(bool checked); - void on_highlightCheckBox_toggled(bool checked); - void on_useWingIconCheckBox_toggled(bool checked); - void on_useCargoIconCheckBox_toggled(bool checked); + // These four are display-only tristate: PartiallyChecked shows a divergent multi-selection, but a + // user click must resolve to checked/unchecked, so they use stateChanged() to intercept the partial. + void on_flipIconCheckBox_stateChanged(int state); + void on_highlightCheckBox_stateChanged(int state); + void on_useWingIconCheckBox_stateChanged(int state); + void on_useCargoIconCheckBox_stateChanged(int state); void on_makeIconButton_clicked(); void on_makeIconFromShipButton_clicked(); @@ -100,6 +102,15 @@ class BriefingEditorDialog : public QDialog, public SexpTreeEditorInterface { void updateUi(); void enableDisableControls(); void captureResetCameraForCurrentStage(); + void deleteSelectedIconsWithConfirm(); // shared by the Delete button and the Delete key + // Opens the Make Icon From Ship dialog and, if accepted, creates the icon at the given placement. + // Shared by the "Make Icon From Ship..." button, Shift+Ctrl+click, and the map context menu. + void createIconFromShipDialog(const vec3d& placement); + void createIconAt(const vec3d& worldPos); // makeIcon at a world position (Ctrl+click / context menu) + + // Right-click context menus on the briefing map. + void showMapContextMenu(const QPoint& globalPos, const vec3d& worldPos); + void showIconContextMenu(const QPoint& globalPos); vec3d _resetCameraPos {}; matrix _resetCameraOrient {}; diff --git a/qtfred/src/ui/widgets/BriefingMapWidget.cpp b/qtfred/src/ui/widgets/BriefingMapWidget.cpp index 2f6389d79ad..fd02ca92d51 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.cpp +++ b/qtfred/src/ui/widgets/BriefingMapWidget.cpp @@ -1,7 +1,9 @@ #include "BriefingMapWidget.h" #include +#include #include +#include #include #include #include @@ -18,7 +20,10 @@ #include "graphics/2d.h" #include "render/3d.h" +#include "render/3dinternal.h" // Matrix_scale, used to unproject clicks with the briefing's projection +#include "math/fvi.h" #include "mission/missionbriefcommon.h" +#include "mission/missiongrid.h" #include #include @@ -328,6 +333,7 @@ void BriefingMapWidget::paintEvent(QPaintEvent* /*event*/) { painter.drawImage(_blitRect, _frameImage); drawSelectionBrackets(painter); + drawSelectionMarquee(painter); } void BriefingMapWidget::drawSelectionBrackets(QPainter& painter) { @@ -379,6 +385,56 @@ void BriefingMapWidget::drawSelectionBrackets(QPainter& painter) { } } +void BriefingMapWidget::drawSelectionMarquee(QPainter& painter) { + if (!_boxSelectActive) { + return; + } + + const QRectF box = QRectF(_boxStartPos, _boxCurrentPos).normalized(); + painter.setPen(QPen(QColor(120, 200, 255), 1, Qt::DashLine)); + painter.setBrush(QColor(120, 200, 255, 40)); + painter.drawRect(box); +} + +void BriefingMapWidget::selectIconsInBox(const QPointF& startLogical, const QPointF& endLogical, bool additive) { + auto* briefPtr = _model->getWipBriefingPtr(_model->getCurrentTeam()); + if (!briefPtr || _currentStage < 0 || _currentStage >= briefPtr->num_stages || _blitRect.width() <= 0 || + _blitRect.height() <= 0 || _lastRenderWidth <= 0 || _lastRenderHeight <= 0) { + return; + } + + // Convert the logical box corners into reference-resolution space (icon coords live there). + const double scaleX = static_cast(_lastRenderWidth) / static_cast(_blitRect.width()); + const double scaleY = static_cast(_lastRenderHeight) / static_cast(_blitRect.height()); + const auto toRefX = [&](double lx) { return (lx - _blitRect.x()) * scaleX; }; + const auto toRefY = [&](double ly) { return (ly - _blitRect.y()) * scaleY; }; + + const double x1 = std::min(toRefX(startLogical.x()), toRefX(endLogical.x())); + const double x2 = std::max(toRefX(startLogical.x()), toRefX(endLogical.x())); + const double y1 = std::min(toRefY(startLogical.y()), toRefY(endLogical.y())); + const double y2 = std::max(toRefY(startLogical.y()), toRefY(endLogical.y())); + + auto& stage = briefPtr->stages[_currentStage]; + + SCP_vector inBox; + for (int i = 0; i < stage.num_icons; ++i) { + auto& icon = stage.icons[i]; + + int iconW = 0, iconH = 0; + brief_common_get_icon_dimensions(&iconW, &iconH, &icon); + const double scaledW = (icon.w > 0) ? icon.w : static_cast(iconW) * icon.scale_factor; + const double scaledH = (icon.h > 0) ? icon.h : static_cast(iconH) * icon.scale_factor; + const double cx = static_cast(icon.x) + scaledW / 2.0; + const double cy = static_cast(icon.y) + scaledH / 2.0; + + if (cx >= x1 && cx <= x2 && cy >= y1 && cy <= y2) { + inBox.push_back(i); + } + } + + Q_EMIT iconsSelectedInBox(inBox, additive); +} + void BriefingMapWidget::maybeRenderCutTransition(float frametime, int width, int height) { (void)frametime; @@ -424,18 +480,19 @@ void BriefingMapWidget::notifyIconVisualsChanged() { brief_set_new_stage(&stage.camera_pos, &stage.camera_orient, 0, _currentStage); brief_reset_icons(_currentStage); - const auto selected = _model->getCurrentIconIndex(); - if (selected >= 0 && selected < stage.num_icons) { - auto& icon = stage.icons[selected]; + // Mirror each icon's BI_HIGHLIGHT into BI_SHOWHIGHLIGHT so the editor renders the highlight anim for + // every highlighted icon, not just the current one (a multi-selection can highlight several at once). + for (int i = 0; i < stage.num_icons; ++i) { + auto& icon = stage.icons[i]; if (icon.flags & BI_HIGHLIGHT) { ensure_highlight_anim_loaded(icon); icon.highlight_anim.time_elapsed = 0.0f; icon.flags |= BI_SHOWHIGHLIGHT; - brief_cancel_pending_highlight_anims(); } else { icon.flags &= ~BI_SHOWHIGHLIGHT; } } + brief_cancel_pending_highlight_anims(); Briefing = savedBriefing; } @@ -574,6 +631,10 @@ void BriefingMapWidget::renderFrame() { brief_camera_move(frametime, _currentStage); updateEditorHighlightPlayback(); brief_render_map(_currentStage, frametime); + // Capture the projection scale while the briefing's g3 frame is still current (it reverts + // to the main editor viewport once we restore that frame). worldPosAtMouse() uses it to + // unproject clicks accurately. + _lastMatrixScale = Matrix_scale; updateEditorHighlightPlayback(); maybeRenderCutTransition(frametime, resW, resH); cameraChanged(brief_get_current_cam_pos(), brief_get_current_cam_orient()); @@ -633,6 +694,37 @@ void BriefingMapWidget::keyPressEvent(QKeyEvent* event) { return; } + if (event->key() == Qt::Key_Delete || event->key() == Qt::Key_Backspace) { + Q_EMIT deleteSelectedIconsRequested(); + event->accept(); + return; + } + + const int key = event->key(); + if (key == Qt::Key_Left || key == Qt::Key_Right || key == Qt::Key_Up || key == Qt::Key_Down) { + // Nudge the selected icon(s) in screen space. Unproject a small pixel step at the grid center + // (Shift for a coarser step) into a world offset, so the nudge follows the cursor's mapping and + // stays consistent across zoom levels. + const float px = (event->modifiers() & Qt::ShiftModifier) ? 16.0f : 4.0f; + const float cx = _lastRenderWidth * 0.5f; + const float cy = _lastRenderHeight * 0.5f; + const vec3d center = worldPosAtMouse(cx, cy); + + vec3d stepped = center; + switch (key) { + case Qt::Key_Left: stepped = worldPosAtMouse(cx - px, cy); break; + case Qt::Key_Right: stepped = worldPosAtMouse(cx + px, cy); break; + case Qt::Key_Up: stepped = worldPosAtMouse(cx, cy - px); break; + case Qt::Key_Down: stepped = worldPosAtMouse(cx, cy + px); break; + default: break; + } + vec3d delta; + vm_vec_sub(&delta, &stepped, ¢er); + Q_EMIT nudgeIconsRequested(delta); + event->accept(); + return; + } + if (!ControlBindings::instance().handleKeyPress(event)) { QWidget::keyPressEvent(event); return; @@ -672,11 +764,131 @@ void BriefingMapWidget::applyBoundCameraControls(float frametime) { } } +vec3d BriefingMapWidget::orbitPivot() { + // Intersect the camera's forward ray with the grid plane; fall back to the grid center / origin. + const vec3d camPos = brief_get_current_cam_pos(); + const matrix camOrient = brief_get_current_cam_orient(); + if (The_grid != nullptr) { + vec3d hit; + const float d = fvi_ray_plane(&hit, &The_grid->center, &The_grid->gmatrix.vec.uvec, &camPos, &camOrient.vec.fvec, 0.0f); + if (d > 0.0f) { + return hit; + } + return The_grid->center; + } + return vmd_zero_vector; +} + +void BriefingMapWidget::beginOrbit(const QPoint& pos) { + // Share the main editor viewport's orbit-inversion preferences. + _cameraController.setInvertOrbitX(_viewport->camera.getInvertOrbitX()); + _cameraController.setInvertOrbitY(_viewport->camera.getInvertOrbitY()); + + _cameraController.view_pos = brief_get_current_cam_pos(); + _cameraController.view_orient = brief_get_current_cam_orient(); + + const vec3d pivot = orbitPivot(); + const matrix gridOrient = (The_grid != nullptr) ? The_grid->gmatrix : vmd_identity_matrix; + _cameraController.orbitCameraInitFromCurrentView(&pivot, &gridOrient); + + _orbitLastMouse = pos; +} + +void BriefingMapWidget::handleOrbitDrag(const QPoint& pos, Qt::KeyboardModifiers modifiers) { + const int dx = pos.x() - _orbitLastMouse.x(); + const int dy = pos.y() - _orbitLastMouse.y(); + _orbitLastMouse = pos; + + if (modifiers & Qt::ShiftModifier) { + _cameraController.orbitCameraPan(dx, dy); + } else { + _cameraController.orbitCameraRotate(dx, dy); + } + applyCameraPoseLikeKeyboardControls(_cameraController.view_pos, _cameraController.view_orient, true); +} + +void BriefingMapWidget::wheelEvent(QWheelEvent* event) { + if (!_initialized) { + QWidget::wheelEvent(event); + return; + } + + // Zoom = orbit distance. Initialize the orbit from the current view if a keyboard move (or nothing) + // left it inactive, matching the main viewport's wheel behavior. + if (!_cameraController.isOrbitActive()) { + _cameraController.view_pos = brief_get_current_cam_pos(); + _cameraController.view_orient = brief_get_current_cam_orient(); + const vec3d pivot = orbitPivot(); + const matrix gridOrient = (The_grid != nullptr) ? The_grid->gmatrix : vmd_identity_matrix; + _cameraController.orbitCameraInitFromCurrentView(&pivot, &gridOrient); + } + + _cameraController.orbitCameraZoom(event->angleDelta().y() / -200.0f); + applyCameraPoseLikeKeyboardControls(_cameraController.view_pos, _cameraController.view_orient, true); + event->accept(); +} + +vec3d BriefingMapWidget::worldPosAtMouse(float mouseRefX, float mouseRefY) const { + const vec3d camPos = brief_get_current_cam_pos(); + const matrix camOrient = brief_get_current_cam_orient(); + + // Reproduce g3_point_to_vec_delayed(): turn the cursor position into a world-space ray using the + // projection scale captured during the briefing's render. (We can't call g3_point_to_vec_delayed() + // directly here because the live g3 state belongs to the main editor viewport by click time.) The + // briefing's Unscaled_matrix is its camera orientation, so we unrotate by camOrient. + const float canvW2 = _lastRenderWidth * 0.5f; + const float canvH2 = _lastRenderHeight * 0.5f; + + vec3d dir; + if (canvW2 > 0.0f && canvH2 > 0.0f && _lastMatrixScale.xyz.x != 0.0f && _lastMatrixScale.xyz.y != 0.0f) { + vec3d tempv; + tempv.xyz.x = ((mouseRefX - canvW2) / canvW2) * _lastMatrixScale.xyz.z / _lastMatrixScale.xyz.x; + tempv.xyz.y = -((mouseRefY - canvH2) / canvH2) * _lastMatrixScale.xyz.z / _lastMatrixScale.xyz.y; + tempv.xyz.z = 1.0f; + vm_vec_normalize(&tempv); + vm_vec_unrotate(&dir, &tempv, &camOrient); + } else { + dir = camOrient.vec.fvec; + } + + if (The_grid != nullptr) { + vec3d hit; + const float d = fvi_ray_plane(&hit, &The_grid->center, &The_grid->gmatrix.vec.uvec, &camPos, &dir, 0.0f); + if (d >= 0.0f) { + return hit; + } + } + + // Fallback: a fixed distance along the ray if it never crosses the grid plane. + vec3d placement; + vm_vec_scale_add(&placement, &camPos, &dir, 500.0f); + return placement; +} + void BriefingMapWidget::mousePressEvent(QMouseEvent* event) { - if (!_initialized || event->button() != Qt::LeftButton) + if (!_initialized) + return; + + // Orbit camera: middle button orbits at once; right button sets up the orbit but defers to a drag + // threshold (a right-click without a drag falls through to the context menu on release). + if (event->button() == Qt::MiddleButton) { + beginOrbit(event->pos()); + _orbitDragging = true; + return; + } + if (event->button() == Qt::RightButton) { + _rbuttonDown = true; + _rbuttonMoved = false; + _rbuttonDownPoint = event->pos(); + beginOrbit(event->pos()); + return; + } + + if (event->button() != Qt::LeftButton) return; _dragStartMousePos = event->position(); + _boxSelectPending = false; // cleared here; only an empty-space press (below) starts a drag-box auto* briefPtr = _model->getWipBriefingPtr(_model->getCurrentTeam()); if (!briefPtr || _currentStage < 0 || _currentStage >= briefPtr->num_stages || _lastRenderWidth <= 0 || _lastRenderHeight <= 0 || @@ -694,58 +906,119 @@ void BriefingMapWidget::mousePressEvent(QMouseEvent* event) { const auto mouseY = (static_cast(event->position().y()) - static_cast(_blitRect.y())) * (static_cast(_lastRenderHeight) / static_cast(_blitRect.height())); - auto& stage = briefPtr->stages[_currentStage]; - - // Collect every icon under the cursor, top-most first (higher index = drawn later = on top). - SCP_vector hits; - for (int i = stage.num_icons - 1; i >= 0; --i) { - auto& icon = stage.icons[i]; - - int iconW = 0, iconH = 0; - brief_common_get_icon_dimensions(&iconW, &iconH, &icon); - const auto scaledW = static_cast((icon.w > 0) ? icon.w : fl2i(static_cast(iconW) * icon.scale_factor)); - const auto scaledH = static_cast((icon.h > 0) ? icon.h : fl2i(static_cast(iconH) * icon.scale_factor)); - const auto left = static_cast(icon.x); - const auto top = static_cast(icon.y); - - if (mouseX >= left && mouseX <= left + scaledW && mouseY >= top && mouseY <= top + scaledH) { - hits.push_back(i); + // Ctrl+click creates a new icon at the cursor; Shift+Ctrl+click opens Make Icon From Ship and places + // the result at the cursor. Either way, no selection or drag. + if (event->modifiers() & Qt::ControlModifier) { + const vec3d worldPos = worldPosAtMouse(mouseX, mouseY); + if (event->modifiers() & Qt::ShiftModifier) { + Q_EMIT iconFromShipCreateRequested(worldPos); + } else { + Q_EMIT iconCreateRequested(worldPos); } + _draggingIcon = false; + _dragIconIndex = -1; + return; } + const auto hits = iconsUnderReference(mouseX, mouseY); + const bool shiftHeld = (event->modifiers() & Qt::ShiftModifier) != 0; if (hits.empty()) { _draggingIcon = false; _dragIconIndex = -1; - if (!shiftHeld) { - Q_EMIT iconSelected(-1, false); - } + // Begin a possible drag-box selection from empty space. A plain click (no drag) clears the + // selection on release unless Shift is held; a drag selects the enclosed icons. + _boxSelectPending = true; + _boxSelectActive = false; + _boxSelectAdditive = shiftHeld; + _boxStartPos = event->position(); + _boxCurrentPos = event->position(); return; } - int pickedIndex = hits.front(); // default: the top-most icon under the cursor + // If the click lands on a member of an existing multi-selection (and no modifier), keep the whole + // selection and drag them together rather than collapsing to the single clicked icon. + int memberHit = -1; if (!shiftHeld) { - // Rolling select: if the currently-selected icon is one of the stacked hits, advance to the - // next one underneath (wrapping bottom -> top) so repeated clicks cycle the whole stack. - const int current = _model->getCurrentIconIndex(); - for (size_t k = 0; k < hits.size(); ++k) { - if (hits[k] == current) { - pickedIndex = hits[(k + 1) % hits.size()]; - break; + const auto& selection = _model->getLineSelection(); + if (selection.size() > 1) { + for (int h : hits) { + if (std::find(selection.begin(), selection.end(), h) != selection.end()) { + memberHit = h; + break; + } + } + } + } + + int anchorIndex; + _pendingCollapseIndex = -1; + if (memberHit >= 0) { + // Leave the selection as-is; this icon is just the drag anchor for depth/scaling. If this turns + // out to be a click rather than a drag, mouseReleaseEvent collapses the selection to this icon. + anchorIndex = memberHit; + _pendingCollapseIndex = memberHit; + } else { + int pickedIndex = hits.front(); // default: the top-most icon under the cursor + if (!shiftHeld) { + // Rolling select: if the currently-selected icon is one of the stacked hits, advance to the + // next one underneath (wrapping bottom -> top) so repeated clicks cycle the whole stack. + const int current = _model->getCurrentIconIndex(); + for (size_t k = 0; k < hits.size(); ++k) { + if (hits[k] == current) { + pickedIndex = hits[(k + 1) % hits.size()]; + break; + } } } + anchorIndex = pickedIndex; + Q_EMIT iconSelected(pickedIndex, shiftHeld); } _draggingIcon = true; - _dragIconIndex = pickedIndex; - _dragStartIconPos = stage.icons[pickedIndex].pos; + _dragIconIndex = anchorIndex; brief_move_icon_reset(); - Q_EMIT iconSelected(pickedIndex, shiftHeld); + _model->beginIconDrag(); // snapshot positions after the selection is finalized } void BriefingMapWidget::mouseMoveEvent(QMouseEvent* event) { - if (!_initialized || !_draggingIcon || _dragIconIndex < 0 || !(event->buttons() & Qt::LeftButton)) + if (!_initialized) + return; + + // Orbit camera drag: middle button, or right button once it passes the click threshold. + if (_orbitDragging && (event->buttons() & Qt::MiddleButton)) { + handleOrbitDrag(event->pos(), event->modifiers()); + return; + } + if (_rbuttonDown && (event->buttons() & Qt::RightButton)) { + if (!_rbuttonMoved && (std::abs(event->pos().x() - _rbuttonDownPoint.x()) > 2 || + std::abs(event->pos().y() - _rbuttonDownPoint.y()) > 2)) { + _rbuttonMoved = true; + } + if (_rbuttonMoved) { + handleOrbitDrag(event->pos(), event->modifiers()); + } + return; + } + + // Drag-box selection: grow the rubber band from the empty-space press point. + if (_boxSelectPending && (event->buttons() & Qt::LeftButton)) { + _boxCurrentPos = event->position(); + if (!_boxSelectActive) { + const double dx = _boxCurrentPos.x() - _boxStartPos.x(); + const double dy = _boxCurrentPos.y() - _boxStartPos.y(); + if (dx * dx + dy * dy >= 9.0) { // > 3 logical px: a drag, not a click + _boxSelectActive = true; + } + } + if (_boxSelectActive) { + update(); + } + return; + } + + if (!_draggingIcon || _dragIconIndex < 0 || !(event->buttons() & Qt::LeftButton)) return; auto* briefPtr = _model->getWipBriefingPtr(_model->getCurrentTeam()); @@ -754,6 +1027,15 @@ void BriefingMapWidget::mouseMoveEvent(QMouseEvent* event) { return; } + // Ignore sub-threshold movement so a click with a little jitter doesn't micro-nudge icons (and, on a + // multi-selection member, still counts as a click that collapses the selection on release). + const double jitterX = event->position().x() - _dragStartMousePos.x(); + const double jitterY = event->position().y() - _dragStartMousePos.y(); + if (jitterX * jitterX + jitterY * jitterY < 9.0) { // < 3 logical px + return; + } + _pendingCollapseIndex = -1; // a real drag is underway; keep the (possibly multi-) selection + // Convert the logical mouse delta into reference-resolution pixels (scaled from the logical // letterbox rectangle to the render-target size). The letterbox offset cancels in a delta. const auto scaleX = static_cast(_lastRenderWidth) / static_cast(_blitRect.width()); @@ -763,32 +1045,134 @@ void BriefingMapWidget::mouseMoveEvent(QMouseEvent* event) { const auto camPos = brief_get_current_cam_pos(); const auto camOrient = brief_get_current_cam_orient(); - const auto& currentIcon = briefPtr->stages[_currentStage].icons[_dragIconIndex]; + // Use the drag anchor's depth to size the world-per-pixel step; every selected icon moves by the same + // world delta so a multi-selection keeps its relative layout. + const auto& anchorIcon = briefPtr->stages[_currentStage].icons[_dragIconIndex]; vec3d toIcon; - vm_vec_sub(&toIcon, ¤tIcon.pos, &camPos); + vm_vec_sub(&toIcon, &anchorIcon.pos, &camPos); const auto depth = vm_vec_dot(&toIcon, &camOrient.vec.fvec); if (depth <= 1.0f) { return; } const auto horizontalFov = g3_get_hfov(Proj_fov); - const auto worldPerPixelX = (2.0f * depth * std::tan(horizontalFov / 2.0f)) / static_cast(_lastRenderWidth); - const auto worldPerPixelY = worldPerPixelX; + const auto worldPerPixel = (2.0f * depth * std::tan(horizontalFov / 2.0f)) / static_cast(_lastRenderWidth); constexpr float DragResponseScale = 1.5f; // This is kind hacky but it makes the drag feel more responsive without having to move the mouse as far, which is nice given the precision required to drag small icons. - vec3d newPos = _dragStartIconPos; - vm_vec_scale_add2(&newPos, &camOrient.vec.rvec, deltaX * worldPerPixelX * DragResponseScale); - vm_vec_scale_add2(&newPos, &camOrient.vec.uvec, -deltaY * worldPerPixelY * DragResponseScale); - _model->setIconPosition(newPos); + vec3d worldDelta = ZERO_VECTOR; + vm_vec_scale_add2(&worldDelta, &camOrient.vec.rvec, deltaX * worldPerPixel * DragResponseScale); + vm_vec_scale_add2(&worldDelta, &camOrient.vec.uvec, -deltaY * worldPerPixel * DragResponseScale); + _model->dragSelectedIconsBy(worldDelta); } void BriefingMapWidget::mouseReleaseEvent(QMouseEvent* event) { - if (!_initialized || event->button() != Qt::LeftButton) + if (!_initialized) + return; + + if (event->button() == Qt::MiddleButton) { + _orbitDragging = false; + return; + } + if (event->button() == Qt::RightButton) { + _rbuttonDown = false; + // _rbuttonMoved stays set so contextMenuEvent can tell an orbit drag (suppress the menu) from a + // plain right-click (show it); it is reset there and on the next right-press. + return; + } + + if (event->button() != Qt::LeftButton) + return; + + // Resolve a drag-box selection (or a plain click on empty space). + if (_boxSelectPending) { + if (_boxSelectActive) { + selectIconsInBox(_boxStartPos, event->position(), _boxSelectAdditive); + } else if (!_boxSelectAdditive) { + Q_EMIT iconSelected(-1, false); // click on empty space clears the selection + } + _boxSelectPending = false; + _boxSelectActive = false; + update(); return; + } + // A click (no drag) on a member of a multi-selection collapses the selection to just that icon. + if (_pendingCollapseIndex >= 0) { + Q_EMIT iconSelected(_pendingCollapseIndex, false); + } + + _pendingCollapseIndex = -1; _draggingIcon = false; _dragIconIndex = -1; } +bool BriefingMapWidget::mouseToReference(const QPointF& logical, float& refX, float& refY) const { + if (_lastRenderWidth <= 0 || _lastRenderHeight <= 0 || _blitRect.width() <= 0 || _blitRect.height() <= 0) { + return false; + } + refX = (static_cast(logical.x()) - static_cast(_blitRect.x())) * + (static_cast(_lastRenderWidth) / static_cast(_blitRect.width())); + refY = (static_cast(logical.y()) - static_cast(_blitRect.y())) * + (static_cast(_lastRenderHeight) / static_cast(_blitRect.height())); + return true; +} + +SCP_vector BriefingMapWidget::iconsUnderReference(float refX, float refY) const { + SCP_vector hits; + + auto* briefPtr = _model->getWipBriefingPtr(_model->getCurrentTeam()); + if (!briefPtr || _currentStage < 0 || _currentStage >= briefPtr->num_stages) { + return hits; + } + + auto& stage = briefPtr->stages[_currentStage]; + // Top-most first (higher index = drawn later = on top). + for (int i = stage.num_icons - 1; i >= 0; --i) { + auto& icon = stage.icons[i]; + + int iconW = 0, iconH = 0; + brief_common_get_icon_dimensions(&iconW, &iconH, &icon); + const auto scaledW = static_cast((icon.w > 0) ? icon.w : fl2i(static_cast(iconW) * icon.scale_factor)); + const auto scaledH = static_cast((icon.h > 0) ? icon.h : fl2i(static_cast(iconH) * icon.scale_factor)); + const auto left = static_cast(icon.x); + const auto top = static_cast(icon.y); + + if (refX >= left && refX <= left + scaledW && refY >= top && refY <= top + scaledH) { + hits.push_back(i); + } + } + return hits; +} + +void BriefingMapWidget::contextMenuEvent(QContextMenuEvent* event) { + // A right-drag that orbited the camera should not also pop the menu. + if (_rbuttonMoved) { + _rbuttonMoved = false; + event->accept(); + return; + } + + float refX = 0.0f; + float refY = 0.0f; + if (!_initialized || !mouseToReference(event->pos(), refX, refY)) { + return; + } + + const auto hits = iconsUnderReference(refX, refY); + if (!hits.empty()) { + const int top = hits.front(); + // Right-clicking an icon that isn't in the current selection selects just it; a member of a + // multi-selection keeps the whole selection so the menu acts on the group. + const auto& selection = _model->getLineSelection(); + if (std::find(selection.begin(), selection.end(), top) == selection.end()) { + Q_EMIT iconSelected(top, false); + } + Q_EMIT iconContextMenuRequested(event->globalPos()); + } else { + Q_EMIT mapContextMenuRequested(event->globalPos(), worldPosAtMouse(refX, refY)); + } + event->accept(); +} + } // namespace fso::fred diff --git a/qtfred/src/ui/widgets/BriefingMapWidget.h b/qtfred/src/ui/widgets/BriefingMapWidget.h index 6f5af74895c..fbe18f4a051 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.h +++ b/qtfred/src/ui/widgets/BriefingMapWidget.h @@ -14,6 +14,8 @@ class QOffscreenSurface; class QPainter; class QPaintEvent; +class QContextMenuEvent; +class QWheelEvent; class briefing; namespace fso::fred::dialogs { @@ -63,6 +65,14 @@ class BriefingMapWidget : public QWidget { signals: void iconSelected(int index, bool toggleSelection); void cameraChanged(vec3d pos, matrix orient); + void iconCreateRequested(vec3d worldPos); // Ctrl+click: make a new icon at this world position + void iconFromShipCreateRequested(vec3d worldPos); // Shift+Ctrl+click: Make Icon From Ship at this position + void deleteSelectedIconsRequested(); // Delete key: remove the selected icon(s) + void nudgeIconsRequested(vec3d worldDelta); // arrow keys: move the selected icon(s) by this offset + // drag-box selection: the icons enclosed by the rubber band (additive = add to the current selection) + void iconsSelectedInBox(SCP_vector indices, bool additive); + void mapContextMenuRequested(QPoint globalPos, vec3d worldPos); // right-click on empty map + void iconContextMenuRequested(QPoint globalPos); // right-click on an icon (selection updated) protected: bool event(QEvent* evt) override; @@ -71,6 +81,8 @@ class BriefingMapWidget : public QWidget { void mousePressEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; void mouseReleaseEvent(QMouseEvent* event) override; + void contextMenuEvent(QContextMenuEvent* event) override; + void wheelEvent(QWheelEvent* event) override; void paintEvent(QPaintEvent* event) override; private: @@ -82,9 +94,24 @@ class BriefingMapWidget : public QWidget { static bool shouldUseCutTransition(int fromStage, int toStage, const briefing* briefPtr); void updateEditorHighlightPlayback() const; void drawSelectionBrackets(QPainter& painter); + void drawSelectionMarquee(QPainter& painter); + // Emit iconsSelectedInBox() for every icon whose center falls inside the rubber band (widget coords). + void selectIconsInBox(const QPointF& startLogical, const QPointF& endLogical, bool additive); QPixmap checkerboardTile(); // subtle, theme-appropriate matte for the letterbox bars void applyCameraPoseLikeKeyboardControls(const vec3d& camPos, const matrix& camOrient, bool updateModel); void applyBoundCameraControls(float frametime); + // Orbit/pan/zoom camera controls, mirroring the main viewport (right/middle drag = orbit, Shift = pan, + // wheel = zoom), sharing the main viewport's orbit-inversion preferences. + static vec3d orbitPivot(); + void beginOrbit(const QPoint& pos); + void handleOrbitDrag(const QPoint& pos, Qt::KeyboardModifiers modifiers); + // Unproject a mouse position (in render-target/reference-resolution pixels) onto the briefing grid + // plane, giving the world position under the cursor for placing a new icon. + vec3d worldPosAtMouse(float mouseRefX, float mouseRefY) const; + // Map a logical widget position to reference-resolution coords; false if the map geometry isn't ready. + bool mouseToReference(const QPointF& logical, float& refX, float& refY) const; + // Icons under the given reference-resolution point, top-most (drawn last) first. + SCP_vector iconsUnderReference(float refX, float refY) const; CameraController _cameraController; @@ -116,10 +143,31 @@ class BriefingMapWidget : public QWidget { bool _draggingIcon = false; int _dragIconIndex = -1; QPointF _dragStartMousePos; - vec3d _dragStartIconPos = ZERO_VECTOR; + // When a plain click lands on a member of a multi-selection we keep the selection (so a drag moves the + // whole group); if the click turns out not to be a drag, this collapses the selection to that icon. + int _pendingCollapseIndex = -1; + + // Drag-box (rubber band) selection, started by pressing on empty space. Positions are logical widget + // coordinates. _boxSelectActive turns on once the drag passes the click threshold. + bool _boxSelectPending = false; + bool _boxSelectActive = false; + bool _boxSelectAdditive = false; + QPointF _boxStartPos; + QPointF _boxCurrentPos; + + // Orbit camera drag state. Middle button orbits immediately; right button orbits only once it moves + // past a small threshold (so a right-click still opens the context menu). + bool _orbitDragging = false; + bool _rbuttonDown = false; + bool _rbuttonMoved = false; + QPoint _rbuttonDownPoint; + QPoint _orbitLastMouse; // Render size icon coordinates are expressed in (the reference/render-target resolution). int _lastRenderWidth = 0; int _lastRenderHeight = 0; + // Projection scale (Matrix_scale) the briefing last rendered with, captured so we can unproject + // clicks accurately even though the live g3 state belongs to the main editor viewport by then. + vec3d _lastMatrixScale = ZERO_VECTOR; // Briefing cut transition state (forward/backward cut + jump cuts) bool _cutFadeIn = false; diff --git a/qtfred/ui/BriefingEditorDialog.ui b/qtfred/ui/BriefingEditorDialog.ui index 1a6093d4367..e30a8559011 100644 --- a/qtfred/ui/BriefingEditorDialog.ui +++ b/qtfred/ui/BriefingEditorDialog.ui @@ -322,7 +322,9 @@ - + + + 0 @@ -422,7 +424,11 @@ - + + + 0.100000000000000 + + @@ -430,7 +436,7 @@ - + Highlight @@ -444,13 +450,6 @@ - - - - Change locally - - - @@ -468,7 +467,7 @@ - + Use Cargo Icon @@ -509,7 +508,16 @@ - + + + + + + Change locally + + + + @@ -539,7 +547,7 @@ - From Ship... + Make Icon From Ship... false