]> git.sesse.net Git - nageru/commitdiff
Make it possible to load/save input mappings.
authorSteinar H. Gunderson <sgunderson@bigfoot.com>
Fri, 16 Sep 2016 18:20:45 +0000 (20:20 +0200)
committerSteinar H. Gunderson <sgunderson@bigfoot.com>
Wed, 19 Oct 2016 22:55:44 +0000 (00:55 +0200)
Settings these up can be a bit of a hassle if you have a lot of them,
so we allow them to be saved to disk and then loaded back (complete
with machinery for fuzzy-matching devices if the state has changed,
e.g. cards moved around).

This introduces a dependency on protocol buffers, because it's a
convenient way to store simple data to disk (including forward
compatibility). We choose to use the text format for the user's
convenience; we don't need the speed or compactness of the binary format.

13 files changed:
.gitignore
Makefile
README
alsa_input.cpp
alsa_input.h
audio_mixer.cpp
audio_mixer.h
input_mapping.cpp [new file with mode: 0644]
input_mapping.h
input_mapping_dialog.cpp
input_mapping_dialog.h
state.proto [new file with mode: 0644]
ui_input_mapping.ui

index ea80de20624fd75bebdb4b3ddef7e1d6e35735cd..8724f1cb1b84bf9ddd7fe85e06acd2255d871b0f 100644 (file)
@@ -1,5 +1,7 @@
 *.d
 *.o
+*.pb.h
+*.pb.cc
 *.moc.cpp
 ui_aboutdialog.h
 ui_audio_expanded_view.h
index 055f811557e0cfd80d5ab7e114fadb6497a083f0..1b1f038e047f5ec41ffb8b0091c763e79fba3dba 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,8 @@
 CXX=g++
+PROTOC=protoc
 INSTALL=install
 EMBEDDED_BMUSB=no
-PKG_MODULES := Qt5Core Qt5Gui Qt5Widgets Qt5OpenGLExtensions Qt5OpenGL libusb-1.0 movit lua52 libmicrohttpd epoxy x264
+PKG_MODULES := Qt5Core Qt5Gui Qt5Widgets Qt5OpenGLExtensions Qt5OpenGL libusb-1.0 movit lua52 libmicrohttpd epoxy x264 protobuf
 CXXFLAGS ?= -O2 -g -Wall  # Will be overridden by environment.
 CXXFLAGS += -std=gnu++11 -fPIC $(shell pkg-config --cflags $(PKG_MODULES)) -pthread -DMOVIT_SHADER_DIR=\"$(shell pkg-config --variable=shaderdir movit)\" -Idecklink/
 
@@ -17,7 +18,7 @@ OBJS=glwidget.o main.o mainwindow.o vumeter.o lrameter.o vu_common.o correlation
 OBJS += glwidget.moc.o mainwindow.moc.o vumeter.moc.o lrameter.moc.o correlation_meter.moc.o aboutdialog.moc.o ellipsis_label.moc.o input_mapping_dialog.moc.o nonlinear_fader.moc.o clickable_label.moc.o
 
 # Mixer objects
-AUDIO_MIXER_OBJS = audio_mixer.o alsa_input.o ebu_r128_proc.o stereocompressor.o resampling_queue.o flags.o correlation_measurer.o filter.o disk_space_estimator.o
+AUDIO_MIXER_OBJS = audio_mixer.o alsa_input.o ebu_r128_proc.o stereocompressor.o resampling_queue.o flags.o correlation_measurer.o filter.o input_mapping.o state.pb.o
 OBJS += mixer.o pbo_frame_allocator.o context.o ref_counted_frame.o theme.o httpd.o flags.o image_input.o alsa_output.o disk_space_estimator.o $(AUDIO_MIXER_OBJS)
 
 # Streaming and encoding objects
@@ -38,6 +39,8 @@ BM_OBJS = benchmark_audio_mixer.o $(AUDIO_MIXER_OBJS) flags.o
        $(CXX) -MMD -MP $(CPPFLAGS) $(CXXFLAGS) -o $@ -c $<
 %.o: %.cc
        $(CXX) -MMD -MP $(CPPFLAGS) $(CXXFLAGS) -o $@ -c $<
+%.pb.cc %.pb.h : %.proto
+       $(PROTOC) --cpp_out=. $<
 
 %.h: %.ui
        uic $< -o $@
