Added ComboBox widget

This commit is contained in:
OmniaX-Dev 2026-04-28 13:02:14 +02:00
parent 4fb5e24bb9
commit 68bf336530
11 changed files with 219 additions and 56 deletions

View file

@ -318,6 +318,7 @@ const $backgroundColor = rgba(30, 30, 30, 255)
selectionTextColor = $accentColorLight
borderColor = $borderColor
showBorder = true
showBackground = true
}
(@tool_button button) {
fontSize = 18
@ -326,7 +327,7 @@ const $backgroundColor = rgba(30, 30, 30, 255)
showBackground = true
backgroundColor = $darkColor
useBackgroundGradient = false
showBorder = true
showBorder = false
borderColor = color_black
showIcon = true
(icon) {
@ -336,12 +337,14 @@ const $backgroundColor = rgba(30, 30, 30, 255)
(@tool_button button:hover) {
borderColor = $textColor
backgroundColor = $accentColorDark
showBorder = true
(icon) {
tint = $textColor
}
}
(@tool_button button:pressed) {
borderColor = $accentColorDark
showBorder = true
(icon) {
tint = $accentColorLight
}
@ -359,13 +362,15 @@ const $backgroundColor = rgba(30, 30, 30, 255)
showBorder = true
triangleIndicatorColor = $accentColor
triangleIndicatorPadding = 8.0
textColor = $textColor
fontSize = 18
}
(combo:hover) {
triangleIndicatorColor = $accentColorLight
borderColor = $accentColorLight
}
(combo:active) {
triangleIndicatorColor = $accentColorDark
borderColor = $accentColorDark
triangleIndicatorColor = $accentColorLight
borderColor = $accentColorLight
}
% ======================

View file

@ -48,6 +48,7 @@ FIX: Widget Content extents increasing negatively not showing scrollbars
FIX: Button state getting hung after showing an openFileDialog
FIX: ListView last item is covered by scrollbar
FIX: ListView last item not clickable even when no horizontal scrollbar present
FIX: Refresh display text in ComboBox when font size changes and also on theme reload

View file

@ -345,6 +345,12 @@ namespace ogfx
}
panel.size.x += m_padding.x + m_padding.w;
panel.size.y += m_padding.y + m_padding.h;
// Enforce minimum width from the Instance — applies to root only. // NEW
// Submenus sit naturally to the right; forcing them wider tends to look // NEW
// odd and can cause needless edge-flipping. // NEW
if (panel.entries == &m_data.entries && panel.size.x < m_data.minWidth) // NEW
panel.size.x = m_data.minWidth; // NEW
}
void ContextMenu::pop_to_depth(size_t depth)

View file

