From f8e09d1c5e2f449542598393343a691e9d50419c Mon Sep 17 00:00:00 2001 From: Oznogon Date: Mon, 2 Mar 2026 17:25:24 -0800 Subject: [PATCH 1/5] Add GuiScrollContainer Add GuiScrollContainer, a subclass of GuiContainer to support arbitrary and nested scrolling elements. This relies on changes in SeriousProton to implement GL_SCISSOR_TEST in RenderTarget. Child element positions and click/hover handling are translated relative to the scroll position. These containers can be nested, and mousewheel and scroll events are passed down the tree. This container element can also replace the bespoke scrolling behaviors in other element types, such as GuiListbox. - Pass focus, text input through GuiScrollContainer. - Position nested GuiSelector popups relative to scroll translation - Add scrollToOffset() function to allow other elements to control scroll position. - Handle layout padding in scissor rects. --- CMakeLists.txt | 2 + src/gui/gui2_container.cpp | 30 +++ src/gui/gui2_container.h | 15 +- src/gui/gui2_scrollcontainer.cpp | 399 +++++++++++++++++++++++++++++++ src/gui/gui2_scrollcontainer.h | 82 +++++++ src/gui/gui2_selector.cpp | 7 +- 6 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 src/gui/gui2_scrollcontainer.cpp create mode 100644 src/gui/gui2_scrollcontainer.h diff --git a/CMakeLists.txt b/CMakeLists.txt index a86dd2be9f..64aa8beac0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -159,6 +159,7 @@ set(GUI_LIB_SOURCES src/gui/gui2_progressbar.cpp src/gui/gui2_progressslider.cpp src/gui/gui2_scrolltext.cpp + src/gui/gui2_scrollcontainer.cpp src/gui/gui2_advancedscrolltext.cpp src/gui/gui2_button.cpp src/gui/gui2_resizabledialog.cpp @@ -196,6 +197,7 @@ set(GUI_LIB_SOURCES src/gui/gui2_resizabledialog.h src/gui/gui2_rotationdial.h src/gui/gui2_scrollbar.h + src/gui/gui2_scrollcontainer.h src/gui/gui2_scrolltext.h src/gui/gui2_selector.h src/gui/gui2_slider.h diff --git a/src/gui/gui2_container.cpp b/src/gui/gui2_container.cpp index 9c87721289..ab4f2049b5 100644 --- a/src/gui/gui2_container.cpp +++ b/src/gui/gui2_container.cpp @@ -132,6 +132,36 @@ void GuiContainer::updateLayout(const sp::Rect& rect) } } +void GuiContainer::clearElementOwner(GuiElement* e) +{ + e->owner = nullptr; +} + +void GuiContainer::setElementHover(GuiElement* e, bool h) +{ + e->hover = h; +} + +void GuiContainer::setElementFocus(GuiElement* e, bool f) +{ + e->focus = f; +} + +void GuiContainer::callDrawElements(GuiContainer* c, glm::vec2 mp, sp::Rect r, sp::RenderTarget& rt) +{ + c->drawElements(mp, r, rt); +} + +GuiElement* GuiContainer::callGetClickElement(GuiContainer* c, sp::io::Pointer::Button b, glm::vec2 p, sp::io::Pointer::ID id) +{ + return c->getClickElement(b, p, id); +} + +GuiElement* GuiContainer::callExecuteScrollOnElement(GuiContainer* c, glm::vec2 p, float v) +{ + return c->executeScrollOnElement(p, v); +} + void GuiContainer::setAttribute(const string& key, const string& value) { if (key == "size") diff --git a/src/gui/gui2_container.h b/src/gui/gui2_container.h index 547aabb80c..c4a44f7203 100644 --- a/src/gui/gui2_container.h +++ b/src/gui/gui2_container.h @@ -54,20 +54,27 @@ class GuiContainer : sp::NonCopyable virtual ~GuiContainer(); template void setLayout() { layout_manager = std::make_unique(); } - void updateLayout(const sp::Rect& rect); + virtual void updateLayout(const sp::Rect& rect); const sp::Rect& getRect() const { return rect; } virtual void setAttribute(const string& key, const string& value); protected: virtual void drawElements(glm::vec2 mouse_position, sp::Rect parent_rect, sp::RenderTarget& window); virtual void drawDebugElements(sp::Rect parent_rect, sp::RenderTarget& window); - GuiElement* getClickElement(sp::io::Pointer::Button button, glm::vec2 position, sp::io::Pointer::ID id); - GuiElement* executeScrollOnElement(glm::vec2 position, float value); + virtual GuiElement* getClickElement(sp::io::Pointer::Button button, glm::vec2 position, sp::io::Pointer::ID id); + virtual GuiElement* executeScrollOnElement(glm::vec2 position, float value); + + // Static helpers for subclass access to protected members. + static void clearElementOwner(GuiElement* element); + static void setElementHover(GuiElement* element, bool has_hover); + static void setElementFocus(GuiElement* element, bool has_focus); + static void callDrawElements(GuiContainer* container, glm::vec2 mouse_pos, sp::Rect rect, sp::RenderTarget& render_target); + static GuiElement* callGetClickElement(GuiContainer* container, sp::io::Pointer::Button button, glm::vec2 pos, sp::io::Pointer::ID id); + static GuiElement* callExecuteScrollOnElement(GuiContainer* container, glm::vec2 pos, float value); friend class GuiElement; sp::Rect rect{0,0,0,0}; -private: std::unique_ptr layout_manager = nullptr; }; diff --git a/src/gui/gui2_scrollcontainer.cpp b/src/gui/gui2_scrollcontainer.cpp new file mode 100644 index 0000000000..24e07b36d0 --- /dev/null +++ b/src/gui/gui2_scrollcontainer.cpp @@ -0,0 +1,399 @@ +#include "gui2_scrollcontainer.h" +#include "gui2_scrollbar.h" +#include "gui2_canvas.h" +#include "gui/layout/layout.h" + + +GuiScrollContainer::GuiScrollContainer(GuiContainer* owner, const string& id, ScrollMode mode) +: GuiElement(owner, id), mode(mode) +{ + // We need to manipulate layout size to hide/show the scrollbar. + layout.match_content_size = false; + + // Add a vertical scrollbar only if this element scrolls or pages. + if (mode == ScrollMode::Scroll || mode == ScrollMode::Page) + { + scrollbar_v = new GuiScrollbar(this, id + "_SCROLLBAR_V", 0, 100, 0, + [this](int value) + { + scroll_offset = static_cast(value); + } + ); + scrollbar_v + ->setPosition(0.0f, 0.0f, sp::Alignment::TopRight) + ->setSize(scrollbar_width, GuiSizeMax); + } +} + +GuiScrollContainer* GuiScrollContainer::setMode(ScrollMode new_mode) +{ + mode = new_mode; + return this; +} + +GuiScrollContainer* GuiScrollContainer::setScrollbarWidth(float width) +{ + scrollbar_width = width; + return this; +} + +void GuiScrollContainer::scrollToFraction(float fraction) +{ + const float max_scroll = std::max(0.0f, content_height - visible_height); + scroll_offset = std::clamp(fraction * max_scroll, 0.0f, max_scroll); + if (scrollbar_v) scrollbar_v->setValue(static_cast(scroll_offset)); +} + +void GuiScrollContainer::scrollToOffset(float pixel_offset) +{ + const float max_scroll = std::max(0.0f, content_height - visible_height); + scroll_offset = std::clamp(pixel_offset, 0.0f, max_scroll); + if (scrollbar_v) scrollbar_v->setValue(static_cast(scroll_offset)); +} + +void GuiScrollContainer::updateLayout(const sp::Rect& rect) +{ + this->rect = rect; + visible_height = rect.size.y - layout.padding.top - layout.padding.bottom; + + // Show the scrollbar only if we're clipping anything. + scrollbar_visible = (scrollbar_v != nullptr) && (content_height > visible_height + 0.5f); + // Don't factor scrollbar width if it isn't visible. + const float sb_width = scrollbar_visible ? scrollbar_width : 0.0f; + + // Manually factor padding into content layout around the scrollbar. + glm::vec2 padding_offset{ + layout.padding.left, + layout.padding.top + }; + + glm::vec2 padding_size{ + layout.padding.left + layout.padding.right, + layout.padding.top + layout.padding.bottom + }; + + sp::Rect content_layout_rect{ + rect.position + padding_offset, + rect.size - padding_size - glm::vec2{sb_width, 0.0f} + }; + + if (!layout_manager) layout_manager = std::make_unique(); + + // Temporarily hide the scrollbar so the layout manager ignores it for + // sizing, then restore it if enabled. + if (scrollbar_v) scrollbar_v->setVisible(false); + + layout_manager->updateLoop(*this, content_layout_rect); + + if (scrollbar_v) + { + scrollbar_v->setVisible(scrollbar_visible); + + // Override the scrollbar rect. + scrollbar_v->updateLayout({ + {rect.position.x + rect.size.x - scrollbar_width, rect.position.y}, + {scrollbar_width, rect.size.y} + }); + } + + // Compute content_height from non-scrollbar visible children. + float max_bottom = 0.0f; + for (GuiElement* child : children) + { + if (child == scrollbar_v) continue; + if (!child->isVisible()) continue; + + const float bottom = child->getRect().position.y + child->getRect().size.y + child->layout.margin.bottom - rect.position.y; + if (bottom > max_bottom) max_bottom = bottom; + } + content_height = max_bottom + layout.padding.bottom; + + // Clamp scroll offset. + scroll_offset = std::clamp(scroll_offset, 0.0f, std::max(0.0f, content_height - visible_height)); + + // Sync scrollbar properties to new layout. + if (scrollbar_v) + { + scrollbar_v->setRange(0, static_cast(content_height)); + scrollbar_v->setValueSize(static_cast(visible_height)); + scrollbar_v->setValue(static_cast(scroll_offset)); + } +} + +void GuiScrollContainer::drawElements(glm::vec2 mouse_position, sp::Rect /* parent_rect */, sp::RenderTarget& renderer) +{ + sp::Rect content_rect = getContentRect(); + + // Capture clipping and scroll translation. + renderer.pushScissorRect(content_rect); + renderer.pushTranslation({0.0f, -scroll_offset}); + + // Track mouse position on element relative to the vertical scroll offset. + glm::vec2 layout_mouse = mouse_position + glm::vec2{0.0f, scroll_offset}; + + // Pass the relative mouse position through to each child element. + for (auto it = children.begin(); it != children.end(); ) + { + GuiElement* element = *it; + + if (element == scrollbar_v) + { + ++it; + continue; + } + + if (element->isDestroyed()) + { + GuiCanvas* canvas = dynamic_cast(element->getTopLevelContainer()); + if (canvas) canvas->unfocusElementTree(element); + + it = children.erase(it); + clearElementOwner(element); + delete element; + + continue; + } + + setElementHover(element, element->getRect().contains(layout_mouse)); + + if (element->isVisible()) + { + element->onDraw(renderer); + callDrawElements(element, layout_mouse, element->getRect(), renderer); + } + + ++it; + } + + // Apply scroll translation and clipping. Order matters here. + renderer.popTranslation(); + renderer.popScissorRect(); + + // Draw the scrollbar. Never clip nor scroll the scrollbar itself. + if (scrollbar_v + && !scrollbar_v->isDestroyed() + && scrollbar_v->isVisible() + ) + { + setElementHover(scrollbar_v, scrollbar_v->getRect().contains(mouse_position)); + scrollbar_v->onDraw(renderer); + callDrawElements(scrollbar_v, mouse_position, scrollbar_v->getRect(), renderer); + } +} + +GuiElement* GuiScrollContainer::getClickElement(sp::io::Pointer::Button button, glm::vec2 position, sp::io::Pointer::ID id) +{ + // Pass the click to the scrollbar first, and don't translate its position. + if (scrollbar_v + && scrollbar_v->isVisible() + && scrollbar_v->isEnabled() + && scrollbar_v->getRect().contains(position) + ) + { + GuiElement* clicked = callGetClickElement(scrollbar_v, button, position, id); + if (clicked) return clicked; + if (scrollbar_v->onMouseDown(button, position, id)) return scrollbar_v; + } + + // Don't pass clicks to elements outside of the content rect. + if (!getContentRect().contains(position)) return nullptr; + + // Pass the click to each nested child, which should take priority if it can + // use it. + glm::vec2 layout_pos = position + glm::vec2{0.0f, scroll_offset}; + + for (auto it = children.rbegin(); it != children.rend(); ++it) + { + GuiElement* element = *it; + + // We already handled the scrollbar. + if (element == scrollbar_v) continue; + // We don't care about buttons that aren't visible or enabled. + if (!element->isVisible() || !element->isEnabled()) continue; + + // Figure out if we can click the element. If so, capture the scroll + // offset to pass to drag events, focus it, and click it. + GuiElement* clicked = callGetClickElement(element, button, layout_pos, id); + if (clicked) + { + switchFocusTo(clicked); + pressed_element = clicked; + pressed_scroll = scroll_offset; + return this; + } + + // The click didn't fire, but we still recurse into children regardless. + // This helps find children or child-like elements (like GuiSelector + // popups) that can exist outside of their parent's rect. + if (element->getRect().contains(layout_pos) && element->onMouseDown(button, layout_pos, id)) + { + switchFocusTo(element); + pressed_element = element; + pressed_scroll = scroll_offset; + return this; + } + } + + // Otherwise, do nothing. + return nullptr; +} + +void GuiScrollContainer::switchFocusTo(GuiElement* new_element) +{ + // Apply focus change, if any. + if (focused_element == new_element) return; + + if (focused_element) + { + setElementFocus(focused_element, false); + focused_element->onFocusLost(); + } + + focused_element = new_element; + + // If this scroll container already has canvas focus, forward focus gained + // to the new child now (GuiCanvas won't call our onFocusGained again). + // If this scroll container is not yet focused, canvas will call our + // onFocusGained after getClickElement returns, which will forward it. + if (focus) + { + setElementFocus(focused_element, true); + focused_element->onFocusGained(); + } +} + +void GuiScrollContainer::onFocusGained() +{ + if (focused_element) + { + setElementFocus(focused_element, true); + focused_element->onFocusGained(); + } +} + +void GuiScrollContainer::onFocusLost() +{ + if (focused_element) + { + setElementFocus(focused_element, false); + focused_element->onFocusLost(); + focused_element = nullptr; + } +} + +void GuiScrollContainer::onTextInput(const string& text) +{ + if (focused_element) focused_element->onTextInput(text); +} + +void GuiScrollContainer::onTextInput(sp::TextInputEvent e) +{ + if (focused_element) focused_element->onTextInput(e); +} + +bool GuiScrollContainer::onMouseDown(sp::io::Pointer::Button button, glm::vec2 position, sp::io::Pointer::ID id) +{ + if (pressed_element) + { + pressed_element->onMouseDown(button, position + glm::vec2{0.0f, pressed_scroll}, id); + pressed_element = nullptr; + return true; + } + + return false; +} + +void GuiScrollContainer::onMouseDrag(glm::vec2 position, sp::io::Pointer::ID id) +{ + if (pressed_element) pressed_element->onMouseDrag(position + glm::vec2{0.0f, pressed_scroll}, id); +} + +void GuiScrollContainer::onMouseUp(glm::vec2 position, sp::io::Pointer::ID id) +{ + if (pressed_element) + { + pressed_element->onMouseUp(position + glm::vec2{0.0f, pressed_scroll}, id); + pressed_element = nullptr; + } +} + +GuiElement* GuiScrollContainer::executeScrollOnElement(glm::vec2 position, float value) +{ + // Pass the scroll to the scrollbar first, and don't translate its position. + if (scrollbar_v + && scrollbar_v->isVisible() + && scrollbar_v->isEnabled() + && scrollbar_v->getRect().contains(position)) + { + GuiElement* scrolled = callExecuteScrollOnElement(scrollbar_v, position, value); + if (scrolled) return scrolled; + // Handle mousewheel scroll, if any. + if (scrollbar_v->onMouseWheelScroll(position, value)) return scrollbar_v; + } + + // Return nothing if the scroll isn't within the container. + if (!getContentRect().contains(position)) return nullptr; + + // Execute the scroll on each nested child. If a child can use the mousewheel + // scroll event, give it to them. + glm::vec2 layout_pos = position + glm::vec2{0.0f, scroll_offset}; + + for (auto it = children.rbegin(); it != children.rend(); ++it) + { + GuiElement* element = *it; + if (element == scrollbar_v) continue; + + if (element + && element->isVisible() + && element->isEnabled() + && element->getRect().contains(layout_pos) + ) + { + GuiElement* scrolled = callExecuteScrollOnElement(element, layout_pos, value); + if (scrolled) return scrolled; + if (element->onMouseWheelScroll(layout_pos, value)) return element; + } + } + + // No child used the mousewheel scroll event, so use it to scroll the + // container. + if (onMouseWheelScroll(position, value)) return this; + + // Otherwise, nothing happens. + return nullptr; +} + +bool GuiScrollContainer::onMouseWheelScroll(glm::vec2 /* position */, float value) +{ + // Don't scroll if used only to clip. + if (mode == ScrollMode::None) return false; + + // Scroll by a default interval of 50, or by the container height if set to + // paged mode. + const float step = (mode == ScrollMode::Page) ? visible_height : 50.0f; + const float max_scroll = std::max(0.0f, content_height - visible_height); + scroll_offset = std::clamp(scroll_offset - value * step, 0.0f, max_scroll); + + // Update the scrollbar if it exists. + if (scrollbar_v) scrollbar_v->setValue(static_cast(scroll_offset)); + + return true; +} + +sp::Rect GuiScrollContainer::getContentRect() const +{ + // Return the rect, inset by padding and minus room for the scrollbar if it's visible. + return sp::Rect{ + rect.position + glm::vec2{layout.padding.left, layout.padding.top}, + { + rect.size.x - layout.padding.left - layout.padding.right - getEffectiveScrollbarWidth(), + rect.size.y - layout.padding.top - layout.padding.bottom + } + }; +} + +float GuiScrollContainer::getEffectiveScrollbarWidth() const +{ + // Save room for the scrollbar only if it's visible. + return (scrollbar_v && scrollbar_visible) ? scrollbar_width : 0.0f; +} diff --git a/src/gui/gui2_scrollcontainer.h b/src/gui/gui2_scrollcontainer.h new file mode 100644 index 0000000000..5185a444d5 --- /dev/null +++ b/src/gui/gui2_scrollcontainer.h @@ -0,0 +1,82 @@ +#pragma once + +#include "gui2_element.h" + +class GuiScrollbar; + +class GuiScrollContainer : public GuiElement +{ +public: + enum class ScrollMode { + None, // Cut overflow off at element borders; no scrolling + Scroll, // Scroll by fixed increments, regardless of contents or element size + Page // Scroll by increments equal to the element size + }; + + // GuiContainer-like GuiElement with support for clipping or scrolling + // arbitrary child elements that overflow its bounds. + GuiScrollContainer(GuiContainer* owner, const string& id, ScrollMode mode = ScrollMode::Scroll); + + // TODO: Right now this clips both horizontally and vertically, but supports + // only vertical scrolling/paging. + + // Set scrolling mode. All modes clip at the element boundaries. + GuiScrollContainer* setMode(ScrollMode mode); + // Set width of scrollbar if visible. + GuiScrollContainer* setScrollbarWidth(float width); + // Scroll element to this fraction of the total scrollbar limit. + // Value passed here represents where the top of the scrollbar pill goes + // on the scrollbar. + void scrollToFraction(float fraction); + // Scroll element to this pixel offset from the top (clamped to valid range). + void scrollToOffset(float pixel_offset); + + // Override layout updates to update child elements and juggle scrollbar + // visibility. + virtual void updateLayout(const sp::Rect& rect) override; + // Handle mousewheel scroll, with behavior depending on the ScrollMode. + virtual bool onMouseWheelScroll(glm::vec2 position, float value) override; + // Pass mouse down to child elements, but only if they're visible. + virtual bool onMouseDown(sp::io::Pointer::Button button, glm::vec2 position, sp::io::Pointer::ID id) override; + // Pass mouse drag to child elements. This relies on + virtual void onMouseDrag(glm::vec2 position, sp::io::Pointer::ID id) override; + // Pass mouse up to child elements. + virtual void onMouseUp(glm::vec2 position, sp::io::Pointer::ID id) override; + // Pass focus to child elements. + virtual void onFocusGained() override; + // Pass focus loss to child elements. + virtual void onFocusLost() override; + // Pass text input events to child elements. + virtual void onTextInput(const string& text) override; + // Pass text input events to child elements. + virtual void onTextInput(sp::TextInputEvent e) override; + +protected: + // Draw elements if they're in view. Translate mouse positions by the scroll + // amount. + virtual void drawElements(glm::vec2 mouse_position, sp::Rect parent_rect, sp::RenderTarget& renderer) override; + // Find the clicked element, checking children of this container if they're + // visible. + virtual GuiElement* getClickElement(sp::io::Pointer::Button button, glm::vec2 position, sp::io::Pointer::ID id) override; + // Scroll the element's children. Pass any mousewheel events to children + // first if they can use it. + virtual GuiElement* executeScrollOnElement(glm::vec2 position, float value) override; + +private: + ScrollMode mode; + float scrollbar_width = 30.0f; + GuiScrollbar* scrollbar_v = nullptr; + + float scroll_offset = 0.0f; + float content_height = 0.0f; + float visible_height = 0.0f; + bool scrollbar_visible = false; + + GuiElement* focused_element = nullptr; + GuiElement* pressed_element = nullptr; + float pressed_scroll = 0.0f; + + sp::Rect getContentRect() const; + float getEffectiveScrollbarWidth() const; + void switchFocusTo(GuiElement* new_element); +}; diff --git a/src/gui/gui2_selector.cpp b/src/gui/gui2_selector.cpp index 72a3fb9967..60dc9cf3d2 100644 --- a/src/gui/gui2_selector.cpp +++ b/src/gui/gui2_selector.cpp @@ -49,13 +49,16 @@ void GuiSelector::onDraw(sp::RenderTarget& renderer) if (!focus) popup->hide(); - float top = rect.position.y; + // rect.position is in layout space; the popup lives at the canvas level + // (no scroll translation), so convert to screen coordinates first. + glm::vec2 screen_pos = rect.position + renderer.getTranslation(); + float top = screen_pos.y; float height = entries.size() * 50; if (selection_index >= 0) top -= selection_index * 50; top = std::max(0.0f, top); top = std::min(900.0f - height, top); - popup->setPosition(rect.position.x, top, sp::Alignment::TopLeft)->setSize(rect.size.x, height); + popup->setPosition(screen_pos.x, top, sp::Alignment::TopLeft)->setSize(rect.size.x, height); } GuiSelector* GuiSelector::setTextSize(float size) From 117dd68a32a0a8398c5ef54b649fc6029eff77ac Mon Sep 17 00:00:00 2001 From: Oznogon Date: Mon, 9 Mar 2026 14:19:16 -0700 Subject: [PATCH 2/5] Edit GuiContainer formatting/style - Use pragma once guard - Internal consistency in formatting - Expand terse varnames - Remove redundant public/protected sections in the header --- src/gui/gui2_container.cpp | 83 +++++++++++++++++--------------------- src/gui/gui2_container.h | 42 ++++++++++--------- 2 files changed, 60 insertions(+), 65 deletions(-) diff --git a/src/gui/gui2_container.cpp b/src/gui/gui2_container.cpp index ab4f2049b5..b6dcdbcc7c 100644 --- a/src/gui/gui2_container.cpp +++ b/src/gui/gui2_container.cpp @@ -4,7 +4,7 @@ GuiContainer::~GuiContainer() { - for(GuiElement* element : children) + for (GuiElement* element : children) { element->owner = nullptr; delete element; @@ -13,15 +13,14 @@ GuiContainer::~GuiContainer() void GuiContainer::drawElements(glm::vec2 mouse_position, sp::Rect parent_rect, sp::RenderTarget& renderer) { - for(auto it = children.begin(); it != children.end(); ) + for (auto it = children.begin(); it != children.end(); ) { GuiElement* element = *it; if (element->destroyed) { //Find the owning cancas, as we need to remove ourselves if we are the focus or click element. GuiCanvas* canvas = dynamic_cast(element->getTopLevelContainer()); - if (canvas) - canvas->unfocusElementTree(element); + if (canvas) canvas->unfocusElementTree(element); //Delete it from our list. it = children.erase(it); @@ -29,7 +28,9 @@ void GuiContainer::drawElements(glm::vec2 mouse_position, sp::Rect parent_rect, // Free up the memory used by the element. element->owner = nullptr; delete element; - }else{ + } + else + { element->hover = element->rect.contains(mouse_position); if (element->visible) @@ -61,49 +62,45 @@ void GuiContainer::drawDebugElements(sp::Rect parent_rect, sp::RenderTarget& ren GuiElement* GuiContainer::getClickElement(sp::io::Pointer::Button button, glm::vec2 position, sp::io::Pointer::ID id) { - for(auto it = children.rbegin(); it != children.rend(); it++) + for (auto it = children.rbegin(); it != children.rend(); it++) { GuiElement* element = *it; if (element->visible && element->enabled && element->rect.contains(position)) { GuiElement* clicked = element->getClickElement(button, position, id); - if (clicked) - return clicked; - if (element->onMouseDown(button, position, id)) - { - return element; - } + if (clicked) return clicked; + if (element->onMouseDown(button, position, id)) return element; } } + return nullptr; } GuiElement* GuiContainer::executeScrollOnElement(glm::vec2 position, float value) { - for(auto it = children.rbegin(); it != children.rend(); it++) + for (auto it = children.rbegin(); it != children.rend(); it++) { GuiElement* element = *it; if (element->visible && element->enabled && element->rect.contains(position)) { GuiElement* scrolled = element->executeScrollOnElement(position, value); - if (scrolled) - return scrolled; - if (element->onMouseWheelScroll(position, value)) - return element; + if (scrolled) return scrolled; + if (element->onMouseWheelScroll(position, value)) return element; } } + return nullptr; } void GuiContainer::updateLayout(const sp::Rect& rect) { this->rect = rect; + if (layout_manager || !children.empty()) { - if (!layout_manager) - layout_manager = std::make_unique(); + if (!layout_manager) layout_manager = std::make_unique(); glm::vec2 padding_size(layout.padding.left + layout.padding.right, layout.padding.top + layout.padding.bottom); layout_manager->updateLoop(*this, sp::Rect(rect.position + glm::vec2{layout.padding.left, layout.padding.top}, rect.size - padding_size)); @@ -111,7 +108,8 @@ void GuiContainer::updateLayout(const sp::Rect& rect) { glm::vec2 content_size_min(std::numeric_limits::max(), std::numeric_limits::max()); glm::vec2 content_size_max(std::numeric_limits::min(), std::numeric_limits::min()); - for(auto w : children) + + for (auto w : children) { if (w && w->isVisible()) { @@ -123,6 +121,7 @@ void GuiContainer::updateLayout(const sp::Rect& rect) content_size_max.y = std::max(content_size_max.y, p1.y + w->layout.margin.bottom); } } + if (content_size_max.x != std::numeric_limits::min()) { this->rect.size = (content_size_max - content_size_min) + padding_size; @@ -132,34 +131,34 @@ void GuiContainer::updateLayout(const sp::Rect& rect) } } -void GuiContainer::clearElementOwner(GuiElement* e) +void GuiContainer::clearElementOwner(GuiElement* element) { - e->owner = nullptr; + element->owner = nullptr; } -void GuiContainer::setElementHover(GuiElement* e, bool h) +void GuiContainer::setElementHover(GuiElement* element, bool has_hover) { - e->hover = h; + element->hover = has_hover; } -void GuiContainer::setElementFocus(GuiElement* e, bool f) +void GuiContainer::setElementFocus(GuiElement* element, bool has_focus) { - e->focus = f; + element->focus = has_focus; } -void GuiContainer::callDrawElements(GuiContainer* c, glm::vec2 mp, sp::Rect r, sp::RenderTarget& rt) +void GuiContainer::callDrawElements(GuiContainer* container, glm::vec2 mouse_pos, sp::Rect rect, sp::RenderTarget& render_target) { - c->drawElements(mp, r, rt); + container->drawElements(mouse_pos, rect, render_target); } -GuiElement* GuiContainer::callGetClickElement(GuiContainer* c, sp::io::Pointer::Button b, glm::vec2 p, sp::io::Pointer::ID id) +GuiElement* GuiContainer::callGetClickElement(GuiContainer* container, sp::io::Pointer::Button button, glm::vec2 pos, sp::io::Pointer::ID id) { - return c->getClickElement(b, p, id); + return container->getClickElement(button, pos, id); } -GuiElement* GuiContainer::callExecuteScrollOnElement(GuiContainer* c, glm::vec2 p, float v) +GuiElement* GuiContainer::callExecuteScrollOnElement(GuiContainer* container, glm::vec2 pos, float value) { - return c->executeScrollOnElement(p, v); + return container->executeScrollOnElement(pos, value); } void GuiContainer::setAttribute(const string& key, const string& value) @@ -217,9 +216,7 @@ void GuiContainer::setAttribute(const string& key, const string& value) { auto values = value.split(",", 3); if (values.size() == 1) - { - layout.padding.top = layout.padding.bottom = layout.padding.left = layout.padding.right = values[0].strip().toFloat(); - } + layout.padding.top = layout.padding.bottom = layout.padding.left = layout.padding.right = values[0].strip().toFloat(); else if (values.size() == 2) { layout.padding.left = layout.padding.right = values[0].strip().toFloat(); @@ -262,17 +259,14 @@ void GuiContainer::setAttribute(const string& key, const string& value) else if (key == "layout") { GuiLayoutClassRegistry* reg; - for(reg = GuiLayoutClassRegistry::first; reg != nullptr; reg = reg->next) - { - if (value == reg->name) - break; - } + + for (reg = GuiLayoutClassRegistry::first; reg != nullptr; reg = reg->next) + if (value == reg->name) break; + if (reg) - { layout_manager = reg->creation_function(); - }else{ + else LOG(Error, "Failed to find layout type:", value); - } } else if (key == "stretch") { @@ -280,6 +274,7 @@ void GuiContainer::setAttribute(const string& key, const string& value) layout.fill_height = layout.fill_width = layout.lock_aspect_ratio = true; else layout.fill_height = layout.fill_width = value.toBool(); + layout.match_content_size = false; } else if (key == "fill_height") @@ -293,7 +288,5 @@ void GuiContainer::setAttribute(const string& key, const string& value) layout.match_content_size = false; } else - { LOG(Warning, "Tried to set unknown widget attribute:", key, "to", value); - } } diff --git a/src/gui/gui2_container.h b/src/gui/gui2_container.h index c4a44f7203..a13799df06 100644 --- a/src/gui/gui2_container.h +++ b/src/gui/gui2_container.h @@ -1,5 +1,4 @@ -#ifndef GUI2_CONTAINER_H -#define GUI2_CONTAINER_H +#pragma once #include #include @@ -17,25 +16,26 @@ namespace sp { class GuiElement; class GuiLayout; class GuiTheme; + class GuiContainer : sp::NonCopyable { public: -public: + // Nested type to capture layout attributes class LayoutInfo { public: class Sides { public: - float left = 0; - float right = 0; - float top = 0; - float bottom = 0; + float left = 0.0f; + float right = 0.0f; + float top = 0.0f; + float bottom = 0.0f; }; - glm::vec2 position{0, 0}; + glm::vec2 position{0.0f, 0.0f}; sp::Alignment alignment = sp::Alignment::TopLeft; - glm::vec2 size{1, 1}; + glm::vec2 size{1.0f, 1.0f}; glm::ivec2 span{1, 1}; Sides margin; Sides padding; @@ -45,20 +45,27 @@ class GuiContainer : sp::NonCopyable bool match_content_size = true; }; - LayoutInfo layout; - std::list children; -protected: - GuiTheme* theme; -public: GuiContainer() = default; virtual ~GuiContainer(); + // Public data + LayoutInfo layout; + std::list children; + + // Public interfaces template void setLayout() { layout_manager = std::make_unique(); } virtual void updateLayout(const sp::Rect& rect); + virtual void setAttribute(const string& key, const string& value); const sp::Rect& getRect() const { return rect; } - virtual void setAttribute(const string& key, const string& value); protected: + GuiTheme* theme; + + // Protected data + sp::Rect rect{0,0,0,0}; + std::unique_ptr layout_manager = nullptr; + + // Protected interfaces virtual void drawElements(glm::vec2 mouse_position, sp::Rect parent_rect, sp::RenderTarget& window); virtual void drawDebugElements(sp::Rect parent_rect, sp::RenderTarget& window); virtual GuiElement* getClickElement(sp::io::Pointer::Button button, glm::vec2 position, sp::io::Pointer::ID id); @@ -73,9 +80,4 @@ class GuiContainer : sp::NonCopyable static GuiElement* callExecuteScrollOnElement(GuiContainer* container, glm::vec2 pos, float value); friend class GuiElement; - - sp::Rect rect{0,0,0,0}; - std::unique_ptr layout_manager = nullptr; }; - -#endif//GUI2_CONTAINER_H From 8b62ab9643b15c676c95e671066bdde41836598d Mon Sep 17 00:00:00 2001 From: oznogon Date: Wed, 11 Mar 2026 22:26:37 -0700 Subject: [PATCH 3/5] Set default GuiScrollContainer click_change to 50 Increase the default scrollbar click_change on GuiScrollContainer to 50, matching mousewheel scroll increments. --- src/gui/gui2_scrollcontainer.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/gui/gui2_scrollcontainer.cpp b/src/gui/gui2_scrollcontainer.cpp index 24e07b36d0..03a1e09c7a 100644 --- a/src/gui/gui2_scrollcontainer.cpp +++ b/src/gui/gui2_scrollcontainer.cpp @@ -19,6 +19,7 @@ GuiScrollContainer::GuiScrollContainer(GuiContainer* owner, const string& id, Sc scroll_offset = static_cast(value); } ); + scrollbar_v->setClickChange(50); scrollbar_v ->setPosition(0.0f, 0.0f, sp::Alignment::TopRight) ->setSize(scrollbar_width, GuiSizeMax); From 379bb0cdcdf6dbda8f9594d9c343c775612be2aa Mon Sep 17 00:00:00 2001 From: Oznogon Date: Mon, 9 Mar 2026 12:37:29 -0700 Subject: [PATCH 4/5] Refactor HotkeyMenu Redesign HotkeyMenu to use a standardized scrolling container, and to split binding inputs into device groups. - Use GuiScrollContainer to make the hotkeys list vertically scrolling instead of horizontally paged. - Use layout attributes for top-level GUI elements. - Split HotkeyMenu binders into keyboard, joystick, and mouse. - Allow binding escape control without triggering it. - Forward-declare GUI subclasses. --- src/menus/hotkeyMenu.cpp | 264 +++++++++++++++++++++------------------ src/menus/hotkeyMenu.h | 35 ++---- 2 files changed, 155 insertions(+), 144 deletions(-) diff --git a/src/menus/hotkeyMenu.cpp b/src/menus/hotkeyMenu.cpp index ea53a5f1ad..83b559adb8 100644 --- a/src/menus/hotkeyMenu.cpp +++ b/src/menus/hotkeyMenu.cpp @@ -1,120 +1,178 @@ +#include "hotkeyMenu.h" #include +#include #include "init/config.h" #include "engine.h" -#include "hotkeyMenu.h" -#include #include "soundManager.h" #include "main.h" #include "gui/hotkeyBinder.h" -#include "gui/gui2_selector.h" +#include "gui/gui2_button.h" +#include "gui/gui2_canvas.h" +#include "gui/gui2_label.h" #include "gui/gui2_overlay.h" -#include "gui/gui2_textentry.h" #include "gui/gui2_panel.h" -#include "gui/gui2_label.h" +#include "gui/gui2_scrollcontainer.h" +#include "gui/gui2_scrolltext.h" +#include "gui/gui2_selector.h" +#include "gui/gui2_textentry.h" HotkeyMenu::HotkeyMenu(OptionsMenu::ReturnTo return_to) : return_to(return_to) { + // Background color/image new GuiOverlay(this, "", colorConfig.background); (new GuiOverlay(this, "", glm::u8vec4{255,255,255,255}))->setTextureTiled("gui/background/crosses.png"); - // TODO: Figure out how to make this an AutoLayout. container = new GuiElement(this, "HOTKEY_CONFIG_CONTAINER"); - container->setSize(GuiElement::GuiSizeMax, GuiElement::GuiSizeMax)->setPosition(0, 0, sp::Alignment::TopLeft)->setMargins(FRAME_MARGIN / 2); + container + ->setSize(GuiElement::GuiSizeMax, GuiElement::GuiSizeMax) + ->setPosition(0.0f, 0.0f, sp::Alignment::TopLeft) + ->setMargins(FRAME_MARGIN * 0.5f) + ->setAttribute("layout", "vertical"); top_row = new GuiElement(container, "TOP_ROW_CONTAINER"); - top_row->setSize(GuiElement::GuiSizeMax, ROW_HEIGHT)->setPosition(0, 0, sp::Alignment::TopLeft); + top_row + ->setSize(GuiElement::GuiSizeMax, ROW_HEIGHT); rebinding_ui = new GuiPanel(container, "REBINDING_UI_CONTAINER"); - rebinding_ui->setSize(GuiElement::GuiSizeMax, KEY_COLUMN_HEIGHT)->setPosition(0, KEY_COLUMN_TOP, sp::Alignment::TopLeft); - info_container = new GuiElement(container, "info_container_CONTAINER"); - info_container->setSize(GuiElement::GuiSizeMax, ROW_HEIGHT)->setPosition(0, KEY_COLUMN_TOP+KEY_COLUMN_HEIGHT, sp::Alignment::TopLeft); + rebinding_ui + ->setSize(GuiElement::GuiSizeMax, GuiElement::GuiSizeMax) + ->setAttribute("layout", "vertical"); + rebinding_ui + ->setAttribute("padding", "20"); + + // Fixed column header row (not scrollable). + auto* header_row = new GuiElement(rebinding_ui, "HOTKEY_HEADER"); + header_row + ->setSize(GuiElement::GuiSizeMax, ROW_HEIGHT * 0.5f) + ->setAttribute("layout", "horizontal"); + + (new GuiLabel(header_row, "HOTKEY_HEADER_SPACER", "", 18.0f)) + ->setSize(KEY_LABEL_WIDTH, GuiElement::GuiSizeMax) + ->setMargins(0.0f, 0.0f, KEY_BINDER_MARGIN, 0.0f); + (new GuiLabel(header_row, "HOTKEY_HEADER_KB", tr("Keyboard"), 18.0f)) + ->setAlignment(sp::Alignment::CenterLeft) + ->setSize(GuiElement::GuiSizeMax, GuiElement::GuiSizeMax) + ->setMargins(0.0f, 0.0f, KEY_BINDER_MARGIN, 0.0f); + (new GuiLabel(header_row, "HOTKEY_HEADER_JS", tr("Joystick"), 18.0f)) + ->setAlignment(sp::Alignment::CenterLeft) + ->setSize(GuiElement::GuiSizeMax, GuiElement::GuiSizeMax) + ->setMargins(0.0f, 0.0f, KEY_BINDER_MARGIN, 0.0f); + (new GuiLabel(header_row, "HOTKEY_HEADER_MS", tr("Mouse"), 18.0f)) + ->setAlignment(sp::Alignment::CenterLeft) + ->setSize(GuiElement::GuiSizeMax, GuiElement::GuiSizeMax) + ->setMargins(0.0f, 0.0f, KEY_BINDER_MARGIN, 0.0f); + + info_container = new GuiElement(container, "INFO_CONTAINER_CONTAINER"); + info_container + ->setSize(GuiElement::GuiSizeMax, ROW_HEIGHT * 3.0f); + bottom_row = new GuiElement(container, "BOTTOM_ROW_CONTAINER"); - bottom_row->setSize(GuiElement::GuiSizeMax, ROW_HEIGHT)->setPosition(0, 0, sp::Alignment::BottomLeft); + bottom_row + ->setSize(GuiElement::GuiSizeMax, ROW_HEIGHT); // Single-column layout // Top: Title and category navigation // Title label - (new GuiLabel(top_row, "CONFIGURE_KEYBOARD_LABEL", tr("Configure Keyboard/Joystick"), 30))->addBackground()->setPosition(0, 0, sp::Alignment::TopLeft)->setSize(350, GuiElement::GuiSizeMax); + (new GuiLabel(top_row, "CONFIGURE_CONTROLS_LABEL", tr("Configure controls"), 30.0f)) + ->addBackground() + ->setSize(350.0f, GuiElement::GuiSizeMax); // Category selector // Get a list of hotkey categories category_list = sp::io::Keybinding::getCategories(); - (new GuiSelector(top_row, "Category", [this](int index, string value) - { - HotkeyMenu::setCategory(index); - }))->setOptions(category_list)->setSelectionIndex(category_index)->setSize(300, GuiElement::GuiSizeMax)->setPosition(0, 0, sp::Alignment::TopCenter); - - // Page navigation - previous_page = new GuiArrowButton(container, "PAGE_LEFT", 0, [this]() - { - HotkeyMenu::pageHotkeys(1); - }); - previous_page->setPosition(0, 0, sp::Alignment::CenterLeft)->setSize(GuiElement::GuiSizeMatchHeight, ROW_HEIGHT)->disable(); - - next_page = new GuiArrowButton(container, "PAGE_RIGHT", 180, [this]() - { - HotkeyMenu::pageHotkeys(-1); - }); - next_page->setPosition(0, 0, sp::Alignment::CenterRight)->setSize(GuiElement::GuiSizeMatchHeight, ROW_HEIGHT)->disable(); + auto* category_selector = new GuiSelector(top_row, "Category", + [this](int index, string value) + { + HotkeyMenu::setCategory(index); + } + ); + category_selector + ->setOptions(category_list) + ->setSelectionIndex(category_index) + ->setSize(300.0f, GuiElement::GuiSizeMax) + ->setPosition(0.0f, 0.0f, sp::Alignment::TopCenter); // Middle: Rebinding UI frame - rebinding_container = new GuiElement(rebinding_ui, "HOTKEY_CONFIG_CONTAINER"); - rebinding_container->setSize(GuiElement::GuiSizeMax, GuiElement::GuiSizeMax)->setPosition(0, 0, sp::Alignment::TopLeft)->setAttribute("layout", "horizontal"); + scroll_container = new GuiScrollContainer(rebinding_ui, "HOTKEY_CONTAINER"); + scroll_container + ->setSize(GuiElement::GuiSizeMax, GuiElement::GuiSizeMax) + ->setAttribute("layout", "vertical"); - // Show category 1 ("General") - HotkeyMenu::setCategory(1); + // Show category 2 ("General") + HotkeyMenu::setCategory(2); + category_selector->setSelectionIndex(2); // Bottom: Menu navigation // Back button to return to the Options menu - (new GuiScrollText(info_container, "INFO_LABEL", tr("Left Click: Assign input. Middle Click: Add input. Right Click: Delete inputs.\nPossible inputs: Keyboard keys, joystick buttons, joystick axes.")))->setPosition(10, 0, sp::Alignment::TopCenter)->setSize(GuiElement::GuiSizeMax, ROW_HEIGHT*3); - (new GuiButton(bottom_row, "BACK", tr("button", "Back"), [this, return_to]() - { - // Close this menu, stop the music, and return to the main menu. - destroy(); - soundManager->stopMusic(); - returnToOptionMenu(return_to); - }))->setPosition(0, 0, sp::Alignment::BottomLeft)->setSize(150, GuiElement::GuiSizeMax); + (new GuiScrollText(info_container, "INFO_LABEL", + tr("Left click: Assign input. Middle click: Add input. Right click: Remove last input.\nSupported inputs: Keyboard keys, joystick buttons and axes, mouse buttons and axes.") + )) + ->setPosition(10.0f, 0.0f, sp::Alignment::TopCenter) + ->setSize(GuiElement::GuiSizeMax, ROW_HEIGHT * 3) + ->setAttribute("margin", "0, 0, 20, 0"); + + (new GuiButton(bottom_row, "BACK", tr("button", "Back"), + [this, return_to]() + { + // Close this menu, stop the music, and return to the main menu. + destroy(); + soundManager->stopMusic(); + returnToOptionMenu(return_to); + } + )) + ->setPosition(0.0f, 0.0f, sp::Alignment::BottomLeft) + ->setSize(150.0f, GuiElement::GuiSizeMax); // Reset keybinds confirmation reset_label = new GuiLabel(bottom_row, "RESET_LABEL", tr("Bindings reset to defaults"), 30.0f); - reset_label->setAlignment(sp::Alignment::CenterRight)->setPosition(0.0f, -50.0f, sp::Alignment::BottomRight)->setSize(100.0f, 50.0f)->hide(); + reset_label + ->setAlignment(sp::Alignment::CenterRight) + ->setPosition(0.0f, -50.0f, sp::Alignment::BottomRight) + ->setSize(100.0f, 50.0f) + ->hide(); // Reset keybinds button - (new GuiButton(bottom_row, "RESET", tr("button", "Reset"), [this]() - { - reset_label->setVisible(true); - reset_label_timer = RESET_LABEL_TIMEOUT; - - // Iterate through all bindings and reset to defaults. - for (auto category : sp::io::Keybinding::getCategories()) + (new GuiButton(bottom_row, "RESET", tr("button", "Reset"), + [this]() { - for (auto item : sp::io::Keybinding::listAllByCategory(category)) - { - // Clear current binding. - item->clearKeys(); + reset_label->setVisible(true); + reset_label_timer = RESET_LABEL_TIMEOUT; - // Get the default binding, if any, and set it as the item's new - // binding. - std::vector default_bindings = item->getDefaultBindings(); - for (auto binding : default_bindings) item->addKey(binding); + // Iterate through all bindings and reset to defaults. + for (auto category : sp::io::Keybinding::getCategories()) + { + for (auto item : sp::io::Keybinding::listAllByCategory(category)) + { + // Clear current binding. + item->clearKeys(); + + // Get the default binding, if any, and set it as the item's new + // binding. + std::vector default_bindings = item->getDefaultBindings(); + for (auto binding : default_bindings) item->addKey(binding); + } } } - }))->setPosition(0.0f, 0.0f, sp::Alignment::BottomRight)->setSize(150.0f, GuiElement::GuiSizeMax); + )) + ->setPosition(0.0f, 0.0f, sp::Alignment::BottomRight) + ->setSize(150.0f, GuiElement::GuiSizeMax); } void HotkeyMenu::update(float delta) { + // Tick countdown to hiding the reset indicator. if (reset_label->isVisible()) { reset_label_timer -= delta; reset_label->setVisible(reset_label_timer > 0.0f); } - if (keys.escape.getDown()) + // Return to the options menu on Esc/Home bind, but not while rebinding. + if (keys.escape.getDown() && !GuiHotkeyBinder::isAnyRebinding()) { destroy(); returnToOptionMenu(return_to); @@ -131,79 +189,47 @@ void HotkeyMenu::setCategory(int cat) label_entries.clear(); for (auto row : rebinding_rows) row->destroy(); rebinding_rows.clear(); - for (auto column : rebinding_columns) column->destroy(); - rebinding_columns.clear(); - // Reset the hotkey frame size and position - int rebinding_ui_width = KEY_COLUMN_WIDTH; - rebinding_ui->setPosition(0, KEY_COLUMN_TOP, sp::Alignment::TopLeft)->setSize(KEY_COLUMN_WIDTH + FRAME_MARGIN, ROW_HEIGHT * (KEY_ROW_COUNT + 2)); + // Reset scroll to top when switching categories. + scroll_container->scrollToOffset(0.0f); // Get the chosen category category_index = cat; category = category_list[cat]; - // Initialize column row count so we can split columns. - int column_row_count = 0; - // Get all hotkeys in this category. hotkey_list = sp::io::Keybinding::listAllByCategory(category); + const sp::io::Keybinding::Type joystick_type = sp::io::Keybinding::Type::Joystick | sp::io::Keybinding::Type::Controller; + const sp::io::Keybinding::Type mouse_type = sp::io::Keybinding::Type::Pointer | sp::io::Keybinding::Type::MouseMovement | sp::io::Keybinding::Type::MouseWheel; + // Begin rendering hotkey rebinding fields for this category. for (auto item : hotkey_list) { - // If we've filled a column, or don't have any rows yet, make a new column. - if (rebinding_rows.size() == 0 || column_row_count >= KEY_ROW_COUNT) - { - column_row_count = 0; - rebinding_columns.push_back(new GuiElement(rebinding_container, "")); - rebinding_columns.back()->setSize(KEY_COLUMN_WIDTH, KEY_COLUMN_HEIGHT)->setMargins(0, 50)->setAttribute("layout", "vertical"); - } - - // Add a rebinding row to the current column. - column_row_count += 1; - rebinding_rows.push_back(new GuiElement(rebinding_columns.back(), "")); - rebinding_rows.back()->setSize(GuiElement::GuiSizeMax, ROW_HEIGHT)->setAttribute("layout", "horizontal"); + // Add a rebinding row to the scroll container. + rebinding_rows.push_back(new GuiElement(scroll_container, "")); + rebinding_rows.back() + ->setSize(GuiElement::GuiSizeMax, ROW_HEIGHT) + ->setAttribute("layout", "horizontal"); // Add a label to the current row. - label_entries.push_back(new GuiLabel(rebinding_rows.back(), "HOTKEY_LABEL_" + item->getName(), item->getLabel(), 30)); - label_entries.back()->setAlignment(sp::Alignment::CenterRight)->setSize(KEY_LABEL_WIDTH, GuiElement::GuiSizeMax)->setMargins(0, 0, FRAME_MARGIN / 2, 0); - - // Add a hotkey rebinding field to the current row. - text_entries.push_back(new GuiHotkeyBinder(rebinding_rows.back(), "HOTKEY_VALUE_" + item->getName(), item)); - text_entries.back()->setSize(KEY_FIELD_WIDTH, GuiElement::GuiSizeMax)->setMargins(0, 0, FRAME_MARGIN / 2, 0); - } - - // Resize the rendering UI panel based on the number of columns. - rebinding_ui_width = KEY_COLUMN_WIDTH * rebinding_columns.size() + FRAME_MARGIN; - rebinding_ui->setSize(rebinding_ui_width, KEY_COLUMN_HEIGHT); - - // Enable pagination buttons if pagination is necessary. - // TODO: Detect viewport width instead of hardcoding breakpoint at - // two columns - if (rebinding_columns.size() > 2) - { - previous_page->enable(); - next_page->enable(); - } else { - previous_page->disable(); - next_page->disable(); + label_entries.push_back(new GuiLabel(rebinding_rows.back(), "HOTKEY_LABEL_" + item->getName(), item->getLabel(), 30.0f)); + label_entries.back() + ->setAlignment(sp::Alignment::CenterRight) + ->setSize(KEY_LABEL_WIDTH, GuiElement::GuiSizeMax) + ->setMargins(0.0f, 0.0f, KEY_BINDER_MARGIN, 0.0f); + + // Keyboard-only binder. + text_entries.push_back(new GuiHotkeyBinder(rebinding_rows.back(), "HOTKEY_KB_" + item->getName(), item, sp::io::Keybinding::Type::Keyboard, sp::io::Keybinding::Type::Keyboard)); + text_entries.back()->setSize(GuiElement::GuiSizeMax, GuiElement::GuiSizeMax)->setMargins(0.0f, 0.0f, KEY_BINDER_MARGIN, 0.0f); + + // Joystick/controller-only binder. + text_entries.push_back(new GuiHotkeyBinder(rebinding_rows.back(), "HOTKEY_JS_" + item->getName(), item, joystick_type, joystick_type)); + text_entries.back()->setSize(GuiElement::GuiSizeMax, GuiElement::GuiSizeMax)->setMargins(0.0f, 0.0f, KEY_BINDER_MARGIN, 0.0f); + + // Mouse-only binder. + text_entries.push_back(new GuiHotkeyBinder(rebinding_rows.back(), "HOTKEY_MS_" + item->getName(), item, mouse_type, mouse_type)); + text_entries.back()->setSize(GuiElement::GuiSizeMax, GuiElement::GuiSizeMax)->setMargins(0.0f, 0.0f, KEY_BINDER_MARGIN, 0.0f); } } -void HotkeyMenu::pageHotkeys(int direction) -{ - auto frame_position = rebinding_ui->getPositionOffset(); - auto frame_size = rebinding_ui->getSize(); - - if (frame_size.x < KEY_COLUMN_WIDTH * 2) return; - - // Move the frame left if the direction is negative, right if it's positive - int new_offset = frame_position.x + KEY_COLUMN_WIDTH * direction; - - // Don't let the frame move right if its left edge is on screen. - // Move the frame left only if its right edge is not on screen. - if (new_offset >= 0) - rebinding_ui->setPosition(0, KEY_COLUMN_TOP, sp::Alignment::TopLeft); - else if (new_offset > -frame_size.x + KEY_COLUMN_WIDTH + FRAME_MARGIN) - rebinding_ui->setPosition(new_offset, KEY_COLUMN_TOP, sp::Alignment::TopLeft); -} diff --git a/src/menus/hotkeyMenu.h b/src/menus/hotkeyMenu.h index ed15975fe0..ae6574553d 100644 --- a/src/menus/hotkeyMenu.h +++ b/src/menus/hotkeyMenu.h @@ -1,36 +1,25 @@ #pragma once #include "optionsMenu.h" -#include "gui/gui2_arrowbutton.h" -#include "gui/gui2_entrylist.h" -#include "gui/gui2_canvas.h" -#include "gui/gui2_scrollbar.h" -#include "gui/gui2_scrolltext.h" #include "gui/hotkeyBinder.h" #include "Updatable.h" -class GuiArrowButton; -class GuiOverlay; -class GuiSlider; -class GuiLabel; class GuiCanvas; +class GuiHotkeyBinder; +class GuiLabel; +class GuiOverlay; class GuiPanel; +class GuiScrollContainer; class GuiScrollText; -class GuiHotkeyBinder; +class GuiSlider; class HotkeyMenu : public GuiCanvas, public Updatable { private: - const int ROW_HEIGHT = 50; - const int FRAME_MARGIN = 50; - const int KEY_LABEL_WIDTH = 375; - const int KEY_FIELD_WIDTH = 150; - const int KEY_LABEL_MARGIN = 25; - const int KEY_COLUMN_TOP = ROW_HEIGHT * 1.5; - const int KEY_ROW_COUNT = 10; - const int KEY_COLUMN_WIDTH = KEY_LABEL_WIDTH + KEY_LABEL_MARGIN + KEY_FIELD_WIDTH; - const int KEY_COLUMN_HEIGHT = ROW_HEIGHT * KEY_ROW_COUNT + FRAME_MARGIN * 2; - const int PAGER_BREAKPOINT = KEY_COLUMN_WIDTH * 2 + FRAME_MARGIN * 2; + const float ROW_HEIGHT = 50.0f; + const float FRAME_MARGIN = 50.0f; + const float KEY_LABEL_WIDTH = 400.0f; + const float KEY_BINDER_MARGIN = 12.5f; const float RESET_LABEL_TIMEOUT = 5.0f; GuiScrollText* help_text; @@ -39,14 +28,11 @@ class HotkeyMenu : public GuiCanvas, public Updatable GuiPanel* rebinding_ui; GuiElement* bottom_row; - GuiElement* rebinding_container; + GuiScrollContainer* scroll_container; GuiElement* info_container; - std::vector rebinding_columns; std::vector rebinding_rows; std::vector text_entries; std::vector label_entries; - GuiArrowButton* previous_page; - GuiArrowButton* next_page; GuiLabel* reset_label; string category = ""; @@ -57,7 +43,6 @@ class HotkeyMenu : public GuiCanvas, public Updatable OptionsMenu::ReturnTo return_to; void setCategory(int cat); - void pageHotkeys(int direction); public: HotkeyMenu(OptionsMenu::ReturnTo return_to=OptionsMenu::ReturnTo::Main); From 2f44a8de5dddd38d50e7888b8153cf6c4420f9f1 Mon Sep 17 00:00:00 2001 From: Oznogon Date: Mon, 9 Mar 2026 14:03:26 -0700 Subject: [PATCH 5/5] Refactor HotkeyBinder - Allow binder to limit scope to mouse, keyboard, or joystick/ gamecontroller types. - Allow binding left/right click without immediately canceling or rebinding. - Update right click to pop last bind instead of clearing all binds. - Prevent premature clearing of binder field before input occurs. --- src/gui/hotkeyBinder.cpp | 120 ++++++++++++++++++++++++++++++++++----- src/gui/hotkeyBinder.h | 22 ++++--- 2 files changed, 121 insertions(+), 21 deletions(-) diff --git a/src/gui/hotkeyBinder.cpp b/src/gui/hotkeyBinder.cpp index a38f2e81d6..f5b9bd0614 100644 --- a/src/gui/hotkeyBinder.cpp +++ b/src/gui/hotkeyBinder.cpp @@ -1,38 +1,130 @@ +#include "hotkeyBinder.h" #include #include "engine.h" #include "hotkeyConfig.h" -#include "hotkeyBinder.h" #include "theme.h" +// Track which binder and which key are actively performing a rebind. +static GuiHotkeyBinder* active_rebinder = nullptr; +static sp::io::Keybinding* active_key = nullptr; -GuiHotkeyBinder::GuiHotkeyBinder(GuiContainer* owner, string id, sp::io::Keybinding* key) -: GuiElement(owner, id), has_focus(false), key(key) +GuiHotkeyBinder::GuiHotkeyBinder(GuiContainer* owner, string id, sp::io::Keybinding* key, + sp::io::Keybinding::Type display_filter, sp::io::Keybinding::Type capture_filter) +: GuiElement(owner, id), key(key), display_filter(display_filter), capture_filter(capture_filter) { + // Use textentry theme styles for binder inputs. + // Someday, this should allow for icon representations instead of relying + // on text. front_style = theme->getStyle("textentry.front"); back_style = theme->getStyle("textentry.back"); } +bool GuiHotkeyBinder::isAnyRebinding() +{ + return active_rebinder != nullptr; +} + +void GuiHotkeyBinder::clearFilteredKeys() +{ + // Filter binds for this control by their type. + int count = 0; + while (key->getKeyType(count) != sp::io::Keybinding::Type::None) count++; + for (int i = count - 1; i >= 0; --i) + if (key->getKeyType(i) & display_filter) key->removeKey(i); +} + bool GuiHotkeyBinder::onMouseDown(sp::io::Pointer::Button button, glm::vec2 position, sp::io::Pointer::ID id) { - if (button != sp::io::Pointer::Button::Middle) - key->clearKeys(); - if (button != sp::io::Pointer::Button::Right) - key->startUserRebind(sp::io::Keybinding::Type::Keyboard | sp::io::Keybinding::Type::Joystick | sp::io::Keybinding::Type::Controller | sp::io::Keybinding::Type::Virtual); + // If this binder is already rebinding, just take the input and skip this. + // This should allow binding left/middle/right-click without also changing + // the binder's state at the same time. + if (active_rebinder == this) return true; + + // Left click: Assign input. Middle click: Add input. + // Right click: Remove last input. Ignore all other mouse buttons. + if (button == sp::io::Pointer::Button::Left) + clearFilteredKeys(); + if (button == sp::io::Pointer::Button::Right) + { + int count = 0; + while (key->getKeyType(count) != sp::io::Keybinding::Type::None) count++; + for (int i = count - 1; i >= 0; --i) + { + if (key->getKeyType(i) & display_filter) + { + key->removeKey(i); + break; + } + } + } + + if (button == sp::io::Pointer::Button::Left || button == sp::io::Pointer::Button::Middle) + { + const sp::io::Keybinding::Type mouse_types = sp::io::Keybinding::Type::Pointer | sp::io::Keybinding::Type::MouseMovement | sp::io::Keybinding::Type::MouseWheel; + if (capture_filter & mouse_types) + { + // Delay startUserRebind until onMouseUp so that the triggering + // mouse click is not immediately captured as the new binding. + pending_rebind = true; + } + else + { + active_rebinder = this; + active_key = key; + key->startUserRebind(capture_filter); + } + } + return true; } +void GuiHotkeyBinder::onMouseUp(glm::vec2 position, sp::io::Pointer::ID id) +{ + // Complete a pending rebind action. + if (pending_rebind) + { + pending_rebind = false; + active_rebinder = this; + active_key = key; + key->startUserRebind(capture_filter); + } +} + void GuiHotkeyBinder::onDraw(sp::RenderTarget& renderer) { - focus = key->isUserRebinding(); + // Clear the active rebind indicator only when the tracked key's rebind + // completes. + if (active_key != nullptr && !active_key->isUserRebinding()) + { + active_rebinder = nullptr; + active_key = nullptr; + } + + bool is_my_rebind = (active_rebinder == this); + focus = is_my_rebind; + const auto& back = back_style->get(getState()); const auto& front = front_style->get(getState()); renderer.drawStretched(rect, back.texture, back.color); - string text = key->getHumanReadableKeyName(0); - for(int n=1; key->getKeyType(n) != sp::io::Keybinding::Type::None; n++) - text += "," + key->getHumanReadableKeyName(n); - if (key->isUserRebinding()) - text = tr("[New input]"); - renderer.drawText(sp::Rect(rect.position.x + 16, rect.position.y, rect.size.x, rect.size.y), text, sp::Alignment::CenterLeft, front.size, front.font, front.color); + string text; + + // If this is the active rebinder, update its state to indicate that it's + // ready for input. Otherwise, list the associated binds. + // TODO: This list can get quite long. What should it do on overflow? + if (is_my_rebind) text = tr("[New input]"); + else + { + for (int n = 0; key->getKeyType(n) != sp::io::Keybinding::Type::None; n++) + { + if (key->getKeyType(n) & display_filter) + { + if (!text.empty()) text += ","; + text += key->getHumanReadableKeyName(n); + } + } + } + + renderer.drawText(sp::Rect(rect.position.x + 16.0f, rect.position.y, rect.size.x, rect.size.y), text, sp::Alignment::CenterLeft, front.size, front.font, front.color); } diff --git a/src/gui/hotkeyBinder.h b/src/gui/hotkeyBinder.h index d5ddf146ac..627000d394 100644 --- a/src/gui/hotkeyBinder.h +++ b/src/gui/hotkeyBinder.h @@ -1,23 +1,31 @@ -#ifndef HOTKEYBINDER_H -#define HOTKEYBINDER_H +#pragma once #include "gui2_element.h" - +#include "io/keybinding.h" class GuiThemeStyle; + class GuiHotkeyBinder : public GuiElement { private: - bool has_focus; sp::io::Keybinding* key; + sp::io::Keybinding::Type display_filter; + sp::io::Keybinding::Type capture_filter; + bool pending_rebind = false; const GuiThemeStyle* front_style; const GuiThemeStyle* back_style; + + void clearFilteredKeys(); public: - GuiHotkeyBinder(GuiContainer* owner, string id, sp::io::Keybinding* key); + GuiHotkeyBinder(GuiContainer* owner, string id, sp::io::Keybinding* key, sp::io::Keybinding::Type display_filter = sp::io::Keybinding::Type::Default, sp::io::Keybinding::Type capture_filter = sp::io::Keybinding::Type::Default); + + // Returns true if any binder is actively rebinding. Used to prevent + // game-wide binds like escape from being handled while binding a key. + // The escape control can't be rebound otherwise. + static bool isAnyRebinding(); virtual bool onMouseDown(sp::io::Pointer::Button button, glm::vec2 position, sp::io::Pointer::ID id) override; + virtual void onMouseUp(glm::vec2 position, sp::io::Pointer::ID id) override; virtual void onDraw(sp::RenderTarget& renderer) override; }; - -#endif //HOTKEYBINDER_H