diff --git a/README b/README
index b61f712ae132438aa97b8100e3a43dbccb74a48c..ca7d8f74fe2c0b76f21ad4c5dc9214a0ae054fa9 100644 (file)
--- a/README
+++ b/README
@@ -70,7 +70,8 @@ with:
   apt install qtbase5-dev libqt5opengl5-dev qt5-default pkg-config libmicrohttpd-dev \
     libusb-1.0-0-dev liblua5.2-dev libzita-resampler-dev libva-dev \
     libavcodec-dev libavformat-dev libswscale-dev libavresample-dev \
-    libmovit-dev libegl1-mesa-dev libasound2-dev libx264-dev libbmusb-dev
+    libmovit-dev libegl1-mesa-dev libasound2-dev libx264-dev libbmusb-dev \
+    protobuf-compiler libprotobuf-dev
 
 Exceptions as of October 2016:
 
index c53b24189da2f43a7335522aecf4aaaf81226a4a..0f8e99c95b9b0d536451f26c85bc1fa3142a9003 100644 (file)
@@ -1,9 +1,11 @@
 #include "alsa_input.h"
 #include "audio_mixer.h"
 #include "defs.h"
+#include "state.pb.h"
 
 #include <sys/inotify.h>
 
+#include <algorithm>
 #include <functional>
 #include <unordered_map>
 
@@ -645,6 +647,43 @@ unsigned ALSAPool::find_free_device_index(const string &name, const string &info
        return devices.size() - 1;
 }
 
+unsigned ALSAPool::create_dead_card(const string &name, const string &info, unsigned num_channels)
+{
+       lock_guard<mutex> lock(mu);
+
+       // See if there are any empty slots. If not, insert one at the end.
+       vector<Device>::iterator free_device =
+               find_if(devices.begin(), devices.end(),
+                       [](const Device &device) { return device.state == Device::State::EMPTY; });
+       if (free_device == devices.end()) {
+               devices.push_back(Device());
+               inputs.emplace_back(nullptr);
+               free_device = devices.end() - 1;
+       }
+
+       free_device->state = Device::State::DEAD;
+       free_device->name = name;
+       free_device->info = info;
+       free_device->num_channels = num_channels;
+       free_device->held = true;
+
+       return distance(devices.begin(), free_device);
+}
+
+void ALSAPool::serialize_device(unsigned index, DeviceSpecProto *serialized)
+{
+       lock_guard<mutex> lock(mu);
+       assert(index < devices.size());
+       assert(devices[index].held);
+       serialized->set_type(DeviceSpecProto::ALSA_INPUT);
+       serialized->set_index(index);
+       serialized->set_display_name(devices[index].display_name());
+       serialized->set_alsa_name(devices[index].name);
+       serialized->set_alsa_info(devices[index].info);
+       serialized->set_num_channels(devices[index].num_channels);
+       serialized->set_address(devices[index].address);
+}
+
 void ALSAPool::free_card(unsigned index)
 {
        DeviceSpec spec{InputSourceType::ALSA_INPUT, index};
index bac13beca366a3884a63fe2c1e32263f46aba17e..3b98885677832ec6f1763555fbb4f36ada45e429 100644 (file)
@@ -22,6 +22,7 @@
 #include "timebase.h"
 
 class ALSAPool;
+class DeviceSpecProto;
 
 class ALSAInput {
 public:
@@ -152,6 +153,15 @@ public:
        // EMPTY or DEAD state. Only for ALSAInput and for internal use.
        void free_card(unsigned index);
 
+       // Create a new card, mark it immediately as DEAD and hold it.
+       // Returns the new index.
+       unsigned create_dead_card(const std::string &name, const std::string &info, unsigned num_channels);
+
+       // Make a protobuf representation of the given card, so that it can be
+       // matched against at a later stage. For AudioMixer only.
+       // The given card must be held.
+       void serialize_device(unsigned index, DeviceSpecProto *serialized);
+
 private:
        mutable std::mutex mu;
        std::vector<Device> devices;  // Under mu.
index 3b40955ef1e43b6604fd2d212e3da1e2c225ff3a..962877ed4647cf986e968e5868b4652608e89c7f 100644 (file)
@@ -13,6 +13,7 @@
 #include "db.h"
 #include "flags.h"
 #include "mixer.h"
+#include "state.pb.h"
 #include "timebase.h"
 
 using namespace bmusb;
@@ -754,6 +755,9 @@ map<DeviceSpec, DeviceInfo> AudioMixer::get_devices()
                DeviceInfo info;
                info.display_name = device.display_name();
                info.num_channels = device.num_channels;
+               info.alsa_name = device.name;
+               info.alsa_info = device.info;
+               info.alsa_address = device.address;
                devices.insert(make_pair(spec, info));
        }
        return devices;
@@ -767,6 +771,24 @@ void AudioMixer::set_display_name(DeviceSpec device_spec, const string &name)
        device->display_name = name;
 }
 
