-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
modified title bar to include radial menu and collapsing
aim of this is to provide a cleaner, less cluttered, and maximised real-estate for charts, flow-graphs, etc. This notably is useful on mobile and reduces the cognitive complexity. Signed-off-by: Ralph J. Steinhagen <[email protected]>
- Loading branch information
1 parent
285da07
commit 5aad4bb
Showing
5 changed files
with
377 additions
and
89 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
add_library( | ||
app_header INTERFACE | ||
"fair_header.h" | ||
RadialCircularMenu.hpp | ||
) | ||
target_include_directories(app_header INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}) | ||
target_link_libraries(app_header INTERFACE stb fmt) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,258 @@ | ||
#ifndef OPENDIGITIZER_RADIALCIRCULARMENU_HPP | ||
#define OPENDIGITIZER_RADIALCIRCULARMENU_HPP | ||
|
||
#include <cmath> | ||
#include <functional> | ||
#include <imgui_internal.h> | ||
#include <string> | ||
#include <vector> | ||
|
||
namespace fair { | ||
|
||
namespace detail { | ||
ImVec4 lightenColor(const ImVec4 &color, float percent) { | ||
float h, s, v; | ||
ImGui::ColorConvertRGBtoHSV(color.x, color.y, color.z, h, s, v); | ||
s = std::max(0.0f, s * percent); | ||
float r, g, b; | ||
ImGui::ColorConvertHSVtoRGB(h, s, v, r, g, b); | ||
return ImVec4(r, g, b, color.w); | ||
} | ||
|
||
ImVec4 darkenColor(const ImVec4 &color, float percent) { | ||
float h, s, v; | ||
ImGui::ColorConvertRGBtoHSV(color.x, color.y, color.z, h, s, v); | ||
v = std::max(0.0f, v * percent); | ||
float r, g, b; | ||
ImGui::ColorConvertHSVtoRGB(h, s, v, r, g, b); | ||
return ImVec4(r, g, b, color.w); | ||
} | ||
} // namespace detail | ||
|
||
struct RadialButton { | ||
using CallbackFun = std::variant<std::function<void()>, std::function<void(RadialButton &)>>; | ||
std::string label; | ||
float size; | ||
CallbackFun onClick; | ||
ImFont *font = nullptr; | ||
std::string toolTip; | ||
bool isTransparent = false; | ||
float padding = std::max(ImGui::GetStyle().FramePadding.x, ImGui::GetStyle().FramePadding.y); | ||
ImVec4 buttonColor = ImGui::GetStyleColorVec4(ImGuiCol_Button); | ||
|
||
[[nodiscard]] bool create() { | ||
const std::string buttonId = fmt::format("#{}", label); | ||
ImGui::PushFont(font); | ||
bool isClicked = false; | ||
const ImVec2 textSize = ImGui::CalcTextSize(label.c_str()); | ||
const float maxSize = std::max(textSize.x, textSize.y); | ||
const float actualButtonSize = std::max(size, 2.f * padding + maxSize); | ||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, .5f * actualButtonSize); | ||
|
||
if (!isTransparent) { | ||
ImVec4 buttonColorHover = detail::lightenColor(buttonColor, 0.7f); | ||
ImVec4 buttonColorActive = detail::darkenColor(buttonColor, 0.7f); | ||
buttonColor.w = 1.0; | ||
buttonColorHover.w = 1.0; | ||
buttonColorActive.w = 1.0; | ||
ImGui::PushStyleColor(ImGuiCol_Button, buttonColor); | ||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, buttonColorHover); | ||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, buttonColorActive); | ||
} | ||
if (ImGui::Button(label.c_str(), ImVec2{ actualButtonSize, actualButtonSize })) { | ||
isClicked = true; | ||
} | ||
if (!isTransparent) { | ||
ImGui::PopStyleColor(3); | ||
} | ||
ImGui::PopStyleVar(); | ||
ImGui::PopFont(); | ||
|
||
if (ImGui::IsItemHovered() && !toolTip.empty()) { | ||
ImGui::SetTooltip("%s", toolTip.c_str()); | ||
} | ||
|
||
return isClicked; | ||
} | ||
}; | ||
|
||
template<std::size_t unique_id> | ||
class RadialCircularMenu { | ||
static std::vector<RadialButton> _buttons; | ||
static const std::string _popupId; | ||
static float _animationProgress; | ||
static bool _isOpen; | ||
const float _padding = ImGui::GetStyle().WindowPadding.x; | ||
ImVec2 _menuSize; | ||
float _startAngle = 0.f; | ||
float _stopAngle = 90.f; | ||
float _extraRadius = 0.f; | ||
float _animationSpeed = 0.25f; | ||
float _timeOut = 0.5f; // Time in seconds to close the menu when the mouse is out of range | ||
|
||
// | ||
[[nodiscard]] float maxButtonSize(std::size_t firstButtonIndex = 0) const { | ||
if (_buttons.empty()) { | ||
return std::max(_menuSize.x, _menuSize.y); | ||
} | ||
float max = 0.0; | ||
for (std::size_t index = firstButtonIndex; index < _buttons.size(); ++index) { | ||
max = std::max(max, _buttons[index].size); | ||
} | ||
return max; | ||
} | ||
|
||
[[nodiscard]] std::pair<std::size_t, float> maxButtonNumberAndSizeForArc(float baseArcRadius, std::size_t firstButtonIndex = 0) const { | ||
const float totalAngle = _stopAngle - _startAngle; | ||
const float arcLength = std::numbers::pi_v<float> * baseArcRadius * (totalAngle / 180.f); | ||
std::size_t buttonCount = 0UL; | ||
float maxButtonSize = .0f; | ||
float cumulativeLength = .0f; | ||
for (auto i = firstButtonIndex; i < _buttons.size(); ++i) { | ||
const auto &button = _buttons[i]; | ||
if ((cumulativeLength + button.size + _padding) > arcLength) { | ||
break; | ||
} | ||
++buttonCount; | ||
maxButtonSize = std::max(button.size, maxButtonSize); | ||
cumulativeLength += button.size + _padding; | ||
} | ||
return { buttonCount, maxButtonSize }; | ||
} | ||
|
||
public: | ||
explicit RadialCircularMenu(ImVec2 menuSize = { 100, 100 }, float startAngle = 0.f, float stopAngle = 360.f, float extraRadius = 0.f, float animationSpeed = .25f, float timeOut = 0.5f) | ||
: _menuSize(menuSize), _startAngle(startAngle), _stopAngle(stopAngle), _extraRadius(extraRadius), _animationSpeed(animationSpeed), _timeOut(timeOut) { | ||
updateAndDraw(); | ||
} | ||
|
||
void addButton(std::string label, auto onClick, float buttonSize = -1.f, std::string toolTip = "") { | ||
if (_animationProgress > 0.f) { // we do not allow to add buttons when the popup is already open or animating | ||
return; | ||
} | ||
_buttons.emplace_back(label, buttonSize, std::move(onClick), nullptr, std::move(toolTip)); | ||
_isOpen = true; | ||
} | ||
|
||
void addButton(std::string label, auto onClick, ImFont *font, std::string toolTip = "") { | ||
if (_animationProgress > 0.f) { // we do not allow to add buttons when the popup is already open or animating | ||
return; | ||
} | ||
float buttonSize = 0.f; | ||
if (font == nullptr) { | ||
buttonSize = ImGui::CalcTextSize(label.c_str()).y; | ||
} else { | ||
buttonSize = font->FontSize; | ||
} | ||
_buttons.emplace_back(label, buttonSize, std::move(onClick), font, std::move(toolTip)); | ||
_isOpen = true; | ||
} | ||
|
||
[[nodiscard]] bool isOpen() const noexcept { | ||
return _isOpen; | ||
} | ||
|
||
void forceClose() { | ||
_isOpen = false; | ||
_animationProgress = 0.0; | ||
_buttons.clear(); | ||
ImGui::CloseCurrentPopup(); | ||
} | ||
|
||
void updateAndDraw() { | ||
static float timeOutOfRadius = 0.0f; | ||
static ImVec2 centre{ -1.f, -1.f }; | ||
|
||
const float deltaTime = ImGui::GetIO().DeltaTime; | ||
_animationProgress = _isOpen ? std::min(1.0f, _animationProgress + deltaTime / _animationSpeed) : std::max(0.0f, _animationProgress - deltaTime / _animationSpeed); | ||
|
||
if (!_isOpen && _animationProgress <= 0.f) { | ||
centre = { -1.f, -1.f }; | ||
if (!_buttons.empty()) { | ||
_buttons.clear(); | ||
} | ||
return; | ||
} else if (_isOpen && (centre.x < 0.f || centre.y < 0.f)) { | ||
centre = ImGui::GetMousePos(); | ||
} | ||
|
||
std::size_t nButtonRows = 1UL; | ||
const float buttonSize = maxButtonSize(); | ||
if (_animationProgress >= 0.0f) { | ||
const ImVec2 oldPos = ImGui::GetCursorPos(); | ||
float requiredPopupSize = 2.f * (_extraRadius + buttonSize * _buttons.size()); | ||
ImGui::SetNextWindowSize(ImVec2(requiredPopupSize, requiredPopupSize)); | ||
ImGui::SetNextWindowPos(ImVec2(centre.x - .5f * requiredPopupSize / 2, centre.y - .5f * requiredPopupSize)); | ||
|
||
ImGui::OpenPopup(_popupId.c_str()); | ||
if (ImGui::BeginPopup(_popupId.c_str(), ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration)) { | ||
nButtonRows = drawButtonsOnArc(centre, _extraRadius + buttonSize + _padding); | ||
ImGui::EndPopup(); | ||
} | ||
ImGui::SetCursorScreenPos(oldPos); | ||
} | ||
|
||
const ImVec2 mousePos = ImGui::GetMousePos(); | ||
const float mouseDistance = std::hypot(mousePos.x - centre.x, mousePos.y - centre.y); | ||
const float mouseAngle = std::atan2(mousePos.y - centre.y, mousePos.x - centre.x) * 180.0f / std::numbers::pi_v<float>; | ||
const float arcRadius = _extraRadius + static_cast<float>(nButtonRows + 1) * (buttonSize + _padding); | ||
// the last statement is to keep the menu open when the mouse is outside the arc segment but on the calling button. | ||
const bool mouseInArc = (mouseDistance <= arcRadius && mouseAngle >= _startAngle && mouseAngle <= _stopAngle) || mouseDistance <= std::max(_menuSize.x, _menuSize.y); | ||
timeOutOfRadius = !mouseInArc ? timeOutOfRadius + deltaTime : 0.f; | ||
if (timeOutOfRadius >= _timeOut) { | ||
_isOpen = false; | ||
} | ||
} | ||
|
||
private: | ||
void drawButton(RadialButton &button) { | ||
if (button.create()) { | ||
std::visit([&](auto &&onClick) { | ||
using T = std::decay_t<decltype(onClick)>; | ||
if constexpr (std::is_same_v<T, std::function<void()>>) { | ||
onClick(); | ||
} else if constexpr (std::is_same_v<T, std::function<void(RadialButton &)>>) { | ||
onClick(button); | ||
} | ||
}, | ||
button.onClick); | ||
} | ||
} | ||
|
||
[[nodiscard]] std::size_t drawButtonsOnArc(const ImVec2 ¢re, float arcRadius) { | ||
const float totalAngle = _stopAngle - _startAngle; | ||
|
||
std::size_t currentRow = 0UL; | ||
for (std::size_t buttonIndex = 0UL; buttonIndex < _buttons.size();) { | ||
// get the maximum number of buttons and button size for the current arc row | ||
const auto [maxButtonsInRow, maxButtonSizeInRow] = maxButtonNumberAndSizeForArc(arcRadius, buttonIndex); | ||
const float angleBetweenButtons = totalAngle / maxButtonsInRow; | ||
const float shiftFactor = 0.5f * totalAngle / maxButtonsInRow; // Shift every second row | ||
|
||
for (std::size_t buttonsInRow = 0UL; buttonIndex < _buttons.size() && buttonsInRow < maxButtonsInRow; ++buttonIndex) { | ||
auto &button = _buttons[buttonIndex]; | ||
const float angle = _startAngle + shiftFactor + static_cast<float>(buttonsInRow) * angleBetweenButtons; | ||
const float angleRad = angle * _animationProgress * (std::numbers::pi_v<float> / 180.0f); | ||
|
||
ImGui::SetCursorScreenPos(ImVec2(centre.x + arcRadius * std::cos(angleRad) - 0.5f * button.size, centre.y + arcRadius * std::sin(angleRad) - 0.5f * button.size)); | ||
drawButton(button); | ||
++buttonsInRow; | ||
} | ||
++currentRow; | ||
arcRadius += maxButtonSizeInRow + 2.f * _padding; // update arc radius | ||
} | ||
return currentRow; | ||
} | ||
}; | ||
template<std::size_t unique_id> | ||
bool RadialCircularMenu<unique_id>::_isOpen = false; | ||
template<std::size_t unique_id> | ||
const std::string RadialCircularMenu<unique_id>::_popupId = fmt::format("RadialMenuPopup_{}", unique_id); | ||
template<std::size_t unique_id> | ||
float RadialCircularMenu<unique_id>::_animationProgress = 0.f; | ||
template<std::size_t unique_id> | ||
std::vector<RadialButton> RadialCircularMenu<unique_id>::_buttons; | ||
|
||
} // namespace fair | ||
|
||
#endif // OPENDIGITIZER_RADIALCIRCULARMENU_HPP |
Oops, something went wrong.