@ -46,6 +46,61 @@ namespace ogfx
using Callback = std::function<void(const Entry&)>;
stdvec<Entry> entries;
Callback onActivate { nullptr };
f32 minWidth { 0 };
// Remove first entry whose text matches. Returns true if something was removed.
inline bool removeByText(const String& text, bool recursive = true)
{
return remove_if(entries, [&](const Entry& e) { return e.text == text; }, recursive);
}
// Remove first entry whose id matches. ID -1 is treated as "no id" and is skipped.
inline bool removeById(i32 id, bool recursive = true)
{
if (id < 0) return false;
return remove_if(entries, [&](const Entry& e) { return e.id == id; }, recursive);
}
// Remove ALL matches (handy if you reuse text/ids across submenus).
inline size_t removeAllByText(const String& text, bool recursive = true)
{
return remove_all_if(entries, [&](const Entry& e) { return e.text == text; }, recursive);
}
inline size_t removeAllById(i32 id, bool recursive = true)
{
if (id < 0) return 0;
return remove_all_if(entries, [&](const Entry& e) { return e.id == id; }, recursive);
}
private:
template <typename Pred>
static bool remove_if(stdvec<Entry>& list, Pred pred, bool recursive)
{
for (auto it = list.begin(); it != list.end(); ++it)
{
if (pred(*it)) { list.erase(it); return true; }
}
if (!recursive) return false;
for (auto& e : list)
{
if (remove_if(e.submenus, pred, true)) return true;
}
return false;
}
template <typename Pred>
static size_t remove_all_if(stdvec<Entry>& list, Pred pred, bool recursive)
{
size_t count = 0;
for (auto it = list.begin(); it != list.end(); )
{
if (pred(*it)) { it = list.erase(it); ++count; }
else ++it;
}
if (recursive)
{
for (auto& e : list)
count += remove_all_if(e.submenus, pred, true);
}
return count;
}
};
private: struct Panel
{

View file

@ -104,8 +104,7 @@ namespace ogfx
// Anchor menu just below the label
Vec2 anchor { slot.rect.x, slot.rect.y + slot.rect.h };
m_window.setContextMenu(slot.instance);
m_window.showContextMenu(anchor);
m_window.showContextMenu(slot.instance, anchor);
m_openIndex = index;
m_active = true;

View file

@ -82,25 +82,19 @@ namespace ogfx
void ToolBar::applyTheme(const ostd::Stylesheet& theme)
{
auto& win = cast<Window&>(getWindow());
setHeight(theme.get<f32>("toolbar.height", getHeight(), {}, {}));
setFontSize(theme.get<i32>("toolbar.fontSize", getFontSize(), {}, {}));
setBackgroundColor(theme.get<Color>("toolbar.backgroundColor", getBackgroundColor(), {}, {}));
setTextColor(theme.get<Color>("toolbar.textColor", getTextColor(), {}, {}));
setBorderColor(theme.get<Color>("toolbar.borderColor", getBorderColor(), {}, {}));
enableBottomBorder(theme.get<bool>("toolbar.showBorder", isBottomBorderEnabled(), {}, {}));
setHeight(getThemeValue<f32>(theme, "height", getHeight()));
setSize(win.getWindowWidth(), m_height);
enableBottomBorder(isBorderEnabled());
disableBorder();
}
void ToolBar::onDraw(BasicRenderer2D& gfx)
void ToolBar::afterDraw(BasicRenderer2D& gfx)
{
gfx.fillRect(*this, m_backgroundColor);
// Bottom border line
if (isBottomBorderEnabled())
{
f32 lineOffset = (isStatusBar() ? 0.0f : m_height);
gfx.drawLine({ { getx(), gety() + lineOffset }, { getx() + getw(), gety() + lineOffset } }, m_borderColor);
gfx.drawLine({ { getx(), gety() + lineOffset }, { getx() + getw(), gety() + lineOffset } }, getBorderColor());
}
}

View file

@ -37,7 +37,7 @@ namespace ogfx
Button& addButton(const String& iconPath, const String& text = "", EventCallback callback = nullptr);
void onWindowResized(const Event& event) override;
void applyTheme(const ostd::Stylesheet& theme) override;
void onDraw(BasicRenderer2D& gfx) override;
void afterDraw(BasicRenderer2D& gfx) override;
void onUpdate(void) override;
inline void setw(f32 ww) override { }
@ -47,10 +47,6 @@ namespace ogfx
inline void setAsStatusBar(bool set = true) { m_isStatusBar = set; }
OSTD_PARAM_GETSET(f32, Height, m_height);
OSTD_PARAM_GETSET(i32, FontSize, m_fontSize);
OSTD_PARAM_GETSET(Color, BackgroundColor, m_backgroundColor);
OSTD_PARAM_GETSET(Color, TextColor, m_textColor);
OSTD_PARAM_GETSET(Color, BorderColor, m_borderColor);
OSTD_BOOL_PARAM_GETSET_E_NEG(ButtonText, m_disableButtonText);
OSTD_BOOL_PARAM_GETSET_E(BottomBorder, m_bottomBorder);
@ -64,10 +60,6 @@ namespace ogfx
f32 m_height { 26 };
bool m_bottomBorder { false };
i32 m_fontSize { 16 };
Color m_backgroundColor { "#6B0A1DFF" };
Color m_textColor { "#F16A85FF" };
Color m_borderColor { "#400000FF" };
};
}
}

View file

