From 8f44cca9ff609d1b258b72b07b553ffcccea4f2a Mon Sep 17 00:00:00 2001 From: Oznogon Date: Mon, 2 Mar 2026 17:25:24 -0800 Subject: [PATCH 1/6] 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 75c618743828fea9b81e404ccb845992dcc42023 Mon Sep 17 00:00:00 2001 From: Oznogon Date: Mon, 9 Mar 2026 14:19:16 -0700 Subject: [PATCH 2/6] 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 7b8e99fad667cdf97e49f2b419dd06310de97746 Mon Sep 17 00:00:00 2001 From: oznogon Date: Wed, 11 Mar 2026 22:26:37 -0700 Subject: [PATCH 3/6] 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 603120914cab687e0caf86189e06f765b3bee281 Mon Sep 17 00:00:00 2001 From: oznogon Date: Wed, 11 Mar 2026 00:11:43 -0700 Subject: [PATCH 4/6] Use GuiScrollContainer for GuiSelector popup list Limit GuiSelector popup lists to 10 visible items and scroll the list if necessary. --- src/gui/gui2_selector.cpp | 72 +++++++++++++++++++++++++-------------- src/gui/gui2_selector.h | 3 ++ 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/src/gui/gui2_selector.cpp b/src/gui/gui2_selector.cpp index 60dc9cf3d2..1cb13c95be 100644 --- a/src/gui/gui2_selector.cpp +++ b/src/gui/gui2_selector.cpp @@ -1,10 +1,11 @@ -#include "gui2_arrowbutton.h" +#include "gui2_selector.h" #include "soundManager.h" #include "theme.h" +#include "gui2_arrowbutton.h" #include "gui2_label.h" #include "gui2_panel.h" -#include "gui2_selector.h" +#include "gui2_scrollcontainer.h" #include "gui2_togglebutton.h" GuiSelector::GuiSelector(GuiContainer* owner, string id, func_t func) @@ -33,6 +34,10 @@ GuiSelector::GuiSelector(GuiContainer* owner, string id, func_t func) popup = new GuiPanel(getTopLevelContainer(), ""); popup->hide(); + popup_scroll = new GuiScrollContainer(popup, id + "_POPUP_SCROLL"); + popup_scroll + ->setSize(GuiElement::GuiSizeMax, GuiElement::GuiSizeMax) + ->setAttribute("layout", "vertical"); } void GuiSelector::onDraw(sp::RenderTarget& renderer) @@ -47,18 +52,16 @@ void GuiSelector::onDraw(sp::RenderTarget& renderer) if (selection_index >= 0 && selection_index < (int)entries.size()) renderer.drawText(rect, entries[selection_index].name, sp::Alignment::Center, text_size, nullptr, front.color); - if (!focus) - popup->hide(); // 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(); + const float max_popup_height = button_height * 10.0f; + float popup_height = std::min(static_cast(entries.size()) * button_height, max_popup_height); 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(screen_pos.x, top, sp::Alignment::TopLeft)->setSize(rect.size.x, height); + top = std::clamp(top, 0.0f, 900.0f - popup_height); + popup + ->setPosition(screen_pos.x, top, sp::Alignment::TopLeft) + ->setSize(rect.size.x, popup_height); } GuiSelector* GuiSelector::setTextSize(float size) @@ -76,35 +79,54 @@ void GuiSelector::onMouseUp(glm::vec2 position, sp::io::Pointer::ID id) { if (rect.contains(position)) { + if (popup->isVisible()) + { + popup->hide(); + return; + } + soundManager->playSound("sfx/button.wav"); - for(unsigned int n=0; nhide(); })); - popup_buttons[n]->setSize(GuiElement::GuiSizeMax, 50); - popup_buttons[n]->setTextSize(text_size); - }else{ - popup_buttons[n]->setText(entries[n].name); - popup_buttons[n]->show(); + + popup_buttons[n] + ->setTextSize(text_size) + ->setSize(GuiElement::GuiSizeMax, button_height); } - popup_buttons[n]->setValue(int(n) == selection_index); - popup_buttons[n]->setPosition(0, n * 50, sp::Alignment::TopLeft); + else + { + popup_buttons[n] + ->setText(entries[n].name) + ->show(); + } + popup_buttons[n]->setValue(static_cast(n) == selection_index); } - for(unsigned int n=entries.size(); nhide(); - } - popup->show()->moveToFront(); + + // Scroll so the selected item is visible. + if (selection_index >= 0) + popup_scroll->scrollToOffset(selection_index * button_height); + + popup + ->show() + ->moveToFront(); } } void GuiSelector::onFocusLost() { - // Explicitly hide the popup when the selector loses focus. - popup->hide(); + // Hide the popup on a focus change outside of the popup rect. + if (!popup->getRect().contains(hover_coordinates)) + popup->hide(); } diff --git a/src/gui/gui2_selector.h b/src/gui/gui2_selector.h index 63cbba511c..7f5ddc523f 100644 --- a/src/gui/gui2_selector.h +++ b/src/gui/gui2_selector.h @@ -1,6 +1,7 @@ #pragma once #include "gui2_entrylist.h" +#include "gui2_scrollcontainer.h" class GuiArrowButton; @@ -11,10 +12,12 @@ class GuiSelector : public GuiEntryList { protected: float text_size = 30.0f; + float button_height = 50.0f; sp::Alignment text_alignment; GuiArrowButton* left; GuiArrowButton* right; GuiElement* popup; + GuiScrollContainer* popup_scroll; std::vector popup_buttons; const GuiThemeStyle* back_style; const GuiThemeStyle* front_style; From 3b5e87c1ea585f7da6739a3ef69aa6fd7e468f18 Mon Sep 17 00:00:00 2001 From: oznogon Date: Thu, 12 Mar 2026 00:52:09 -0700 Subject: [PATCH 5/6] Implement GuiSelector setPopupWidth(), text fitting --- src/gui/gui2_selector.cpp | 69 ++++++++++++++++++++++++++------------- src/gui/gui2_selector.h | 4 +++ 2 files changed, 51 insertions(+), 22 deletions(-) diff --git a/src/gui/gui2_selector.cpp b/src/gui/gui2_selector.cpp index 1cb13c95be..55ac8f40e0 100644 --- a/src/gui/gui2_selector.cpp +++ b/src/gui/gui2_selector.cpp @@ -13,27 +13,40 @@ GuiSelector::GuiSelector(GuiContainer* owner, string id, func_t func) { back_style = theme->getStyle("selector.back"); front_style = theme->getStyle("selector.front"); - left = new GuiArrowButton(this, id + "_ARROW_LEFT", 0, [this]() { - soundManager->playSound("sfx/button.wav"); - if (getSelectionIndex() <= 0) - setSelectionIndex(entries.size() - 1); - else - setSelectionIndex(getSelectionIndex() - 1); - callback(); - }); - left->setPosition(0, 0, sp::Alignment::TopLeft)->setSize(GuiSizeMatchHeight, GuiSizeMax); - right = new GuiArrowButton(this, id + "_ARROW_RIGHT", 180, [this]() { - soundManager->playSound("sfx/button.wav"); - if (getSelectionIndex() >= (int)entries.size() - 1) - setSelectionIndex(0); - else - setSelectionIndex(getSelectionIndex() + 1); - callback(); - }); - right->setPosition(0, 0, sp::Alignment::TopRight)->setSize(GuiSizeMatchHeight, GuiSizeMax); + + left = new GuiArrowButton(this, id + "_ARROW_LEFT", 0, + [this]() + { + const int index = getSelectionIndex(); + if (index <= 0) + setSelectionIndex(static_cast(entries.size()) - 1); + else + setSelectionIndex(index - 1); + callback(); + } + ); + left + ->setPosition(0.0f, 0.0f, sp::Alignment::TopLeft) + ->setSize(GuiElement::GuiSizeMatchHeight, GuiElement::GuiSizeMax); + + right = new GuiArrowButton(this, id + "_ARROW_RIGHT", 180, + [this]() + { + const int index = getSelectionIndex(); + if (index >= static_cast(entries.size()) - 1) + setSelectionIndex(0); + else + setSelectionIndex(index + 1); + callback(); + } + ); + right + ->setPosition(0.0f, 0.0f, sp::Alignment::TopRight) + ->setSize(GuiElement::GuiSizeMatchHeight, GuiElement::GuiSizeMax); popup = new GuiPanel(getTopLevelContainer(), ""); popup->hide(); + popup_scroll = new GuiScrollContainer(popup, id + "_POPUP_SCROLL"); popup_scroll ->setSize(GuiElement::GuiSizeMax, GuiElement::GuiSizeMax) @@ -49,8 +62,11 @@ void GuiSelector::onDraw(sp::RenderTarget& renderer) const auto& front = front_style->get(getState()); renderer.drawStretched(rect, back.texture, back.color); - if (selection_index >= 0 && selection_index < (int)entries.size()) - renderer.drawText(rect, entries[selection_index].name, sp::Alignment::Center, text_size, nullptr, front.color); + + // Fit selector text between the arrow buttons. + sp::Rect text_rect(rect.position.x + rect.size.y * 0.5f, rect.position.y, rect.size.x - rect.size.y, rect.size.y); + if (selection_index >= 0 && selection_index < static_cast(entries.size())) + renderer.drawText(text_rect, entries[selection_index].name, sp::Alignment::Center, text_size, nullptr, front.color, sp::Font::FlagClip); // rect.position is in layout space; the popup lives at the canvas level // (no scroll translation), so convert to screen coordinates first. @@ -59,6 +75,9 @@ void GuiSelector::onDraw(sp::RenderTarget& renderer) float popup_height = std::min(static_cast(entries.size()) * button_height, max_popup_height); float top = screen_pos.y; top = std::clamp(top, 0.0f, 900.0f - popup_height); + + // Size and position the popup, factoring its override width if set. + setPopupWidth(popup_width); popup ->setPosition(screen_pos.x, top, sp::Alignment::TopLeft) ->setSize(rect.size.x, popup_height); @@ -70,6 +89,12 @@ GuiSelector* GuiSelector::setTextSize(float size) return this; } +GuiSelector* GuiSelector::setPopupWidth(float width) +{ + popup_width = std::max(rect.size.x, width); + return this; +} + bool GuiSelector::onMouseDown(sp::io::Pointer::Button button, glm::vec2 position, sp::io::Pointer::ID id) { return true; @@ -85,8 +110,7 @@ void GuiSelector::onMouseUp(glm::vec2 position, sp::io::Pointer::ID id) return; } - soundManager->playSound("sfx/button.wav"); - for (unsigned int n = 0; n < entries.size(); n++) + for (size_t n = 0; n < entries.size(); n++) { if (popup_buttons.size() <= n) { @@ -118,6 +142,7 @@ void GuiSelector::onMouseUp(glm::vec2 position, sp::io::Pointer::ID id) if (selection_index >= 0) popup_scroll->scrollToOffset(selection_index * button_height); + // Show and elevate the popup. popup ->show() ->moveToFront(); diff --git a/src/gui/gui2_selector.h b/src/gui/gui2_selector.h index 7f5ddc523f..7c486152af 100644 --- a/src/gui/gui2_selector.h +++ b/src/gui/gui2_selector.h @@ -12,6 +12,7 @@ class GuiSelector : public GuiEntryList { protected: float text_size = 30.0f; + float popup_width = 0.0f; float button_height = 50.0f; sp::Alignment text_alignment; GuiArrowButton* left; @@ -30,4 +31,7 @@ class GuiSelector : public GuiEntryList virtual void onFocusLost() override; GuiSelector* setTextSize(float size); + // Define a width for the popup, but only if it's larger than the + // GuiSelector's width, + GuiSelector* setPopupWidth(float width); }; From 4ec0c5834ea07bc0eedfefb62998a7843346a60a Mon Sep 17 00:00:00 2001 From: Oznogon Date: Thu, 12 Mar 2026 18:24:13 -0700 Subject: [PATCH 6/6] Add icon rendering to GuiSelector Use GuiEntryList's existing icon support to render icons for GuiSelector options, both in the selector and in its popup list. --- src/gui/gui2_selector.cpp | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/gui/gui2_selector.cpp b/src/gui/gui2_selector.cpp index 55ac8f40e0..a9cb5d55a0 100644 --- a/src/gui/gui2_selector.cpp +++ b/src/gui/gui2_selector.cpp @@ -64,9 +64,26 @@ void GuiSelector::onDraw(sp::RenderTarget& renderer) renderer.drawStretched(rect, back.texture, back.color); // Fit selector text between the arrow buttons. - sp::Rect text_rect(rect.position.x + rect.size.y * 0.5f, rect.position.y, rect.size.x - rect.size.y, rect.size.y); + sp::Rect inner_rect = { + {rect.position.x + rect.size.y * 0.5f, rect.position.y,}, + {rect.size.x - rect.size.y, rect.size.y} + }; + sp::Rect text_rect = inner_rect; + if (selection_index >= 0 && selection_index < static_cast(entries.size())) - renderer.drawText(text_rect, entries[selection_index].name, sp::Alignment::Center, text_size, nullptr, front.color, sp::Font::FlagClip); + { + // Draw icon if one is defined and realign text. + const string& icon = entries[selection_index].icon_name; + if (icon != "") + { + renderer.drawRotatedSprite(icon, glm::vec2(inner_rect.position.x + inner_rect.size.y * 0.5f, inner_rect.position.y + inner_rect.size.y * 0.5f), inner_rect.size.y * 0.8f, 0.0f, front.color); + text_rect.position.x += inner_rect.size.y; + text_rect.size.x -= inner_rect.size.y; + renderer.drawText(text_rect, entries[selection_index].name, sp::Alignment::CenterLeft, text_size, nullptr, front.color, sp::Font::FlagClip); + } + else + renderer.drawText(text_rect, entries[selection_index].name, sp::Alignment::Center, text_size, nullptr, front.color, sp::Font::FlagClip); + } // rect.position is in layout space; the popup lives at the canvas level // (no scroll translation), so convert to screen coordinates first. @@ -131,7 +148,9 @@ void GuiSelector::onMouseUp(glm::vec2 position, sp::io::Pointer::ID id) ->setText(entries[n].name) ->show(); } - popup_buttons[n]->setValue(static_cast(n) == selection_index); + popup_buttons[n] + ->setValue(static_cast(n) == selection_index) + ->setIcon(entries[n].icon_name); } // Hide each popup button. @@ -152,6 +171,5 @@ void GuiSelector::onMouseUp(glm::vec2 position, sp::io::Pointer::ID id) void GuiSelector::onFocusLost() { // Hide the popup on a focus change outside of the popup rect. - if (!popup->getRect().contains(hover_coordinates)) - popup->hide(); + if (!popup->getRect().contains(hover_coordinates)) popup->hide(); }