From 38224eee08905003ff0ec89be680c0b6a00466af Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Wed, 29 Jul 2026 10:42:30 -0500 Subject: [PATCH 1/7] qtfred: briefing icon editing conveniences and multi-select support Map editing: - Delete key and the Delete button remove all selected icons, with a confirmation prompt - Ctrl+click on the map creates a new icon at the cursor - arrow keys nudge the selected icon(s); Shift for a coarser step - unproject clicks through the briefing's own captured projection scale so Ctrl+click placement lands under the cursor across the whole map Current Icon controls now handle a multi-selection: - label, closeup, image/ship/team combos and scale blank on divergence and apply edits to the whole selection - the ID field stays single-icon (ids are unique per stage): disabled and blanked when more than one icon is selected - the flag checkboxes show a partial state for a divergent selection and a click always resolves to checked/unchecked (never rests on partial) Change Locally is treated as an editing-mode toggle rather than an icon property: moved to the group's top-right corner, enabled whenever a stage exists instead of gated on an icon being selected. Fix Highlight not rendering for every icon in a multi-selection: notifyIconVisualsChanged now mirrors BI_HIGHLIGHT into BI_SHOWHIGHLIGHT for all icons in the stage, not just the current one. --- .../dialogs/BriefingEditorDialogModel.cpp | 114 +++++++++---- .../dialogs/BriefingEditorDialogModel.h | 3 +- .../src/ui/dialogs/BriefingEditorDialog.cpp | 152 ++++++++++++++---- qtfred/src/ui/dialogs/BriefingEditorDialog.h | 11 +- qtfred/src/ui/widgets/BriefingMapWidget.cpp | 92 ++++++++++- qtfred/src/ui/widgets/BriefingMapWidget.h | 9 ++ qtfred/ui/BriefingEditorDialog.ui | 22 +-- 7 files changed, 318 insertions(+), 85 deletions(-) diff --git a/qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp b/qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp index 87781756589..bb105551c0e 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,15 @@ 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); + }); +} + int BriefingEditorDialogModel::getIconId() const { const auto& b = _wipBriefings[_currentTeam]; @@ -856,10 +889,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 +910,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 +930,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 +945,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 +961,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 +976,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 +1325,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 +1356,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..7e2bab04ac0 100644 --- a/qtfred/src/mission/dialogs/BriefingEditorDialogModel.h +++ b/qtfred/src/mission/dialogs/BriefingEditorDialogModel.h @@ -73,6 +73,7 @@ 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 int getIconId() const; // returns false if the requested id was rejected (e.g. it collides with another icon) bool setIconId(int id); @@ -109,7 +110,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; diff --git a/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp b/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp index 08b5c878021..4045fe13d38 100644 --- a/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp +++ b/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -182,6 +183,24 @@ 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) { + _model->makeIcon("New Icon", 0, 0, 0); + _model->setLineSelection({_model->getCurrentIconIndex()}); + _model->setIconPosition(worldPos); + updateUi(); + }); + + // 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(); + }); + // Set the initial stage if (_model->getTotalStages() > 0) { _mapWidget->setStage(_model->getCurrentStage()); @@ -195,6 +214,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 +305,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 +407,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 +423,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,27 +645,43 @@ 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(); } @@ -648,8 +718,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..3e94c564b34 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,7 @@ class BriefingEditorDialog : public QDialog, public SexpTreeEditorInterface { void updateUi(); void enableDisableControls(); void captureResetCameraForCurrentStage(); + void deleteSelectedIconsWithConfirm(); // shared by the Delete button and the Delete key vec3d _resetCameraPos {}; matrix _resetCameraOrient {}; diff --git a/qtfred/src/ui/widgets/BriefingMapWidget.cpp b/qtfred/src/ui/widgets/BriefingMapWidget.cpp index 2f6389d79ad..de41e09b26e 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.cpp +++ b/qtfred/src/ui/widgets/BriefingMapWidget.cpp @@ -18,7 +18,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 @@ -424,18 +427,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 +578,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 +641,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,6 +711,43 @@ void BriefingMapWidget::applyBoundCameraControls(float frametime) { } } +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) return; @@ -694,6 +770,14 @@ void BriefingMapWidget::mousePressEvent(QMouseEvent* event) { const auto mouseY = (static_cast(event->position().y()) - static_cast(_blitRect.y())) * (static_cast(_lastRenderHeight) / static_cast(_blitRect.height())); + // Ctrl+click: create a new icon at the cursor instead of selecting or dragging. + if (event->modifiers() & Qt::ControlModifier) { + Q_EMIT iconCreateRequested(worldPosAtMouse(mouseX, mouseY)); + _draggingIcon = false; + _dragIconIndex = -1; + return; + } + auto& stage = briefPtr->stages[_currentStage]; // Collect every icon under the cursor, top-most first (higher index = drawn later = on top). diff --git a/qtfred/src/ui/widgets/BriefingMapWidget.h b/qtfred/src/ui/widgets/BriefingMapWidget.h index 6f5af74895c..dbc358e879c 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.h +++ b/qtfred/src/ui/widgets/BriefingMapWidget.h @@ -63,6 +63,9 @@ 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 deleteSelectedIconsRequested(); // Delete key: remove the selected icon(s) + void nudgeIconsRequested(vec3d worldDelta); // arrow keys: move the selected icon(s) by this offset protected: bool event(QEvent* evt) override; @@ -85,6 +88,9 @@ class BriefingMapWidget : public QWidget { QPixmap checkerboardTile(); // subtle, theme-appropriate matte for the letterbox bars void applyCameraPoseLikeKeyboardControls(const vec3d& camPos, const matrix& camOrient, bool updateModel); void applyBoundCameraControls(float frametime); + // 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; CameraController _cameraController; @@ -120,6 +126,9 @@ class BriefingMapWidget : public QWidget { // 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..fdf5edce853 100644 --- a/qtfred/ui/BriefingEditorDialog.ui +++ b/qtfred/ui/BriefingEditorDialog.ui @@ -322,7 +322,9 @@ - + + + 0 @@ -444,13 +446,6 @@ - - - - Change locally - - - @@ -509,7 +504,16 @@ - + + + + + + Change locally + + + + From fb97db3e08b0de500f1f32a12793b3126e043f09 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Wed, 29 Jul 2026 11:12:15 -0500 Subject: [PATCH 2/7] qtfred: relative multi-icon drag and drag-box selection on the briefing map Mouse dragging now respects a multi-selection: - clicking a member of a multi-selection keeps the whole selection and drags the group; each icon moves by the same world offset from its own start position instead of snapping onto the cursor - a plain click (no drag) on a member collapses the selection to that icon - a small drag threshold keeps a click from micro-nudging icons Relative dragging is backed by new model methods beginIconDrag() / dragSelectedIconsBy(), which snapshot the selected icons at press and offset each from its snapshot (forward-propagating like setIconPosition). Add a drag-box (rubber band) selector, started by pressing on empty space: - releasing selects every icon whose center is inside the box - Shift extends the current selection instead of replacing it - the band is drawn with QPainter over the map; hit-testing mirrors the main viewport's point-in-box marking Scale spinbox now steps by 0.1 instead of 1.0. --- .../dialogs/BriefingEditorDialogModel.cpp | 48 +++++ .../dialogs/BriefingEditorDialogModel.h | 12 ++ .../src/ui/dialogs/BriefingEditorDialog.cpp | 13 ++ qtfred/src/ui/widgets/BriefingMapWidget.cpp | 175 +++++++++++++++--- qtfred/src/ui/widgets/BriefingMapWidget.h | 17 +- qtfred/ui/BriefingEditorDialog.ui | 10 +- 6 files changed, 248 insertions(+), 27 deletions(-) diff --git a/qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp b/qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp index bb105551c0e..5a8b00d3804 100644 --- a/qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp @@ -805,6 +805,54 @@ void BriefingEditorDialogModel::nudgeSelectedIcons(const vec3d& worldDelta) }); } +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]; diff --git a/qtfred/src/mission/dialogs/BriefingEditorDialogModel.h b/qtfred/src/mission/dialogs/BriefingEditorDialogModel.h index 7e2bab04ac0..c5d06bc2bad 100644 --- a/qtfred/src/mission/dialogs/BriefingEditorDialogModel.h +++ b/qtfred/src/mission/dialogs/BriefingEditorDialogModel.h @@ -74,6 +74,10 @@ class BriefingEditorDialogModel : public AbstractDialogModel { 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); @@ -166,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 4045fe13d38..740fd015aad 100644 --- a/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp +++ b/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp @@ -201,6 +201,19 @@ void BriefingEditorDialog::setupMapWidget() updateUi(); }); + // Drag-box selection: replace (or, when additive, extend) the selection with the enclosed icons. + connect(_mapWidget, &fso::fred::BriefingMapWidget::iconsSelectedInBox, this, [this](SCP_vector indices, bool additive) { + SCP_vector selection = additive ? _model->getLineSelection() : SCP_vector(); + for (int idx : indices) { + if (std::find(selection.begin(), selection.end(), idx) == selection.end()) { + selection.push_back(idx); + } + } + _model->setLineSelection(selection); + _model->setCurrentIconIndex(selection.empty() ? -1 : selection.back()); + updateUi(); + }); + // Set the initial stage if (_model->getTotalStages() > 0) { _mapWidget->setStage(_model->getCurrentStage()); diff --git a/qtfred/src/ui/widgets/BriefingMapWidget.cpp b/qtfred/src/ui/widgets/BriefingMapWidget.cpp index de41e09b26e..226fc22f602 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.cpp +++ b/qtfred/src/ui/widgets/BriefingMapWidget.cpp @@ -331,6 +331,7 @@ void BriefingMapWidget::paintEvent(QPaintEvent* /*event*/) { painter.drawImage(_blitRect, _frameImage); drawSelectionBrackets(painter); + drawSelectionMarquee(painter); } void BriefingMapWidget::drawSelectionBrackets(QPainter& painter) { @@ -382,6 +383,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; @@ -753,6 +804,7 @@ void BriefingMapWidget::mousePressEvent(QMouseEvent* event) { 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 || @@ -802,34 +854,82 @@ void BriefingMapWidget::mousePressEvent(QMouseEvent* event) { 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; + + // 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()); @@ -838,6 +938,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()); @@ -847,30 +956,50 @@ 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) 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; } diff --git a/qtfred/src/ui/widgets/BriefingMapWidget.h b/qtfred/src/ui/widgets/BriefingMapWidget.h index dbc358e879c..8048978c978 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.h +++ b/qtfred/src/ui/widgets/BriefingMapWidget.h @@ -66,6 +66,8 @@ class BriefingMapWidget : public QWidget { void iconCreateRequested(vec3d worldPos); // Ctrl+click: make a new icon at this world 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); protected: bool event(QEvent* evt) override; @@ -85,6 +87,9 @@ 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); @@ -122,7 +127,17 @@ 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; // Render size icon coordinates are expressed in (the reference/render-target resolution). int _lastRenderWidth = 0; int _lastRenderHeight = 0; diff --git a/qtfred/ui/BriefingEditorDialog.ui b/qtfred/ui/BriefingEditorDialog.ui index fdf5edce853..1c443f85810 100644 --- a/qtfred/ui/BriefingEditorDialog.ui +++ b/qtfred/ui/BriefingEditorDialog.ui @@ -424,7 +424,11 @@ - + + + 0.100000000000000 + + @@ -432,7 +436,7 @@ - + Highlight @@ -463,7 +467,7 @@ - + Use Cargo Icon From 72aff88c4674acf2ecdef7faa566b93bce409250 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Wed, 29 Jul 2026 11:17:11 -0500 Subject: [PATCH 3/7] qtfred: Shift+Ctrl+click to make a briefing icon from a ship at the cursor - Shift+Ctrl+click on the briefing map opens the Make Icon From Ship dialog and places the resulting icon at the cursor (plain Ctrl+click still makes a blank icon there). The From-Ship flow is shared with the toolbar button via createIconFromShipDialog(). - Rename the "From Ship..." button to "Make Icon From Ship..." to pair clearly with the adjacent "Make Icon" button. --- qtfred/src/ui/dialogs/BriefingEditorDialog.cpp | 12 +++++++++++- qtfred/src/ui/dialogs/BriefingEditorDialog.h | 3 +++ qtfred/src/ui/widgets/BriefingMapWidget.cpp | 10 ++++++++-- qtfred/src/ui/widgets/BriefingMapWidget.h | 3 ++- qtfred/ui/BriefingEditorDialog.ui | 2 +- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp b/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp index 740fd015aad..a6c4b848264 100644 --- a/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp +++ b/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp @@ -191,6 +191,11 @@ void BriefingEditorDialog::setupMapWidget() updateUi(); }); + // 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); + }); + // Delete key removes the selected icon(s), after a confirmation prompt. connect(_mapWidget, &fso::fred::BriefingMapWidget::deleteSelectedIconsRequested, this, &BriefingEditorDialog::deleteSelectedIconsWithConfirm); @@ -707,6 +712,11 @@ void BriefingEditorDialog::on_makeIconButton_clicked() } void BriefingEditorDialog::on_makeIconFromShipButton_clicked() +{ + createIconFromShipDialog(getNewIconPlacement()); +} + +void BriefingEditorDialog::createIconFromShipDialog(const vec3d& placement) { IconFromShipDialog dlg(this, _model.get()); if (dlg.exec() == QDialog::Accepted) { @@ -718,7 +728,7 @@ void BriefingEditorDialog::on_makeIconFromShipButton_clicked() return; } _model->setLineSelection({_model->getCurrentIconIndex()}); - _model->setIconPosition(getNewIconPlacement()); + _model->setIconPosition(placement); updateUi(); } } diff --git a/qtfred/src/ui/dialogs/BriefingEditorDialog.h b/qtfred/src/ui/dialogs/BriefingEditorDialog.h index 3e94c564b34..675ca0b8abe 100644 --- a/qtfred/src/ui/dialogs/BriefingEditorDialog.h +++ b/qtfred/src/ui/dialogs/BriefingEditorDialog.h @@ -103,6 +103,9 @@ class BriefingEditorDialog : public QDialog, public SexpTreeEditorInterface { 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 "From Ship..." button and Shift+Ctrl+click on the map. + void createIconFromShipDialog(const vec3d& placement); vec3d _resetCameraPos {}; matrix _resetCameraOrient {}; diff --git a/qtfred/src/ui/widgets/BriefingMapWidget.cpp b/qtfred/src/ui/widgets/BriefingMapWidget.cpp index 226fc22f602..a82031a73f6 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.cpp +++ b/qtfred/src/ui/widgets/BriefingMapWidget.cpp @@ -822,9 +822,15 @@ void BriefingMapWidget::mousePressEvent(QMouseEvent* event) { const auto mouseY = (static_cast(event->position().y()) - static_cast(_blitRect.y())) * (static_cast(_lastRenderHeight) / static_cast(_blitRect.height())); - // Ctrl+click: create a new icon at the cursor instead of selecting or dragging. + // 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) { - Q_EMIT iconCreateRequested(worldPosAtMouse(mouseX, mouseY)); + 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; diff --git a/qtfred/src/ui/widgets/BriefingMapWidget.h b/qtfred/src/ui/widgets/BriefingMapWidget.h index 8048978c978..a4d3106c71b 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.h +++ b/qtfred/src/ui/widgets/BriefingMapWidget.h @@ -63,7 +63,8 @@ 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 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) diff --git a/qtfred/ui/BriefingEditorDialog.ui b/qtfred/ui/BriefingEditorDialog.ui index 1c443f85810..e30a8559011 100644 --- a/qtfred/ui/BriefingEditorDialog.ui +++ b/qtfred/ui/BriefingEditorDialog.ui @@ -547,7 +547,7 @@ - From Ship... + Make Icon From Ship... false From 9a72f2c4ed102ddc42d99dd7090c6ca8b5c1d62c Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Wed, 29 Jul 2026 11:32:15 -0500 Subject: [PATCH 4/7] qtfred: right-click context menus on the briefing map Right-click on empty space: - toggle grid rendering - Make Icon / Make Icon From Ship at the cursor position Right-click on an icon (or a multi-selection): - Delete Icon(s) and Icon Coordinates (single-selection only) - checkable Flip Icon / Highlight / Use Cargo Icon / Use Wing Icon that apply to the whole selection - Icon Image / Ship Type / Team submenus built with populateDataListMenu (the same helper the viewport's create-ship menu uses), with the current value bolded Right-clicking an unselected icon selects it first; a member of a multi- selection keeps the group. Hit-testing is shared with mousePressEvent via new iconsUnderReference()/mouseToReference() helpers, and the map-menu creation reuses createIconAt()/createIconFromShipDialog(). --- .../src/ui/dialogs/BriefingEditorDialog.cpp | 114 +++++++++++++++++- qtfred/src/ui/dialogs/BriefingEditorDialog.h | 7 +- qtfred/src/ui/widgets/BriefingMapWidget.cpp | 81 ++++++++++--- qtfred/src/ui/widgets/BriefingMapWidget.h | 8 ++ 4 files changed, 186 insertions(+), 24 deletions(-) diff --git a/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp b/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp index a6c4b848264..d52e0411b07 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,11 @@ #include #include +#include #include #include #include +#include #include #include #include @@ -185,10 +188,7 @@ void BriefingEditorDialog::setupMapWidget() // Ctrl+click on the map creates a new icon at the cursor. connect(_mapWidget, &fso::fred::BriefingMapWidget::iconCreateRequested, this, [this](vec3d worldPos) { - _model->makeIcon("New Icon", 0, 0, 0); - _model->setLineSelection({_model->getCurrentIconIndex()}); - _model->setIconPosition(worldPos); - updateUi(); + createIconAt(worldPos); }); // Shift+Ctrl+click opens Make Icon From Ship and places the resulting icon at the cursor. @@ -196,6 +196,14 @@ void BriefingEditorDialog::setupMapWidget() 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); @@ -704,13 +712,109 @@ void BriefingEditorDialog::on_useCargoIconCheckBox_stateChanged(int state) } 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()); diff --git a/qtfred/src/ui/dialogs/BriefingEditorDialog.h b/qtfred/src/ui/dialogs/BriefingEditorDialog.h index 675ca0b8abe..d84cb81dc08 100644 --- a/qtfred/src/ui/dialogs/BriefingEditorDialog.h +++ b/qtfred/src/ui/dialogs/BriefingEditorDialog.h @@ -104,8 +104,13 @@ class BriefingEditorDialog : public QDialog, public SexpTreeEditorInterface { 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 "From Ship..." button and Shift+Ctrl+click on the map. + // 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 a82031a73f6..242d5d58b6e 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.cpp +++ b/qtfred/src/ui/widgets/BriefingMapWidget.cpp @@ -1,6 +1,7 @@ #include "BriefingMapWidget.h" #include +#include #include #include #include @@ -836,24 +837,7 @@ void BriefingMapWidget::mousePressEvent(QMouseEvent* event) { return; } - 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); - } - } + const auto hits = iconsUnderReference(mouseX, mouseY); const bool shiftHeld = (event->modifiers() & Qt::ShiftModifier) != 0; @@ -1010,4 +994,65 @@ void BriefingMapWidget::mouseReleaseEvent(QMouseEvent* event) { _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) { + 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 a4d3106c71b..4b138ed34fd 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.h +++ b/qtfred/src/ui/widgets/BriefingMapWidget.h @@ -14,6 +14,7 @@ class QOffscreenSurface; class QPainter; class QPaintEvent; +class QContextMenuEvent; class briefing; namespace fso::fred::dialogs { @@ -69,6 +70,8 @@ class BriefingMapWidget : public QWidget { 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; @@ -77,6 +80,7 @@ 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 paintEvent(QPaintEvent* event) override; private: @@ -97,6 +101,10 @@ class BriefingMapWidget : public QWidget { // 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; From 4fcfc2c050519788de3553693cb6b9205d05d217 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Wed, 29 Jul 2026 12:23:21 -0500 Subject: [PATCH 5/7] briefing map camera controls --- qtfred/src/ui/widgets/BriefingMapWidget.cpp | 124 +++++++++++++++++++- qtfred/src/ui/widgets/BriefingMapWidget.h | 15 +++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/qtfred/src/ui/widgets/BriefingMapWidget.cpp b/qtfred/src/ui/widgets/BriefingMapWidget.cpp index 242d5d58b6e..c21b33248fa 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.cpp +++ b/qtfred/src/ui/widgets/BriefingMapWidget.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -763,6 +764,70 @@ void BriefingMapWidget::applyBoundCameraControls(float frametime) { } } +vec3d BriefingMapWidget::orbitPivot() const { + // 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(); @@ -801,7 +866,25 @@ vec3d BriefingMapWidget::worldPosAtMouse(float mouseRefX, float mouseRefY) const } 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(); @@ -903,6 +986,22 @@ void BriefingMapWidget::mouseMoveEvent(QMouseEvent* event) { 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(); @@ -968,7 +1067,21 @@ void BriefingMapWidget::mouseMoveEvent(QMouseEvent* event) { } 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). @@ -1033,6 +1146,13 @@ SCP_vector BriefingMapWidget::iconsUnderReference(float refX, float refY) c } 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)) { diff --git a/qtfred/src/ui/widgets/BriefingMapWidget.h b/qtfred/src/ui/widgets/BriefingMapWidget.h index 4b138ed34fd..741eb02676e 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.h +++ b/qtfred/src/ui/widgets/BriefingMapWidget.h @@ -15,6 +15,7 @@ class QOffscreenSurface; class QPainter; class QPaintEvent; class QContextMenuEvent; +class QWheelEvent; class briefing; namespace fso::fred::dialogs { @@ -81,6 +82,7 @@ class BriefingMapWidget : public QWidget { 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: @@ -98,6 +100,11 @@ class BriefingMapWidget : public QWidget { 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. + vec3d orbitPivot() const; + 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; @@ -147,6 +154,14 @@ class BriefingMapWidget : public QWidget { 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; From 5042be29fbef2e6cc43f85681bca8cf9eeba2deb Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Sat, 1 Aug 2026 11:17:46 -0500 Subject: [PATCH 6/7] qtfred: fix clang-tidy warnings on the briefing map controls - modernize-use-transparent-functors: std::greater() -> std::greater<>() - performance-unnecessary-value-param: take the drag-box index list by const reference in the iconsSelectedInBox handler - readability-convert-member-functions-to-static: orbitPivot() reads only the briefing camera and grid globals, so make it static --- qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp | 2 +- qtfred/src/ui/dialogs/BriefingEditorDialog.cpp | 2 +- qtfred/src/ui/widgets/BriefingMapWidget.cpp | 2 +- qtfred/src/ui/widgets/BriefingMapWidget.h | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp b/qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp index 5a8b00d3804..60601887280 100644 --- a/qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/BriefingEditorDialogModel.cpp @@ -1417,7 +1417,7 @@ void BriefingEditorDialogModel::deleteSelectedIcons() // 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()); + std::sort(indices.begin(), indices.end(), std::greater<>()); indices.erase(std::unique(indices.begin(), indices.end()), indices.end()); bool removedAny = false; diff --git a/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp b/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp index d52e0411b07..8ac126b6035 100644 --- a/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp +++ b/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp @@ -215,7 +215,7 @@ void BriefingEditorDialog::setupMapWidget() }); // Drag-box selection: replace (or, when additive, extend) the selection with the enclosed icons. - connect(_mapWidget, &fso::fred::BriefingMapWidget::iconsSelectedInBox, this, [this](SCP_vector indices, bool additive) { + connect(_mapWidget, &fso::fred::BriefingMapWidget::iconsSelectedInBox, this, [this](const SCP_vector& indices, bool additive) { SCP_vector selection = additive ? _model->getLineSelection() : SCP_vector(); for (int idx : indices) { if (std::find(selection.begin(), selection.end(), idx) == selection.end()) { diff --git a/qtfred/src/ui/widgets/BriefingMapWidget.cpp b/qtfred/src/ui/widgets/BriefingMapWidget.cpp index c21b33248fa..fd02ca92d51 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.cpp +++ b/qtfred/src/ui/widgets/BriefingMapWidget.cpp @@ -764,7 +764,7 @@ void BriefingMapWidget::applyBoundCameraControls(float frametime) { } } -vec3d BriefingMapWidget::orbitPivot() const { +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(); diff --git a/qtfred/src/ui/widgets/BriefingMapWidget.h b/qtfred/src/ui/widgets/BriefingMapWidget.h index 741eb02676e..fbe18f4a051 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.h +++ b/qtfred/src/ui/widgets/BriefingMapWidget.h @@ -102,7 +102,7 @@ class BriefingMapWidget : public QWidget { 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. - vec3d orbitPivot() const; + 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 From ee2aac8a6e7296d1756bfbc6d6703a4abec54e91 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Sat, 1 Aug 2026 12:06:26 -0500 Subject: [PATCH 7/7] qtfred: fix -Wshadow error in the drag-box selection handler The drag-box lambda's loop variable shadowed the layout index declared earlier in setupMapWidget(). Rename it to iconIdx. --- qtfred/src/ui/dialogs/BriefingEditorDialog.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp b/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp index 8ac126b6035..825871c0935 100644 --- a/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp +++ b/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp @@ -217,9 +217,9 @@ void BriefingEditorDialog::setupMapWidget() // 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 idx : indices) { - if (std::find(selection.begin(), selection.end(), idx) == selection.end()) { - selection.push_back(idx); + for (int iconIdx : indices) { + if (std::find(selection.begin(), selection.end(), iconIdx) == selection.end()) { + selection.push_back(iconIdx); } } _model->setLineSelection(selection);