]> git.sesse.net Git - pkanalytics/blobdiff - mainwindow.cpp
Build without QtMultimedia.
[pkanalytics] / mainwindow.cpp
index 5a1b7456cd90e6d701a6e9479d88504fe8353b6c..fa29c954713b8b2e60fe5857a4c7eef61fcb11f0 100644 (file)
@@ -1,8 +1,8 @@
-#include <QMediaPlayer>
 #include <QMainWindow>
 #include <QApplication>
 #include <QGridLayout>
 #include <QShortcut>
+#include <QFileDialog>
 #include <QInputDialog>
 #include <QTimer>
 #include <algorithm>
@@ -35,16 +35,142 @@ string format_timestamp(uint64_t pos)
        return buf;
 }
 
+string get_video_filename(sqlite3 *db, int match_id)
+{
+       sqlite3_stmt *stmt;
+
+       int ret = sqlite3_prepare_v2(db, "SELECT video_filename FROM match WHERE match=?", -1, &stmt, 0);
+       if (ret != SQLITE_OK) {
+               fprintf(stderr, "SELECT prepare: %s\n", sqlite3_errmsg(db));
+               abort();
+       }
+
+       sqlite3_bind_int64(stmt, 1, match_id);
+
+       ret = sqlite3_step(stmt);
+       if (ret != SQLITE_ROW) {
+               fprintf(stderr, "SELECT step: %s\n", sqlite3_errmsg(db));
+               abort();
+       }
+
+       if (sqlite3_column_type(stmt, 0) != SQLITE_TEXT) {
+               return "";
+       }
+       string filename = (const char *)sqlite3_column_text(stmt, 0);
+
+       ret = sqlite3_finalize(stmt);
+       if (ret != SQLITE_OK) {
+               fprintf(stderr, "SELECT finalize: %s\n", sqlite3_errmsg(db));
+               abort();
+       }
+       return filename;
+}
+
+bool get_match_property(sqlite3 *db, int match_id, const string &prop_name)
+{
+       sqlite3_stmt *stmt;
+
+       int ret = sqlite3_prepare_v2(db, ("SELECT " + prop_name + " FROM match WHERE match=?").c_str(), -1, &stmt, 0);
+       if (ret != SQLITE_OK) {
+               fprintf(stderr, "SELECT prepare: %s\n", sqlite3_errmsg(db));
+               abort();
+       }
+
+       sqlite3_bind_int64(stmt, 1, match_id);
+
+       ret = sqlite3_step(stmt);
+       if (ret != SQLITE_ROW) {
+               fprintf(stderr, "SELECT step: %s\n", sqlite3_errmsg(db));
+               abort();
+       }
+
+       if (sqlite3_column_type(stmt, 0) != SQLITE_INTEGER) {
+               return "";
+       }
+       bool value = sqlite3_column_int(stmt, 0);
+
+       ret = sqlite3_finalize(stmt);
+       if (ret != SQLITE_OK) {
+               fprintf(stderr, "SELECT finalize: %s\n", sqlite3_errmsg(db));
+               abort();
+       }
+       return value;
+}
+
+void save_video_filename(sqlite3 *db, int match_id, const string &filename)
+{
+       sqlite3_stmt *stmt;
+
+       int ret = sqlite3_prepare_v2(db, "UPDATE match SET video_filename=? WHERE match=?", -1, &stmt, 0);
+       if (ret != SQLITE_OK) {
+               fprintf(stderr, "SELECT prepare: %s\n", sqlite3_errmsg(db));
+               abort();
+       }
+
+       sqlite3_bind_text(stmt, 1, filename.data(), filename.size(), SQLITE_STATIC);
+       sqlite3_bind_int64(stmt, 2, match_id);
+
+       ret = sqlite3_step(stmt);
+       if (ret == SQLITE_ROW) {
+               fprintf(stderr, "UPDATE step: %s\n", sqlite3_errmsg(db));
+               abort();
+       }
+
+       ret = sqlite3_finalize(stmt);
+       if (ret != SQLITE_OK) {
+               fprintf(stderr, "SELECT finalize: %s\n", sqlite3_errmsg(db));
+               abort();
+       }
+}
+
+void save_match_property(sqlite3 *db, int match_id, const string &prop_name, bool value)
+{
+       sqlite3_stmt *stmt;
+
+       int ret = sqlite3_prepare_v2(db, ("UPDATE match SET " + prop_name + "=? WHERE match=?").c_str(), -1, &stmt, 0);
+       if (ret != SQLITE_OK) {
+               fprintf(stderr, "SELECT prepare: %s\n", sqlite3_errmsg(db));
+               abort();
+       }
+
+       sqlite3_bind_int64(stmt, 1, value);
+       sqlite3_bind_int64(stmt, 2, match_id);
+
+       ret = sqlite3_step(stmt);
+       if (ret == SQLITE_ROW) {
+               fprintf(stderr, "UPDATE step: %s\n", sqlite3_errmsg(db));
+               abort();
+       }
+
+       ret = sqlite3_finalize(stmt);
+       if (ret != SQLITE_OK) {
+               fprintf(stderr, "SELECT finalize: %s\n", sqlite3_errmsg(db));
+               abort();
+       }
+}
+
 MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
