From fe8470b445f27da712881cc7ac2361139744e8ff Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:17:12 +0000 Subject: [PATCH 1/2] Make overflowing menus scroll instead of running off-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cap popup/submenu window height to the display and let ui::Menu hide items outside the visible window, showing combobox_arrow_up/down skin parts as scroll indicators (clickable, mouse-wheel and keyboard navigation all scroll). Also replace the hand-drawn submenu triangle with the combobox_arrow_right skin parts. Fixes #138 Co-authored-by: Daniel Praźmo <4753308+Nidrax@users.noreply.github.com> --- src/app/ui/skin/skin_theme.cpp | 52 ++++---- src/ui/menu.cpp | 214 +++++++++++++++++++++++++++++++-- src/ui/menu.h | 26 ++++ 3 files changed, 260 insertions(+), 32 deletions(-) diff --git a/src/app/ui/skin/skin_theme.cpp b/src/app/ui/skin/skin_theme.cpp index 3d9adb418..625bce773 100644 --- a/src/app/ui/skin/skin_theme.cpp +++ b/src/app/ui/skin/skin_theme.cpp @@ -1580,10 +1580,27 @@ namespace app::skin void SkinTheme::paintMenu(PaintEvent& ev) { - const Widget* widget = ev.getSource(); + const auto widget = static_cast(ev.getSource()); Graphics* g = ev.graphics(); g->fillRect(BGCOLOR, g->getClipBounds()); + + // When the menu doesn't fit in the display, draw scroll + // indicators over the reserved strips at the top/bottom. + if (widget->canScrollUp()) { + const gfx::Rect rc = widget->scrollUpBounds(); + she::Surface* icon = parts.comboboxArrowUpSelected()->bitmap(0); + g->drawRgbaSurface(icon, + rc.x + rc.w/2 - icon->width()/2, + rc.y + rc.h/2 - icon->height()/2); + } + if (widget->canScrollDown()) { + const gfx::Rect rc = widget->scrollDownBounds(); + she::Surface* icon = parts.comboboxArrowDownSelected()->bitmap(0); + g->drawRgbaSurface(icon, + rc.x + rc.w/2 - icon->width()/2, + rc.y + rc.h/2 - icon->height()/2); + } } void SkinTheme::paintMenuItem(PaintEvent& ev) @@ -1648,29 +1665,20 @@ namespace app::skin // For menu-box if (!bar) { - // Draw the arrown (to indicate which this menu has a sub-menu) + // Draw the arrow (to indicate that this menu has a sub-menu) if (widget->getSubmenu()) { - int c; - // Enabled - if (widget->isEnabled()) { - for (c = 0; c < 3 * scale; c++) - g->drawVLine(fg, - bounds.x2() - 3 * scale - c, - bounds.y + bounds.h / 2 - c, 2 * c + 1); - } - // Disabled - else { - for (c = 0; c < 3 * scale; c++) - g->drawVLine(colors.background(), - bounds.x2() - 3 * scale - c + 1, - bounds.y + bounds.h / 2 - c + 1, 2 * c + 1); - - for (c = 0; c < 3 * scale; c++) - g->drawVLine(colors.disabled(), - bounds.x2() - 3 * scale - c, - bounds.y + bounds.h / 2 - c, 2 * c + 1); - } + she::Surface* icon; + if (!widget->isEnabled()) + icon = parts.comboboxArrowRightDisabled()->bitmap(0); + else if (widget->isHighlighted()) + icon = parts.comboboxArrowRightSelected()->bitmap(0); + else + icon = parts.comboboxArrowRight()->bitmap(0); + + const int x = bounds.x2() - 3 * scale - icon->width(); + const int y = bounds.y + bounds.h / 2 - icon->height() / 2; + g->drawRgbaSurface(icon, x, y); } // Draw the keyboard shortcut else if (const auto appMenuItem = dynamic_cast(widget)) { diff --git a/src/ui/menu.cpp b/src/ui/menu.cpp index 228c1f66f..aed622ed5 100644 --- a/src/ui/menu.cpp +++ b/src/ui/menu.cpp @@ -1,5 +1,6 @@ // Aseprite UI Library // Copyright (C) 2001-2016 David Capello +// Besprited | Copyright (C) 2026 Veritaware // // This file is released under the terms of the MIT license. // Read LICENSE.txt for more information. @@ -130,6 +131,9 @@ static MenuItem* find_previtem(Menu* menu, MenuItem* menuitem); Menu::Menu() : Widget(kMenuWidget) , m_menuitem(NULL) + , m_scrollable(false) + , m_hasMoreBelow(false) + , m_scrollTopIndex(0) { initTheme(); } @@ -286,6 +290,13 @@ void Menu::showPopup(const gfx::Point& pos) window->remapWindow(); + // Don't let the popup window be taller than the display, so long + // menus become scrollable instead of overflowing the screen. + if (window->bounds().h > ui::display_h()) { + window->setBounds(Rect(window->bounds().x, window->bounds().y, + window->bounds().w, ui::display_h())); + } + // Menubox position window->positionWindow( MID(0, pos.x, ui::display_w() - window->bounds().w), @@ -316,25 +327,158 @@ void Menu::onPaint(PaintEvent& ev) void Menu::onResize(ResizeEvent& ev) { setBoundsQuietly(ev.bounds()); + layoutItems(); +} +// Positions the menu's children, hiding whichever items don't fit in +// the available height and reserving room for scroll indicators when +// that happens. Menu-bars are never scrollable, they lay out +// horizontally and always show every item. +void Menu::layoutItems() +{ Rect cpos = childrenBounds(); - bool isBar = (parent()->type() == kMenuBarWidget); + bool isBar = (parent() && parent()->type() == kMenuBarWidget); - for (auto child : children()) { - Size reqSize = child->sizeHint(); + if (isBar) { + m_scrollable = false; + m_hasMoreBelow = false; + m_scrollTopIndex = 0; + m_scrollUpBounds = m_scrollDownBounds = Rect(); - if (isBar) + for (auto child : children()) { + Size reqSize = child->sizeHint(); cpos.w = reqSize.w; - else - cpos.h = reqSize.h; + child->setVisible(true); + child->setBounds(cpos); + cpos.x += cpos.w; + } + return; + } - child->setBounds(cpos); + const WidgetsList& items = children(); + const int n = (int)items.size(); - if (isBar) - cpos.x += cpos.w; - else + int totalHeight = 0; + int rowHeight = 0; + for (auto child : items) { + int h = child->sizeHint().h; + totalHeight += h; + rowHeight = MAX(rowHeight, h); + } + + m_scrollable = (n > 0 && rowHeight > 0 && totalHeight > cpos.h); + + if (!m_scrollable) { + m_scrollTopIndex = 0; + m_hasMoreBelow = false; + m_scrollUpBounds = m_scrollDownBounds = Rect(); + + for (auto child : items) { + Size reqSize = child->sizeHint(); + cpos.h = reqSize.h; + child->setVisible(true); + child->setBounds(cpos); cpos.y += cpos.h; + } + return; + } + + m_scrollTopIndex = MID(0, m_scrollTopIndex, n-1); + + // How many items (starting from m_scrollTopIndex) fit in the given + // available height. Always fits at least one item. + auto fitCount = [&items, this](int avail) { + int used = 0, count = 0; + for (int i = m_scrollTopIndex; i < (int)items.size(); ++i) { + int h = items[i]->sizeHint().h; + if (count > 0 && used + h > avail) + break; + used += h; + ++count; + } + return count; + }; + + bool showTopArrow = (m_scrollTopIndex > 0); + int available = cpos.h - (showTopArrow ? rowHeight : 0); + int count = fitCount(available); + bool showBottomArrow = (m_scrollTopIndex + count < n); + if (showBottomArrow) { + available -= rowHeight; + count = fitCount(available); + showBottomArrow = (m_scrollTopIndex + count < n); + } + m_hasMoreBelow = showBottomArrow; + + int y = cpos.y + (showTopArrow ? rowHeight : 0); + for (int i = 0; i < n; ++i) { + Widget* child = items[i]; + if (i >= m_scrollTopIndex && i < m_scrollTopIndex + count) { + Size reqSize = child->sizeHint(); + child->setVisible(true); + child->setBounds(Rect(cpos.x, y, cpos.w, reqSize.h)); + y += reqSize.h; + } + else { + child->setVisible(false); + } + } + + m_scrollUpBounds = showTopArrow ? + Rect(cpos.x, cpos.y, cpos.w, rowHeight) : Rect(); + m_scrollDownBounds = showBottomArrow ? + Rect(cpos.x, cpos.y + cpos.h - rowHeight, cpos.w, rowHeight) : Rect(); +} + +// Scrolls the menu by the given number of items (negative scrolls up). +void Menu::scrollBy(int itemDelta) +{ + if (!m_scrollable) + return; + + int n = (int)children().size(); + int newTop = MID(0, m_scrollTopIndex + itemDelta, n-1); + if (newTop == m_scrollTopIndex) + return; + + m_scrollTopIndex = newTop; + layoutItems(); + invalidate(); +} + +// Scrolls, if needed, so that the given item (usually the newly +// highlighted one) is visible. +void Menu::ensureVisible(Widget* item) +{ + if (!item || !m_scrollable) + return; + + const WidgetsList& items = children(); + int index = -1; + for (int i = 0; i < (int)items.size(); ++i) { + if (items[i] == item) { + index = i; + break; + } + } + if (index < 0) + return; + + int oldTop = m_scrollTopIndex; + + if (index < m_scrollTopIndex) { + m_scrollTopIndex = index; + layoutItems(); + } + else { + while (!items[index]->isVisible() && m_scrollTopIndex < index) { + ++m_scrollTopIndex; + layoutItems(); + } } + + if (m_scrollTopIndex != oldTop) + invalidate(); } void Menu::onSizeHint(SizeHintEvent& ev) @@ -362,6 +506,47 @@ void Menu::onSizeHint(SizeHintEvent& ev) ev.setSizeHint(size); } +bool Menu::onProcessMessage(Message* msg) +{ + switch (msg->type()) { + + case kMouseDownMessage: + case kMouseMoveMessage: + if (m_scrollable) { + MenuBaseData* base = get_base(this); + if (base && base->is_processing) + break; + + gfx::Point mousePos = static_cast(msg)->position(); + + if (!m_scrollUpBounds.isEmpty() && m_scrollUpBounds.contains(mousePos)) { + if (msg->type() == kMouseDownMessage) + scrollBy(-1); + return true; + } + if (!m_scrollDownBounds.isEmpty() && m_scrollDownBounds.contains(mousePos)) { + if (msg->type() == kMouseDownMessage) + scrollBy(1); + return true; + } + } + break; + + case kMouseWheelMessage: + if (m_scrollable) { + gfx::Point wheelDelta = static_cast(msg)->wheelDelta(); + if (wheelDelta.y != 0) { + scrollBy(wheelDelta.y > 0 ? 1 : -1); + return true; + } + } + break; + + } + + return Widget::onProcessMessage(msg); +} + bool MenuBox::onProcessMessage(Message* msg) { Menu* menu = MenuBox::getMenu(); @@ -733,6 +918,13 @@ bool MenuItem::onProcessMessage(Message* msg) // New window and new menu-box Window* window = new CustomizedWindowForMenuBox(menubox); + // Don't let the submenu window be taller than the display, so + // long menus become scrollable instead of overflowing the screen. + if (window->bounds().h > ui::display_h()) { + window->setBounds(Rect(window->bounds().x, window->bounds().y, + window->bounds().w, ui::display_h())); + } + // Menubox position Rect pos = window->bounds(); @@ -962,6 +1154,8 @@ void Menu::highlightItem(MenuItem* menuitem, bool click, bool open_submenu, bool } if (menuitem) { + ensureVisible(menuitem); + if (!menuitem->isHighlighted()) { menuitem->setHighlighted(true); menuitem->invalidate(); diff --git a/src/ui/menu.h b/src/ui/menu.h index e5533a021..cf08a73f7 100644 --- a/src/ui/menu.h +++ b/src/ui/menu.h @@ -1,5 +1,6 @@ // Aseprite UI Library // Copyright (C) 2001-2013, 2015 David Capello +// Besprited | Copyright (C) 2026 Veritaware // // This file is released under the terms of the MIT license. // Read LICENSE.txt for more information. @@ -7,6 +8,7 @@ #pragma once #include "base/signal.h" +#include "gfx/rect.h" #include "ui/register_message.h" #include "ui/separator.h" #include "ui/widget.h" @@ -29,10 +31,22 @@ namespace ui { return m_menuitem; } + // True when this menu's items don't fit in the available height + // and must be scrolled vertically. + bool isScrollable() const { return m_scrollable; } + bool canScrollUp() const { return m_scrollable && m_scrollTopIndex > 0; } + bool canScrollDown() const { return m_scrollable && m_hasMoreBelow; } + + // Bounds (in screen coordinates) of the scroll indicators, empty + // when scrolling in that direction isn't possible/needed. + const gfx::Rect& scrollUpBounds() const { return m_scrollUpBounds; } + const gfx::Rect& scrollDownBounds() const { return m_scrollDownBounds; } + protected: virtual void onPaint(PaintEvent& ev) override; virtual void onResize(ResizeEvent& ev) override; virtual void onSizeHint(SizeHintEvent& ev) override; + virtual bool onProcessMessage(Message* msg) override; private: void setOwnerMenuItem(MenuItem* ownerMenuItem) { @@ -45,8 +59,20 @@ namespace ui { void highlightItem(MenuItem* menuitem, bool click, bool open_submenu, bool select_first_child); void unhighlightItem(); + void layoutItems(); + void scrollBy(int itemDelta); + void ensureVisible(Widget* item); + MenuItem* m_menuitem; // From where the menu was open + // Vertical scrolling state (only used for popup/submenu menus, + // never for the top-level menu-bar). + bool m_scrollable; + bool m_hasMoreBelow; + int m_scrollTopIndex; + gfx::Rect m_scrollUpBounds; + gfx::Rect m_scrollDownBounds; + friend class MenuBox; friend class MenuItem; }; From 7da28831af7e13b05d65f3c51271fafa83445936 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:05:19 +0000 Subject: [PATCH 2/2] Auto-scroll menus by hovering the fixed arrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace click/wheel-based menu scrolling, which relied on the mouse having already moved over the popup to register, with a Timer-driven auto-scroll that starts as soon as the cursor hovers a scroll arrow and keeps scrolling until it leaves or the end of the list is reached. Also fixes scrollBy() so wheel/timer scrolling can't advance past the point where the last (or first) item is fully in view. Co-authored-by: Daniel Praźmo <4753308+Nidrax@users.noreply.github.com> --- src/ui/menu.cpp | 64 +++++++++++++++++++++++++++++++++++++++++++++---- src/ui/menu.h | 9 +++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/ui/menu.cpp b/src/ui/menu.cpp index aed622ed5..32851f8b6 100644 --- a/src/ui/menu.cpp +++ b/src/ui/menu.cpp @@ -20,6 +20,7 @@ #include static const int kTimeoutToOpenSubmenu = 250; +static const int kMenuAutoScrollInterval = 90; namespace ui { @@ -134,6 +135,7 @@ Menu::Menu() , m_scrollable(false) , m_hasMoreBelow(false) , m_scrollTopIndex(0) + , m_autoScrollDirection(0) { initTheme(); } @@ -431,10 +433,18 @@ void Menu::layoutItems() } // Scrolls the menu by the given number of items (negative scrolls up). +// Refuses to scroll past either end: up is bounded by m_scrollTopIndex +// reaching 0, down is bounded by m_hasMoreBelow (computed by the last +// layoutItems() call) so the last item can't be scrolled past into +// trailing blank space. void Menu::scrollBy(int itemDelta) { if (!m_scrollable) return; + if (itemDelta > 0 && !m_hasMoreBelow) + return; + if (itemDelta < 0 && m_scrollTopIndex <= 0) + return; int n = (int)children().size(); int newTop = MID(0, m_scrollTopIndex + itemDelta, n-1); @@ -446,6 +456,37 @@ void Menu::scrollBy(int itemDelta) invalidate(); } +// Starts (or redirects) continuous scrolling while the mouse hovers +// over a scroll arrow. direction is -1 (up) or 1 (down). Does nothing +// (and stops any current auto-scroll) if that direction is already at +// its bound. +void Menu::startAutoScroll(int direction) +{ + if ((direction > 0 && !m_hasMoreBelow) || + (direction < 0 && m_scrollTopIndex <= 0)) { + stopAutoScroll(); + return; + } + + if (m_autoScrollDirection == direction) + return; + + m_autoScrollDirection = direction; + + if (!m_scrollTimer) + m_scrollTimer.reset(new Timer(kMenuAutoScrollInterval, this)); + + scrollBy(direction); + m_scrollTimer->start(); +} + +void Menu::stopAutoScroll() +{ + m_autoScrollDirection = 0; + if (m_scrollTimer) + m_scrollTimer->stop(); +} + // Scrolls, if needed, so that the given item (usually the newly // highlighted one) is visible. void Menu::ensureVisible(Widget* item) @@ -520,18 +561,21 @@ bool Menu::onProcessMessage(Message* msg) gfx::Point mousePos = static_cast(msg)->position(); if (!m_scrollUpBounds.isEmpty() && m_scrollUpBounds.contains(mousePos)) { - if (msg->type() == kMouseDownMessage) - scrollBy(-1); + startAutoScroll(-1); return true; } if (!m_scrollDownBounds.isEmpty() && m_scrollDownBounds.contains(mousePos)) { - if (msg->type() == kMouseDownMessage) - scrollBy(1); + startAutoScroll(1); return true; } + stopAutoScroll(); } break; + case kMouseLeaveMessage: + stopAutoScroll(); + break; + case kMouseWheelMessage: if (m_scrollable) { gfx::Point wheelDelta = static_cast(msg)->wheelDelta(); @@ -542,6 +586,18 @@ bool Menu::onProcessMessage(Message* msg) } break; + case kTimerMessage: + if (m_scrollTimer && + static_cast(msg)->timer() == m_scrollTimer.get()) { + if ((m_autoScrollDirection > 0 && !m_hasMoreBelow) || + (m_autoScrollDirection < 0 && m_scrollTopIndex <= 0)) + stopAutoScroll(); + else + scrollBy(m_autoScrollDirection); + return true; + } + break; + } return Widget::onProcessMessage(msg); diff --git a/src/ui/menu.h b/src/ui/menu.h index cf08a73f7..64a486164 100644 --- a/src/ui/menu.h +++ b/src/ui/menu.h @@ -13,6 +13,8 @@ #include "ui/separator.h" #include "ui/widget.h" +#include + namespace ui { class MenuItem; @@ -62,6 +64,8 @@ namespace ui { void layoutItems(); void scrollBy(int itemDelta); void ensureVisible(Widget* item); + void startAutoScroll(int direction); + void stopAutoScroll(); MenuItem* m_menuitem; // From where the menu was open @@ -73,6 +77,11 @@ namespace ui { gfx::Rect m_scrollUpBounds; gfx::Rect m_scrollDownBounds; + // While the mouse hovers over one of the scroll arrows, this timer + // repeatedly scrolls the menu (-1 = up, 0 = not auto-scrolling, 1 = down). + std::unique_ptr m_scrollTimer; + int m_autoScrollDirection; + friend class MenuBox; friend class MenuItem; };