+void AudioMixer::serialize_device(DeviceSpec device_spec, DeviceSpecProto *device_spec_proto)
+{
+       lock_guard<timed_mutex> lock(audio_mutex);
+       switch (device_spec.type) {
+               case InputSourceType::SILENCE:
+                       device_spec_proto->set_type(DeviceSpecProto::SILENCE);
+                       break;
+               case InputSourceType::CAPTURE_CARD:
+                       device_spec_proto->set_type(DeviceSpecProto::CAPTURE_CARD);
+                       device_spec_proto->set_index(device_spec.index);
+                       device_spec_proto->set_display_name(video_cards[device_spec.index].display_name);
+                       break;
+               case InputSourceType::ALSA_INPUT:
+                       alsa_pool.serialize_device(device_spec.index, device_spec_proto);
+                       break;
+       }
+}
+
 void AudioMixer::set_input_mapping(const InputMapping &new_input_mapping)
 {
        lock_guard<timed_mutex> lock(audio_mutex);
index 7ae5464d44ab61fe336e28603460f693cc9a33ea..3a1334a66656e9979cc8ca3148f3c7761acced5e 100644 (file)
@@ -76,8 +76,18 @@ public:
                return alsa_pool.get_card_state(index);
        }
 
+       // See comments on ALSAPool::create_dead_card().
+       DeviceSpec create_dead_card(const std::string &name, const std::string &info, unsigned num_channels)
+       {
+               unsigned dead_card_index = alsa_pool.create_dead_card(name, info, num_channels);
+               return DeviceSpec{InputSourceType::ALSA_INPUT, dead_card_index};
+       }
+
        void set_display_name(DeviceSpec device_spec, const std::string &name);
 
+       // Note: The card should be held (currently this isn't enforced, though).
+       void serialize_device(DeviceSpec device_spec, DeviceSpecProto *device_spec_proto);
+
        void set_input_mapping(const InputMapping &input_mapping);
        InputMapping get_input_mapping() const;
 