-                       FormationsModel *offensive_formations, FormationsModel *defensive_formations)
-       : events(events), players(players), offensive_formations(offensive_formations), defensive_formations(defensive_formations)
+                       FormationsModel *offensive_formations, FormationsModel *defensive_formations,
+                       sqlite3 *db, int match_id)
+       : events(events), players(players), offensive_formations(offensive_formations), defensive_formations(defensive_formations), db(db), match_id(match_id)
 {
        ui = new Ui::MainWindow;
        ui->setupUi(this);
 
-       if (!ui->video->open("/home/sesse/dev/stats/ultimate-prores.mkv")) {
-               // TODO: Pop up a dialog box here instead
-               fprintf(stderr, "WARNING: Video opening failed\n");
+       string filename = get_video_filename(db, match_id);
+       bool need_save_filename = false;
+       for ( ;; ) {
+               if (!filename.empty() && ui->video->open(filename.c_str())) {
+                       break;
+               }
+
+               // TODO: Probably relativize this path, so that we can move the .db
+               // more easily with the videos.
+               filename = QFileDialog::getOpenFileName(this, "Open video").toUtf8();
+               need_save_filename = true;
+       }
+       if (need_save_filename) {
+               save_video_filename(db, match_id, filename);
        }
        ui->video->play();
 
@@ -92,22 +218,22 @@ MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
        // It's not really clear whether PgUp should be forwards or backwards,
        // but mpv does at least up = forwards, so that's probably standard.
        QShortcut *pgdown = new QShortcut(QKeySequence(Qt::Key_PageDown), this);
-       connect(pgdown, &QShortcut::activated, [this]() { ui->video->seek(-120000); });
+       connect(pgdown, &QShortcut::activated, [this] { ui->video->seek(-120000); });
        QShortcut *pgup = new QShortcut(QKeySequence(Qt::Key_PageUp), this);
-       connect(pgup, &QShortcut::activated, [this]() { ui->video->seek(120000); });
+       connect(pgup, &QShortcut::activated, [this] { ui->video->seek(120000); });
 
-       connect(ui->minus10s, &QPushButton::clicked, [this]() { ui->video->seek(-10000); });
-       connect(ui->plus10s, &QPushButton::clicked, [this]() { ui->video->seek(10000); });
+       connect(ui->minus10s, &QPushButton::clicked, [this] { ui->video->seek(-10000); });
+       connect(ui->plus10s, &QPushButton::clicked, [this] { ui->video->seek(10000); });
 
-       connect(ui->minus2s, &QPushButton::clicked, [this]() { ui->video->seek(-2000); });
-       connect(ui->plus2s, &QPushButton::clicked, [this]() { ui->video->seek(2000); });
-       connect(ui->video, &VideoWidget::mouse_back_clicked, [this]() { ui->video->seek(-2000); });
-       connect(ui->video, &VideoWidget::mouse_forward_clicked, [this]() { ui->video->seek(2000); });
+       connect(ui->minus2s, &QPushButton::clicked, [this] { ui->video->seek(-2000); });
+       connect(ui->plus2s, &QPushButton::clicked, [this] { ui->video->seek(2000); });
+       connect(ui->video, &VideoWidget::mouse_back_clicked, [this] { ui->video->seek(-2000); });
+       connect(ui->video, &VideoWidget::mouse_forward_clicked, [this] { ui->video->seek(2000); });
 
-       connect(ui->minus1f, &QPushButton::clicked, [this]() { ui->video->seek_frames(-1); });
-       connect(ui->plus1f, &QPushButton::clicked, [this]() { ui->video->seek_frames(1); });
+       connect(ui->minus1f, &QPushButton::clicked, [this] { ui->video->seek_frames(-1); });
+       connect(ui->plus1f, &QPushButton::clicked, [this] { ui->video->seek_frames(1); });
 
-       connect(ui->play_pause, &QPushButton::clicked, [this]() {
+       connect(ui->play_pause, &QPushButton::clicked, [this] {
                if (playing) {
                        ui->video->pause();
                        ui->play_pause->setText("Play (space)");
@@ -121,18 +247,18 @@ MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
                ui->play_pause->setShortcut(QCoreApplication::translate("MainWindow", "Space", nullptr));
        });
 
-       connect(ui->player_1, &QPushButton::clicked, [this]() { insert_player_event(0); });
-       connect(ui->player_2, &QPushButton::clicked, [this]() { insert_player_event(1); });
-       connect(ui->player_3, &QPushButton::clicked, [this]() { insert_player_event(2); });
-       connect(ui->player_4, &QPushButton::clicked, [this]() { insert_player_event(3); });
-       connect(ui->player_5, &QPushButton::clicked, [this]() { insert_player_event(4); });
-       connect(ui->player_6, &QPushButton::clicked, [this]() { insert_player_event(5); });
-       connect(ui->player_7, &QPushButton::clicked, [this]() { insert_player_event(6); });
+       connect(ui->player_1, &QPushButton::clicked, [this] { insert_player_event(0); });
+       connect(ui->player_2, &QPushButton::clicked, [this] { insert_player_event(1); });
+       connect(ui->player_3, &QPushButton::clicked, [this] { insert_player_event(2); });
+       connect(ui->player_4, &QPushButton::clicked, [this] { insert_player_event(3); });
+       connect(ui->player_5, &QPushButton::clicked, [this] { insert_player_event(4); });
+       connect(ui->player_6, &QPushButton::clicked, [this] { insert_player_event(5); });
+       connect(ui->player_7, &QPushButton::clicked, [this] { insert_player_event(6); });
 
        // Offensive events
-       connect(ui->offense_label, &ClickableLabel::clicked, [this]() { insert_noplayer_event("set_offense"); });
-       connect(ui->catch_, &QPushButton::clicked, [this]() { set_current_event_type("catch"); });
-       connect(ui->throwaway, &QPushButton::clicked, [this, events]() {
+       connect(ui->offense_label, &ClickableLabel::clicked, [this] { insert_noplayer_event("set_offense"); });
+       connect(ui->catch_, &QPushButton::clicked, [this] { set_current_event_type("catch"); });
+       connect(ui->throwaway, &QPushButton::clicked, [this, events] {
                EventsModel::Status s = events->get_status_at(ui->video->get_position());
                if (s.attack_state == EventsModel::Status::DEFENSE && s.pull_state == EventsModel::Status::PULL_IN_AIR) {
                        insert_noplayer_event("pull_oob");
@@ -140,13 +266,29 @@ MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
                        set_current_event_type("throwaway");
                }
        });
-       connect(ui->drop, &QPushButton::clicked, [this]() { set_current_event_type("drop"); });
-       connect(ui->goal, &QPushButton::clicked, [this]() { set_current_event_type("goal"); });
-       connect(ui->offensive_soft_plus, &QPushButton::clicked, [this]() { set_current_event_type("offensive_soft_plus"); });
-       connect(ui->offensive_soft_minus, &QPushButton::clicked, [this]() { set_current_event_type("offensive_soft_minus"); });
-       connect(ui->pull_or_was_d, &QPushButton::clicked, [this, events]() {
+       connect(ui->drop, &QPushButton::clicked, [this] { set_current_event_type("drop"); });
+       connect(ui->goal, &QPushButton::clicked, [this] { set_current_event_type("goal"); });
+       connect(ui->stallout, &QPushButton::clicked, [this] { set_current_event_type("stallout"); });
+       connect(ui->soft_plus, &QPushButton::clicked, [this, events] {
                EventsModel::Status s = events->get_status_at(ui->video->get_position());
-               if (s.pull_state == EventsModel::Status::SHOULD_PULL) {
+               if (s.attack_state == EventsModel::Status::OFFENSE) {
+                       set_current_event_type("offensive_soft_plus");
+               } else if (s.attack_state == EventsModel::Status::DEFENSE) {
+                       set_current_event_type("defensive_soft_plus");
+               }
+       });
+       connect(ui->soft_minus, &QPushButton::clicked, [this, events] {
+               EventsModel::Status s = events->get_status_at(ui->video->get_position());
+               if (s.attack_state == EventsModel::Status::OFFENSE) {
+                       set_current_event_type("offensive_soft_minus");
+               } else if (s.attack_state == EventsModel::Status::DEFENSE) {
+                       set_current_event_type("defensive_soft_minus");
+               }
+       });
+       connect(ui->pull_or_was_d, &QPushButton::clicked, [this, events] {
+               EventsModel::Status s = events->get_status_at(ui->video->get_position());
+               if (s.pull_state == EventsModel::Status::SHOULD_PULL ||
+                   events->get_status_at(ui->video->get_position() - 1).pull_state == EventsModel::Status::SHOULD_PULL) {
                        set_current_event_type("pull");
                } else if (s.pull_state == EventsModel::Status::PULL_IN_AIR) {
                        insert_noplayer_event("pull_landed");
@@ -155,27 +297,25 @@ MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
                }
        });
 
-       // Defensive events (TODO add more)
-       connect(ui->interception, &QPushButton::clicked, [this]() { set_current_event_type("interception"); });
-       connect(ui->defense_label, &ClickableLabel::clicked, [this]() { insert_noplayer_event("set_defense"); });
-       connect(ui->their_throwaway, &QPushButton::clicked, [this]() { insert_noplayer_event("their_throwaway"); });
-       connect(ui->their_goal, &QPushButton::clicked, [this]() { insert_noplayer_event("their_goal"); });
-       connect(ui->their_pull, &QPushButton::clicked, [this, events]() {
+       // Defensive events.
+       connect(ui->interception, &QPushButton::clicked, [this] { set_current_event_type("interception"); });
+       connect(ui->defense_label, &ClickableLabel::clicked, [this] { insert_noplayer_event("set_defense"); });
+       connect(ui->their_throwaway, &QPushButton::clicked, [this] { insert_noplayer_event("their_throwaway"); });
+       connect(ui->their_goal, &QPushButton::clicked, [this] { insert_noplayer_event("their_goal"); });
+       connect(ui->their_pull, &QPushButton::clicked, [this, events] {
                EventsModel::Status s = events->get_status_at(ui->video->get_position());
                if (s.pull_state == EventsModel::Status::SHOULD_PULL) {
                        insert_noplayer_event("their_pull");
                }
        });
-       connect(ui->our_defense, &QPushButton::clicked, [this]() { set_current_event_type("defense"); });
-       connect(ui->defensive_soft_plus, &QPushButton::clicked, [this]() { set_current_event_type("defensive_soft_plus"); });
-       connect(ui->defensive_soft_minus, &QPushButton::clicked, [this]() { set_current_event_type("defensive_soft_minus"); });
+       connect(ui->our_defense, &QPushButton::clicked, [this] { set_current_event_type("defense"); });
 
-       connect(ui->offensive_formation, &QPushButton::clicked, [this]() { insert_or_change_formation(/*offense=*/true); });
-       connect(ui->defensive_formation, &QPushButton::clicked, [this]() { insert_or_change_formation(/*offense=*/false); });
+       connect(ui->offensive_formation, &QPushButton::clicked, [this] { insert_or_change_formation(/*offense=*/true); });
+       connect(ui->defensive_formation, &QPushButton::clicked, [this] { insert_or_change_formation(/*offense=*/false); });
 
        // Misc. events
-       connect(ui->substitution, &QPushButton::clicked, [this]() { make_substitution(); });
-       connect(ui->stoppage, &QPushButton::clicked, [this, events]() {
+       connect(ui->substitution, &QPushButton::clicked, [this] { make_substitution(); });
+       connect(ui->stoppage, &QPushButton::clicked, [this, events] {
                EventsModel::Status s = events->get_status_at(ui->video->get_position());
                if (s.stoppage) {
                        insert_noplayer_event("restart");
@@ -183,11 +323,48 @@ MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
                        insert_noplayer_event("stoppage");
                }
        });
-       connect(ui->unknown, &QPushButton::clicked, [this]() { insert_noplayer_event("unknown"); });
+       connect(ui->unknown, &QPushButton::clicked, [this] { insert_noplayer_event("unknown"); });
 
        QShortcut *key_delete = new QShortcut(QKeySequence(Qt::Key_Delete), this);
-       connect(key_delete, &QShortcut::activated, [this]() { ui->delete_->animateClick(); });
-       connect(ui->delete_, &QPushButton::clicked, [this]() { delete_current_event(); });
+       connect(key_delete, &QShortcut::activated, [this] { ui->delete_->animateClick(); });
+       connect(ui->delete_, &QPushButton::clicked, [this] { delete_current_event(); });
+
+       // Player list shortcuts.
+       connect(ui->get_current_players, &QPushButton::clicked, [this, players, events] {
+               uint64_t t = ui->video->get_position();
+               QItemSelection selection;
+               set<int> team = events->get_team_at(t);
+               for (int row = 0; row < players->rowCount(QModelIndex()); ++row) {
+                       if (team.count(players->get_player_id(row))) {
+                               selection.select(players->get_row_start_qt(row), players->get_row_end_qt(row));
+                       }
+               }
+               ui->player_view->selectionModel()->select(selection, QItemSelectionModel::ClearAndSelect);
+       });
+       connect(ui->clear_player_list, &QPushButton::clicked, [this] {
+               ui->player_view->selectionModel()->clear();
+       });
+       connect(ui->player_view->selectionModel(), &QItemSelectionModel::selectionChanged, [this] {
+               update_gender_ratio(ui->video->get_position());
+       });
+       update_gender_ratio(0);
+
+       // The shortcuts take up so much space that we really need, so we sacrifice the header.
+       ui->player_view->horizontalHeader()->hide();
+
+       // Menus.
+       connect(ui->action_exit, &QAction::triggered, [this] { close(); });
+       connect(ui->action_export_json, &QAction::triggered, [db] { export_to_json(db, "ultimate.json"); });
+
+       ui->action_gender_rule_a->setChecked(get_match_property(db, match_id, "gender_rule_a"));
+       ui->action_gender_pull_rule->setChecked(get_match_property(db, match_id, "gender_pull_rule"));
+       connect(ui->action_gender_rule_a, &QAction::toggled, [this, db, match_id] {
+               save_match_property(db, match_id, "gender_rule_a", ui->action_gender_rule_a->isChecked());
+       });
+       connect(ui->action_gender_pull_rule, &QAction::toggled, [this, db, match_id] {
+               save_match_property(db, match_id, "gender_pull_rule", ui->action_gender_pull_rule->isChecked());
+               update_gender_ratio(ui->video->get_position());
+       });
 }
 
 void MainWindow::position_changed(uint64_t pos)
@@ -268,7 +445,7 @@ void MainWindow::insert_or_change_formation(bool offense)
        QItemSelectionModel *select = ui->event_view->selectionModel();
        if (select->hasSelection()) {
                int row = select->selectedRows().front().row();  // Should only be one, due to our selection behavior.
-               string expected_type = offense ? "formation_offense" : "formation_defense";
+               EventType expected_type = offense ? EventType::FORMATION_OFFENSE : EventType::FORMATION_DEFENSE;
                if (events->get_event_type(row) == expected_type) {
                        events->set_event_formation(row, formation_id);
                        update_ui_from_time(ui->video->get_position());
@@ -310,6 +487,7 @@ void MainWindow::update_ui_from_time(uint64_t t)
        update_status(t);
        update_player_buttons(t);
        update_action_buttons(t);
+       update_gender_ratio(t);
 }
 
 void MainWindow::update_status(uint64_t t)
@@ -334,7 +512,7 @@ void MainWindow::update_status(uint64_t t)
        snprintf(buf, sizeof(buf), "%d–%d | %s | %d passes, %d sec possession",
                s.our_score, s.their_score, formation.c_str(), s.num_passes, s.possession_sec);
        if (s.stoppage_sec > 0) {
-               char buf2[256];
+               char buf2[512];
                snprintf(buf2, sizeof(buf2), "%s (plus %d sec stoppage)", buf, s.stoppage_sec);
                ui->status->setText(buf2);
        } else {
@@ -415,15 +593,14 @@ void MainWindow::update_action_buttons(uint64_t t)
                ui->throwaway->setEnabled(false);
                ui->drop->setEnabled(false);
                ui->goal->setEnabled(false);
-               ui->offensive_soft_plus->setEnabled(false);
-               ui->offensive_soft_minus->setEnabled(false);
+               ui->stallout->setEnabled(false);
+               ui->soft_plus->setEnabled(false);
+               ui->soft_minus->setEnabled(false);
                ui->pull_or_was_d->setEnabled(false);
                ui->interception->setEnabled(false);
                ui->their_throwaway->setEnabled(false);
                ui->our_defense->setEnabled(false);
                ui->their_goal->setEnabled(false);
-               ui->defensive_soft_plus->setEnabled(false);
-               ui->defensive_soft_minus->setEnabled(false);
                ui->their_pull->setEnabled(false);
                return;
        } else {
@@ -439,7 +616,8 @@ void MainWindow::update_action_buttons(uint64_t t)
        ui->throwaway->setText("Throwaway (&t)");
        ui->throwaway->setShortcut(QCoreApplication::translate("MainWindow", "T", nullptr));
 
-       if (s.pull_state == EventsModel::Status::SHOULD_PULL) {
+       if (s.pull_state == EventsModel::Status::SHOULD_PULL ||
+           (has_selection_with_player && events->get_status_at(ui->video->get_position() - 1).pull_state == EventsModel::Status::SHOULD_PULL)) {  // Can change this event to pull.
                ui->pull_or_was_d->setEnabled(s.attack_state == EventsModel::Status::DEFENSE && has_selection_with_player);
                ui->their_pull->setEnabled(s.attack_state == EventsModel::Status::OFFENSE);
 
@@ -447,14 +625,13 @@ void MainWindow::update_action_buttons(uint64_t t)
                ui->throwaway->setEnabled(false);
                ui->drop->setEnabled(false);
                ui->goal->setEnabled(false);
-               ui->offensive_soft_plus->setEnabled(false);
-               ui->offensive_soft_minus->setEnabled(false);
+               ui->stallout->setEnabled(false);
+               ui->soft_plus->setEnabled(false);
+               ui->soft_minus->setEnabled(false);
                ui->interception->setEnabled(false);
                ui->their_throwaway->setEnabled(false);
                ui->our_defense->setEnabled(false);
                ui->their_goal->setEnabled(false);
-               ui->defensive_soft_plus->setEnabled(false);
-               ui->defensive_soft_minus->setEnabled(false);
                return;
        }
        if (s.pull_state == EventsModel::Status::PULL_IN_AIR) {
@@ -475,14 +652,13 @@ void MainWindow::update_action_buttons(uint64_t t)
                ui->catch_->setEnabled(false);
                ui->drop->setEnabled(false);
                ui->goal->setEnabled(false);
-               ui->offensive_soft_plus->setEnabled(false);
-               ui->offensive_soft_minus->setEnabled(false);
+               ui->stallout->setEnabled(false);
+               ui->soft_plus->setEnabled(false);
+               ui->soft_minus->setEnabled(false);
                ui->interception->setEnabled(false);
                ui->their_throwaway->setEnabled(false);
                ui->our_defense->setEnabled(false);
                ui->their_goal->setEnabled(false);
-               ui->defensive_soft_plus->setEnabled(false);
-               ui->defensive_soft_minus->setEnabled(false);
                return;
        }
 
@@ -495,19 +671,179 @@ void MainWindow::update_action_buttons(uint64_t t)
        ui->throwaway->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
        ui->drop->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
        ui->goal->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
-       ui->offensive_soft_plus->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
-       ui->offensive_soft_minus->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
+       ui->stallout->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
+       ui->soft_plus->setEnabled(s.attack_state != EventsModel::Status::NOT_STARTED && has_selection_with_player);
+       ui->soft_minus->setEnabled(s.attack_state != EventsModel::Status::NOT_STARTED && has_selection_with_player);
        ui->pull_or_was_d->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);  // Was d-ed.
 
        ui->interception->setEnabled(s.attack_state == EventsModel::Status::DEFENSE && has_selection_with_player);
        ui->their_throwaway->setEnabled(s.attack_state == EventsModel::Status::DEFENSE);
        ui->our_defense->setEnabled(s.attack_state == EventsModel::Status::DEFENSE && has_selection_with_player);
        ui->their_goal->setEnabled(s.attack_state == EventsModel::Status::DEFENSE);
-       ui->defensive_soft_plus->setEnabled(s.attack_state == EventsModel::Status::DEFENSE && has_selection_with_player);
-       ui->defensive_soft_minus->setEnabled(s.attack_state == EventsModel::Status::DEFENSE && has_selection_with_player);
        ui->their_pull->setEnabled(false);
 }
 
+vector<pair<string, int>> sort_gender(const map<string, int> &gender_count)
+{
+       vector<pair<string, int>> sorted_gender;
+       for (const auto &[gender, count] : gender_count) {
+               sorted_gender.emplace_back(gender, count);
+       }
+       sort(sorted_gender.begin(), sorted_gender.end(), [](const pair<string, int> &a, const pair<string, int> &b) {
+               if (a.second != b.second) {
+                       return b.second < a.second;
+               }
+               return a.first < b.first;
+       });
+       return sorted_gender;
+}
+
+string format_gender_counts(const map<string, int> &gender_count)
+{
+       vector<pair<string, int>> sorted_gender = sort_gender(gender_count);
+
+       string str;
+       for (const auto &[gender, count] : sorted_gender) {
+               if (!str.empty()) {
+                       str += ", ";
+               }
+               char buf[256];
+               snprintf(buf, sizeof(buf), "%d ", count);
+               str += buf;
+               str += gender;
+       }
+       return str;
+}
+
+string format_gender_counts(const map<string, int> &gender_count, const map<string, int> &reference_gender_count)
+{
+       vector<pair<string, int>> sorted_gender = sort_gender(reference_gender_count);  // Less swapping around this way.
+
+       string str;
+       for (const auto &[gender, count] : sorted_gender) {
+               if (!str.empty()) {
+                       str += ", ";
+               }
+               char buf[256];
+               snprintf(buf, sizeof(buf), "%d/%d ", gender_count.find(gender)->second, count);
+               str += buf;
+               str += gender;
+       }
+       return str;
+}
+
+void MainWindow::update_gender_ratio(uint64_t t)
+{
+       string str;
+       bool ok = true;
+
+       // Count the gender ratio in the current selection.
+       map<string, int> gender_count;
+       for (int i = 0; i < players->rowCount(QModelIndex()); ++i) {
+               string gender = players->get_player_gender(i);
+               gender_count[gender] = 0;
+       }
+       int num_players = 0;
+       QItemSelectionModel *select = ui->player_view->selectionModel();
+       for (QModelIndex row : select->selectedRows()) {
+               string gender = players->get_player_gender(row.row());
+               ++gender_count[gender];
+               ++num_players;
+       }
+
+       const bool gender_rule_a = ui->action_gender_rule_a->isChecked();
+       if (gender_rule_a) {
+               // This is tricky. We don't want to hard-code assumptions about gender,
+               // since there are so many possible variations (e.g. 5 players for indoors,
+               // or loose mixed). We can't get everything right, but our general
+               // strategy will be:
+               //
+               //  - We assume ABBA pattern is followed throughout, ie., we switch
+               //    on odd-numbered points. We use goals as reference for points.
+               //  - We always use the two or three latest points as reference;
+               //    this means an issue with the first point won't persist forever.
+               //  - When we don't switch, we expect the identical number as last time.
+               //  - When we _do_ switch, we copy the variation from two points ago
+               //    if we have it; if not, we simply expect it to be different.
+               //  - We should always have the same number of people as last point.
+               //
+               // The viewer's warnings also checks that things are correct across
+               // stoppages, which we don't yet.
+               map<string, int> current_gender_count;
+               for (int i = 0; i < players->rowCount(QModelIndex()); ++i) {
+                       string gender = players->get_player_gender(i);
+                       current_gender_count[gender] = 0;
+               }
+               vector<map<string, int>> historical_gender_counts;
+               for (int row = 0; row < events->rowCount(QModelIndex()); ++row) {
+                       if (events->get_time(row) > t) {
+                               break;
+                       }
+                       EventType type = events->get_event_type(row);
+                       if (type == EventType::GOAL || type == EventType::THEIR_GOAL) {
+                               historical_gender_counts.push_back(current_gender_count);
+                       } else if (type == EventType::IN) {
+                               string gender = players->get_player_gender_by_id(*events->get_player_id(row));
+                               ++current_gender_count[gender];
+                       } else if (type == EventType::OUT) {
+                               string gender = players->get_player_gender_by_id(*events->get_player_id(row));
+                               --current_gender_count[gender];
+                       }
+               }
+
+               ok = true;
+               if (historical_gender_counts.empty()) {
+                       // We don't have any points yet. Just output the ratio.
+                       str = format_gender_counts(gender_count);
+               } else if (historical_gender_counts.size() == 1) {
+                       // We have one, so this one must be different, but we don't know what it must be.
+                       // It must have the same number of players, though.
+                       str = format_gender_counts(gender_count);
+                       ok = (gender_count != historical_gender_counts.back());
+                       int old_sum = 0, new_sum = 0;
+                       for (const auto &[gender, count] : historical_gender_counts.back()) {
+                               old_sum += count;
+                       }
+                       for (const auto &[gender, count] : gender_count) {
+                               new_sum += count;
+                       }
+                       if (old_sum != new_sum) {
+                               ok = false;
+                       }
+               } else if (historical_gender_counts.size() % 2 == 0) {
+                       // Must be same as previous.
+                       str = format_gender_counts(gender_count, historical_gender_counts.back());
+                       ok = (gender_count == historical_gender_counts.back());
+               } else {
+                       // Must be same as two points ago.
+                       const auto &ref = historical_gender_counts[historical_gender_counts.size() - 3];
+                       str = format_gender_counts(gender_count, ref);
+                       ok = (gender_count == ref);
+               }
+       } else if (gender_count.size() == 1) {
+               // Everybody is either of the same gender or nobody has gender noted,
+               // so just count the number of players. We don't make red here.
+               char buf[256];
+               snprintf(buf, sizeof(buf), "%d selected", num_players);
+               str = buf;
+       } else {
+               // We don't have gender rule A, but we have gender counts,
+               // so show that. We don't make red here.
+               string str = format_gender_counts(gender_count);
+       }
+
+       // Seemingly this setting this every frame is very costly, so we diff.
+       if (QString::fromUtf8(str) != ui->selected_gender_ratio->text()) {
+               ui->selected_gender_ratio->setText(QString::fromUtf8(str));
+       }
+       bool current_ok = ui->selected_gender_ratio->styleSheet().isEmpty();
+       if (ok && !current_ok) {
+               ui->selected_gender_ratio->setStyleSheet("");
+       } else if (!ok && current_ok) {
+               ui->selected_gender_ratio->setStyleSheet("QLabel { color: red }");
+       }
+}
+
 void MainWindow::formation_double_clicked(bool offense, unsigned row)
 {
        FormationsModel *formations = offense ? offensive_formations : defensive_formations;