@ -20,6 +20,7 @@
#include "ComboBox.hpp"
#include "../../render/BasicRenderer.hpp"
#include "../Window.hpp"
namespace ogfx
{
@ -32,37 +33,48 @@ namespace ogfx
disableChildren();
setStylesheetCategoryName("combo");
connectSignal(ostd::BuiltinSignals::MouseReleased);
connectSignal(ostd::BuiltinSignals::MouseMoved);
validate();
return *this;
}
void ComboBox::onDraw(ogfx::BasicRenderer2D& gfx)
i32 ComboBox::addMenuItem(const String& text)
{
gfx.outlinedRect(*this, getBackgroundColor(), getBorderColor(), getBorderWidth());
m_menu.entries.push_back({ text, m_nextItemID++ });
if (m_menu.entries.size() == 1)
{
auto& entry = m_menu.entries.back();
m_selectedEntry = entry;
m_selectedEntryText = get_display_text(entry.text);
}
return m_nextItemID - 1;
}
// Triangle Indicator
const f32 triPad = getTriangleIndicatorPadding();
const f32 triSide = geth() - (triPad * 2);
Triangle tri {
getGlobalPosition() + Vec2 { getw() - triPad - triSide, triPad },
getGlobalPosition() + Vec2 { getw() - triPad, triPad },
getGlobalPosition() + Vec2 { getw() - triPad - (triSide * 0.5f), geth() - triPad }
bool ComboBox::removeMenuItem(const String& text)
{
return m_menu.removeByText(text, false);
}
bool ComboBox::removeMenuItem(i32 id)
{
return m_menu.removeById(id, false);
}
void ComboBox::setItemCallback(ContextMenu::Instance::Callback callback)
{
m_menu.onActivate = [this, cb = std::move(callback)] (const ContextMenu::Entry& entry) -> void {
m_selectedEntry = entry;
m_selectedEntryText = get_display_text(entry.text); // Caching because get_display_text is expensive
if (cb) cb(entry);
};
gfx.fillTriangle(tri, getTriangleIndicatorColor());
}
void ComboBox::onUpdate(void)
void ComboBox::setw(f32 w)
{
}
void ComboBox::onMouseReleased(const Event& event)
{
if (isMouseInside())
{
setThemeQualifier("active", true);
m_dropDownShown = true;
}
Rectangle::setw(w);
m_menu.minWidth = getw();
if (m_selectedEntry.id >= 0)
m_selectedEntryText = get_display_text(m_selectedEntry.text);
}
void ComboBox::applyTheme(const ostd::Stylesheet& theme)
@ -71,16 +83,97 @@ namespace ogfx
setTriangleIndicatorPadding(getThemeValue<f32>(theme, "triangleIndicatorPadding", getTriangleIndicatorPadding()));
}
void ComboBox::handleSignal(ostd::Signal& signal)
void ComboBox::onDraw(ogfx::BasicRenderer2D& gfx)
{
gfx.outlinedRect(*this, getBackgroundColor(), getBorderColor(), getBorderWidth());
auto bounds = getGlobalBounds();
gfx.drawCenteredString(m_selectedEntryText, bounds - Rectangle { 0, 0, geth(), 0 }, getTextColor(), getFontSize());
// Triangle Indicator
const f32 triPad = getTriangleIndicatorPadding();
const f32 triSide = geth() - (triPad * 2);
Triangle tri {
bounds.getPosition() + Vec2 { getw() - triPad - triSide, triPad },
bounds.getPosition() + Vec2 { getw() - triPad, triPad },
bounds.getPosition() + Vec2 { getw() - triPad - (triSide * 0.5f), geth() - triPad }
};
gfx.fillTriangle(tri, getTriangleIndicatorColor());
}
void ComboBox::onMouseReleased(const Event& event)
{
if (contains(event.mouse->position_x, event.mouse->position_y))
{
if (m_dropDownShown)
{
setThemeQualifier("active", false);
m_dropDownShown = false;
return;
}
setThemeQualifier("active", true);
m_dropDownShown = true;
Vec2 anchor = getGlobalPosition() + Vec2 { 0, geth() };
cast<Window&>(getWindow()).showContextMenu(m_menu, anchor);
}
}
void ComboBox::handleSignal(ostd::Signal& signal) // This is to get the global events, in order to bypass
// the fact that some components like MenuBar call
// event.handle() at the very beginning of the event chain
{
if (signal.ID == ostd::BuiltinSignals::MouseReleased)
{
if (isMouseInside())
auto& med = cast<MouseEventData&>(signal.userData);
if (contains(med.position_x, med.position_y))
return;
if (!m_dropDownShown)
return;
setThemeQualifier("active", false);
}
reloadTheme();
m_dropDownShown = false;
}
else if (signal.ID == ostd::BuiltinSignals::MouseMoved)
{
auto& med = cast<MouseEventData&>(signal.userData);
if (contains(med.position_x, med.position_y))
return;
setThemeQualifier("hover", false);
}
}
String ComboBox::get_display_text(const String& entry)
{
auto& gfx = getWindow().getGFX();
f32 _w = getw() - geth(); // Reserve space for HxH triangle indicator
// Fast path: full text fits.
if (gfx.getStringDimensions(entry, getFontSize()).x <= _w)
return entry;
// Reserve space for the ellipsis.
f32 ellipsisW = gfx.getStringDimensions("...", getFontSize()).x;
f32 budget = _w - ellipsisW;
if (budget <= 0)
return ""; // Not even room for "..." — degenerate but possible if the combo is tiny
// Binary search for the largest prefix length that fits within `budget`.
// Invariant: any length <= lo fits; any length > hi does not.
size_t lo = 0;
size_t hi = entry.len(); // adjust to your String's length method
while (lo < hi)
{
size_t mid = lo + (hi - lo + 1) / 2; // upper-mid, avoids infinite loop when hi == lo+1
String prefix = entry.new_substr(0, mid);
if (gfx.getStringDimensions(prefix, getFontSize()).x <= budget)
lo = mid; // mid fits, try longer
else
hi = mid - 1; // mid doesn't fit, must be shorter
}
return entry.new_substr(0, lo) + "...";
}
}
}

