diff --git a/extra/DefaultTheme.oss b/extra/DefaultTheme.oss index e1b6ca1..7c26c5c 100644 --- a/extra/DefaultTheme.oss +++ b/extra/DefaultTheme.oss @@ -289,5 +289,7 @@ const $accentColorDark = #6B0A1DFF textColor = $textColor submenuIndicatorColor = $accentColorDark borderColor = #400000FF + useSelectionGradient = true + selectionGradient = gradientV(#C21135FF - #820B23FF) } % ========================== diff --git a/other/TODO.txt b/other/TODO.txt index 922e103..fb973c8 100644 --- a/other/TODO.txt +++ b/other/TODO.txt @@ -56,13 +56,15 @@ Implement following widgets: ***Horizontal Slider ***Vertical Slider ***ListBox + ***Context Menu ComboBox - Context Menu - Menu Bar + Menu Bar + Toolbar Layouts Vertical Horizontal Grid + Fill Text Input TreeView Hex Editor diff --git a/other/build.nr b/other/build.nr index 0bbd854..883bec9 100644 --- a/other/build.nr +++ b/other/build.nr @@ -1 +1 @@ -2073 +2074 diff --git a/src/ogfx/gui/ContextMenu.cpp b/src/ogfx/gui/ContextMenu.cpp index b2a9f03..b29b46a 100644 --- a/src/ogfx/gui/ContextMenu.cpp +++ b/src/ogfx/gui/ContextMenu.cpp @@ -39,9 +39,12 @@ namespace ogfx } } + + + + ContextMenu& ContextMenu::create(void) { - setSize(130, 300); return *this; } @@ -57,64 +60,185 @@ namespace ogfx setTextColor(theme.get("context.textColor", getTextColor(), {}, {})); setSubmenuIndicatorColor(theme.get("context.submenuIndicatorColor", getSubmenuIndicatorColor(), {}, {})); setBorderColor(theme.get("context.borderColor", getBorderColor(), {}, {})); + enableSelectionGradient(theme.get("context.useSelectionGradient", isSelectionGradientEnabled(), {}, {})); + setSelectionGradient(theme.get("context.selectionGradient", getSelectionGradient(), {}, {})); } void ContextMenu::draw(BasicRenderer2D& gfx) { - gfx.fillRect(*this, getBackgroundColor()); - const f32 triPad = 14; - f32 y = 0; - i32 i = 0; - for (auto& entry : m_data.entries) + if (!m_visible) return; + for (const auto& panel : m_panels) + draw_panel(gfx, panel); + } + + void ContextMenu::update(void) + { + if (!m_visible) return; + + if (m_pendingOpenPanelDepth >= 0 && m_hoverOpenTimerActive && m_hoverOpenTimer.read() >= SubmenuOpenDelayMs) { - Vec2 entryPos = getPosition() + Vec2 { m_padding.x, y + m_padding.y }; - Rectangle rect = { entryPos - Vec2 { m_padding.x, 0 }, getw(), m_entryHeight }; - if (rect.contains(m_mousePos, true)) - { - gfx.outlinedRect(rect, getSelectionColor(), getSeparatorLineColor(), 1, false, false, i != m_data.entries.size() , false); - gfx.drawVCenteredString(entry.text, { entryPos, getw(), m_entryHeight }, getSelectionTextColor(), getFontSize()); - } - else - { - gfx.drawRect(rect, getSeparatorLineColor(), 1, false, false, i != m_data.entries.size() , false); - gfx.drawVCenteredString(entry.text, { entryPos, getw(), m_entryHeight }, getTextColor(), getFontSize()); - } - if (entry.submenus.size() > 0) - { - Triangle tri { - { rect.x + rect.w - m_entryHeight + triPad, rect.y + (triPad * 0.5f) }, - { rect.x + rect.w - (triPad * 0.5f), rect.y + (rect.h * 0.5f) }, - { rect.x + rect.w - m_entryHeight + triPad, rect.y + rect.h - (triPad * 0.5f) } - }; - gfx.fillTriangle(tri, getSubmenuIndicatorColor()); - } - y += m_entryHeight; - i++; + Panel& parent = m_panels[m_pendingOpenPanelDepth]; + i32 entryIdx = m_pendingOpenEntryIndex; + Entry& e = (*parent.entries)[entryIdx]; + + pop_to_depth(m_pendingOpenPanelDepth); + + Rectangle r = entry_rect(parent, entryIdx); + Vec2 anchor { parent.position.x + parent.size.x, r.y }; + push_panel(e.submenus, anchor, false); + m_panels[m_pendingOpenPanelDepth].openedSubmenuIndex = entryIdx; + + m_hoverOpenTimer.endCount(); + m_hoverOpenTimerActive = false; + m_pendingOpenPanelDepth = -1; + m_pendingOpenEntryIndex = -1; + } + + if (m_hoverCloseTimerActive && m_hoverCloseTimer.read() >= SubmenuCloseDelayMs) + { + i32 keepDepth = -1; + for (i32 i = 0; i < (i32)m_panels.size(); ++i) + { + Rectangle r { m_panels[i].position, m_panels[i].size }; + if (r.contains(m_mousePos, true)) keepDepth = i; + } + if (keepDepth >= 0) + { + pop_to_depth(keepDepth); + m_panels[keepDepth].openedSubmenuIndex = -1; + } + m_hoverCloseTimer.endCount(); + m_hoverCloseTimerActive = false; } - gfx.drawRect(*this, getBorderColor(), 2); } void ContextMenu::onMouseReleased(const Event& event) { + if (!m_visible) return; Vec2 mousePos { event.mouse->position_x, event.mouse->position_y }; - if (contains(mousePos, true)) - { - } - else + for (i32 i = (i32)m_panels.size() - 1; i >= 0; --i) { - hide(); + i32 idx = entry_index_at(m_panels[i], mousePos); + if (idx >= 0) + { + Entry& e = (*m_panels[i].entries)[idx]; + if (e.submenus.empty()) + { + if (m_data.onActivate && e.id >= 0) + m_data.onActivate(e); + hide(); + event.handle(); + hide(); + } + else + { + pop_to_depth(i); + Rectangle r = entry_rect(m_panels[i], idx); + Vec2 anchor { m_panels[i].position.x + m_panels[i].size.x, r.y }; + push_panel(e.submenus, anchor, false); + m_panels[i].openedSubmenuIndex = idx; + } + event.handle(); + return; + } } + + hide(); event.handle(); } void ContextMenu::onMouseMoved(const Event& event) { + if (!m_visible) return; Vec2 mousePos { event.mouse->position_x, event.mouse->position_y }; - if (contains(mousePos, true)) + m_mousePos = mousePos; + + i32 hitPanel = -1; + i32 hitEntry = -1; + for (i32 i = (i32)m_panels.size() - 1; i >= 0; --i) { - m_mousePos = mousePos; + i32 idx = entry_index_at(m_panels[i], mousePos); + if (idx >= 0) { hitPanel = i; hitEntry = idx; break; } + Rectangle panelRect { m_panels[i].position, m_panels[i].size }; + if (panelRect.contains(mousePos, true)) { hitPanel = i; hitEntry = -1; break; } } + + for (i32 i = 0; i < (i32)m_panels.size(); ++i) + m_panels[i].hoveredIndex = (i == hitPanel ? hitEntry : -1); + + if (hitPanel < 0) + { + if (!m_hoverCloseTimerActive) + { + m_hoverCloseTimer.startCount(ostd::eTimeUnits::Milliseconds); + m_hoverCloseTimerActive = true; + } + if (m_hoverOpenTimerActive) + { + m_hoverOpenTimer.endCount(); + m_hoverOpenTimerActive = false; + } + m_pendingOpenPanelDepth = -1; + event.handle(); + return; + } + + if ((i32)m_panels.size() - 1 > hitPanel) + { + if (hitEntry != m_panels[hitPanel].openedSubmenuIndex) + { + if (!m_hoverCloseTimerActive) + { + m_hoverCloseTimer.startCount(ostd::eTimeUnits::Milliseconds); + m_hoverCloseTimerActive = true; + } + } + else + { + if (m_hoverCloseTimerActive) + { + m_hoverCloseTimer.endCount(); + m_hoverCloseTimerActive = false; + } + } + } + else + { + if (m_hoverCloseTimerActive) + { + m_hoverCloseTimer.endCount(); + m_hoverCloseTimerActive = false; + } + } + + if (hitEntry >= 0) + { + Entry& e = (*m_panels[hitPanel].entries)[hitEntry]; + bool hasChildren = e.submenus.size() > 0; + bool alreadyOpen = (m_panels[hitPanel].openedSubmenuIndex == hitEntry) && ((i32)m_panels.size() - 1 > hitPanel); + + if (hasChildren && !alreadyOpen) + { + if (m_pendingOpenPanelDepth != hitPanel || m_pendingOpenEntryIndex != hitEntry) + { + m_pendingOpenPanelDepth = hitPanel; + m_pendingOpenEntryIndex = hitEntry; + m_hoverOpenTimer.startCount(ostd::eTimeUnits::Milliseconds); + m_hoverOpenTimerActive = true; + } + } + else + { + if (m_hoverOpenTimerActive) + { + m_hoverOpenTimer.endCount(); + m_hoverOpenTimerActive = false; + } + m_pendingOpenPanelDepth = -1; + } + } + event.handle(); } @@ -128,49 +252,163 @@ namespace ogfx event.handle(); } - void ContextMenu::show(void) - { - m_visible = true; - } - void ContextMenu::show(const Vec2& pos) { - setPosition(pos); + m_panels.clear(); + push_panel(m_data.entries, pos, false); m_visible = true; } void ContextMenu::hide(void) { + m_panels.clear(); + m_hoverOpenTimer.endCount(); + m_hoverCloseTimer.endCount(); + m_hoverOpenTimerActive = false; + m_hoverCloseTimerActive = false; + m_pendingOpenPanelDepth = -1; + m_pendingOpenEntryIndex = -1; m_visible = false; } void ContextMenu::setInstance(const Instance& instance) { m_data = instance; - update_size(); + m_panels.clear(); + m_pendingOpenPanelDepth = -1; + m_pendingOpenEntryIndex = -1; + m_hoverOpenTimer.endCount(); + m_hoverCloseTimer.endCount(); + if (m_visible) + push_panel(m_data.entries, getPosition(), false); } void ContextMenu::setFontSize(i32 size) { m_fontSize = size; - update_size(); + relayout_panels(); } - void ContextMenu::update_size(void) + void ContextMenu::push_panel(stdvec& entries, const Vec2& anchorTopLeft, bool flipLeft) { - setSize(0, 0); + Panel p; + p.entries = &entries; + p.position = anchorTopLeft; + compute_panel_size(p); + + f32 windowW = (f32)m_window.getWindowWidth(); + f32 windowH = (f32)m_window.getWindowHeight(); + if (flipLeft && !m_panels.empty()) + { + // anchorTopLeft was computed assuming "open right"; flip to open left + p.position.x = m_panels.back().position.x - p.size.x; + } + else if (p.position.x + p.size.x > windowW && !m_panels.empty()) + { + p.position.x = m_panels.back().position.x - p.size.x; + } + + if (p.position.y + p.size.y > windowH) + p.position.y = windowH - p.size.y; + if (p.position.y < 0) p.position.y = 0; + if (p.position.x < 0) p.position.x = 0; + + m_panels.push_back(p); + } + + void ContextMenu::compute_panel_size(Panel& panel) + { + panel.size = { 0, 0 }; auto& gfx = m_window.getGFX(); - for (auto& entry : m_data.entries) + for (auto& entry : *panel.entries) { auto s = gfx.getStringDimensions(entry.text, getFontSize()); f32 extra = (entry.submenus.size() > 0 ? s.y : 0); - if (s.x + extra > getw()) - setw(s.x + extra); - addh(s.y + m_spacing); - m_entryHeight = s.y + m_spacing; - entry.update_size(gfx, getFontSize()); + if (s.x + extra > panel.size.x) + panel.size.x = s.x + extra; + panel.size.y += s.y + m_spacing; + panel.entryHeight = s.y + m_spacing; } - addSize({ m_padding.x + m_padding.w, m_padding.y + m_padding.h }); + panel.size.x += m_padding.x + m_padding.w; + panel.size.y += m_padding.y + m_padding.h; + } + + void ContextMenu::pop_to_depth(size_t depth) + { + if (depth + 1 < m_panels.size()) + m_panels.resize(depth + 1); + } + + Rectangle ContextMenu::entry_rect(const Panel& panel, i32 index) const + { + return { + panel.position.x, + panel.position.y + m_padding.y + (index * panel.entryHeight), + panel.size.x, + panel.entryHeight + }; + } + + i32 ContextMenu::entry_index_at(const Panel& panel, const Vec2& mousePos) const + { + Rectangle panelRect { panel.position, panel.size }; + if (!panelRect.contains(mousePos, true)) return -1; + for (i32 i = 0; i < (i32)panel.entries->size(); ++i) + { + if (entry_rect(panel, i).contains(mousePos, true)) + return i; + } + return -1; + } + + void ContextMenu::relayout_panels(void) + { + for (auto& panel : m_panels) + compute_panel_size(panel); + } + + void ContextMenu::draw_panel(BasicRenderer2D& gfx, const Panel& panel) + { + gfx.fillRect({ panel.position, panel.size }, getBackgroundColor()); + const f32 triPad = 14; + f32 y = 0; + i32 i = 0; + auto& entries = *panel.entries; + for (auto& entry : entries) + { + Vec2 entryPos = panel.position + Vec2 { m_padding.x, y + m_padding.y }; + Rectangle rect = { entryPos - Vec2 { m_padding.x, 0 }, panel.size.x, panel.entryHeight }; + + bool highlighted = (panel.hoveredIndex == i) + || (panel.openedSubmenuIndex == i); + + if (highlighted) + { + if (isSelectionGradientEnabled()) + gfx.fillGradientRect(rect, m_selectionGradient); + else + gfx.fillRect(rect, getSelectionColor()); + gfx.drawRect(rect, getSeparatorLineColor(), 1, false, false, i != entries.size() , false); + gfx.drawVCenteredString(entry.text, { entryPos, panel.size.x, panel.entryHeight }, getSelectionTextColor(), getFontSize()); + } + else + { + gfx.drawRect(rect, getSeparatorLineColor(), 1, false, false, i != entries.size() , false); + gfx.drawVCenteredString(entry.text, { entryPos, panel.size.x, panel.entryHeight }, getTextColor(), getFontSize()); + } + if (entry.submenus.size() > 0) + { + Triangle tri { + { rect.x + rect.w - panel.entryHeight + triPad, rect.y + (triPad * 0.5f) }, + { rect.x + rect.w - (triPad * 0.5f), rect.y + (rect.h * 0.5f) }, + { rect.x + rect.w - panel.entryHeight + triPad, rect.y + rect.h - (triPad * 0.5f) } + }; + gfx.fillTriangle(tri, getSubmenuIndicatorColor()); + } + y += panel.entryHeight; + i++; + } + gfx.drawRect({ panel.position, panel.size }, getBorderColor(), 2); } } } diff --git a/src/ogfx/gui/ContextMenu.hpp b/src/ogfx/gui/ContextMenu.hpp index bb5be85..38e9f54 100644 --- a/src/ogfx/gui/ContextMenu.hpp +++ b/src/ogfx/gui/ContextMenu.hpp @@ -30,30 +30,42 @@ namespace ogfx { public: struct Entry { - inline Entry(const String& t, const stdvec& sub = {}) { text = t; submenus = sub; } + inline Entry(const String& t, i32 id = -1, const stdvec& sub = {}) { text = t; this->id = id; submenus = sub; } + inline Entry(const String& t, const stdvec& sub) { text = t; id = -1; submenus = sub; } String text { "" }; + i32 id { -1 }; stdvec submenus; private: Vec2 size; void update_size(BasicRenderer2D& gfx, i32 fontSize); - friend class ContextMenu; }; public: struct Instance { + using Callback = std::function; stdvec entries; + Callback onActivate { nullptr }; + }; + private: struct Panel + { + stdvec* entries { nullptr }; + Vec2 position { 0, 0 }; + Vec2 size { 0, 0 }; + f32 entryHeight { 0 }; + i32 hoveredIndex { -1 }; + i32 openedSubmenuIndex { -1 }; }; public: inline ContextMenu(WindowCore& window) : m_window(window) { create(); } ContextMenu& create(void); void applyTheme(const ostd::Stylesheet& theme); void draw(BasicRenderer2D& gfx); + void update(void); void onMouseReleased(const Event& event); void onMouseMoved(const Event& event); void onMousePressed(const Event& event); void onMouseScrolled(const Event& event); - void show(void); void show(const Vec2& pos); void hide(void); void setInstance(const Instance& instance); @@ -71,9 +83,17 @@ namespace ogfx OSTD_PARAM_GETSET(Color, TextColor, m_textColor); OSTD_PARAM_GETSET(Color, SubmenuIndicatorColor, m_submenuIndicatorColor); OSTD_PARAM_GETSET(Color, BorderColor, m_borderColor); + OSTD_PARAM_GETSET(ColorGradient, SelectionGradient, m_selectionGradient); + OSTD_BOOL_PARAM_GETSET_E(SelectionGradient, m_useSelectionGradient); private: - void update_size(void); + void push_panel(stdvec& entries, const Vec2& anchorTopLeft, bool flipLeft = false); + void pop_to_depth(size_t depth); + void compute_panel_size(Panel& panel); + i32 entry_index_at(const Panel& panel, const Vec2& mousePos) const; + Rectangle entry_rect(const Panel& panel, i32 index) const; + void draw_panel(BasicRenderer2D& gfx, const Panel& panel); + void relayout_panels(void); private: WindowCore& m_window; @@ -82,6 +102,15 @@ namespace ogfx f32 m_entryHeight { 0 }; Vec2 m_mousePos { 0, 0 }; + stdvec m_panels; + + i32 m_pendingOpenPanelDepth { -1 }; + i32 m_pendingOpenEntryIndex { -1 }; + ostd::Timer m_hoverOpenTimer; + ostd::Timer m_hoverCloseTimer; + bool m_hoverOpenTimerActive { false }; + bool m_hoverCloseTimerActive { false }; + Rectangle m_padding { 16, 0, 16, 0 }; f32 m_spacing { 8 }; i32 m_fontSize { 18 }; @@ -92,8 +121,11 @@ namespace ogfx Color m_textColor { "#F16A85FF" }; Color m_submenuIndicatorColor { "#111111FF" }; Color m_borderColor { "#400000FF" }; + bool m_useSelectionGradient { true }; + ColorGradient m_selectionGradient { { "#C21135FF", "#820B23FF" }, { 1.0f } }; - + static constexpr u64 SubmenuOpenDelayMs { 300 }; + static constexpr u64 SubmenuCloseDelayMs { 50 }; friend class Instance; }; } diff --git a/src/ogfx/gui/Window.cpp b/src/ogfx/gui/Window.cpp index 639173a..88929af 100644 --- a/src/ogfx/gui/Window.cpp +++ b/src/ogfx/gui/Window.cpp @@ -993,6 +993,7 @@ namespace ogfx void Window::__on_update(f64 delta) { + m_cmenu.update(); onUpdate(delta); } diff --git a/src/test/GuiTest.cpp b/src/test/GuiTest.cpp index ba8e3c3..ff5d6b8 100644 --- a/src/test/GuiTest.cpp +++ b/src/test/GuiTest.cpp @@ -183,6 +183,11 @@ class Window : public ogfx::gui::Window return true; }); + + m_menu.onActivate = [this](const ogfx::gui::ContextMenu::Entry& e) { + std::cout << e.text << " - " << (i32)e.id << "\n"; + }; + setContextMenu(m_menu); } @@ -234,15 +239,23 @@ class Window : public ogfx::gui::Window ListView m_list { *this }; Label m_drawCallsLbl { *this }; - ogfx::gui::ContextMenu::Instance m_menu { - { - { "Update" }, - { "File" }, - { "Open RAW", { { "Image" }, { "Text File" }, { "Audio" } } }, - { "Mouse Settings", { { "Test" } } }, - { "Terror" }, - { "Properties" } - } + enum MenuId { New = 1, Open, Save, SaveAs, Exit, CopyRaw, CopyFormatted }; + + ogfx::gui::ContextMenu::Instance m_menu { { + { "File", -1, { + { "New", MenuId::New }, + { "Open...", MenuId::Open }, + { "Save", MenuId::Save }, + { "Save As", MenuId::SaveAs }, + }}, + { "Edit", -1, { + { "Copy", -1, { + { "As Text", MenuId::CopyRaw }, + { "As HTML", MenuId::CopyFormatted }, + }}, + }}, + { "Exit", MenuId::Exit } }, + nullptr }; ostd::StepTimer m_timer;