]> git.sesse.net Git - pkanalytics/blob - mainwindow.cpp
Build without QtMultimedia.
[pkanalytics] / mainwindow.cpp
1 #include <QMainWindow>
2 #include <QApplication>
3 #include <QGridLayout>
4 #include <QShortcut>
5 #include <QFileDialog>
6 #include <QInputDialog>
7 #include <QTimer>
8 #include <algorithm>
9 #include <string>
10 #include <map>
11 #include <vector>
12 #include <optional>
13 #include <sqlite3.h>
14 #include "mainwindow.h"
15 #include "ui_mainwindow.h"
16 #include "events.h"
17 #include "players.h"
18 #include "formations.h"
19 #include "json.h"
20 #include "video_widget.h"
21
22 using namespace std;
23
24 string format_timestamp(uint64_t pos)
25 {
26         int ms = pos % 1000;
27         pos /= 1000;
28         int sec = pos % 60;
29         pos /= 60;
30         int min = pos % 60;
31         int hour = pos / 60;
32
33         char buf[256];
34         snprintf(buf, sizeof(buf), "%d:%02d:%02d.%03d", hour, min, sec, ms);
35         return buf;
36 }
37
38 string get_video_filename(sqlite3 *db, int match_id)
39 {
40         sqlite3_stmt *stmt;
41
42         int ret = sqlite3_prepare_v2(db, "SELECT video_filename FROM match WHERE match=?", -1, &stmt, 0);
43         if (ret != SQLITE_OK) {
44                 fprintf(stderr, "SELECT prepare: %s\n", sqlite3_errmsg(db));
45                 abort();
46         }
47
48         sqlite3_bind_int64(stmt, 1, match_id);
49
50         ret = sqlite3_step(stmt);
51         if (ret != SQLITE_ROW) {
52                 fprintf(stderr, "SELECT step: %s\n", sqlite3_errmsg(db));
53                 abort();
54         }
55
56         if (sqlite3_column_type(stmt, 0) != SQLITE_TEXT) {
57                 return "";
58         }
59         string filename = (const char *)sqlite3_column_text(stmt, 0);
60
61         ret = sqlite3_finalize(stmt);
62         if (ret != SQLITE_OK) {
63                 fprintf(stderr, "SELECT finalize: %s\n", sqlite3_errmsg(db));
64                 abort();
65         }
66         return filename;
67 }
68
69 bool get_match_property(sqlite3 *db, int match_id, const string &prop_name)
70 {
71         sqlite3_stmt *stmt;
72
73         int ret = sqlite3_prepare_v2(db, ("SELECT " + prop_name + " FROM match WHERE match=?").c_str(), -1, &stmt, 0);
74         if (ret != SQLITE_OK) {
75                 fprintf(stderr, "SELECT prepare: %s\n", sqlite3_errmsg(db));
76                 abort();
77         }
78
79         sqlite3_bind_int64(stmt, 1, match_id);
80
81         ret = sqlite3_step(stmt);
82         if (ret != SQLITE_ROW) {
83                 fprintf(stderr, "SELECT step: %s\n", sqlite3_errmsg(db));
84                 abort();
85         }
86
87         if (sqlite3_column_type(stmt, 0) != SQLITE_INTEGER) {
88                 return "";
89         }
90         bool value = sqlite3_column_int(stmt, 0);
91
92         ret = sqlite3_finalize(stmt);
93         if (ret != SQLITE_OK) {
94                 fprintf(stderr, "SELECT finalize: %s\n", sqlite3_errmsg(db));
95                 abort();
96         }
97         return value;
98 }
99
100 void save_video_filename(sqlite3 *db, int match_id, const string &filename)
101 {
102         sqlite3_stmt *stmt;
103
104         int ret = sqlite3_prepare_v2(db, "UPDATE match SET video_filename=? WHERE match=?", -1, &stmt, 0);
105         if (ret != SQLITE_OK) {
106                 fprintf(stderr, "SELECT prepare: %s\n", sqlite3_errmsg(db));
107                 abort();
108         }
109
110         sqlite3_bind_text(stmt, 1, filename.data(), filename.size(), SQLITE_STATIC);
111         sqlite3_bind_int64(stmt, 2, match_id);
112
113         ret = sqlite3_step(stmt);
114         if (ret == SQLITE_ROW) {
115                 fprintf(stderr, "UPDATE step: %s\n", sqlite3_errmsg(db));
116                 abort();
117         }
118
119         ret = sqlite3_finalize(stmt);
120         if (ret != SQLITE_OK) {
121                 fprintf(stderr, "SELECT finalize: %s\n", sqlite3_errmsg(db));
122                 abort();
123         }
124 }
125
126 void save_match_property(sqlite3 *db, int match_id, const string &prop_name, bool value)
127 {
128         sqlite3_stmt *stmt;
129
130         int ret = sqlite3_prepare_v2(db, ("UPDATE match SET " + prop_name + "=? WHERE match=?").c_str(), -1, &stmt, 0);
131         if (ret != SQLITE_OK) {
132                 fprintf(stderr, "SELECT prepare: %s\n", sqlite3_errmsg(db));
133                 abort();
134         }
135
136         sqlite3_bind_int64(stmt, 1, value);
137         sqlite3_bind_int64(stmt, 2, match_id);
138
139         ret = sqlite3_step(stmt);
140         if (ret == SQLITE_ROW) {
141                 fprintf(stderr, "UPDATE step: %s\n", sqlite3_errmsg(db));
142                 abort();
143         }
144
145         ret = sqlite3_finalize(stmt);
146         if (ret != SQLITE_OK) {
147                 fprintf(stderr, "SELECT finalize: %s\n", sqlite3_errmsg(db));
148                 abort();
149         }
150 }
151
152 MainWindow::MainWindow(EventsModel *events, PlayersModel *players,
153                        FormationsModel *offensive_formations, FormationsModel *defensive_formations,
154                        sqlite3 *db, int match_id)
155         : events(events), players(players), offensive_formations(offensive_formations), defensive_formations(defensive_formations), db(db), match_id(match_id)
156 {
157         ui = new Ui::MainWindow;
158         ui->setupUi(this);
159
160         string filename = get_video_filename(db, match_id);
161         bool need_save_filename = false;
162         for ( ;; ) {
163                 if (!filename.empty() && ui->video->open(filename.c_str())) {
164                         break;
165                 }
166
167                 // TODO: Probably relativize this path, so that we can move the .db
168                 // more easily with the videos.
169                 filename = QFileDialog::getOpenFileName(this, "Open video").toUtf8();
170                 need_save_filename = true;
171         }
172         if (need_save_filename) {
173                 save_video_filename(db, match_id, filename);
174         }
175         ui->video->play();
176
177         ui->event_view->setModel(events);
178         ui->event_view->setColumnWidth(1, 150);
179         ui->event_view->setColumnWidth(2, 150);
180         connect(ui->event_view->selectionModel(), &QItemSelectionModel::currentRowChanged,
181                 [this, events](const QModelIndex &current, const QModelIndex &previous) {
182                         uint64_t t = events->get_time(current.row());
183                         if (t != ui->video->get_position()) {
184                                 ui->video->seek_absolute(events->get_time(current.row()));
185                         } else {
186                                 // Selection could have changed, so we still need to update.
187                                 // (Just calling setPosition() would not give us the signal
188                                 // in this case.)
189                                 update_ui_from_time(t);
190                         }
191                 });
192
193         ui->player_view->setModel(players);
194         ui->player_view->setColumnWidth(0, 30);
195         ui->player_view->setColumnWidth(1, 20);
196         ui->player_view->horizontalHeader()->setStretchLastSection(true);
197
198         auto formation_changed = [this](const QModelIndex &current, const QModelIndex &previous) {
199                 QTimer::singleShot(1, [=]{  // The selection is wrong until the callback actually returns.
200                         update_action_buttons(ui->video->get_position());
201                 });
202         };
203         ui->offensive_formation_view->setModel(offensive_formations);
204         ui->defensive_formation_view->setModel(defensive_formations);
205         connect(ui->offensive_formation_view->selectionModel(), &QItemSelectionModel::currentRowChanged, formation_changed);
206         connect(ui->defensive_formation_view->selectionModel(), &QItemSelectionModel::currentRowChanged, formation_changed);
207         connect(ui->offensive_formation_view, &QListView::doubleClicked, [this](const QModelIndex &index) {
208                 formation_double_clicked(true, index.row());
209         });
210         connect(ui->defensive_formation_view, &QListView::doubleClicked, [this](const QModelIndex &index) {
211                 formation_double_clicked(false, index.row());
212         });
213
214         connect(ui->video, &VideoWidget::position_changed, [this](uint64_t pos) {
215                 position_changed(pos);
216         });
217
218         // It's not really clear whether PgUp should be forwards or backwards,
219         // but mpv does at least up = forwards, so that's probably standard.
220         QShortcut *pgdown = new QShortcut(QKeySequence(Qt::Key_PageDown), this);
221         connect(pgdown, &QShortcut::activated, [this] { ui->video->seek(-120000); });
222         QShortcut *pgup = new QShortcut(QKeySequence(Qt::Key_PageUp), this);
223         connect(pgup, &QShortcut::activated, [this] { ui->video->seek(120000); });
224
225         connect(ui->minus10s, &QPushButton::clicked, [this] { ui->video->seek(-10000); });
226         connect(ui->plus10s, &QPushButton::clicked, [this] { ui->video->seek(10000); });
227
228         connect(ui->minus2s, &QPushButton::clicked, [this] { ui->video->seek(-2000); });
229         connect(ui->plus2s, &QPushButton::clicked, [this] { ui->video->seek(2000); });
230         connect(ui->video, &VideoWidget::mouse_back_clicked, [this] { ui->video->seek(-2000); });
231         connect(ui->video, &VideoWidget::mouse_forward_clicked, [this] { ui->video->seek(2000); });
232
233         connect(ui->minus1f, &QPushButton::clicked, [this] { ui->video->seek_frames(-1); });
234         connect(ui->plus1f, &QPushButton::clicked, [this] { ui->video->seek_frames(1); });
235
236         connect(ui->play_pause, &QPushButton::clicked, [this] {
237                 if (playing) {
238                         ui->video->pause();
239                         ui->play_pause->setText("Play (space)");
240                 } else {
241                         ui->video->play();
242                         ui->play_pause->setText("Pause (space)");
243                 }
244                 playing = !playing;
245
246                 // Needs to be set anew when we modify setText(), evidently.
247                 ui->play_pause->setShortcut(QCoreApplication::translate("MainWindow", "Space", nullptr));
248         });
249
250         connect(ui->player_1, &QPushButton::clicked, [this] { insert_player_event(0); });
251         connect(ui->player_2, &QPushButton::clicked, [this] { insert_player_event(1); });
252         connect(ui->player_3, &QPushButton::clicked, [this] { insert_player_event(2); });
253         connect(ui->player_4, &QPushButton::clicked, [this] { insert_player_event(3); });
254         connect(ui->player_5, &QPushButton::clicked, [this] { insert_player_event(4); });
255         connect(ui->player_6, &QPushButton::clicked, [this] { insert_player_event(5); });
256         connect(ui->player_7, &QPushButton::clicked, [this] { insert_player_event(6); });
257
258         // Offensive events
259         connect(ui->offense_label, &ClickableLabel::clicked, [this] { insert_noplayer_event("set_offense"); });
260         connect(ui->catch_, &QPushButton::clicked, [this] { set_current_event_type("catch"); });
261         connect(ui->throwaway, &QPushButton::clicked, [this, events] {
262                 EventsModel::Status s = events->get_status_at(ui->video->get_position());
263                 if (s.attack_state == EventsModel::Status::DEFENSE && s.pull_state == EventsModel::Status::PULL_IN_AIR) {
264                         insert_noplayer_event("pull_oob");
265                 } else {
266                         set_current_event_type("throwaway");
267                 }
268         });
269         connect(ui->drop, &QPushButton::clicked, [this] { set_current_event_type("drop"); });
270         connect(ui->goal, &QPushButton::clicked, [this] { set_current_event_type("goal"); });
271         connect(ui->stallout, &QPushButton::clicked, [this] { set_current_event_type("stallout"); });
272         connect(ui->soft_plus, &QPushButton::clicked, [this, events] {
273                 EventsModel::Status s = events->get_status_at(ui->video->get_position());
274                 if (s.attack_state == EventsModel::Status::OFFENSE) {
275                         set_current_event_type("offensive_soft_plus");
276                 } else if (s.attack_state == EventsModel::Status::DEFENSE) {
277                         set_current_event_type("defensive_soft_plus");
278                 }
279         });
280         connect(ui->soft_minus, &QPushButton::clicked, [this, events] {
281                 EventsModel::Status s = events->get_status_at(ui->video->get_position());
282                 if (s.attack_state == EventsModel::Status::OFFENSE) {
283                         set_current_event_type("offensive_soft_minus");
284                 } else if (s.attack_state == EventsModel::Status::DEFENSE) {
285                         set_current_event_type("defensive_soft_minus");
286                 }
287         });
288         connect(ui->pull_or_was_d, &QPushButton::clicked, [this, events] {
289                 EventsModel::Status s = events->get_status_at(ui->video->get_position());
290                 if (s.pull_state == EventsModel::Status::SHOULD_PULL ||
291                     events->get_status_at(ui->video->get_position() - 1).pull_state == EventsModel::Status::SHOULD_PULL) {
292                         set_current_event_type("pull");
293                 } else if (s.pull_state == EventsModel::Status::PULL_IN_AIR) {
294                         insert_noplayer_event("pull_landed");
295                 } else if (s.pull_state == EventsModel::Status::NOT_PULLING) {
296                         set_current_event_type("was_d");
297                 }
298         });
299
300         // Defensive events.
301         connect(ui->interception, &QPushButton::clicked, [this] { set_current_event_type("interception"); });
302         connect(ui->defense_label, &ClickableLabel::clicked, [this] { insert_noplayer_event("set_defense"); });
303         connect(ui->their_throwaway, &QPushButton::clicked, [this] { insert_noplayer_event("their_throwaway"); });
304         connect(ui->their_goal, &QPushButton::clicked, [this] { insert_noplayer_event("their_goal"); });
305         connect(ui->their_pull, &QPushButton::clicked, [this, events] {
306                 EventsModel::Status s = events->get_status_at(ui->video->get_position());
307                 if (s.pull_state == EventsModel::Status::SHOULD_PULL) {
308                         insert_noplayer_event("their_pull");
309                 }
310         });
311         connect(ui->our_defense, &QPushButton::clicked, [this] { set_current_event_type("defense"); });
312
313         connect(ui->offensive_formation, &QPushButton::clicked, [this] { insert_or_change_formation(/*offense=*/true); });
314         connect(ui->defensive_formation, &QPushButton::clicked, [this] { insert_or_change_formation(/*offense=*/false); });
315
316         // Misc. events
317         connect(ui->substitution, &QPushButton::clicked, [this] { make_substitution(); });
318         connect(ui->stoppage, &QPushButton::clicked, [this, events] {
319                 EventsModel::Status s = events->get_status_at(ui->video->get_position());
320                 if (s.stoppage) {
321                         insert_noplayer_event("restart");
322                 } else {
323                         insert_noplayer_event("stoppage");
324                 }
325         });
326         connect(ui->unknown, &QPushButton::clicked, [this] { insert_noplayer_event("unknown"); });
327
328         QShortcut *key_delete = new QShortcut(QKeySequence(Qt::Key_Delete), this);
329         connect(key_delete, &QShortcut::activated, [this] { ui->delete_->animateClick(); });
330         connect(ui->delete_, &QPushButton::clicked, [this] { delete_current_event(); });
331
332         // Player list shortcuts.
333         connect(ui->get_current_players, &QPushButton::clicked, [this, players, events] {
334                 uint64_t t = ui->video->get_position();
335                 QItemSelection selection;
336                 set<int> team = events->get_team_at(t);
337                 for (int row = 0; row < players->rowCount(QModelIndex()); ++row) {
338                         if (team.count(players->get_player_id(row))) {
339                                 selection.select(players->get_row_start_qt(row), players->get_row_end_qt(row));
340                         }
341                 }
342                 ui->player_view->selectionModel()->select(selection, QItemSelectionModel::ClearAndSelect);
343         });
344         connect(ui->clear_player_list, &QPushButton::clicked, [this] {
345                 ui->player_view->selectionModel()->clear();
346         });
347         connect(ui->player_view->selectionModel(), &QItemSelectionModel::selectionChanged, [this] {
348                 update_gender_ratio(ui->video->get_position());
349         });
350         update_gender_ratio(0);
351
352         // The shortcuts take up so much space that we really need, so we sacrifice the header.
353         ui->player_view->horizontalHeader()->hide();
354
355         // Menus.
356         connect(ui->action_exit, &QAction::triggered, [this] { close(); });
357         connect(ui->action_export_json, &QAction::triggered, [db] { export_to_json(db, "ultimate.json"); });
358
359         ui->action_gender_rule_a->setChecked(get_match_property(db, match_id, "gender_rule_a"));
360         ui->action_gender_pull_rule->setChecked(get_match_property(db, match_id, "gender_pull_rule"));
361         connect(ui->action_gender_rule_a, &QAction::toggled, [this, db, match_id] {
362                 save_match_property(db, match_id, "gender_rule_a", ui->action_gender_rule_a->isChecked());
363         });
364         connect(ui->action_gender_pull_rule, &QAction::toggled, [this, db, match_id] {
365                 save_match_property(db, match_id, "gender_pull_rule", ui->action_gender_pull_rule->isChecked());
366                 update_gender_ratio(ui->video->get_position());
367         });
368 }
369
370 void MainWindow::position_changed(uint64_t pos)
371 {
372         ui->timestamp->setText(QString::fromUtf8(format_timestamp(pos)));
373         if (!playing) {
374                 ui->video->pause();  // We only played to get a picture.
375         }
376         if (playing) {
377                 QModelIndex row = events->get_last_event_qt(ui->video->get_position());
378                 ui->event_view->scrollTo(row, QAbstractItemView::PositionAtCenter);
379         }
380         update_ui_from_time(pos);
381 }
382
383 void MainWindow::insert_player_event(int button_id)
384 {
385         uint64_t t = ui->video->get_position();
386         vector<int> team = events->sort_team(events->get_team_at(t));
387         if (unsigned(button_id) >= team.size()) {
388                 return;
389         }
390         int player_id = team[button_id];
391
392         EventsModel::Status s = events->get_status_at(t);
393
394         ui->event_view->selectionModel()->blockSignals(true);
395         if (s.attack_state == EventsModel::Status::OFFENSE) {
396                 // TODO: Perhaps not if that player already did the last catch?
397                 ui->event_view->selectRow(events->insert_event(t, player_id, nullopt, "catch"));
398         } else {
399                 ui->event_view->selectRow(events->insert_event(t, player_id, nullopt));
400         }
401         ui->event_view->selectionModel()->blockSignals(false);
402
403         update_ui_from_time(t);
404 }
405
406 void MainWindow::insert_noplayer_event(const string &type)
407 {
408         uint64_t t = ui->video->get_position();
409
410         ui->event_view->selectionModel()->blockSignals(true);
411         ui->event_view->selectRow(events->insert_event(t, nullopt, nullopt, type));
412         ui->event_view->selectionModel()->blockSignals(false);
413
414         update_ui_from_time(t);
415 }
416
417 void MainWindow::set_current_event_type(const string &type)
418 {
419         QItemSelectionModel *select = ui->event_view->selectionModel();
420         if (!select->hasSelection()) {
421                 return;
422         }
423         int row = select->selectedRows().front().row();  // Should only be one, due to our selection behavior.
424         events->set_event_type(row, type);
425         update_ui_from_time(ui->video->get_position());
426 }
427
428 // Formation buttons either modify the existing formation (if we've selected
429 // a formation change event), or insert a new one (if not).
430 void MainWindow::insert_or_change_formation(bool offense)
431 {
432         FormationsModel *formations = offense ? offensive_formations : defensive_formations;
433         QListView *formation_view = offense ? ui->offensive_formation_view : ui->defensive_formation_view;
434         if (!formation_view->selectionModel()->hasSelection()) {
435                 // This shouldn't happen; the button should not have been enabled.
436                 return;
437         }
438         int formation_row = formation_view->selectionModel()->selectedRows().front().row();  // Should only be one, due to our selection behavior.
439         int formation_id = formations->get_formation_id(formation_row);
440         if (formation_id == -1) {
441                 // This also shouldn't happen (“Add new…” selected).
442                 return;
443         }
444
445         QItemSelectionModel *select = ui->event_view->selectionModel();
446         if (select->hasSelection()) {
447                 int row = select->selectedRows().front().row();  // Should only be one, due to our selection behavior.
448                 EventType expected_type = offense ? EventType::FORMATION_OFFENSE : EventType::FORMATION_DEFENSE;
449                 if (events->get_event_type(row) == expected_type) {
450                         events->set_event_formation(row, formation_id);
451                         update_ui_from_time(ui->video->get_position());
452                         return;
453                 }
454         }
455
456         // Insert a new formation event instead (same as double-click on the selected one).
457         events->set_formation_at(ui->video->get_position(), offense, formation_id);
458         update_ui_from_time(ui->video->get_position());
459 }
460
461 void MainWindow::delete_current_event()
462 {
463         QItemSelectionModel *select = ui->event_view->selectionModel();
464         if (!select->hasSelection()) {
465                 return;
466         }
467         int row = select->selectedRows().front().row();  // Should only be one, due to our selection behavior.
468         ui->event_view->selectionModel()->blockSignals(true);
469         events->delete_event(row);
470         ui->event_view->selectionModel()->blockSignals(false);
471         update_ui_from_time(ui->video->get_position());
472 }
473
474 void MainWindow::make_substitution()
475 {
476         QItemSelectionModel *select = ui->player_view->selectionModel();
477         set<int> new_team;
478         for (QModelIndex row : select->selectedRows()) {
479                 new_team.insert(players->get_player_id(row.row()));
480         }
481         events->set_team_at(ui->video->get_position(), new_team);
482         update_player_buttons(ui->video->get_position());
483 }
484
485 void MainWindow::update_ui_from_time(uint64_t t)
486 {
487         update_status(t);
488         update_player_buttons(t);
489         update_action_buttons(t);
490         update_gender_ratio(t);
491 }
492
493 void MainWindow::update_status(uint64_t t)
494 {
495         EventsModel::Status s = events->get_status_at(t);
496         char buf[256];
497         std::string formation = "Not started";
498         if (s.attack_state == EventsModel::Status::OFFENSE) {
499                 if (s.offensive_formation != 0) {
500                         formation = offensive_formations->get_formation_name_by_id(s.offensive_formation);
501                 } else {
502                         formation = "Offense";
503                 }
504         } else if (s.attack_state == EventsModel::Status::DEFENSE) {
505                 if (s.defensive_formation != 0) {
506                         formation = defensive_formations->get_formation_name_by_id(s.defensive_formation);
507                 } else {
508                         formation = "Defense";
509                 }
510         }
511
512         snprintf(buf, sizeof(buf), "%d–%d | %s | %d passes, %d sec possession",
513                 s.our_score, s.their_score, formation.c_str(), s.num_passes, s.possession_sec);
514         if (s.stoppage_sec > 0) {
515                 char buf2[512];
516                 snprintf(buf2, sizeof(buf2), "%s (plus %d sec stoppage)", buf, s.stoppage_sec);
517                 ui->status->setText(buf2);
518         } else {
519                 ui->status->setText(buf);
520         }
521 }
522
523 void MainWindow::update_player_buttons(uint64_t t)
524 {
525         QPushButton *buttons[] = {
526                 ui->player_1,
527                 ui->player_2,
528                 ui->player_3,
529                 ui->player_4,
530                 ui->player_5,
531                 ui->player_6,
532                 ui->player_7
533         };
534         const char shortcuts[] = "qweasdf";
535         int num_players = 0;
536         for (int player_id : events->sort_team(events->get_team_at(t))) {
537                 QPushButton *btn = buttons[num_players];
538                 string label = players->get_player_name_by_id(player_id) + " (&" + shortcuts[num_players] + ")";
539                 char shortcut[2] = "";
540                 shortcut[0] = toupper(shortcuts[num_players]);
541                 btn->setText(QString::fromUtf8(label));
542                 btn->setShortcut(QCoreApplication::translate("MainWindow", shortcut, nullptr));
543                 btn->setEnabled(true);
544                 if (++num_players == 7) {
545                         break;
546                 }
547         }
548         for (int i = num_players; i < 7; ++i) {
549                 QPushButton *btn = buttons[i];
550                 btn->setText("No player");
551                 btn->setEnabled(false);
552         }
553 }
554
555 void MainWindow::update_action_buttons(uint64_t t)
556 {
557         {
558                 QItemSelectionModel *select = ui->offensive_formation_view->selectionModel();
559                 if (select->hasSelection()) {
560                         int row = select->selectedRows().front().row();  // Should only be one, due to our selection behavior.
561                         ui->offensive_formation->setEnabled(offensive_formations->get_formation_id(row) != -1);
562                 } else {
563                         ui->offensive_formation->setEnabled(false);
564                 }
565         }
566         {
567                 QItemSelectionModel *select = ui->defensive_formation_view->selectionModel();
568                 if (select->hasSelection()) {
569                         int row = select->selectedRows().front().row();  // Should only be one, due to our selection behavior.
570                         ui->defensive_formation->setEnabled(defensive_formations->get_formation_id(row) != -1);
571                 } else {
572                         ui->defensive_formation->setEnabled(false);
573                 }
574         }
575
576         EventsModel::Status s = events->get_status_at(t);
577
578         bool has_selection = false;
579         bool has_selection_with_player = false;
580
581         QItemSelectionModel *select = ui->event_view->selectionModel();
582         if (select->hasSelection()) {
583                 has_selection = true;
584                 int row = select->selectedRows().front().row();  // Should only be one, due to our selection behavior.
585                 has_selection_with_player = events->get_player_id(row).has_value();
586         }
587         ui->delete_->setEnabled(has_selection);
588
589         if (s.stoppage) {
590                 ui->stoppage->setText("Restart (&v)");
591                 ui->stoppage->setShortcut(QCoreApplication::translate("MainWindow", "V", nullptr));
592                 ui->catch_->setEnabled(false);
593                 ui->throwaway->setEnabled(false);
594                 ui->drop->setEnabled(false);
595                 ui->goal->setEnabled(false);
596                 ui->stallout->setEnabled(false);
597                 ui->soft_plus->setEnabled(false);
598                 ui->soft_minus->setEnabled(false);
599                 ui->pull_or_was_d->setEnabled(false);
600                 ui->interception->setEnabled(false);
601                 ui->their_throwaway->setEnabled(false);
602                 ui->our_defense->setEnabled(false);
603                 ui->their_goal->setEnabled(false);
604                 ui->their_pull->setEnabled(false);
605                 return;
606         } else {
607                 ui->stoppage->setText("Stoppage (&v)");
608                 ui->stoppage->setShortcut(QCoreApplication::translate("MainWindow", "V", nullptr));
609         }
610
611         // Defaults for pull-related buttons.
612         ui->pull_or_was_d->setText("Pull (&p)");
613         ui->their_pull->setText("Their pull (&p)");
614         ui->pull_or_was_d->setShortcut(QCoreApplication::translate("MainWindow", "P", nullptr));
615         ui->their_pull->setShortcut(QCoreApplication::translate("MainWindow", "P", nullptr));
616         ui->throwaway->setText("Throwaway (&t)");
617         ui->throwaway->setShortcut(QCoreApplication::translate("MainWindow", "T", nullptr));
618
619         if (s.pull_state == EventsModel::Status::SHOULD_PULL ||
620             (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.
621                 ui->pull_or_was_d->setEnabled(s.attack_state == EventsModel::Status::DEFENSE && has_selection_with_player);
622                 ui->their_pull->setEnabled(s.attack_state == EventsModel::Status::OFFENSE);
623
624                 ui->catch_->setEnabled(false);
625                 ui->throwaway->setEnabled(false);
626                 ui->drop->setEnabled(false);
627                 ui->goal->setEnabled(false);
628                 ui->stallout->setEnabled(false);
629                 ui->soft_plus->setEnabled(false);
630                 ui->soft_minus->setEnabled(false);
631                 ui->interception->setEnabled(false);
632                 ui->their_throwaway->setEnabled(false);
633                 ui->our_defense->setEnabled(false);
634                 ui->their_goal->setEnabled(false);
635                 return;
636         }
637         if (s.pull_state == EventsModel::Status::PULL_IN_AIR) {
638                 if (s.attack_state == EventsModel::Status::DEFENSE) {
639                         ui->pull_or_was_d->setText("Pull landed (&p)");
640                         ui->pull_or_was_d->setShortcut(QCoreApplication::translate("MainWindow", "P", nullptr));
641                         ui->pull_or_was_d->setEnabled(true);
642
643                         ui->throwaway->setText("Pull OOB (&t)");
644                         ui->throwaway->setShortcut(QCoreApplication::translate("MainWindow", "T", nullptr));
645                         ui->throwaway->setEnabled(true);
646                 } else {
647                         ui->pull_or_was_d->setEnabled(false);
648                         ui->throwaway->setEnabled(false);
649                 }
650                 ui->their_pull->setEnabled(false);  // We don't track their pull landings; only by means of catch etc.
651
652                 ui->catch_->setEnabled(false);
653                 ui->drop->setEnabled(false);
654                 ui->goal->setEnabled(false);
655                 ui->stallout->setEnabled(false);
656                 ui->soft_plus->setEnabled(false);
657                 ui->soft_minus->setEnabled(false);
658                 ui->interception->setEnabled(false);
659                 ui->their_throwaway->setEnabled(false);
660                 ui->our_defense->setEnabled(false);
661                 ui->their_goal->setEnabled(false);
662                 return;
663         }
664
665         // Not pulling, so reuse the pull button for got d-ed.
666         ui->pull_or_was_d->setText("Was d-ed (&z)");
667         ui->pull_or_was_d->setShortcut(QCoreApplication::translate("MainWindow", "Z", nullptr));
668         ui->pull_or_was_d->setEnabled(true);
669
670         ui->catch_->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
671         ui->throwaway->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
672         ui->drop->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
673         ui->goal->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
674         ui->stallout->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
675         ui->soft_plus->setEnabled(s.attack_state != EventsModel::Status::NOT_STARTED && has_selection_with_player);
676         ui->soft_minus->setEnabled(s.attack_state != EventsModel::Status::NOT_STARTED && has_selection_with_player);
677         ui->pull_or_was_d->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);  // Was d-ed.
678
679         ui->interception->setEnabled(s.attack_state == EventsModel::Status::DEFENSE && has_selection_with_player);
680         ui->their_throwaway->setEnabled(s.attack_state == EventsModel::Status::DEFENSE);
681         ui->our_defense->setEnabled(s.attack_state == EventsModel::Status::DEFENSE && has_selection_with_player);
682         ui->their_goal->setEnabled(s.attack_state == EventsModel::Status::DEFENSE);
683         ui->their_pull->setEnabled(false);
684 }
685
686 vector<pair<string, int>> sort_gender(const map<string, int> &gender_count)
687 {
688         vector<pair<string, int>> sorted_gender;
689         for (const auto &[gender, count] : gender_count) {
690                 sorted_gender.emplace_back(gender, count);
691         }
692         sort(sorted_gender.begin(), sorted_gender.end(), [](const pair<string, int> &a, const pair<string, int> &b) {
693                 if (a.second != b.second) {
694                         return b.second < a.second;
695                 }
696                 return a.first < b.first;
697         });
698         return sorted_gender;
699 }
700
701 string format_gender_counts(const map<string, int> &gender_count)
702 {
703         vector<pair<string, int>> sorted_gender = sort_gender(gender_count);
704
705         string str;
706         for (const auto &[gender, count] : sorted_gender) {
707                 if (!str.empty()) {
708                         str += ", ";
709                 }
710                 char buf[256];
711                 snprintf(buf, sizeof(buf), "%d ", count);
712                 str += buf;
713                 str += gender;
714         }
715         return str;
716 }
717
718 string format_gender_counts(const map<string, int> &gender_count, const map<string, int> &reference_gender_count)
719 {
720         vector<pair<string, int>> sorted_gender = sort_gender(reference_gender_count);  // Less swapping around this way.
721
722         string str;
723         for (const auto &[gender, count] : sorted_gender) {
724                 if (!str.empty()) {
725                         str += ", ";
726                 }
727                 char buf[256];
728                 snprintf(buf, sizeof(buf), "%d/%d ", gender_count.find(gender)->second, count);
729                 str += buf;
730                 str += gender;
731         }
732         return str;
733 }
734
735 void MainWindow::update_gender_ratio(uint64_t t)
736 {
737         string str;
738         bool ok = true;
739
740         // Count the gender ratio in the current selection.
741         map<string, int> gender_count;
742         for (int i = 0; i < players->rowCount(QModelIndex()); ++i) {
743                 string gender = players->get_player_gender(i);
744                 gender_count[gender] = 0;
745         }
746         int num_players = 0;
747         QItemSelectionModel *select = ui->player_view->selectionModel();
748         for (QModelIndex row : select->selectedRows()) {
749                 string gender = players->get_player_gender(row.row());
750                 ++gender_count[gender];
751                 ++num_players;
752         }
753
754         const bool gender_rule_a = ui->action_gender_rule_a->isChecked();
755         if (gender_rule_a) {
756                 // This is tricky. We don't want to hard-code assumptions about gender,
757                 // since there are so many possible variations (e.g. 5 players for indoors,
758                 // or loose mixed). We can't get everything right, but our general
759                 // strategy will be:
760                 //
761                 //  - We assume ABBA pattern is followed throughout, ie., we switch
762                 //    on odd-numbered points. We use goals as reference for points.
763                 //  - We always use the two or three latest points as reference;
764                 //    this means an issue with the first point won't persist forever.
765                 //  - When we don't switch, we expect the identical number as last time.
766                 //  - When we _do_ switch, we copy the variation from two points ago
767                 //    if we have it; if not, we simply expect it to be different.
768                 //  - We should always have the same number of people as last point.
769                 //
770                 // The viewer's warnings also checks that things are correct across
771                 // stoppages, which we don't yet.
772                 map<string, int> current_gender_count;
773                 for (int i = 0; i < players->rowCount(QModelIndex()); ++i) {
774                         string gender = players->get_player_gender(i);
775                         current_gender_count[gender] = 0;
776                 }
777                 vector<map<string, int>> historical_gender_counts;
778                 for (int row = 0; row < events->rowCount(QModelIndex()); ++row) {
779                         if (events->get_time(row) > t) {
780                                 break;
781                         }
782                         EventType type = events->get_event_type(row);
783                         if (type == EventType::GOAL || type == EventType::THEIR_GOAL) {
784                                 historical_gender_counts.push_back(current_gender_count);
785                         } else if (type == EventType::IN) {
786                                 string gender = players->get_player_gender_by_id(*events->get_player_id(row));
787                                 ++current_gender_count[gender];
788                         } else if (type == EventType::OUT) {
789                                 string gender = players->get_player_gender_by_id(*events->get_player_id(row));
790                                 --current_gender_count[gender];
791                         }
792                 }
793
794                 ok = true;
795                 if (historical_gender_counts.empty()) {
796                         // We don't have any points yet. Just output the ratio.
797                         str = format_gender_counts(gender_count);
798                 } else if (historical_gender_counts.size() == 1) {
799                         // We have one, so this one must be different, but we don't know what it must be.
800                         // It must have the same number of players, though.
801                         str = format_gender_counts(gender_count);
802                         ok = (gender_count != historical_gender_counts.back());
803                         int old_sum = 0, new_sum = 0;
804                         for (const auto &[gender, count] : historical_gender_counts.back()) {
805                                 old_sum += count;
806                         }
807                         for (const auto &[gender, count] : gender_count) {
808                                 new_sum += count;
809                         }
810                         if (old_sum != new_sum) {
811                                 ok = false;
812                         }
813                 } else if (historical_gender_counts.size() % 2 == 0) {
814                         // Must be same as previous.
815                         str = format_gender_counts(gender_count, historical_gender_counts.back());
816                         ok = (gender_count == historical_gender_counts.back());
817                 } else {
818                         // Must be same as two points ago.
819                         const auto &ref = historical_gender_counts[historical_gender_counts.size() - 3];
820                         str = format_gender_counts(gender_count, ref);
821                         ok = (gender_count == ref);
822                 }
823         } else if (gender_count.size() == 1) {
824                 // Everybody is either of the same gender or nobody has gender noted,
825                 // so just count the number of players. We don't make red here.
826                 char buf[256];
827                 snprintf(buf, sizeof(buf), "%d selected", num_players);
828                 str = buf;
829         } else {
830                 // We don't have gender rule A, but we have gender counts,
831                 // so show that. We don't make red here.
832                 string str = format_gender_counts(gender_count);
833         }
834
835         // Seemingly this setting this every frame is very costly, so we diff.
836         if (QString::fromUtf8(str) != ui->selected_gender_ratio->text()) {
837                 ui->selected_gender_ratio->setText(QString::fromUtf8(str));
838         }
839         bool current_ok = ui->selected_gender_ratio->styleSheet().isEmpty();
840         if (ok && !current_ok) {
841                 ui->selected_gender_ratio->setStyleSheet("");
842         } else if (!ok && current_ok) {
843                 ui->selected_gender_ratio->setStyleSheet("QLabel { color: red }");
844         }
845 }
846
847 void MainWindow::formation_double_clicked(bool offense, unsigned row)
848 {
849         FormationsModel *formations = offense ? offensive_formations : defensive_formations;
850         int id = formations->get_formation_id(row);
851         if (id == -1) {  // “Add new” clicked.
852                 bool ok;
853                 QString new_formation_str = QInputDialog::getText(this, "New formation", "Choose name for new formation:", QLineEdit::Normal, "", &ok);
854                 if (!ok || new_formation_str.isEmpty()) {
855                         return;
856                 }
857
858                 id = formations->insert_new(new_formation_str.toStdString());
859                 QListView *view = offense ? ui->offensive_formation_view : ui->defensive_formation_view;
860                 view->selectionModel()->select(formations->index(formations->get_row_from_id(id), 0), QItemSelectionModel::ClearAndSelect);
861                 events->inserted_new_formation(id, new_formation_str.toStdString());
862         } else {
863                 events->set_formation_at(ui->video->get_position(), offense, id);
864         }
865         update_ui_from_time(ui->video->get_position());
866 }
867