From 214271bf1cc3b50008bf7cfe8dedba87cbaf89c4 Mon Sep 17 00:00:00 2001 From: Louis Moureaux Date: Fri, 17 Nov 2023 01:27:09 +0100 Subject: [PATCH 1/4] New generic widget: multi_slider I would like to have a generic version of fc_double_edge available. The general idea is that there is a fixed number of items and the user can distribute them across multiple categories. This could be useful for specialists in big cities. In order to level up the field a bit, I intend to make the new widget useable with the keyboard only. This commits starts implementing such a widget. Exploratory keyboard interaction has been implemented but I'm not satisfied with the result. --- client/CMakeLists.txt | 1 + client/widgets/multi_slider.cpp | 257 ++++++++++++++++++++++++++++++++ client/widgets/multi_slider.h | 68 +++++++++ 3 files changed, 326 insertions(+) create mode 100644 client/widgets/multi_slider.cpp create mode 100644 client/widgets/multi_slider.h diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 46e8b1e94a..495b93b4c4 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -139,6 +139,7 @@ target_sources( widgets/city/city_icon_widget.cpp widgets/city/governor_widget.cpp widgets/city/upkeep_widget.cpp + widgets/multi_slider.cpp widgets/report_widget.cpp # Generated diff --git a/client/widgets/multi_slider.cpp b/client/widgets/multi_slider.cpp new file mode 100644 index 0000000000..b032040e05 --- /dev/null +++ b/client/widgets/multi_slider.cpp @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: GPLv3-or-later +// SPDX-FileCopyrightText: Louis Moureaux + +#include "widgets/multi_slider.h" + +#include "log.h" + +#include +#include +#include +#include + +#include +#include + +namespace { + // Handle metrics + const double handle_bar_gap = 2; + const double handle_bar_width = 4; + const double handle_gap = 1; + const double handle_radius = 8; + const double handle_indicator_radius = 4; + const double handle_active_indicator_radius = handle_indicator_radius + 1; + const double handle_extra_height = handle_gap + handle_radius * 2; +} // anonymous namespace + +namespace freeciv { + +multi_slider::multi_slider(QWidget *parent): QAbstractSlider(parent) +{ + setFocusPolicy(Qt::StrongFocus); +} + +std::size_t multi_slider::add_category(const QString &name, const QPixmap &icon) +{ + m_categories.push_back({name, icon}); + m_values.push_back(0); + return m_categories.size() - 1; +} + +void multi_slider::set_range(std::size_t category, unsigned min, unsigned max) +{ + fc_assert_ret(category < m_categories.size()); + fc_assert_ret(min <= max); + m_categories[category].minimum = min; + m_categories[category].maximum = max; + // TODO modify current values if needed? -- user's responsibility +} + +void multi_slider::set_values(const std::vector &values) +{ + fc_assert_ret(values.size() == m_categories.size()); + m_values = values; + m_total = std::accumulate(m_values.begin(), m_values.end(), 0u); +} + +std::size_t multi_slider::total() const +{ + return m_total; +} + +QSize multi_slider::sizeHint() const +{ + if (m_categories.empty()) { + return QSize(); + } + + auto icon_size = m_categories.front().icon.size(); + return QSize(total() * icon_size.width() + 2 * handle_radius, + icon_size.height() + handle_extra_height); +} + +QSize multi_slider::minimumSizeHint() const +{ + if (m_categories.empty()) { + return QSize(); + } + + auto icon_size = m_categories.front().icon.size(); + return QSize(total() * 5 + 2 * handle_radius, + icon_size.height() + handle_extra_height); +} + +bool multi_slider::event(QEvent *event) +{ + // Allow using Tab and Backtab to move between visible handles + // We need to trap those early to override the default behaviour + if (event->type() == QEvent::KeyPress) { + auto kevt = dynamic_cast(event); + if (kevt->key() == Qt::Key_Tab) { + // Tab - check if focus should be moved to the next handle + auto handles = visible_handles(); + if (m_active_handle + 1 < handles.size()) { + m_active_handle++; + event->accept(); + update(); + return true; + } + } else if (kevt->key() == Qt::Key_Backtab) { + // Backtab - check if focus should be moved to the previous handle + if (m_active_handle > 0) { + m_active_handle--; + event->accept(); + update(); + return true; + } + } + } + return QAbstractSlider::event(event); +} + +void multi_slider::focusInEvent(QFocusEvent *event) +{ + if (m_values.size() > 2) { + if (event->reason() == Qt::BacktabFocusReason) { + auto handles = visible_handles(); + m_active_handle = handles.size() - 1; + } else { +// if (event->reason() == Qt::TabFocusReason) { + // TODO mouse focus + m_active_handle = 0; + } + } + QAbstractSlider::focusInEvent(event); +} + +void multi_slider::keyPressEvent(QKeyEvent *event) +{ + if (event->modifiers() == Qt::NoModifier) { + if (event->key() == Qt::Key_Left && move_handle_left()) { + event->accept(); + update(); + return; + } else if (event->key() == Qt::Key_Right && move_handle_right()) { + event->accept(); + update(); + return; + } + } + QAbstractSlider::keyPressEvent(event); +} + +void multi_slider::paintEvent(QPaintEvent *event) +{ + if (m_categories.empty()) { + return; + } + + // Assume all icons have the same width + const auto icon_size = m_categories.front().icon.size(); + const double step_width = std::min( + icon_size.width(), static_cast(width()) / total()); + + // Draw icons + QPainter p(this); + double xmin = 0, xmax = 0; + for (std::size_t i = 0; i < m_values.size(); ++i) { + xmax += m_values[i] * step_width; + p.drawTiledPixmap(QRectF(xmin, 0, xmax - xmin, icon_size.height()), + m_categories[i].icon, + QPointF(xmin, 0)); + xmin = xmax; + } + + // Draw handles (skipping the dummy last one) + p.save(); + p.setRenderHint(QPainter::Antialiasing); + p.setBrush(Qt::lightGray); + p.setPen(Qt::NoPen); + auto handles = visible_handles(); + for (auto location: handles) { + auto x = step_width * location; + + // Background + p.drawRect(QRectF(x - handle_bar_width / 2, handle_bar_gap, + handle_bar_width, icon_size.height() + handle_gap)); + p.drawEllipse(QPointF(x, icon_size.height() + handle_gap + handle_radius - 1), + handle_radius, handle_radius); + + // Active handle indicator + bool is_active = hasFocus() && location == handles[m_active_handle]; + double inner_radius = is_active ? handle_active_indicator_radius + : handle_indicator_radius; + p.setBrush(is_active ? Qt::red : Qt::darkGray); + p.drawEllipse(QPointF(x, icon_size.height() + handle_gap + handle_radius - 1), + inner_radius, inner_radius); + p.setBrush(Qt::lightGray); + } + p.restore(); +} + +std::vector multi_slider::visible_handles() const +{ + std::vector handles; + bool first = true; + unsigned location = 0; + for (auto it = m_values.begin(); it != m_values.end() - 1; ++it) { + location += *it; + if (first || *it > 0) { + handles.push_back(location); + } + first = false; + } + return handles; +} + +bool multi_slider::move_handle_left() +{ + auto handles = visible_handles(); + auto handle_location = handles[m_active_handle]; + if (handle_location == 0) { + return false; + } + + // Find categories to modify (starting from the left) + auto location = 0; + for (auto it = m_values.begin(); it != m_values.end(); ++it) { + location += *it; + if (location == handle_location) { + // Found + if (*it == 1) { + m_active_handle--; + } + (*it)--; + (*next(it))++; + return true; + } + } + return false; +} + +bool multi_slider::move_handle_right() +{ + auto handles = visible_handles(); + auto handle_location = handles[m_active_handle]; + if (handle_location == m_total) { + return false; + } + + // Find categories to modify (starting from the right) + auto location = m_total; + for (auto it = m_values.rbegin(); it != m_values.rend(); ++it) { + location -= *it; + if (location == handle_location) { + // Found + (*it)--; + if (*next(it) == 0) { + m_active_handle++; + } + (*next(it))++; + return true; + } + } + return false; +} + +} // namespace freeciv diff --git a/client/widgets/multi_slider.h b/client/widgets/multi_slider.h new file mode 100644 index 0000000000..cb10edfd3d --- /dev/null +++ b/client/widgets/multi_slider.h @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPLv3-or-later +// SPDX-FileCopyrightText: Louis Moureaux + +#pragma once + +#include + +#include + +namespace freeciv { + +/** + * \brief A widget that lets the user distribute a fixed number of items across + * multiple categories. + * + * Assumptions: + * - Categories are identified by their id + * - Categories are not added or removed dynamically + * - No category can have a negative number of items + * - Category names are translated + * - Icons for all categories are equally sized + * + * If space allows, one icon is used to represent one item. + */ +class multi_slider: public QAbstractSlider +{ + Q_OBJECT + + struct category + { + QString name; + QPixmap icon; + unsigned minimum = 0, maximum = -1; + }; + +public: + explicit multi_slider(QWidget *parent = nullptr); + virtual ~multi_slider() = default; + + std::size_t add_category(const QString &name, const QPixmap &icon); + void set_range(std::size_t category, unsigned min, unsigned max); + + void set_values(const std::vector &values); + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + + std::size_t total() const; + +protected: + bool event(QEvent *event) override; + void focusInEvent(QFocusEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void paintEvent(QPaintEvent *event) override; + +private: + std::vector visible_handles() const; + bool move_handle_left(); + bool move_handle_right(); + + // Invariant: m_categories.size() == m_handles.size() + std::vector m_categories; + std::vector m_values; + unsigned m_total; // Cached + std::size_t m_active_handle = 0; +}; + +} // namespace freeciv From 3c72a722f5dbc26afa48d2e3860bd95665f48818 Mon Sep 17 00:00:00 2001 From: Louis Moureaux Date: Thu, 16 Nov 2023 18:32:23 +0100 Subject: [PATCH 2/4] Better keyboard interaction for multi slider The first implementation of the multi slider allowed the user to move the handles with the keyboard. The result was confusing when handles came to overlap in the middle of a change. The diagram below explains the situation, with ABC three categories and the pipes representing the handles: We start with: AAAA|B|CCCCC Moving the left handle: AAAAA||CCCCC Here the two handles are on top of each other and cannot be distinguished. The implementation was chosing the rightmost handle, so moving the handle further right would result in: AAAAA|B|CCCC So the user was increasing the amount of A, but by repeating the same action the amount of A remains fixed and the amount of B increases. There are several possible remedies to this situation: * Use the left handle instead of the right one and keep increasing A. This doesn't work in the opposite direction (when increasing C). * Stop merging overlapping handles. The visual representation sounds complicated. * Remember what the user did last and apply some heuristics. Heuristics are bound to fail. Instead a completely different approach is needed. This is necessarily different from the mouse interaction, for which handles work very well (as fc_double_edge has proven). This commit choses to focus on the categories themselves, letting the user increase or decrease the number of items in the "current" category. Other categories are affected in the process and the code does its best to accomodate the user's desires (searching for available items first to the right, then to the left). Having experimented a bit with this new interface, I find it more natural to work with than the previous one. With some practice one can probably become quite efficient at using it. --- client/widgets/multi_slider.cpp | 237 +++++++++++++++++++------------- client/widgets/multi_slider.h | 31 +++-- 2 files changed, 160 insertions(+), 108 deletions(-) diff --git a/client/widgets/multi_slider.cpp b/client/widgets/multi_slider.cpp index b032040e05..2bac5b88ec 100644 --- a/client/widgets/multi_slider.cpp +++ b/client/widgets/multi_slider.cpp @@ -14,21 +14,38 @@ #include namespace { - // Handle metrics +/// Widget dimensions, in logical pixels +namespace metrics { + /// Gap between the icons and the focus indicator + const double focus_bar_gap = 1; + /// Height of the focus indicator + const double focus_bar_height = 2; + /// Gap at the top of the handle bar const double handle_bar_gap = 2; + /// Width of the handle bar const double handle_bar_width = 4; + /// Gap between the icons and the handle (circle) const double handle_gap = 1; + /// Radius of the handle const double handle_radius = 8; + /// Radius of the small disk inside the handle const double handle_indicator_radius = 4; + /// Radius of the small disk inside the handle, when the handle is active const double handle_active_indicator_radius = handle_indicator_radius + 1; - const double handle_extra_height = handle_gap + handle_radius * 2; + /// Height added to the icon height by control elements (handle etc) + const double extra_height = std::max(focus_bar_gap + focus_bar_height, + handle_gap + handle_radius * 2); +} // namespace metrics } // anonymous namespace namespace freeciv { -multi_slider::multi_slider(QWidget *parent): QAbstractSlider(parent) +multi_slider::multi_slider(QWidget *parent): QWidget(parent) { setFocusPolicy(Qt::StrongFocus); + setSizePolicy(QSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed, + QSizePolicy::Slider)); + setMouseTracking(true); } std::size_t multi_slider::add_category(const QString &name, const QPixmap &icon) @@ -38,7 +55,7 @@ std::size_t multi_slider::add_category(const QString &name, const QPixmap &icon) return m_categories.size() - 1; } -void multi_slider::set_range(std::size_t category, unsigned min, unsigned max) +void multi_slider::set_range(std::size_t category, int min, int max) { fc_assert_ret(category < m_categories.size()); fc_assert_ret(min <= max); @@ -47,11 +64,11 @@ void multi_slider::set_range(std::size_t category, unsigned min, unsigned max) // TODO modify current values if needed? -- user's responsibility } -void multi_slider::set_values(const std::vector &values) +void multi_slider::set_values(const std::vector &values) { fc_assert_ret(values.size() == m_categories.size()); m_values = values; - m_total = std::accumulate(m_values.begin(), m_values.end(), 0u); + m_total = std::accumulate(m_values.begin(), m_values.end(), 0); } std::size_t multi_slider::total() const @@ -66,8 +83,8 @@ QSize multi_slider::sizeHint() const } auto icon_size = m_categories.front().icon.size(); - return QSize(total() * icon_size.width() + 2 * handle_radius, - icon_size.height() + handle_extra_height); + return QSize(total() * icon_size.width() + 2 * metrics::handle_radius, + icon_size.height() + metrics::extra_height); } QSize multi_slider::minimumSizeHint() const @@ -77,67 +94,75 @@ QSize multi_slider::minimumSizeHint() const } auto icon_size = m_categories.front().icon.size(); - return QSize(total() * 5 + 2 * handle_radius, - icon_size.height() + handle_extra_height); + return QSize(total() * 5 + 2 * metrics::handle_radius, + icon_size.height() + metrics::extra_height); } bool multi_slider::event(QEvent *event) { - // Allow using Tab and Backtab to move between visible handles + // Allow using Tab and Backtab to move between visible categories // We need to trap those early to override the default behaviour if (event->type() == QEvent::KeyPress) { auto kevt = dynamic_cast(event); - if (kevt->key() == Qt::Key_Tab) { - // Tab - check if focus should be moved to the next handle - auto handles = visible_handles(); - if (m_active_handle + 1 < handles.size()) { - m_active_handle++; - event->accept(); - update(); - return true; - } - } else if (kevt->key() == Qt::Key_Backtab) { - // Backtab - check if focus should be moved to the previous handle - if (m_active_handle > 0) { - m_active_handle--; - event->accept(); - update(); - return true; - } + // Check if focus can be moved to the next visible category + if (kevt->key() == Qt::Key_Tab && move_focus(true)) { + event->accept(); + return true; + } else if (kevt->key() == Qt::Key_Backtab && move_focus(false)) { + event->accept(); + return true; } } - return QAbstractSlider::event(event); + return QWidget::event(event); } void multi_slider::focusInEvent(QFocusEvent *event) { if (m_values.size() > 2) { if (event->reason() == Qt::BacktabFocusReason) { - auto handles = visible_handles(); - m_active_handle = handles.size() - 1; + m_focused_category = m_categories.size() - 1; } else { // if (event->reason() == Qt::TabFocusReason) { // TODO mouse focus - m_active_handle = 0; + m_focused_category = 0; } } - QAbstractSlider::focusInEvent(event); + QWidget::focusInEvent(event); } void multi_slider::keyPressEvent(QKeyEvent *event) { if (event->modifiers() == Qt::NoModifier) { - if (event->key() == Qt::Key_Left && move_handle_left()) { - event->accept(); - update(); - return; - } else if (event->key() == Qt::Key_Right && move_handle_right()) { - event->accept(); - update(); - return; + switch (event->key()) { + case Qt::Key_Up: + if (exchange(m_focused_category, 1)) { + event->accept(); + return; + } + break; + case Qt::Key_Down: + if (exchange(m_focused_category, -1)) { + event->accept(); + return; + } + break; + case Qt::Key_Left: + if (move_focus(false)) { + event->accept(); + return; + } + break; + case Qt::Key_Right: + if (move_focus(true)) { + event->accept(); + return; + } + break; + default: + break; } } - QAbstractSlider::keyPressEvent(event); + QWidget::keyPressEvent(event); } void multi_slider::paintEvent(QPaintEvent *event) @@ -153,105 +178,123 @@ void multi_slider::paintEvent(QPaintEvent *event) // Draw icons QPainter p(this); + p.setPen(Qt::NoPen); + p.setRenderHint(QPainter::Antialiasing); + double xmin = 0, xmax = 0; for (std::size_t i = 0; i < m_values.size(); ++i) { xmax += m_values[i] * step_width; p.drawTiledPixmap(QRectF(xmin, 0, xmax - xmin, icon_size.height()), m_categories[i].icon, QPointF(xmin, 0)); + + // Focus indicator + if (hasFocus() && i == m_focused_category) { + p.setBrush(Qt::white); + p.drawRect(QRectF(xmin, icon_size.height() + metrics::focus_bar_gap, + xmax - xmin, metrics::focus_bar_height)); + } xmin = xmax; } // Draw handles (skipping the dummy last one) - p.save(); - p.setRenderHint(QPainter::Antialiasing); p.setBrush(Qt::lightGray); - p.setPen(Qt::NoPen); auto handles = visible_handles(); for (auto location: handles) { auto x = step_width * location; // Background - p.drawRect(QRectF(x - handle_bar_width / 2, handle_bar_gap, - handle_bar_width, icon_size.height() + handle_gap)); - p.drawEllipse(QPointF(x, icon_size.height() + handle_gap + handle_radius - 1), - handle_radius, handle_radius); + p.drawRect(QRectF(x - metrics::handle_bar_width / 2, metrics::handle_bar_gap, + metrics::handle_bar_width, icon_size.height() + metrics::handle_gap)); + p.drawEllipse(QPointF(x, icon_size.height() + metrics::handle_gap + metrics::handle_radius - 1), + metrics::handle_radius, metrics::handle_radius); // Active handle indicator - bool is_active = hasFocus() && location == handles[m_active_handle]; - double inner_radius = is_active ? handle_active_indicator_radius - : handle_indicator_radius; + bool is_active = false; // FIXME mouse + double inner_radius = is_active ? metrics::handle_active_indicator_radius + : metrics::handle_indicator_radius; p.setBrush(is_active ? Qt::red : Qt::darkGray); - p.drawEllipse(QPointF(x, icon_size.height() + handle_gap + handle_radius - 1), + p.drawEllipse(QPointF(x, icon_size.height() + metrics::handle_gap + metrics::handle_radius - 1), inner_radius, inner_radius); p.setBrush(Qt::lightGray); } - p.restore(); } -std::vector multi_slider::visible_handles() const +void multi_slider::exchange(std::size_t giver, std::size_t taker, int amount) { - std::vector handles; - bool first = true; - unsigned location = 0; - for (auto it = m_values.begin(); it != m_values.end() - 1; ++it) { - location += *it; - if (first || *it > 0) { - handles.push_back(location); - } - first = false; - } - return handles; + m_values[giver] -= amount; + m_values[taker] += amount; + focus_some_category(); + update(); } -bool multi_slider::move_handle_left() +bool multi_slider::exchange(std::size_t taker, int amount) { - auto handles = visible_handles(); - auto handle_location = handles[m_active_handle]; - if (handle_location == 0) { + const auto &category = m_categories[m_focused_category]; + if (!category.allowed(m_values[m_focused_category] + amount)) { return false; } - // Find categories to modify (starting from the left) - auto location = 0; - for (auto it = m_values.begin(); it != m_values.end(); ++it) { - location += *it; - if (location == handle_location) { - // Found - if (*it == 1) { - m_active_handle--; - } - (*it)--; - (*next(it))++; + // Find category to exchange with. First look to the right... + for (int i = m_focused_category + 1; i < m_categories.size(); ++i) { + if (m_categories[i].allowed(m_values[i] - amount)) { + exchange(i, m_focused_category, amount); + return true; + } + } + + // No luck to the right. Try on the other side + for (int i = m_focused_category - 1; i >= 0; --i) { + if (m_categories[i].allowed(m_values[i] - amount)) { + exchange(i, m_focused_category, amount); return true; } } + + // No luck return false; } -bool multi_slider::move_handle_right() +void multi_slider::focus_some_category() { - auto handles = visible_handles(); - auto handle_location = handles[m_active_handle]; - if (handle_location == m_total) { - return false; + if (m_values[m_focused_category] > 0) { + // Already good + return; } - // Find categories to modify (starting from the right) - auto location = m_total; - for (auto it = m_values.rbegin(); it != m_values.rend(); ++it) { - location -= *it; - if (location == handle_location) { - // Found - (*it)--; - if (*next(it) == 0) { - m_active_handle++; - } - (*next(it))++; + // One of them will always succeed + if (!move_focus(true)) { + move_focus(false); + } +} + +bool multi_slider::move_focus(bool forward) +{ + int step = forward ? 1 : -1; + // Check if focus can be moved to the next visible category + for (int i = m_focused_category + step; i >= 0 && i < m_categories.size(); i += step) { + if (m_values[i] > 0) { + m_focused_category = i; + update(); return true; } } return false; } +std::vector multi_slider::visible_handles() const +{ + std::vector handles; + bool first = true; + int location = 0; + for (auto it = m_values.begin(); it != m_values.end() - 1; ++it) { + location += *it; + if (first || *it > 0) { + handles.push_back(location); + } + first = false; + } + return handles; +} + } // namespace freeciv diff --git a/client/widgets/multi_slider.h b/client/widgets/multi_slider.h index cb10edfd3d..d6e459d251 100644 --- a/client/widgets/multi_slider.h +++ b/client/widgets/multi_slider.h @@ -3,8 +3,9 @@ #pragma once -#include +#include +#include #include namespace freeciv { @@ -19,10 +20,12 @@ namespace freeciv { * - No category can have a negative number of items * - Category names are translated * - Icons for all categories are equally sized + * - Maximum is exclusive * * If space allows, one icon is used to represent one item. + * TODO tooltips */ -class multi_slider: public QAbstractSlider +class multi_slider: public QWidget { Q_OBJECT @@ -30,7 +33,9 @@ class multi_slider: public QAbstractSlider { QString name; QPixmap icon; - unsigned minimum = 0, maximum = -1; + int minimum = 0, maximum = std::numeric_limits::max(); + + bool allowed(int value) const { return value >= minimum && value < maximum; } }; public: @@ -38,9 +43,9 @@ class multi_slider: public QAbstractSlider virtual ~multi_slider() = default; std::size_t add_category(const QString &name, const QPixmap &icon); - void set_range(std::size_t category, unsigned min, unsigned max); + void set_range(std::size_t category, int min, int max); - void set_values(const std::vector &values); + void set_values(const std::vector &values); QSize sizeHint() const override; QSize minimumSizeHint() const override; @@ -54,15 +59,19 @@ class multi_slider: public QAbstractSlider void paintEvent(QPaintEvent *event) override; private: - std::vector visible_handles() const; - bool move_handle_left(); - bool move_handle_right(); + void exchange(std::size_t giver, std::size_t taker, int amount); + bool exchange(std::size_t taker, int amount); + + void focus_some_category(); + bool move_focus(bool forward); + + std::vector visible_handles() const; // Invariant: m_categories.size() == m_handles.size() std::vector m_categories; - std::vector m_values; - unsigned m_total; // Cached - std::size_t m_active_handle = 0; + std::vector m_values; + int m_total; // Cached + int m_focused_category = 0; }; } // namespace freeciv From 18d62858de9bfd3ac335d3e52b2f0b9d8b532a9c Mon Sep 17 00:00:00 2001 From: Louis Moureaux Date: Fri, 17 Nov 2023 01:07:40 +0100 Subject: [PATCH 3/4] Implement mouse interaction for the multi slider Mouse interaction for the multi slider bears a lot to fc_double_edge. It was improved in a few areas: * Single clicks no longer make the handles jump around. Drag or double click is now required. * Better rounding results in smoother moving of the handles. * Small visual hints indicate which handle is active. The visual style is significantly different. More work is likely needed to properly integrate the widget with Classic (though it is usable). --- client/widgets/multi_slider.cpp | 486 +++++++++++++++++++++++++++----- client/widgets/multi_slider.h | 75 +++-- 2 files changed, 457 insertions(+), 104 deletions(-) diff --git a/client/widgets/multi_slider.cpp b/client/widgets/multi_slider.cpp index 2bac5b88ec..6d8c46bc4d 100644 --- a/client/widgets/multi_slider.cpp +++ b/client/widgets/multi_slider.cpp @@ -5,77 +5,184 @@ #include "log.h" -#include #include #include +#include #include #include -#include +#include namespace { +/// Colors +namespace colors { +/// Focus indicator (thin line under one of the categories) +const QColor focus_indicator = Qt::gray; +/// Background of the handles +const QColor handle_background = Qt::lightGray; +/// Small dot on the handles +const QColor handle_indicator = Qt::gray; +/// Small dot on the handles, when the handle is hovered +const QColor handle_hover = Qt::darkGray; +/// Small dot on the handles, when the handle is being dragged +const QColor handle_dragged = handle_hover; +} // namespace colors /// Widget dimensions, in logical pixels namespace metrics { - /// Gap between the icons and the focus indicator - const double focus_bar_gap = 1; - /// Height of the focus indicator - const double focus_bar_height = 2; - /// Gap at the top of the handle bar - const double handle_bar_gap = 2; - /// Width of the handle bar - const double handle_bar_width = 4; - /// Gap between the icons and the handle (circle) - const double handle_gap = 1; - /// Radius of the handle - const double handle_radius = 8; - /// Radius of the small disk inside the handle - const double handle_indicator_radius = 4; - /// Radius of the small disk inside the handle, when the handle is active - const double handle_active_indicator_radius = handle_indicator_radius + 1; - /// Height added to the icon height by control elements (handle etc) - const double extra_height = std::max(focus_bar_gap + focus_bar_height, - handle_gap + handle_radius * 2); +/// Gap between the icons and the focus indicator +const double focus_bar_gap = 3; +/// Height of the focus indicator +const double focus_bar_height = 1; +/// Gap at the top of the handle bar +const double handle_bar_gap = 2; +/// Width of the handle bar +const double handle_bar_width = 4; +/// Gap between the icons and the handle (circle) +const double handle_gap = 1; +/// Radius of the handle +const double handle_radius = 8; +/// Radius of the small disk inside the handle +const double handle_indicator_radius = 4; +/// Radius of the small disk inside the handle, when the handle is active +const double handle_active_indicator_radius = handle_indicator_radius + 1; +/// Height added to the icon height by control elements (handle etc) +const double extra_height = std::max(focus_bar_gap + focus_bar_height, + handle_gap + handle_radius * 2); } // namespace metrics } // anonymous namespace namespace freeciv { -multi_slider::multi_slider(QWidget *parent): QWidget(parent) +/** + * \class multi_slider + * \brief A widget that lets the user distribute a fixed number of items + * across multiple categories. + * + * This widget provides a slider with multiple handles. The width of the + * slider represents a number of items that the user can distribute across + * multiple categories. For instance, the items could be citizens that would + * be distributed to perform various tasks. + * + * The widget needs an icon for each category. The icon should represent a + * single item in the category. When possible, the widget displays each item + * using one complete icon. It is important that all icons be of the same + * size. + * + * Categories are initially added with \ref add_category. A minimum and + * maximum number of items in each category can optionally be set with \ref + * set_range. The displayed values are set using \ref set_values and + * recovered with \ref values. The signal \ref values_changed is emitted each + * time the user redistributes items (which can be quite frequent). + * + * Users can interact with this widget using the keyboard or the mouse, with + * interaction patterns optimized for each device. When using the keyboard, + * the user can navigate between categories using the left and right arrow + * keys, and add or remove items to the current category using the up and + * down arrows. Of course, in doing so they also modify other categories. The + * current category is indicated with a slight underline and is also + * integrated in tab navigation. + * + * When using the mouse, the user can grab handles shown between categories + * and drag them wherever they want to adjust the number of items. It is also + * possible to double-click, which moves the closest handle to the location + * pointed to by the mouse. + * + * It is a good idea to have a legend explaining what the icons mean next to + * this widget, as it is not self-explanatory. + * + * \internal + * Handles are represented in two ways in the implementation: + * * Sometimes we use an explicit struct handle; + * * Sometimes we use a simple integer (index). + * The index refers to the two categories between which the handle sits. When + * some categories are hidden because no items are assigned to them, the + * handles overlap and some of them are hidden. The struct handle is + * used only for handles displayed on the screen. + */ + +/** + * \brief Constructor. + */ +multi_slider::multi_slider(QWidget *parent) : QWidget(parent) { setFocusPolicy(Qt::StrongFocus); + setMouseTracking(true); setSizePolicy(QSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed, QSizePolicy::Slider)); - setMouseTracking(true); } -std::size_t multi_slider::add_category(const QString &name, const QPixmap &icon) +/** + * \brief Adds a category. + * \param icon An icon representing a single item in the category. All icons + * must have the same size. + * \returns The index of the new category. + */ +std::size_t multi_slider::add_category(const QPixmap &icon) { - m_categories.push_back({name, icon}); + m_categories.push_back({icon}); m_values.push_back(0); + + if (icon.size() != m_categories.front().icon.size()) { + qWarning() << "Inconsistent icon sizes:" << icon.size() << "and" + << m_categories.front().icon.size(); + } + return m_categories.size() - 1; } +/** + * \brief Sets the minimum and maximum number of items a category can have. + * + * By default the minimum is zero and the maximum is very large. + * + * \param category The index of the category to modify. + * \param min The smallest allowed value, may not be smaller than zero. + * \param max The largest allowed value. + */ void multi_slider::set_range(std::size_t category, int min, int max) { fc_assert_ret(category < m_categories.size()); + fc_assert_ret(min >= 0); fc_assert_ret(min <= max); + m_categories[category].minimum = min; m_categories[category].maximum = max; - // TODO modify current values if needed? -- user's responsibility + + update_cached_geometry(); + updateGeometry(); } +/** + * \brief Sets the contents of all item categories. + * + * \note It is the user's responsibility to ensure that min/max constraints + * are satisfied. + */ void multi_slider::set_values(const std::vector &values) { fc_assert_ret(values.size() == m_categories.size()); + m_values = values; - m_total = std::accumulate(m_values.begin(), m_values.end(), 0); + update_cached_geometry(); + updateGeometry(); + + emit values_changed(values); } +/** + * \brief Returns the total number of items controlled by this widget. + */ std::size_t multi_slider::total() const { - return m_total; + return std::accumulate(m_values.begin(), m_values.end(), 0); } +/** + * \brief Preferred size of the widget. + * + * The width is the icon width times the number of items plus extra space for + * handles, the height is the icon height plus space for handles. + */ QSize multi_slider::sizeHint() const { if (m_categories.empty()) { @@ -87,6 +194,12 @@ QSize multi_slider::sizeHint() const icon_size.height() + metrics::extra_height); } +/** + * \brief Minimum size of the widget. + * + * The width is 5 times the number of items plus extra space for handles, the + * height is the icon height plus space for handles. + */ QSize multi_slider::minimumSizeHint() const { if (m_categories.empty()) { @@ -98,6 +211,9 @@ QSize multi_slider::minimumSizeHint() const icon_size.height() + metrics::extra_height); } +/** + * \brief Overrides tab handling to also cycle through visible categories. + */ bool multi_slider::event(QEvent *event) { // Allow using Tab and Backtab to move between visible categories @@ -116,32 +232,47 @@ bool multi_slider::event(QEvent *event) return QWidget::event(event); } +/** + * \brief Focuses the first or last category when focus is gained with the + * keyboard. + */ void multi_slider::focusInEvent(QFocusEvent *event) { - if (m_values.size() > 2) { + if (!m_categories.empty()) { if (event->reason() == Qt::BacktabFocusReason) { m_focused_category = m_categories.size() - 1; - } else { -// if (event->reason() == Qt::TabFocusReason) { - // TODO mouse focus + } else if (event->reason() == Qt::TabFocusReason) { m_focused_category = 0; + } else { + // Keep old category alive if still present, making sure it's >= 0 + m_focused_category = std::max(m_focused_category, 0); } } QWidget::focusInEvent(event); } +/** + * \brief Handles arrow keys: left/right to change the focused category, + * up/down to add or remove items. + */ void multi_slider::keyPressEvent(QKeyEvent *event) { + if (m_categories.empty()) { + return; + } + if (event->modifiers() == Qt::NoModifier) { switch (event->key()) { case Qt::Key_Up: - if (exchange(m_focused_category, 1)) { + if (grab_item(m_focused_category, 1)) { + emit values_changed(values()); event->accept(); return; } break; case Qt::Key_Down: - if (exchange(m_focused_category, -1)) { + if (grab_item(m_focused_category, -1)) { + emit values_changed(values()); event->accept(); return; } @@ -165,61 +296,148 @@ void multi_slider::keyPressEvent(QKeyEvent *event) QWidget::keyPressEvent(event); } +/** + * \brief Sopts highlighting the closest handle. + */ +void multi_slider::leaveEvent(QEvent *event) +{ + m_closest_handle = -1; + update(); + QWidget::leaveEvent(event); +} + +/** + * \brief Moves the closest handle when double-clicking. + */ +void multi_slider::mouseDoubleClickEvent(QMouseEvent *event) +{ + if (event->buttons() == Qt::LeftButton + && event->modifiers() == Qt::NoModifier) { + // Double click + move_handle(handle_near(event->pos()), event->pos()); + } + QWidget::mouseMoveEvent(event); +} + +/** + * \brief Moves the current handle when dragging the mouse. + */ +void multi_slider::mouseMoveEvent(QMouseEvent *event) +{ + if (m_dragged_handle >= 0 && event->buttons() == Qt::LeftButton + && event->modifiers() == Qt::NoModifier) { + // Drag + move_handle(m_dragged_handle, event->pos()); + } + + // Update the closest handle + auto new_handle = handle_near(event->pos()); + if (new_handle != m_closest_handle) { + m_closest_handle = new_handle; + update(); + } + + QWidget::mouseMoveEvent(event); +} + +/** + * \brief Sets the current handle when pressing a mouse button. + */ +void multi_slider::mousePressEvent(QMouseEvent *event) +{ + if (event->buttons() == Qt::LeftButton + && event->modifiers() == Qt::NoModifier) { + m_dragged_handle = handle_near(event->pos()); + update(); + } + QWidget::mousePressEvent(event); +} + +/** + * \brief Unsets the current handle when releasing a mouse button. + */ +void multi_slider::mouseReleaseEvent(QMouseEvent *event) +{ + m_dragged_handle = -1; + update(); + QWidget::mouseReleaseEvent(event); +} + +/** + * \brief Draws the widget. + */ void multi_slider::paintEvent(QPaintEvent *event) { - if (m_categories.empty()) { + if (m_categories.empty() || total() <= 0) { return; } // Assume all icons have the same width - const auto icon_size = m_categories.front().icon.size(); - const double step_width = std::min( - icon_size.width(), static_cast(width()) / total()); + const auto iheight = m_categories.front().icon.height(); - // Draw icons + // Center everything QPainter p(this); + p.translate(m_geom.left_margin, 0); + + // Draw icons p.setPen(Qt::NoPen); p.setRenderHint(QPainter::Antialiasing); - double xmin = 0, xmax = 0; for (std::size_t i = 0; i < m_values.size(); ++i) { - xmax += m_values[i] * step_width; - p.drawTiledPixmap(QRectF(xmin, 0, xmax - xmin, icon_size.height()), - m_categories[i].icon, - QPointF(xmin, 0)); + xmax += m_values[i] * m_geom.item_width; + p.drawTiledPixmap(QRectF(xmin, 0, xmax - xmin, iheight), + m_categories[i].icon, QPointF(xmin, 0)); // Focus indicator if (hasFocus() && i == m_focused_category) { - p.setBrush(Qt::white); - p.drawRect(QRectF(xmin, icon_size.height() + metrics::focus_bar_gap, - xmax - xmin, metrics::focus_bar_height)); + p.setBrush(colors::focus_indicator); + p.drawRect(QRectF(xmin, iheight + metrics::focus_bar_gap, xmax - xmin, + metrics::focus_bar_height)); } xmin = xmax; } // Draw handles (skipping the dummy last one) - p.setBrush(Qt::lightGray); auto handles = visible_handles(); - for (auto location: handles) { - auto x = step_width * location; + for (auto h : handles) { + auto x = m_geom.item_width * h.location; // Background - p.drawRect(QRectF(x - metrics::handle_bar_width / 2, metrics::handle_bar_gap, - metrics::handle_bar_width, icon_size.height() + metrics::handle_gap)); - p.drawEllipse(QPointF(x, icon_size.height() + metrics::handle_gap + metrics::handle_radius - 1), + p.setBrush(colors::handle_background); + p.drawRect(QRectF(x - metrics::handle_bar_width / 2, + metrics::handle_bar_gap, metrics::handle_bar_width, + iheight + metrics::handle_gap)); + p.drawEllipse(QPointF(x, iheight + metrics::handle_gap + + metrics::handle_radius - 1), metrics::handle_radius, metrics::handle_radius); // Active handle indicator - bool is_active = false; // FIXME mouse - double inner_radius = is_active ? metrics::handle_active_indicator_radius - : metrics::handle_indicator_radius; - p.setBrush(is_active ? Qt::red : Qt::darkGray); - p.drawEllipse(QPointF(x, icon_size.height() + metrics::handle_gap + metrics::handle_radius - 1), + bool is_closest = h.index == m_closest_handle; + bool is_dragged = h.index == m_dragged_handle; + double inner_radius = is_dragged + ? metrics::handle_active_indicator_radius + : metrics::handle_indicator_radius; + p.setBrush(is_dragged ? colors::handle_dragged + : is_closest ? colors::handle_hover + : colors::handle_indicator); + p.drawEllipse(QPointF(x, iheight + metrics::handle_gap + + metrics::handle_radius - 1), inner_radius, inner_radius); - p.setBrush(Qt::lightGray); } } +/** + * \brief Updates cached geometry information. + */ +void multi_slider::resizeEvent(QResizeEvent *event) +{ + update_cached_geometry(); +} + +/** + * \brief Exchange items between two categories. + * \warning This is a low-level function that doesn't check anything. + */ void multi_slider::exchange(std::size_t giver, std::size_t taker, int amount) { m_values[giver] -= amount; @@ -228,26 +446,42 @@ void multi_slider::exchange(std::size_t giver, std::size_t taker, int amount) update(); } -bool multi_slider::exchange(std::size_t taker, int amount) +/** + * \brief Grab an item from elsewhere and adds it to the @c taker category. + * \param taker Index of the category to add an item to. + * \param amount -1 to give an item away instead. + * \param from_left Allows taking items from (or giving them to) categories + * on the left of \c taker. \param from_right Allows taking items from (or + * giving them to) categories on the right of \c taker. \return Whether an + * item could be found. + */ +bool multi_slider::grab_item(std::size_t taker, int amount, bool from_left, + bool from_right) { - const auto &category = m_categories[m_focused_category]; - if (!category.allowed(m_values[m_focused_category] + amount)) { + fc_assert_ret_val(taker < m_categories.size(), false); + + const auto &category = m_categories[taker]; + if (!category.allowed(m_values[taker] + amount)) { return false; } // Find category to exchange with. First look to the right... - for (int i = m_focused_category + 1; i < m_categories.size(); ++i) { - if (m_categories[i].allowed(m_values[i] - amount)) { - exchange(i, m_focused_category, amount); - return true; + if (from_right) { + for (int i = taker + 1; i < m_categories.size(); ++i) { + if (m_categories[i].allowed(m_values[i] - amount)) { + exchange(i, taker, amount); + return true; + } } } // No luck to the right. Try on the other side - for (int i = m_focused_category - 1; i >= 0; --i) { - if (m_categories[i].allowed(m_values[i] - amount)) { - exchange(i, m_focused_category, amount); - return true; + if (from_left) { + for (int i = taker - 1; i >= 0; --i) { + if (m_categories[i].allowed(m_values[i] - amount)) { + exchange(i, taker, amount); + return true; + } } } @@ -255,9 +489,12 @@ bool multi_slider::exchange(std::size_t taker, int amount) return false; } +/** + * \brief Makes sure the focused category is a visible one. + */ void multi_slider::focus_some_category() { - if (m_values[m_focused_category] > 0) { + if (m_categories.empty() || m_values[m_focused_category] > 0) { // Already good return; } @@ -268,11 +505,18 @@ void multi_slider::focus_some_category() } } +/** + * \brief Moves focus to the next or previous visible category. + * \param forward Whether to move focus to the right (\c true) or to the left + * (\c false). + * \returns True if a valid category is now focused. + */ bool multi_slider::move_focus(bool forward) { int step = forward ? 1 : -1; // Check if focus can be moved to the next visible category - for (int i = m_focused_category + step; i >= 0 && i < m_categories.size(); i += step) { + for (int i = m_focused_category + step; i >= 0 && i < m_categories.size(); + i += step) { if (m_values[i] > 0) { m_focused_category = i; update(); @@ -282,15 +526,101 @@ bool multi_slider::move_focus(bool forward) return false; } -std::vector multi_slider::visible_handles() const +/** + * \brief Finds the index of the handle closest to the given position. + */ +int multi_slider::handle_near(const QPoint &where) +{ + const auto handles = visible_handles(); + const auto handle_x = [this](const handle &h) { + return m_geom.left_margin + h.location * m_geom.item_width; + }; + const auto best_handle = + std::min_element(handles.begin(), handles.end(), + [where, handle_x](const handle &a, const handle &b) { + return std::abs(where.x() - handle_x(a)) + < std::abs(where.x() - handle_x(b)); + }); + return best_handle->index; +} + +/** + * \brief Tries to move a handle closer to a given position. + * \param handle The index of the handle to move. + * \param where The location where to move it. + * \returns True one success. + */ +bool multi_slider::move_handle(int handle, const QPoint &where) +{ + // Target location of the handle + int target = + std::round((where.x() - m_geom.left_margin) / m_geom.item_width); + + // Current location of the handle + int current = + std::accumulate(m_values.begin(), m_values.begin() + handle + 1, 0); + + // Direction in which we move the handle + bool moving_left = current > target; + + // Category gaining items + int taker = moving_left ? handle + 1 : handle; + + // Try to transfer items to the taker + for (int i = 0; i < std::abs(current - target); ++i) { + // grab_item works in units of 1 item + if (!grab_item(taker, 1, moving_left, !moving_left)) { + // Nothing more we can do to move the handle in this direction + return false; + } + } + emit values_changed(values()); + return true; +} + +/** + * \brief Updates cached geometry information. + */ +void multi_slider::update_cached_geometry() +{ + const auto icon_size = m_categories.front().icon.size(); + const auto items = total(); + + // Safety - we shouldn't be used this way... + if (items <= 0) { + m_geom = {1, 0, 1}; + return; + } + + m_geom.icons_width = items * icon_size.width(); + int total_width = m_geom.icons_width + 2 * metrics::handle_radius; + + // Adjust if we don't have enough space + if (total_width > width()) { + int available_width = width() - 2 * metrics::handle_radius; + int icon_count = + available_width / icon_size.width(); // Note we round down + m_geom.icons_width = icon_count * icon_size.width(); + } + + m_geom.left_margin = (width() - m_geom.icons_width) / 2; + + // If we have enough space, this is equal to the width of one icon + m_geom.item_width = static_cast(m_geom.icons_width) / items; +} + +/** + * \brief Returns the list of all visible handles. + */ +std::vector multi_slider::visible_handles() const { - std::vector handles; + std::vector handles; bool first = true; int location = 0; - for (auto it = m_values.begin(); it != m_values.end() - 1; ++it) { - location += *it; - if (first || *it > 0) { - handles.push_back(location); + for (int i = 0; i < m_values.size() - 1; ++i) { + location += m_values[i]; + if (first || m_values[i] > 0) { + handles.push_back({i, location}); } first = false; } diff --git a/client/widgets/multi_slider.h b/client/widgets/multi_slider.h index d6e459d251..65182cdbd0 100644 --- a/client/widgets/multi_slider.h +++ b/client/widgets/multi_slider.h @@ -10,41 +10,34 @@ namespace freeciv { -/** - * \brief A widget that lets the user distribute a fixed number of items across - * multiple categories. - * - * Assumptions: - * - Categories are identified by their id - * - Categories are not added or removed dynamically - * - No category can have a negative number of items - * - Category names are translated - * - Icons for all categories are equally sized - * - Maximum is exclusive - * - * If space allows, one icon is used to represent one item. - * TODO tooltips - */ -class multi_slider: public QWidget -{ +class multi_slider : public QWidget { Q_OBJECT - struct category - { - QString name; + struct category { QPixmap icon; int minimum = 0, maximum = std::numeric_limits::max(); - bool allowed(int value) const { return value >= minimum && value < maximum; } + /// Checks if the category could take some value + bool allowed(int value) const + { + return value >= minimum && value <= maximum; + } + }; + + struct handle { + int index; + int location; }; public: explicit multi_slider(QWidget *parent = nullptr); virtual ~multi_slider() = default; - std::size_t add_category(const QString &name, const QPixmap &icon); + std::size_t add_category(const QPixmap &icon); void set_range(std::size_t category, int min, int max); + /// Retrieves the number of items in each category. + std::vector values() const { return m_values; } void set_values(const std::vector &values); QSize sizeHint() const override; @@ -52,26 +45,56 @@ class multi_slider: public QWidget std::size_t total() const; +signals: + void values_changed(const std::vector &values) const; + protected: bool event(QEvent *event) override; + void focusInEvent(QFocusEvent *event) override; void keyPressEvent(QKeyEvent *event) override; + void leaveEvent(QEvent *event) override; + void mouseDoubleClickEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; void paintEvent(QPaintEvent *event) override; + void resizeEvent(QResizeEvent *event) override; private: void exchange(std::size_t giver, std::size_t taker, int amount); - bool exchange(std::size_t taker, int amount); + bool grab_item(std::size_t taker, int amount, bool from_left = true, + bool from_right = true); void focus_some_category(); bool move_focus(bool forward); - std::vector visible_handles() const; + int handle_near(const QPoint &where); + bool move_handle(int handle, const QPoint &where); - // Invariant: m_categories.size() == m_handles.size() + void update_cached_geometry(); + std::vector visible_handles() const; + + /// Category data std::vector m_categories; + // Invariant: m_categories.size() == m_handles.size() + + /// Number of items in each category std::vector m_values; - int m_total; // Cached + + /// Index of the category receiving keyboard input int m_focused_category = 0; + + /// Index of the handle being dragged with the mouse + int m_closest_handle = -1; + int m_dragged_handle = -1; + + /// Cached geometry information + struct { + int icons_width = 1; ///< Width of the area covered with icons + int left_margin = 0; ///< Empty space left of the icons + double item_width = 1; ///< The logical width of one item + } m_geom; }; } // namespace freeciv From 4e520ee368bbfddb5e1173adac7ee5d72e29d2e1 Mon Sep 17 00:00:00 2001 From: Louis Moureaux Date: Fri, 17 Nov 2023 01:26:49 +0100 Subject: [PATCH 4/4] Hook the new multi slider into the budget dialog This removes fc_double_edge, of which multi_slider is a more capable version. --- client/ratesdlg.cpp | 235 +++++--------------------------------------- client/ratesdlg.h | 34 +------ 2 files changed, 29 insertions(+), 240 deletions(-) diff --git a/client/ratesdlg.cpp b/client/ratesdlg.cpp index 6312465330..d2a6ce2e56 100644 --- a/client/ratesdlg.cpp +++ b/client/ratesdlg.cpp @@ -18,6 +18,7 @@ #include // common #include "effects.h" +#include "fc_types.h" #include "government.h" #include "multipliers.h" #include "packets.h" @@ -28,7 +29,7 @@ #include "dialogs.h" #include "fc_client.h" #include "icons.h" -#include "tileset/sprite.h" +#include "widgets/multi_slider.h" static int scale_to_mult(const struct multiplier *pmul, int scale); static int mult_to_scale(const struct multiplier *pmul, int val); @@ -72,8 +73,10 @@ national_budget_dialog::national_budget_dialog(QWidget *parent) some_layout->addWidget(cancel_button); some_layout->addWidget(apply_button); some_layout->addWidget(ok_button); - fcde = new fc_double_edge(this); - main_layout->addWidget(fcde); + + slider = new freeciv::multi_slider; + main_layout->addWidget(slider); + main_layout->addSpacing(20); main_layout->addLayout(some_layout); setLayout(main_layout); @@ -93,7 +96,21 @@ void national_budget_dialog::refresh() .arg(government_name_for_player(client.conn.playing), QString::number(max))); - fcde->refresh(); + if (!slider_init) { + for (auto tax : {O_GOLD, O_SCIENCE, O_LUXURY}) { + auto sprite = get_tax_sprite(tileset, tax); + slider->add_category(sprite->scaled(sprite->size() * 2)); + } + slider_init = true; + } + slider->set_range(0, 0, max / 10); + slider->set_range(1, 0, max / 10); + slider->set_range(2, 0, max / 10); + slider->set_values({ + client.conn.playing->economic.tax / 10, + client.conn.playing->economic.science / 10, + client.conn.playing->economic.luxury / 10, + }); } /** @@ -101,9 +118,11 @@ void national_budget_dialog::refresh() */ void national_budget_dialog::apply() { - dsend_packet_player_rates(&client.conn, 10 * fcde->current_min, - 10 * (10 - fcde->current_max), - 10 * (fcde->current_max - fcde->current_min)); + auto rates = slider->values(); + dsend_packet_player_rates(&client.conn, + 10 * rates[0], // Tax + 10 * rates[2], // Lux + 10 * rates[1]); // Sci } /** @@ -247,205 +266,3 @@ void popup_multiplier_dialog() mrd = new multipler_rates_dialog(king()->central_wdg); mrd->show(); } - -/** - Double edged slider constructor - */ -fc_double_edge::fc_double_edge(QWidget *parent) : QWidget(parent) -{ - mouse_x = 0.; - moved = 0; - on_min = false; - on_max = false; - cursor_size = 0; - - cursor_pix = *fcIcons::instance()->getPixmap(QStringLiteral("control")); - setMouseTracking(true); - - setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); - - refresh(); -} - -/** - Double edged slider destructor - */ -fc_double_edge::~fc_double_edge() = default; - -/** - * Refreshes tax data - */ -void fc_double_edge::refresh() -{ - if (client.conn.playing) { - current_min = client.conn.playing->economic.tax / 10; - current_max = 10 - (client.conn.playing->economic.luxury / 10); - max_rates = get_player_bonus(client.conn.playing, EFT_MAX_RATES) / 10; - } else { - current_min = 0; - current_max = 10; - max_rates = 10; - } -} - -/** - Default size for double edge slider - */ -QSize fc_double_edge::sizeHint() const -{ - const auto sprite = get_tax_sprite(tileset, O_LUXURY); - return QSize(20 * sprite->width(), 2 * sprite->height()); -} - -/** - Double edge paint event - */ -void fc_double_edge::paintEvent(QPaintEvent *event) -{ - Q_UNUSED(event) - QPainter p; - int i, j, pos; - QPixmap pix_scaled; - QSize s; - double x_min, x_max; - - cursor_pix = cursor_pix.scaled(width() / 20, height()); - cursor_size = cursor_pix.width(); - p.begin(this); - - x_min = static_cast(current_min) / 10 - * ((width() - 1) - 2 * cursor_size) - + cursor_size; - x_max = static_cast(current_max) / 10 - * ((width() - 1) - 2 * cursor_size) - + cursor_size; - - pos = cursor_size; - auto pix = get_tax_sprite(tileset, O_GOLD); - s.setWidth((width() - 2 * cursor_size) / 10); - s.setHeight(height()); - pix_scaled = - pix->scaled(s, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - for (i = 0; i < current_min; i++) { - p.drawPixmap(pos, 0, pix_scaled); - pos = pos + pix_scaled.width(); - } - j = i; - pix = get_tax_sprite(tileset, O_SCIENCE); - pix_scaled = - pix->scaled(s, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - for (i = j; i < current_max; i++) { - p.drawPixmap(pos, 0, pix_scaled); - pos = pos + pix_scaled.width(); - } - j = i; - pix = get_tax_sprite(tileset, O_LUXURY); - pix_scaled = - pix->scaled(s, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - for (i = j; i < 10; i++) { - p.drawPixmap(pos, 0, pix_scaled); - pos = pos + pix_scaled.width(); - } - p.drawPixmap(x_max - cursor_size / 2, 0, cursor_pix); - p.drawPixmap(x_min - cursor_size / 2, 0, cursor_pix); - p.end(); -} - -/** - Double edged slider mouse press event - */ -void fc_double_edge::mousePressEvent(QMouseEvent *event) -{ - if (event->buttons() & Qt::LeftButton) { - mouse_x = static_cast(event->x()); - - if (mouse_x <= current_max * width() / 10 - 2 * cursor_size) { - moved = 1; - } else { - moved = 2; - } - } else { - moved = 0; - } - mouseMoveEvent(event); - update(); -} - -/** - Double edged slider mouse move event - */ -void fc_double_edge::mouseMoveEvent(QMouseEvent *event) -{ - float x_min, x_max, x_mouse; - - if (on_max || on_min) { - setCursor(Qt::SizeHorCursor); - } else { - setCursor(Qt::ArrowCursor); - } - - x_mouse = static_cast(event->x()); - x_min = static_cast(current_min) / 10 - * ((width() - 1) - 2 * cursor_size) - + cursor_size; - x_max = static_cast(current_max) / 10 - * ((width() - 1) - 2 * cursor_size) - + cursor_size; - - on_min = (((x_mouse > (x_min - cursor_size * 1.1)) - && (x_mouse < (x_min + cursor_size * 1.1))) - && (!on_max)) - || (moved == 1); - on_max = (((x_mouse > (x_max - cursor_size * 1.1)) - && (x_mouse < (x_max + cursor_size * 1.1))) - && !on_min) - || (moved == 2); - if (event->buttons() & Qt::LeftButton) { - if ((moved != 2) && on_min) { - x_min = x_mouse * width() / ((width() - 1) - 2 * cursor_size) - - cursor_size; - if (x_min < 0) { - x_min = 0; - } - if (x_min > width()) { - x_min = width(); - } - current_min = (x_min * 10 / (width() - 1)); - if (current_min > max_rates) { - current_min = max_rates; - } - if (current_max < current_min) { - current_max = current_min; - } - if (current_max - current_min > max_rates) { - current_min = current_max - max_rates; - } - moved = 1; - } else if ((moved != 1) && on_max) { - x_max = x_mouse * width() / ((width() - 1) - 2 * cursor_size) - - cursor_size; - if (x_max < 0) { - x_max = 0; - } - if (x_max > width()) { - x_max = width(); - } - current_max = (x_max * 10 / (width() - 1)); - if (current_max > max_rates + current_min) { - current_max = max_rates + current_min; - } - if (current_max < 10 - max_rates) { - current_max = 10 - max_rates; - } - if (current_min > current_max) { - current_min = current_max; - } - moved = 2; - } - update(); - } else { - moved = 0; - } - - mouse_x = x_mouse; -} diff --git a/client/ratesdlg.h b/client/ratesdlg.h index c7fece784d..6b0fc897dd 100644 --- a/client/ratesdlg.h +++ b/client/ratesdlg.h @@ -14,6 +14,7 @@ #include // gui-qt #include "dialogs.h" +#include "widgets/multi_slider.h" class QMouseEvent; class QObject; @@ -22,36 +23,6 @@ class QPushButton; class QSize; class QSlider; -/************************************************************************** - * Custom slider with two settable values - */ -class fc_double_edge : public QWidget { - Q_OBJECT - -private: - double cursor_size; - double mouse_x; - int moved; - bool on_min; - bool on_max; - int max_rates; - QPixmap cursor_pix; - -public: - fc_double_edge(QWidget *parent = nullptr); - ~fc_double_edge() override; - int current_min; - int current_max; - - void refresh(); - QSize sizeHint() const override; - -protected: - void paintEvent(QPaintEvent *event) override; - void mousePressEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *event) override; -}; - /************************************************************************** * Dialog used to change national budget */ @@ -64,7 +35,8 @@ class national_budget_dialog : public qfc_dialog { void refresh(); private: - fc_double_edge *fcde; + freeciv::multi_slider *slider; + bool slider_init = false; QLabel *m_info; void apply();