View file

@ -32,19 +32,29 @@ namespace ogfx
public:
inline ComboBox(WindowCore& window) : Widget({ 0, 0, 0, 0 }, window) { create(); }
ComboBox& create(void);
void onDraw(ogfx::BasicRenderer2D& gfx) override;
void onUpdate(void) override;
void onMouseReleased(const Event& event) override;
i32 addMenuItem(const String& text);
bool removeMenuItem(const String& text);
bool removeMenuItem(i32 id);
void setItemCallback(ContextMenu::Instance::Callback callback);
void setw(f32 w) override;
void applyTheme(const ostd::Stylesheet& theme) override;
void onDraw(ogfx::BasicRenderer2D& gfx) override;
void onMouseReleased(const Event& event) override;
void handleSignal(ostd::Signal& signal) override;
inline ContextMenu::Entry getSelectedEntry(void) const { return m_selectedEntry; }
OSTD_PARAM_GETSET(Color, TriangleIndicatorColor, m_triangleColor);
OSTD_PARAM_GETSET(f32, TriangleIndicatorPadding, m_trianglePadding);
private:
String get_display_text(const String& entry);
private:
bool m_dropDownShown { false };
ContextMenu::Instance m_menu;
i32 m_nextItemID { 0 };
ContextMenu::Entry m_selectedEntry { "", -1 };
String m_selectedEntryText { "" }; // Truncated text, not always valid, so can't be exposed
Color m_triangleColor { "#008000FF" };
f32 m_trianglePadding { 8.0f };

View file

@ -76,7 +76,7 @@ namespace ogfx
bool addThemeID(const String& id);
bool removeThemeID(const String& id);
void setVisible(bool v);
void setCallback(eCallback type, EventCallback callback);
virtual void setCallback(eCallback type, EventCallback callback);
using Rectangle::contains; bool contains(Vec2 p, bool includeBounds = false) const override;
template<typename T>
inline T getThemeValue(const ostd::Stylesheet &theme, const String& key, const T& fallback)

View file

@ -201,6 +201,14 @@ class TestWindow : public Window
footer->layoutHint().preferred = { -1, 24 };
m_combo.setSize(200, 30);
m_combo.addMenuItem("Item 1");
m_combo.addMenuItem("Item 2");
m_combo.addMenuItem("Item 3");
m_combo.addMenuItem("Item 4");
m_combo.addMenuItem("ItemITEMITEMTIEMTIITEM");
m_combo.setItemCallback([&](const ContextMenu::Entry& entry) -> void {
std::cout << "COMBO: " << entry.text << "\n";
});
t2.addWidget(*header); // each addWidget() call triggers a relayout
t2.addWidget(*body);
@ -448,7 +456,7 @@ i32 main(i32 argc, char** argv)
ostd::Random::autoSeed();
ostd::initialize();
TestWindow window;
window.initialize(1200, 800, "OmniaFramework - Test Window");
window.initialize(1200, 1100, "OmniaFramework - Test Window");
window.setClearColor({ 0, 0, 0 });
window.setPosition({ 50, 50 });
// window.setWindowState(ogfx::WindowCore::eWindowState::Maximized);