]> git.sesse.net Git - pkanalytics/blob - mainwindow.cpp
Make the throwaway behavior more sane.
[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                         insert_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 void MainWindow::insert_throwaway()
429 {
430         uint64_t t = ui->video->get_position();
431
432         QItemSelectionModel *select = ui->event_view->selectionModel();
433         if (select->hasSelection()) {
434                 int row = select->selectedRows().front().row();  // Should only be one, due to our selection behavior.
435                 // We could want to modify this catch event into a throwaway. See if that is the case.
436                 int last_catching_player = events->get_status_at(events->get_time(row) - 1).last_catching_player;
437                 if (last_catching_player != -1 && last_catching_player == events->get_player_id(row)) {
438                         // Last event was that this player caught the disc, so yes, make this a throwaway.
439                         events->set_event_type(row, "throwaway");
440                         update_ui_from_time(t);
441                         return;
442                 }
443                 // It doesn't make sense that the player throws it away without holding the disc first,
444                 // so we insert a new event where the person holding the disc throws it away.
445                 // (In other words, fall back to inserting a new one based on time.)
446         }
447
448         int last_catching_player = events->get_status_at(t - 1).last_catching_player;
449         if (last_catching_player == -1) {
450                 return;
451         }
452
453         ui->event_view->selectionModel()->blockSignals(true);
454         ui->event_view->selectRow(events->insert_event(t, last_catching_player, nullopt, "throwaway"));
455         ui->event_view->selectionModel()->blockSignals(false);
456 }
457
458 // Formation buttons either modify the existing formation (if we've selected
459 // a formation change event), or insert a new one (if not).
460 void MainWindow::insert_or_change_formation(bool offense)
461 {
462         FormationsModel *formations = offense ? offensive_formations : defensive_formations;
463         QListView *formation_view = offense ? ui->offensive_formation_view : ui->defensive_formation_view;
464         if (!formation_view->selectionModel()->hasSelection()) {
465                 // This shouldn't happen; the button should not have been enabled.
466                 return;
467         }
468         int formation_row = formation_view->selectionModel()->selectedRows().front().row();  // Should only be one, due to our selection behavior.
469         int formation_id = formations->get_formation_id(formation_row);
470         if (formation_id == -1) {
471                 // This also shouldn't happen (“Add new…” selected).
472                 return;
473         }
474
475         QItemSelectionModel *select = ui->event_view->selectionModel();
476         if (select->hasSelection()) {
477                 int row = select->selectedRows().front().row();  // Should only be one, due to our selection behavior.
478                 EventType expected_type = offense ? EventType::FORMATION_OFFENSE : EventType::FORMATION_DEFENSE;
479                 if (events->get_event_type(row) == expected_type) {
480                         events->set_event_formation(row, formation_id);
481                         update_ui_from_time(ui->video->get_position());
482                         return;
483                 }
484         }
485
486         // Insert a new formation event instead (same as double-click on the selected one).
487         events->set_formation_at(ui->video->get_position(), offense, formation_id);
488         update_ui_from_time(ui->video->get_position());
489 }
490
491 void MainWindow::delete_current_event()
492 {
493         QItemSelectionModel *select = ui->event_view->selectionModel();
494         if (!select->hasSelection()) {
495                 return;
496         }
497         int row = select->selectedRows().front().row();  // Should only be one, due to our selection behavior.
498         ui->event_view->selectionModel()->blockSignals(true);
499         events->delete_event(row);
500         ui->event_view->selectionModel()->blockSignals(false);
501         update_ui_from_time(ui->video->get_position());
502 }
503
504 void MainWindow::make_substitution()
505 {
506         QItemSelectionModel *select = ui->player_view->selectionModel();
507         set<int> new_team;
508         for (QModelIndex row : select->selectedRows()) {
509                 new_team.insert(players->get_player_id(row.row()));
510         }
511         events->set_team_at(ui->video->get_position(), new_team);
512         update_player_buttons(ui->video->get_position());
513 }
514
515 void MainWindow::update_ui_from_time(uint64_t t)
516 {
517         update_status(t);
518         update_player_buttons(t);
519         update_action_buttons(t);
520         update_gender_ratio(t);
521 }
522
523 void MainWindow::update_status(uint64_t t)
524 {
525         EventsModel::Status s = events->get_status_at(t);
526         char buf[256];
527         std::string formation = "Not started";
528         if (s.attack_state == EventsModel::Status::OFFENSE) {
529                 if (s.offensive_formation != 0) {
530                         formation = offensive_formations->get_formation_name_by_id(s.offensive_formation);
531                 } else {
532                         formation = "Offense";
533                 }
534         } else if (s.attack_state == EventsModel::Status::DEFENSE) {
535                 if (s.defensive_formation != 0) {
536                         formation = defensive_formations->get_formation_name_by_id(s.defensive_formation);
537                 } else {
538                         formation = "Defense";
539                 }
540         }
541
542         snprintf(buf, sizeof(buf), "%d–%d | %s | %d passes, %d sec possession",
543                 s.our_score, s.their_score, formation.c_str(), s.num_passes, s.possession_sec);
544         if (s.stoppage_sec > 0) {
545                 char buf2[512];
546                 snprintf(buf2, sizeof(buf2), "%s (plus %d sec stoppage)", buf, s.stoppage_sec);
547                 ui->status->setText(buf2);
548         } else {
549                 ui->status->setText(buf);
550         }
551 }
552
553 void MainWindow::update_player_buttons(uint64_t t)
554 {
555         QPushButton *buttons[] = {
556                 ui->player_1,
557                 ui->player_2,
558                 ui->player_3,
559                 ui->player_4,
560                 ui->player_5,
561                 ui->player_6,
562                 ui->player_7
563         };
564         const char shortcuts[] = "qweasdf";
565         int num_players = 0;
566         for (int player_id : events->sort_team(events->get_team_at(t))) {
567                 QPushButton *btn = buttons[num_players];
568                 string label = players->get_player_name_by_id(player_id) + " (&" + shortcuts[num_players] + ")";
569                 char shortcut[2] = "";
570                 shortcut[0] = toupper(shortcuts[num_players]);
571                 btn->setText(QString::fromUtf8(label));
572                 btn->setShortcut(QCoreApplication::translate("MainWindow", shortcut, nullptr));
573                 btn->setEnabled(true);
574                 if (++num_players == 7) {
575                         break;
576                 }
577         }
578         for (int i = num_players; i < 7; ++i) {
579                 QPushButton *btn = buttons[i];
580                 btn->setText("No player");
581                 btn->setEnabled(false);
582         }
583 }
584
585 void MainWindow::update_action_buttons(uint64_t t)
586 {
587         {
588                 QItemSelectionModel *select = ui->offensive_formation_view->selectionModel();
589                 if (select->hasSelection()) {
590                         int row = select->selectedRows().front().row();  // Should only be one, due to our selection behavior.
591                         ui->offensive_formation->setEnabled(offensive_formations->get_formation_id(row) != -1);
592                 } else {
593                         ui->offensive_formation->setEnabled(false);
594                 }
595         }
596         {
597                 QItemSelectionModel *select = ui->defensive_formation_view->selectionModel();
598                 if (select->hasSelection()) {
599                         int row = select->selectedRows().front().row();  // Should only be one, due to our selection behavior.
600                         ui->defensive_formation->setEnabled(defensive_formations->get_formation_id(row) != -1);
601                 } else {
602                         ui->defensive_formation->setEnabled(false);
603                 }
604         }
605
606         EventsModel::Status s = events->get_status_at(t);
607
608         bool has_selection = false;
609         bool has_selection_with_player = false;
610
611         QItemSelectionModel *select = ui->event_view->selectionModel();
612         if (select->hasSelection()) {
613                 has_selection = true;
614                 int row = select->selectedRows().front().row();  // Should only be one, due to our selection behavior.
615                 has_selection_with_player = events->get_player_id(row).has_value();
616         }
617         ui->delete_->setEnabled(has_selection);
618
619         if (s.stoppage) {
620                 ui->stoppage->setText("Restart (&v)");
621                 ui->stoppage->setShortcut(QCoreApplication::translate("MainWindow", "V", nullptr));
622                 ui->catch_->setEnabled(false);
623                 ui->throwaway->setEnabled(false);
624                 ui->drop->setEnabled(false);
625                 ui->goal->setEnabled(false);
626                 ui->stallout->setEnabled(false);
627                 ui->soft_plus->setEnabled(false);
628                 ui->soft_minus->setEnabled(false);
629                 ui->pull_or_was_d->setEnabled(false);
630                 ui->interception->setEnabled(false);
631                 ui->their_throwaway->setEnabled(false);
632                 ui->our_defense->setEnabled(false);
633                 ui->their_goal->setEnabled(false);
634                 ui->their_pull->setEnabled(false);
635                 return;
636         } else {
637                 ui->stoppage->setText("Stoppage (&v)");
638                 ui->stoppage->setShortcut(QCoreApplication::translate("MainWindow", "V", nullptr));
639         }
640
641         // Defaults for pull-related buttons.
642         ui->pull_or_was_d->setText("Pull (&p)");
643         ui->their_pull->setText("Their pull (&p)");
644         ui->pull_or_was_d->setShortcut(QCoreApplication::translate("MainWindow", "P", nullptr));
645         ui->their_pull->setShortcut(QCoreApplication::translate("MainWindow", "P", nullptr));
646         ui->throwaway->setText("Throwaway (&t)");
647         ui->throwaway->setShortcut(QCoreApplication::translate("MainWindow", "T", nullptr));
648
649         if (s.pull_state == EventsModel::Status::SHOULD_PULL ||
650             (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.
651                 ui->pull_or_was_d->setEnabled(s.attack_state == EventsModel::Status::DEFENSE && has_selection_with_player);
652                 ui->their_pull->setEnabled(s.attack_state == EventsModel::Status::OFFENSE);
653
654                 ui->catch_->setEnabled(false);
655                 ui->throwaway->setEnabled(false);
656                 ui->drop->setEnabled(false);
657                 ui->goal->setEnabled(false);
658                 ui->stallout->setEnabled(false);
659                 ui->soft_plus->setEnabled(false);
660                 ui->soft_minus->setEnabled(false);
661                 ui->interception->setEnabled(false);
662                 ui->their_throwaway->setEnabled(false);
663                 ui->our_defense->setEnabled(false);
664                 ui->their_goal->setEnabled(false);
665                 return;
666         }
667         if (s.pull_state == EventsModel::Status::PULL_IN_AIR) {
668                 if (s.attack_state == EventsModel::Status::DEFENSE) {
669                         ui->pull_or_was_d->setText("Pull landed (&p)");
670                         ui->pull_or_was_d->setShortcut(QCoreApplication::translate("MainWindow", "P", nullptr));
671                         ui->pull_or_was_d->setEnabled(true);
672
673                         ui->throwaway->setText("Pull OOB (&t)");
674                         ui->throwaway->setShortcut(QCoreApplication::translate("MainWindow", "T", nullptr));
675                         ui->throwaway->setEnabled(true);
676                 } else {
677                         ui->pull_or_was_d->setEnabled(false);
678                         ui->throwaway->setEnabled(false);
679                 }
680                 ui->their_pull->setEnabled(false);  // We don't track their pull landings; only by means of catch etc.
681
682                 ui->catch_->setEnabled(false);
683                 ui->drop->setEnabled(false);
684                 ui->goal->setEnabled(false);
685                 ui->stallout->setEnabled(false);
686                 ui->soft_plus->setEnabled(false);
687                 ui->soft_minus->setEnabled(false);
688                 ui->interception->setEnabled(false);
689                 ui->their_throwaway->setEnabled(false);
690                 ui->our_defense->setEnabled(false);
691                 ui->their_goal->setEnabled(false);
692                 return;
693         }
694
695         // Not pulling, so reuse the pull button for got d-ed.
696         ui->pull_or_was_d->setText("Was d-ed (&z)");
697         ui->pull_or_was_d->setShortcut(QCoreApplication::translate("MainWindow", "Z", nullptr));
698         ui->pull_or_was_d->setEnabled(true);
699
700         ui->catch_->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
701         ui->throwaway->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && s.last_catching_player != -1);
702         ui->drop->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
703         ui->goal->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
704         ui->stallout->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
705         ui->soft_plus->setEnabled(s.attack_state != EventsModel::Status::NOT_STARTED && has_selection_with_player);
706         ui->soft_minus->setEnabled(s.attack_state != EventsModel::Status::NOT_STARTED && has_selection_with_player);
707         ui->pull_or_was_d->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);  // Was d-ed.
708
709         ui->interception->setEnabled(s.attack_state == EventsModel::Status::DEFENSE && has_selection_with_player);
710         ui->their_throwaway->setEnabled(s.attack_state == EventsModel::Status::DEFENSE);
711         ui->our_defense->setEnabled(s.attack_state == EventsModel::Status::DEFENSE && has_selection_with_player);
712         ui->their_goal->setEnabled(s.attack_state == EventsModel::Status::DEFENSE);
713         ui->their_pull->setEnabled(false);
714 }
715
716 vector<pair<string, int>> sort_gender(const map<string, int> &gender_count)
717 {
718         vector<pair<string, int>> sorted_gender;
719         for (const auto &[gender, count] : gender_count) {
720                 sorted_gender.emplace_back(gender, count);
721         }
722         sort(sorted_gender.begin(), sorted_gender.end(), [](const pair<string, int> &a, const pair<string, int> &b) {
723                 if (a.second != b.second) {
724                         return b.second < a.second;
725                 }
726                 return a.first < b.first;
727         });
728         return sorted_gender;
729 }
730
731 string format_gender_counts(const map<string, int> &gender_count)
732 {
733         vector<pair<string, int>> sorted_gender = sort_gender(gender_count);
734
735         string str;
736         for (const auto &[gender, count] : sorted_gender) {
737                 if (!str.empty()) {
738                         str += ", ";
739                 }
740                 char buf[256];
741                 snprintf(buf, sizeof(buf), "%d ", count);
742                 str += buf;
743                 str += gender;
744         }
745         return str;
746 }
747
748 string format_gender_counts(const map<string, int> &gender_count, const map<string, int> &reference_gender_count)
749 {
750         vector<pair<string, int>> sorted_gender = sort_gender(reference_gender_count);  // Less swapping around this way.
751
752         string str;
753         for (const auto &[gender, count] : sorted_gender) {
754                 if (!str.empty()) {
755                         str += ", ";
756                 }
757                 char buf[256];
758                 snprintf(buf, sizeof(buf), "%d/%d ", gender_count.find(gender)->second, count);
759                 str += buf;
760                 str += gender;
761         }
762         return str;
763 }
764
765 void MainWindow::update_gender_ratio(uint64_t t)
766 {
767         string str;
768         bool ok = true;
769
770         // Count the gender ratio in the current selection.
771         map<string, int> gender_count;
772         for (int i = 0; i < players->rowCount(QModelIndex()); ++i) {
773                 string gender = players->get_player_gender(i);
774                 gender_count[gender] = 0;
775         }
776         int num_players = 0;
777         QItemSelectionModel *select = ui->player_view->selectionModel();
778         for (QModelIndex row : select->selectedRows()) {
779                 string gender = players->get_player_gender(row.row());
780                 ++gender_count[gender];
781                 ++num_players;
782         }
783
784         const bool gender_rule_a = ui->action_gender_rule_a->isChecked();
785         if (gender_rule_a) {
786                 // This is tricky. We don't want to hard-code assumptions about gender,
787                 // since there are so many possible variations (e.g. 5 players for indoors,
788                 // or loose mixed). We can't get everything right, but our general
789                 // strategy will be:
790                 //
791                 //  - We assume ABBA pattern is followed throughout, ie., we switch
792                 //    on odd-numbered points. We use goals as reference for points.
793                 //  - We always use the two or three latest points as reference;
794                 //    this means an issue with the first point won't persist forever.
795                 //  - When we don't switch, we expect the identical number as last time.
796                 //  - When we _do_ switch, we copy the variation from two points ago
797                 //    if we have it; if not, we simply expect it to be different.
798                 //  - We should always have the same number of people as last point.
799                 //
800                 // The viewer's warnings also checks that things are correct across
801                 // stoppages, which we don't yet.
802                 map<string, int> current_gender_count;
803                 for (int i = 0; i < players->rowCount(QModelIndex()); ++i) {
804                         string gender = players->get_player_gender(i);
805                         current_gender_count[gender] = 0;
806                 }
807                 vector<map<string, int>> historical_gender_counts;
808                 for (int row = 0; row < events->rowCount(QModelIndex()); ++row) {
809                         if (events->get_time(row) > t) {
810                                 break;
811                         }
812                         EventType type = events->get_event_type(row);
813                         if (type == EventType::GOAL || type == EventType::THEIR_GOAL) {
814                                 historical_gender_counts.push_back(current_gender_count);
815                         } else if (type == EventType::SWAP_IN) {
816                                 string gender = players->get_player_gender_by_id(*events->get_player_id(row));
817                                 ++current_gender_count[gender];
818                         } else if (type == EventType::SWAP_OUT) {
819                                 string gender = players->get_player_gender_by_id(*events->get_player_id(row));
820                                 --current_gender_count[gender];
821                         }
822                 }
823
824                 ok = true;
825                 if (historical_gender_counts.empty()) {
826                         // We don't have any points yet. Just output the ratio.
827                         str = format_gender_counts(gender_count);
828                 } else if (historical_gender_counts.size() == 1) {
829                         // We have one, so this one must be different, but we don't know what it must be.
830                         // It must have the same number of players, though.
831                         str = format_gender_counts(gender_count);
832                         ok = (gender_count != historical_gender_counts.back());
833                         int old_sum = 0, new_sum = 0;
834                         for (const auto &[gender, count] : historical_gender_counts.back()) {
835                                 old_sum += count;
836                         }
837                         for (const auto &[gender, count] : gender_count) {
838                                 new_sum += count;
839                         }
840                         if (old_sum != new_sum) {
841                                 ok = false;
842                         }
843                 } else if (historical_gender_counts.size() % 2 == 0) {
844                         // Must be same as previous.
845                         str = format_gender_counts(gender_count, historical_gender_counts.back());
846                         ok = (gender_count == historical_gender_counts.back());
847                 } else {
848                         // Must be same as two points ago.
849                         const auto &ref = historical_gender_counts[historical_gender_counts.size() - 3];
850                         str = format_gender_counts(gender_count, ref);
851                         ok = (gender_count == ref);
852                 }
853         } else if (gender_count.size() == 1) {
854                 // Everybody is either of the same gender or nobody has gender noted,
855                 // so just count the number of players. We don't make red here.
856                 char buf[256];
857                 snprintf(buf, sizeof(buf), "%d selected", num_players);
858                 str = buf;
859         } else {
860                 // We don't have gender rule A, but we have gender counts,
861                 // so show that. We don't make red here.
862                 string str = format_gender_counts(gender_count);
863         }
864
865         // Seemingly this setting this every frame is very costly, so we diff.
866         if (QString::fromUtf8(str) != ui->selected_gender_ratio->text()) {
867                 ui->selected_gender_ratio->setText(QString::fromUtf8(str));
868         }
869         bool current_ok = ui->selected_gender_ratio->styleSheet().isEmpty();
870         if (ok && !current_ok) {
871                 ui->selected_gender_ratio->setStyleSheet("");
872         } else if (!ok && current_ok) {
873                 ui->selected_gender_ratio->setStyleSheet("QLabel { color: red }");
874         }
875 }
876
877 void MainWindow::formation_double_clicked(bool offense, unsigned row)
878 {
879         FormationsModel *formations = offense ? offensive_formations : defensive_formations;
880         int id = formations->get_formation_id(row);
881         if (id == -1) {  // “Add new” clicked.
882                 bool ok;
883                 QString new_formation_str = QInputDialog::getText(this, "New formation", "Choose name for new formation:", QLineEdit::Normal, "", &ok);
884                 if (!ok || new_formation_str.isEmpty()) {
885                         return;
886                 }
887
888                 id = formations->insert_new(new_formation_str.toStdString());
889                 QListView *view = offense ? ui->offensive_formation_view : ui->defensive_formation_view;
890                 view->selectionModel()->select(formations->index(formations->get_row_from_id(id), 0), QItemSelectionModel::ClearAndSelect);
891                 events->inserted_new_formation(id, new_formation_str.toStdString());
892         } else {
893                 events->set_formation_at(ui->video->get_position(), offense, id);
894         }
895         update_ui_from_time(ui->video->get_position());
896 }
897