diff --git a/input_mapping.cpp b/input_mapping.cpp
new file mode 100644 (file)
index 0000000..4b28a59
--- /dev/null
@@ -0,0 +1,189 @@
+#include <stdio.h>
+#include <fcntl.h>
+#include <unistd.h>
+
+#include <google/protobuf/text_format.h>
+#include <google/protobuf/io/zero_copy_stream.h>
+#include <google/protobuf/io/zero_copy_stream_impl.h>
+
+#include "audio_mixer.h" 
+#include "input_mapping.h"
+#include "state.pb.h"
+
+using namespace std;
+using namespace google::protobuf;
+
+bool save_input_mapping_to_file(const map<DeviceSpec, DeviceInfo> &devices, const InputMapping &input_mapping, const string &filename)
+{
+       InputMappingProto mapping_proto;
+       {
+               map<DeviceSpec, unsigned> used_devices;
+               for (const InputMapping::Bus &bus : input_mapping.buses) {
+                       if (!used_devices.count(bus.device)) {
+                               used_devices.emplace(bus.device, used_devices.size());
+                               global_audio_mixer->serialize_device(bus.device, mapping_proto.add_device());
+                       }
+
+                       BusProto *bus_proto = mapping_proto.add_bus();
+                       bus_proto->set_name(bus.name);
+                       bus_proto->set_device_index(used_devices[bus.device]);
+                       bus_proto->set_source_channel_left(bus.source_channel[0]);
+                       bus_proto->set_source_channel_right(bus.source_channel[1]);
+               }
+       }
+
+       // Save to disk. We use the text format because it's friendlier
+       // for a user to look at and edit.
+       int fd = open(filename.c_str(), O_WRONLY | O_TRUNC | O_CREAT, 0666);
+       if (fd == -1) {
+               perror(filename.c_str());
+               return false;
+       }
+       io::FileOutputStream output(fd);  // Takes ownership of fd.
+       if (!TextFormat::Print(mapping_proto, &output)) {
+               // TODO: Don't overwrite the old file (if any) on error.
+               output.Close();
+               return false;
+       }
+
+       output.Close();
+       return true;
+}
+
+bool load_input_mapping_from_file(const map<DeviceSpec, DeviceInfo> &devices, const string &filename, InputMapping *new_mapping)
+{
+       // Read and parse the protobuf from disk.
+       int fd = open(filename.c_str(), O_RDONLY);
+       if (fd == -1) {
+               perror(filename.c_str());
+               return false;
+       }
+       io::FileInputStream input(fd);  // Takes ownership of fd.
+       InputMappingProto mapping_proto;
+       if (!TextFormat::Parse(&input, &mapping_proto)) {
+               input.Close();
+               return false;
+       }
+       input.Close();
+
+       // Map devices in the proto to our current ones:
+
+       // Get a list of all active devices.
+       set<DeviceSpec> remaining_devices;
+       for (const auto &device_spec_and_info : devices) {
+               remaining_devices.insert(device_spec_and_info.first);
+       }
+
+       // Now look at every device in the serialized protobuf and try to map
+       // it to one device we haven't taken yet. This isn't a full maximal matching,
+       // but it's good enough for our uses.
+       vector<DeviceSpec> device_mapping;
+       for (unsigned device_index = 0; device_index < unsigned(mapping_proto.device_size()); ++device_index) {
+               const DeviceSpecProto &device_proto = mapping_proto.device(device_index);
+               switch (device_proto.type()) {
+               case DeviceSpecProto::SILENCE:
+                       device_mapping.push_back(DeviceSpec{InputSourceType::SILENCE, 0});
+                       break;
+               case DeviceSpecProto::CAPTURE_CARD: {
+                       // First see if there's a card that matches on both index and name.
+                       DeviceSpec spec{InputSourceType::CAPTURE_CARD, unsigned(device_proto.index())};
+                       assert(devices.count(spec));
+                       const DeviceInfo &dev = devices.find(spec)->second;
+                       if (remaining_devices.count(spec) &&
+                           dev.display_name == device_proto.display_name()) {
+                               device_mapping.push_back(spec);
+                               remaining_devices.erase(spec);
+                               goto found_capture_card;
+                       }
+
+                       // Scan and see if there's a match on name alone.
+                       for (const DeviceSpec &spec : remaining_devices) {
+                               if (spec.type == InputSourceType::CAPTURE_CARD &&
+                                   dev.display_name == device_proto.display_name()) {
+                                       device_mapping.push_back(spec);
+                                       remaining_devices.erase(spec);
+                                       goto found_capture_card;
+                               }
+                       }
+
+                       // OK, see if at least the index is free.
+                       if (remaining_devices.count(spec)) {
+                               device_mapping.push_back(spec);
+                               remaining_devices.erase(spec);
+                               goto found_capture_card;
+                       }
+
+                       // Give up.
+                       device_mapping.push_back(DeviceSpec{InputSourceType::SILENCE, 0});
+found_capture_card:
+                       break;
+               }
+               case DeviceSpecProto::ALSA_INPUT: {
+                       // For ALSA, we don't really care about index, but we can use address
+                       // in its place.
+
+                       // First see if there's a card that matches on name, num_channels and address.
+                       for (const DeviceSpec &spec : remaining_devices) {
+                               assert(devices.count(spec));
+                               const DeviceInfo &dev = devices.find(spec)->second;
+                               if (spec.type == InputSourceType::ALSA_INPUT &&
+                                   dev.alsa_name == device_proto.alsa_name() &&
+                                   dev.alsa_info == device_proto.alsa_info() &&
+                                   int(dev.num_channels) == device_proto.num_channels() &&
+                                   dev.alsa_address == device_proto.address()) {
+                                       device_mapping.push_back(spec);
+                                       remaining_devices.erase(spec);
+                                       goto found_alsa_input;
+                               }
+                       }
+
+                       // Looser check: Ignore the address.
+                       for (const DeviceSpec &spec : remaining_devices) {
+                               assert(devices.count(spec));
+                               const DeviceInfo &dev = devices.find(spec)->second;
+                               if (spec.type == InputSourceType::ALSA_INPUT &&
+                                   dev.alsa_name == device_proto.alsa_name() &&
+                                   dev.alsa_info == device_proto.alsa_info() &&
+                                   int(dev.num_channels) == device_proto.num_channels()) {
+                                       device_mapping.push_back(spec);
+                                       remaining_devices.erase(spec);
+                                       goto found_alsa_input;
+                               }
+                       }
+
+                       // OK, so we couldn't map this to a device, but perhaps one is added
+                       // at some point in the future through hotplug. Create a dead card
+                       // matching this one; right now, it will give only silence,
+                       // but it could be replaced with something later.
+                       //
+                       // NOTE: There's a potential race condition here, if the card
+                       // gets inserted while we're doing the device remapping
+                       // (or perhaps more realistically, while we're reading the
+                       // input mapping from disk).
+                       DeviceSpec dead_card_spec;
+                       dead_card_spec = global_audio_mixer->create_dead_card(
+                               device_proto.alsa_name(), device_proto.alsa_info(), device_proto.num_channels());
+                       device_mapping.push_back(dead_card_spec);
+
+found_alsa_input:
+                       break;
+               }
+               default:
+                       assert(false);
+               }
+       }
+
+       for (const BusProto &bus_proto : mapping_proto.bus()) {
+               if (bus_proto.device_index() < 0 || unsigned(bus_proto.device_index()) >= device_mapping.size()) {
+                       return false;
+               }
+               InputMapping::Bus bus;
+               bus.name = bus_proto.name();
+               bus.device = device_mapping[bus_proto.device_index()];
+               bus.source_channel[0] = bus_proto.source_channel_left();
+               bus.source_channel[1] = bus_proto.source_channel_right();
+               new_mapping->buses.push_back(bus);
+       }
+
+       return true;
+}
index a1e90eb483e245b049dc6b2cb3c13c643b56273a..10c3ca2901be0afe870ec9cbefc40ec9c31e0a60 100644 (file)
@@ -2,6 +2,7 @@
 #define _INPUT_MAPPING_H 1
 
 #include <stdint.h>
