]> git.sesse.net Git - nageru/blob - midi_mapping_dialog.cpp
2c84567d1015c8ff39341cfa5c82b2855f2b2a26
[nageru] / midi_mapping_dialog.cpp
1 #include "midi_mapping_dialog.h"
2
3 #include "midi_mapper.h"
4 #include "midi_mapping.pb.h"
5 #include "post_to_main_thread.h"
6 #include "ui_midi_mapping.h"
7
8 #include <QComboBox>
9 #include <QFileDialog>
10 #include <QMessageBox>
11 #include <QSpinBox>
12
13 #include <algorithm>
14 #include <string>
15
16 using namespace google::protobuf;
17 using namespace std;
18
19 vector<MIDIMappingDialog::Control> per_bus_controllers = {
20         { "Treble",                   MIDIMappingBusProto::kTrebleFieldNumber, MIDIMappingProto::kTrebleBankFieldNumber },
21         { "Mid",                      MIDIMappingBusProto::kMidFieldNumber,    MIDIMappingProto::kMidBankFieldNumber },
22         { "Bass",                     MIDIMappingBusProto::kBassFieldNumber,   MIDIMappingProto::kBassBankFieldNumber },
23         { "Gain",                     MIDIMappingBusProto::kGainFieldNumber,   MIDIMappingProto::kGainBankFieldNumber },
24         { "Compressor threshold",     MIDIMappingBusProto::kCompressorThresholdFieldNumber,
25                                       MIDIMappingProto::kCompressorThresholdBankFieldNumber},
26         { "Fader",                    MIDIMappingBusProto::kFaderFieldNumber,  MIDIMappingProto::kFaderBankFieldNumber }
27 };
28 vector<MIDIMappingDialog::Control> per_bus_buttons = {
29         { "Toggle locut",             MIDIMappingBusProto::kToggleLocutFieldNumber,
30                                       MIDIMappingProto::kToggleLocutBankFieldNumber },
31         { "Togle auto gain staging",  MIDIMappingBusProto::kToggleAutoGainStagingFieldNumber,
32                                       MIDIMappingProto::kToggleAutoGainStagingBankFieldNumber },
33         { "Togle compressor",         MIDIMappingBusProto::kToggleCompressorFieldNumber,
34                                       MIDIMappingProto::kToggleCompressorBankFieldNumber },
35         { "Clear peak",               MIDIMappingBusProto::kClearPeakFieldNumber,
36                                       MIDIMappingProto::kClearPeakBankFieldNumber }
37 };
38 vector<MIDIMappingDialog::Control> global_controllers = {
39         { "Locut cutoff",             MIDIMappingBusProto::kLocutFieldNumber,  MIDIMappingProto::kLocutBankFieldNumber },
40         { "Limiter threshold",        MIDIMappingBusProto::kLimiterThresholdFieldNumber,
41                                       MIDIMappingProto::kLimiterThresholdBankFieldNumber },
42         { "Makeup gain",              MIDIMappingBusProto::kMakeupGainFieldNumber,
43                                       MIDIMappingProto::kMakeupGainBankFieldNumber }
44 };
45 vector<MIDIMappingDialog::Control> global_buttons = {
46         { "Previous bank",            MIDIMappingBusProto::kPrevBankFieldNumber, 0 },
47         { "Next bank",                MIDIMappingBusProto::kNextBankFieldNumber, 0 },
48         { "Select bank 1",            MIDIMappingBusProto::kSelectBank1FieldNumber, 0 },
49         { "Select bank 2",            MIDIMappingBusProto::kSelectBank2FieldNumber, 0 },
50         { "Select bank 3",            MIDIMappingBusProto::kSelectBank3FieldNumber, 0 },
51         { "Select bank 4",            MIDIMappingBusProto::kSelectBank4FieldNumber, 0 },
52         { "Select bank 5",            MIDIMappingBusProto::kSelectBank5FieldNumber, 0 },
53         { "Toggle limiter",           MIDIMappingBusProto::kToggleLimiterFieldNumber, MIDIMappingProto::kToggleLimiterBankFieldNumber },
54         { "Toggle auto makeup gain",  MIDIMappingBusProto::kToggleAutoMakeupGainFieldNumber, MIDIMappingProto::kToggleAutoMakeupGainBankFieldNumber }
55 };
56
57 namespace {
58
59 int get_bank(const MIDIMappingProto &mapping_proto, int bank_field_number, int default_value)
60 {
61         const FieldDescriptor *bank_descriptor = mapping_proto.GetDescriptor()->FindFieldByNumber(bank_field_number);
62         const Reflection *reflection = mapping_proto.GetReflection();
63         if (!reflection->HasField(mapping_proto, bank_descriptor)) {
64                 return default_value;
65         }
66         return reflection->GetInt32(mapping_proto, bank_descriptor);
67 }
68
69 int get_controller_mapping(const MIDIMappingProto &mapping_proto, size_t bus_idx, int field_number, int default_value)
70 {
71         if (bus_idx >= size_t(mapping_proto.bus_mapping_size())) {
72                 return default_value;
73         }
74
75         const MIDIMappingBusProto &bus_mapping = mapping_proto.bus_mapping(bus_idx);
76         const FieldDescriptor *descriptor = bus_mapping.GetDescriptor()->FindFieldByNumber(field_number);
77         const Reflection *bus_reflection = bus_mapping.GetReflection();
78         if (!bus_reflection->HasField(bus_mapping, descriptor)) {
79                 return default_value;
80         }
81         const MIDIControllerProto &controller_proto = 
82                 static_cast<const MIDIControllerProto &>(bus_reflection->GetMessage(bus_mapping, descriptor));
83         return controller_proto.controller_number();
84 }
85
86 int get_button_mapping(const MIDIMappingProto &mapping_proto, size_t bus_idx, int field_number, int default_value)
87 {
88         if (bus_idx >= size_t(mapping_proto.bus_mapping_size())) {
89                 return default_value;
90         }
91
92         const MIDIMappingBusProto &bus_mapping = mapping_proto.bus_mapping(bus_idx);
93         const FieldDescriptor *descriptor = bus_mapping.GetDescriptor()->FindFieldByNumber(field_number);
94         const Reflection *bus_reflection = bus_mapping.GetReflection();
95         if (!bus_reflection->HasField(bus_mapping, descriptor)) {
96                 return default_value;
97         }
98         const MIDIButtonProto &bus_proto = 
99                 static_cast<const MIDIButtonProto &>(bus_reflection->GetMessage(bus_mapping, descriptor));
100         return bus_proto.note_number();
101 }
102
103 }  // namespace
104
105 MIDIMappingDialog::MIDIMappingDialog(MIDIMapper *mapper)
106         : ui(new Ui::MIDIMappingDialog),
107           mapper(mapper)
108 {
109         ui->setupUi(this);
110
111         const MIDIMappingProto mapping_proto = mapper->get_current_mapping();  // Take a copy.
112         old_receiver = mapper->set_receiver(this);
113
114         QStringList labels;
115         labels << "";
116         labels << "Controller bank";
117         for (unsigned bus_idx = 0; bus_idx < num_buses; ++bus_idx) {
118                 char buf[256];
119                 snprintf(buf, sizeof(buf), "Bus %d", bus_idx + 1);
120                 labels << buf;
121         }
122         labels << "";
123         ui->treeWidget->setColumnCount(num_buses + 3);
124         ui->treeWidget->setHeaderLabels(labels);
125
126         add_controls("Per-bus controllers", ControlType::CONTROLLER, SpinnerGroup::PER_BUS_CONTROLLERS, mapping_proto, per_bus_controllers);
127         add_controls("Per-bus buttons", ControlType::BUTTON, SpinnerGroup::PER_BUS_BUTTONS, mapping_proto, per_bus_buttons);
128         add_controls("Global controllers", ControlType::CONTROLLER, SpinnerGroup::GLOBAL_CONTROLLERS, mapping_proto, global_controllers);
129         add_controls("Global buttons", ControlType::BUTTON, SpinnerGroup::GLOBAL_BUTTONS, mapping_proto, global_buttons);
130         fill_controls_from_mapping(mapping_proto);
131
132         // Auto-resize every column but the last.
133         for (unsigned column_idx = 0; column_idx < num_buses + 3; ++column_idx) {
134                 ui->treeWidget->resizeColumnToContents(column_idx);
135         }
136
137         connect(ui->guess_bus_button, &QPushButton::clicked,
138                 bind(&MIDIMappingDialog::guess_clicked, this, false));
139         connect(ui->guess_group_button, &QPushButton::clicked,
140                 bind(&MIDIMappingDialog::guess_clicked, this, true));
141         connect(ui->ok_cancel_buttons, &QDialogButtonBox::accepted, this, &MIDIMappingDialog::ok_clicked);
142         connect(ui->ok_cancel_buttons, &QDialogButtonBox::rejected, this, &MIDIMappingDialog::cancel_clicked);
143         connect(ui->save_button, &QPushButton::clicked, this, &MIDIMappingDialog::save_clicked);
144         connect(ui->load_button, &QPushButton::clicked, this, &MIDIMappingDialog::load_clicked);
145
146         update_guess_button_state();
147 }
148
149 MIDIMappingDialog::~MIDIMappingDialog()
150 {
151         mapper->set_receiver(old_receiver);
152         mapper->refresh_highlights();
153 }
154
155 bool MIDIMappingDialog::eventFilter(QObject *obj, QEvent *event)
156 {
157         if (event->type() == QEvent::FocusIn ||
158             event->type() == QEvent::FocusOut) {
159                 // We ignore the guess buttons themselves; it should be allowed
160                 // to navigate from a spinner to focus on a button (to click it).
161                 if (obj != ui->guess_bus_button && obj != ui->guess_group_button) {
162                         update_guess_button_state();
163                 }
164         }
165         return false;
166 }
167
168 void MIDIMappingDialog::guess_clicked(bool limit_to_group)
169 {
170         FocusInfo focus = find_focus();
171         if (focus.bus_idx == -1) {
172                 // The guess button probably took the focus away from us.
173                 focus = last_focus;
174         }
175         assert(focus.bus_idx != -1);  // The button should have been disabled.
176         pair<int, int> bus_and_offset = guess_offset(focus.bus_idx, limit_to_group ? focus.spinner_group : SpinnerGroup::ALL_GROUPS);
177         const int source_bus_idx = bus_and_offset.first;
178         const int offset = bus_and_offset.second;
179         assert(source_bus_idx != -1);  // The button should have been disabled.
180
181         for (const auto &field_number_and_spinner : spinners[focus.bus_idx]) {
182                 int field_number = field_number_and_spinner.first;
183                 QSpinBox *spinner = field_number_and_spinner.second.spinner;
184                 SpinnerGroup this_spinner_group = field_number_and_spinner.second.group;
185
186                 if (limit_to_group && this_spinner_group != focus.spinner_group) {
187                         continue;
188                 }
189
190                 assert(spinners[source_bus_idx].count(field_number));
191                 QSpinBox *source_spinner = spinners[source_bus_idx][field_number].spinner;
192                 assert(spinners[source_bus_idx][field_number].group == this_spinner_group);
193
194                 if (source_spinner->value() != 0) {
195                         spinner->setValue(source_spinner->value() + offset);
196                 }
197         }
198
199         // See if we can find a “next” bus to move the focus to.
200         const int next_bus_idx = focus.bus_idx + (focus.bus_idx - source_bus_idx);  // Note: Could become e.g. -1.
201         for (const InstantiatedSpinner &is : controller_spinners) {
202                 if (int(is.bus_idx) == next_bus_idx && is.field_number == focus.field_number) {
203                         is.spinner->setFocus();
204                 }
205         }
206         for (const InstantiatedSpinner &is : button_spinners) {
207                 if (int(is.bus_idx) == next_bus_idx && is.field_number == focus.field_number) {
208                         is.spinner->setFocus();
209                 }
210         }
211 }
212
213 void MIDIMappingDialog::ok_clicked()
214 {
215         unique_ptr<MIDIMappingProto> new_mapping = construct_mapping_proto_from_ui();
216         mapper->set_midi_mapping(*new_mapping);
217         mapper->set_receiver(old_receiver);
218         accept();
219 }
220
221 void MIDIMappingDialog::cancel_clicked()
222 {
223         mapper->set_receiver(old_receiver);
224         reject();
225 }
226
227 void MIDIMappingDialog::save_clicked()
228 {
229         unique_ptr<MIDIMappingProto> new_mapping = construct_mapping_proto_from_ui();
230         QString filename = QFileDialog::getSaveFileName(this,
231                 "Save MIDI mapping", QString(), tr("Mapping files (*.midimapping)"));
232         if (!filename.endsWith(".midimapping")) {
233                 filename += ".midimapping";
234         }
235         if (!save_midi_mapping_to_file(*new_mapping, filename.toStdString())) {
236                 QMessageBox box;
237                 box.setText("Could not save mapping to '" + filename + "'. Check that you have the right permissions and try again.");
238                 box.exec();
239         }
240 }
241
242 void MIDIMappingDialog::load_clicked()
243 {
244         QString filename = QFileDialog::getOpenFileName(this,
245                 "Load MIDI mapping", QString(), tr("Mapping files (*.midimapping)"));
246         MIDIMappingProto new_mapping;
247         if (!load_midi_mapping_from_file(filename.toStdString(), &new_mapping)) {
248                 QMessageBox box;
249                 box.setText("Could not load mapping from '" + filename + "'. Check that the file exists, has the right permissions and is valid.");
250                 box.exec();
251                 return;
252         }
253
254         fill_controls_from_mapping(new_mapping);
255 }
256
257 namespace {
258
259 template<class T>
260 T *get_mutable_bus_message(MIDIMappingProto *mapping_proto, unsigned bus_idx, int field_number)
261 {
262         while (size_t(mapping_proto->bus_mapping_size()) <= bus_idx) {
263                 mapping_proto->add_bus_mapping();
264         }
265
266         MIDIMappingBusProto *bus_mapping = mapping_proto->mutable_bus_mapping(bus_idx);
267         const FieldDescriptor *descriptor = bus_mapping->GetDescriptor()->FindFieldByNumber(field_number);
268         const Reflection *bus_reflection = bus_mapping->GetReflection();
269         return static_cast<T *>(bus_reflection->MutableMessage(bus_mapping, descriptor));
270 }
271
272 }  // namespace
273
274 unique_ptr<MIDIMappingProto> MIDIMappingDialog::construct_mapping_proto_from_ui()
275 {
276         unique_ptr<MIDIMappingProto> mapping_proto(new MIDIMappingProto);
277         for (const InstantiatedSpinner &is : controller_spinners) {
278                 const int val = is.spinner->value();
279                 if (val == 0) {
280                         continue;
281                 }
282
283                 MIDIControllerProto *controller_proto =
284                         get_mutable_bus_message<MIDIControllerProto>(mapping_proto.get(), is.bus_idx, is.field_number);
285                 controller_proto->set_controller_number(val);
286         }
287         for (const InstantiatedSpinner &is : button_spinners) {
288                 const int val = is.spinner->value();
289                 if (val == 0) {
290                         continue;
291                 }
292
293                 MIDIButtonProto *button_proto =
294                         get_mutable_bus_message<MIDIButtonProto>(mapping_proto.get(), is.bus_idx, is.field_number);
295                 button_proto->set_note_number(val);
296         }
297         int highest_bank_used = 0;  // 1-indexed.
298         for (const InstantiatedComboBox &ic : bank_combo_boxes) {
299                 const int val = ic.combo_box->currentIndex();
300                 highest_bank_used = std::max(highest_bank_used, val);
301                 if (val == 0) {
302                         continue;
303                 }
304
305                 const FieldDescriptor *descriptor = mapping_proto->GetDescriptor()->FindFieldByNumber(ic.field_number);
306                 const Reflection *bus_reflection = mapping_proto->GetReflection();
307                 bus_reflection->SetInt32(mapping_proto.get(), descriptor, val - 1);
308         }
309         mapping_proto->set_num_controller_banks(highest_bank_used);
310         return mapping_proto;
311 }
312
313 void MIDIMappingDialog::add_bank_selector(QTreeWidgetItem *item, const MIDIMappingProto &mapping_proto, int bank_field_number)
314 {
315         if (bank_field_number == 0) {
316                 return;
317         }
318         QComboBox *bank_selector = new QComboBox(this);
319         bank_selector->addItems(QStringList() << "" << "Bank 1" << "Bank 2" << "Bank 3" << "Bank 4" << "Bank 5");
320         bank_selector->setAutoFillBackground(true);
321
322         bank_combo_boxes.push_back(InstantiatedComboBox{ bank_selector, bank_field_number });
323
324         ui->treeWidget->setItemWidget(item, 1, bank_selector);
325 }
326
327 void MIDIMappingDialog::add_controls(const string &heading,
328                                      MIDIMappingDialog::ControlType control_type,
329                                      MIDIMappingDialog::SpinnerGroup spinner_group,
330                                      const MIDIMappingProto &mapping_proto,
331                                      const vector<MIDIMappingDialog::Control> &controls)
332 {
333         QTreeWidgetItem *heading_item = new QTreeWidgetItem(ui->treeWidget);
334         heading_item->setText(0, QString::fromStdString(heading));
335         heading_item->setFirstColumnSpanned(true);
336         heading_item->setExpanded(true);
337         for (const Control &control : controls) {
338                 QTreeWidgetItem *item = new QTreeWidgetItem(heading_item);
339                 heading_item->addChild(item);
340                 add_bank_selector(item, mapping_proto, control.bank_field_number);
341                 item->setText(0, QString::fromStdString(control.label + "   "));
342
343                 for (unsigned bus_idx = 0; bus_idx < num_buses; ++bus_idx) {
344                         QSpinBox *spinner = new QSpinBox(this);
345                         spinner->setRange(0, 127);
346                         spinner->setAutoFillBackground(true);
347                         spinner->setSpecialValueText("\u200d");  // Zero-width joiner (ie., empty).
348                         spinner->installEventFilter(this);  // So we know when the focus changes.
349                         ui->treeWidget->setItemWidget(item, bus_idx + 2, spinner);
350
351                         if (control_type == ControlType::CONTROLLER) {
352                                 controller_spinners.push_back(InstantiatedSpinner{ spinner, bus_idx, spinner_group, control.field_number });
353                         } else {
354                                 assert(control_type == ControlType::BUTTON);
355                                 button_spinners.push_back(InstantiatedSpinner{ spinner, bus_idx, spinner_group, control.field_number });
356                         }
357                         spinners[bus_idx][control.field_number] = SpinnerAndGroup{ spinner, spinner_group };
358                         connect(spinner, static_cast<void(QSpinBox::*)(int)>(&QSpinBox::valueChanged),
359                                 bind(&MIDIMappingDialog::update_guess_button_state, this));
360                 }
361         }
362         ui->treeWidget->addTopLevelItem(heading_item);
363 }
364
365 void MIDIMappingDialog::fill_controls_from_mapping(const MIDIMappingProto &mapping_proto)
366 {
367         for (const InstantiatedSpinner &is : controller_spinners) {
368                 is.spinner->setValue(get_controller_mapping(mapping_proto, is.bus_idx, is.field_number, 0));
369         }
370         for (const InstantiatedSpinner &is : button_spinners) {
371                 is.spinner->setValue(get_button_mapping(mapping_proto, is.bus_idx, is.field_number, 0));
372         }
373         for (const InstantiatedComboBox &ic : bank_combo_boxes) {
374                 ic.combo_box->setCurrentIndex(get_bank(mapping_proto, ic.field_number, -1) + 1);
375         }
376 }
377
378 void MIDIMappingDialog::controller_changed(unsigned controller)
379 {
380         for (const InstantiatedSpinner &is : controller_spinners) {
381                 if (is.spinner->hasFocus()) {
382                         is.spinner->setValue(controller);
383                         is.spinner->selectAll();
384                 }
385         }
386 }
387
388 void MIDIMappingDialog::note_on(unsigned note)
389 {
390         for (const InstantiatedSpinner &is : button_spinners) {
391                 if (is.spinner->hasFocus()) {
392                         is.spinner->setValue(note);
393                         is.spinner->selectAll();
394                 }
395         }
396 }
397
398 pair<int, int> MIDIMappingDialog::guess_offset(unsigned bus_idx, MIDIMappingDialog::SpinnerGroup spinner_group)
399 {
400         constexpr pair<int, int> not_found(-1, 0);
401
402         if (bus_is_empty(bus_idx, spinner_group)) {
403                 return not_found;
404         }
405
406         // See if we can find a non-empty bus to source from (prefer from the left).
407         unsigned source_bus_idx;
408         if (bus_idx > 0 && !bus_is_empty(bus_idx - 1, spinner_group)) {
409                 source_bus_idx = bus_idx - 1;
410         } else if (bus_idx < num_buses - 1 && !bus_is_empty(bus_idx + 1, spinner_group)) {
411                 source_bus_idx = bus_idx + 1;
412         } else {
413                 return not_found;
414         }
415
416         // See if we can find a consistent offset.
417         bool found_offset = false;
418         int offset = 0;
419         int minimum_allowed_offset = numeric_limits<int>::min();
420         int maximum_allowed_offset = numeric_limits<int>::max();
421         for (const auto &field_number_and_spinner : spinners[bus_idx]) {
422                 int field_number = field_number_and_spinner.first;
423                 QSpinBox *spinner = field_number_and_spinner.second.spinner;
424                 SpinnerGroup this_spinner_group = field_number_and_spinner.second.group;
425                 assert(spinners[source_bus_idx].count(field_number));
426                 QSpinBox *source_spinner = spinners[source_bus_idx][field_number].spinner;
427                 assert(spinners[source_bus_idx][field_number].group == this_spinner_group);
428
429                 if (spinner_group != SpinnerGroup::ALL_GROUPS &&
430                     spinner_group != this_spinner_group) {
431                         continue;
432                 }
433                 if (spinner->value() == 0) {
434                         if (source_spinner->value() != 0) {
435                                 // If the source value is e.g. 3, offset can't be less than -2 or larger than 124.
436                                 // Otherwise, we'd extrapolate values outside [1..127].
437                                 minimum_allowed_offset = max(minimum_allowed_offset, 1 - source_spinner->value());
438                                 maximum_allowed_offset = min(maximum_allowed_offset, 127 - source_spinner->value());
439                         }
440                         continue;
441                 }
442                 if (source_spinner->value() == 0) {
443                         // The bus has a controller set that the source bus doesn't set.
444                         return not_found;
445                 }
446
447                 int candidate_offset = spinner->value() - source_spinner->value();
448                 if (!found_offset) {
449                         offset = candidate_offset;
450                         found_offset = true;
451                 } else if (candidate_offset != offset) {
452                         return not_found;
453                 }
454         }
455
456         if (!found_offset) {
457                 // Given that the bus wasn't empty, this shouldn't happen.
458                 assert(false);
459                 return not_found;
460         }
461
462         if (offset < minimum_allowed_offset || offset > maximum_allowed_offset) {
463                 return not_found;
464         }
465         return make_pair(source_bus_idx, offset);
466 }
467
468 bool MIDIMappingDialog::bus_is_empty(unsigned bus_idx, SpinnerGroup spinner_group)
469 {
470         for (const auto &field_number_and_spinner : spinners[bus_idx]) {
471                 QSpinBox *spinner = field_number_and_spinner.second.spinner;
472                 SpinnerGroup this_spinner_group = field_number_and_spinner.second.group;
473                 if (spinner_group != SpinnerGroup::ALL_GROUPS &&
474                     spinner_group != this_spinner_group) {
475                         continue;
476                 }
477                 if (spinner->value() != 0) {
478                         return false;
479                 }
480         }
481         return true;
482 }
483
484 void MIDIMappingDialog::update_guess_button_state()
485 {
486         FocusInfo focus = find_focus();
487         if (focus.bus_idx < 0) {
488                 return;
489         }
490         {
491                 pair<int, int> bus_and_offset = guess_offset(focus.bus_idx, SpinnerGroup::ALL_GROUPS);
492                 ui->guess_bus_button->setEnabled(bus_and_offset.first != -1);
493         }
494         {
495                 pair<int, int> bus_and_offset = guess_offset(focus.bus_idx, focus.spinner_group);
496                 ui->guess_group_button->setEnabled(bus_and_offset.first != -1);
497         }
498         last_focus = focus;
499 }
500
501 MIDIMappingDialog::FocusInfo MIDIMappingDialog::find_focus() const
502 {
503         for (const InstantiatedSpinner &is : controller_spinners) {
504                 if (is.spinner->hasFocus()) {
505                         return FocusInfo{ int(is.bus_idx), is.spinner_group, is.field_number };
506                 }
507         }
508         for (const InstantiatedSpinner &is : button_spinners) {
509                 if (is.spinner->hasFocus()) {
510                         return FocusInfo{ int(is.bus_idx), is.spinner_group, is.field_number };
511                 }
512         }
513         for (const InstantiatedSpinner &is : light_spinners) {
514                 if (is.spinner->hasFocus()) {
515                         return FocusInfo{ int(is.bus_idx), is.spinner_group, is.field_number };
516                 }
517         }
518         return FocusInfo{ -1, SpinnerGroup::ALL_GROUPS, -1 };
519 }