+#include <map>
 #include <string>
 #include <vector>
 
@@ -46,4 +47,11 @@ struct InputMapping {
        std::vector<Bus> buses;
 };
 
+bool save_input_mapping_to_file(const std::map<DeviceSpec, DeviceInfo> &devices,
+                                const InputMapping &mapping,
+                                const std::string &filename);
+bool load_input_mapping_from_file(const std::map<DeviceSpec, DeviceInfo> &devices,
+                                  const std::string &filename,
+                                  InputMapping *mapping);
+
 #endif  // !defined(_INPUT_MAPPING_H)
index 6fcf7a8d61fc3cd40856c5411a16d7c2a188172c..232a88db20c795acfd869f1dd1b03bb13a0b9bf2 100644 (file)
@@ -4,6 +4,8 @@
 #include "ui_input_mapping.h"
 
 #include <QComboBox>
+#include <QFileDialog>
+#include <QMessageBox>
 
 using namespace std;
 using namespace std::placeholders;
@@ -26,6 +28,8 @@ InputMappingDialog::InputMappingDialog()
        connect(ui->remove_button, &QPushButton::clicked, this, &InputMappingDialog::remove_clicked);
        connect(ui->up_button, &QPushButton::clicked, bind(&InputMappingDialog::updown_clicked, this, -1));
        connect(ui->down_button, &QPushButton::clicked, bind(&InputMappingDialog::updown_clicked, this, 1));
+       connect(ui->save_button, &QPushButton::clicked, this, &InputMappingDialog::save_clicked);
+       connect(ui->load_button, &QPushButton::clicked, this, &InputMappingDialog::load_clicked);
 
        update_button_state();
        connect(ui->table, &QTableWidget::itemSelectionChanged, this, &InputMappingDialog::update_button_state);
@@ -231,6 +235,37 @@ void InputMappingDialog::updown_clicked(int direction)
        ui->table->setRangeSelected(b_sel, true);
 }
 
+void InputMappingDialog::save_clicked()
+{
+       QString filename = QFileDialog::getSaveFileName(this,
+               "Save input mapping", QString(), tr("Mapping files (*.mapping)"));
+       if (!filename.endsWith(".mapping")) {
+               filename += ".mapping";
+       }
+       if (!save_input_mapping_to_file(devices, mapping, filename.toStdString())) {
+               QMessageBox box;
+               box.setText("Could not save mapping to '" + filename + "'. Check that you have the right permissions and try again.");
+               box.exec();
+       }
+}
+
+void InputMappingDialog::load_clicked()
+{
+       QString filename = QFileDialog::getOpenFileName(this,
+               "Load input mapping", QString(), tr("Mapping files (*.mapping)"));
+       InputMapping new_mapping;
+       if (!load_input_mapping_from_file(devices, filename.toStdString(), &new_mapping)) {
+               QMessageBox box;
+               box.setText("Could not load mapping from '" + filename + "'. Check that the file exists, has the right permissions and is valid.");
+               box.exec();
+               return;
+       }
+
+       mapping = new_mapping;
+       devices = global_audio_mixer->get_devices();  // New dead cards may have been made.
+       fill_ui_from_mapping(mapping);
+}
+
 void InputMappingDialog::update_button_state()
 {
        ui->add_button->setDisabled(mapping.buses.size() >= MAX_BUSES);
index 0ca81d976866fbd3079015d5da9abdb0bc48e19c..e24a801f0489c46b0ce974bc36572ed5144216a9 100644 (file)
@@ -35,6 +35,8 @@ private:
        void add_clicked();
        void remove_clicked();
        void updown_clicked(int direction);
+       void save_clicked();
+       void load_clicked();
        void update_button_state();
 
        Ui::InputMappingDialog *ui;
@@ -45,8 +47,7 @@ private:
        // held forever).
        InputMapping old_mapping;
 
-       std::map<DeviceSpec, DeviceInfo> devices;
-
+       std::map<DeviceSpec, DeviceInfo> devices;  // Needs no lock, accessed only on the UI thread.
        AudioMixer::state_changed_callback_t saved_callback;
 };
 
diff --git a/state.proto b/state.proto
new file mode 100644 (file)
index 0000000..8ea6b97
--- /dev/null
@@ -0,0 +1,35 @@
+// Used to serialize state between runs. Currently only audio input mappings,
+// but in theory we could do the entire mix, video inputs, etc.
+
+syntax = "proto2";
+
+// Similar to DeviceSpec, but only devices that are used are stored,
+// and contains additional information that will help us try to map
+// to the right device even if the devices have moved around.
+message DeviceSpecProto {
+       // Members from DeviceSpec itself.
+       enum InputSourceType { SILENCE = 0; CAPTURE_CARD = 1; ALSA_INPUT = 2; };
+       optional InputSourceType type = 1;
+       optional int32 index = 2;
+
+       // Additional information.
+       optional string display_name = 3;
+       optional string alsa_name = 4;  // Only for ALSA devices.
+       optional string alsa_info = 5;  // Only for ALSA devices.
+       optional int32 num_channels = 6;  // Only for ALSA devices.
+       optional string address = 7;  // Only for ALSA devices.
+}
+
+// Corresponds to InputMapping::Bus.
+message BusProto {
+       optional string name = 1;
+       optional int32 device_index = 2;  // Index into the "devices" array.
+       optional int32 source_channel_left = 3;
+       optional int32 source_channel_right = 4;
+}
+
+// Corresponds to InputMapping.
+message InputMappingProto {
+       repeated DeviceSpecProto device = 1;
+       repeated BusProto bus = 2;
+}
index 7b45338c856b035e0e134c5732dad3cbc489c146..4487b9419506fa2618a2f833063c2b4bcfd8c00a 100644 (file)
@@ -39,7 +39,7 @@
     </widget>
    </item>
    <item>
-    <layout class="QHBoxLayout" name="horizontalLayout_2" stretch="0,0,1,0,0,1,0">
+    <layout class="QHBoxLayout" name="horizontalLayout_2" stretch="0,0,0,0,1,0,0,1,0">
      <item>
       <widget class="QPushButton" name="add_button">
        <property name="sizePolicy">
        </property>
       </widget>
      </item>
-     <item>
-      <spacer name="horizontalSpacer_2">
-       <property name="orientation">
-        <enum>Qt::Horizontal</enum>
-       </property>
-       <property name="sizeHint" stdset="0">
-        <size>
-         <width>40</width>
-         <height>20</height>
-        </size>
-       </property>
-      </spacer>
-     </item>
      <item>
       <widget class="QPushButton" name="up_button">
        <property name="maximumSize">
        </property>
       </widget>
      </item>
+     <item>
+      <spacer name="horizontalSpacer_2">
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="sizeHint" stdset="0">
+        <size>
+         <width>40</width>
+         <height>20</height>
+        </size>
+       </property>
+      </spacer>
+     </item>
+     <item>
+      <widget class="QPushButton" name="save_button">
+       <property name="text">
+        <string>&amp;Save…</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QPushButton" name="load_button">
+       <property name="text">
+        <string>&amp;Load…</string>
+       </property>
+      </widget>
+     </item>
      <item>
       <spacer name="horizontalSpacer">
        <property name="orientation">