Added TreeView widget

This commit is contained in:
OmniaX-Dev 2026-05-01 05:06:21 +02:00
parent 2de45908a9
commit 796b03f08c
11 changed files with 832 additions and 453 deletions

View file

@ -100,8 +100,8 @@ list(APPEND OGFX_SOURCE_FILES
${CMAKE_CURRENT_LIST_DIR}/src/ogfx/gui/widgets/RadioButton.cpp
${CMAKE_CURRENT_LIST_DIR}/src/ogfx/gui/widgets/ProgressBar.cpp
${CMAKE_CURRENT_LIST_DIR}/src/ogfx/gui/widgets/Slider.cpp
${CMAKE_CURRENT_LIST_DIR}/src/ogfx/gui/widgets/ListView.cpp
${CMAKE_CURRENT_LIST_DIR}/src/ogfx/gui/widgets/ComboBox.cpp
${CMAKE_CURRENT_LIST_DIR}/src/ogfx/gui/widgets/TreeView.cpp
# render
${CMAKE_CURRENT_LIST_DIR}/src/ogfx/render/BasicRenderer.cpp

View file

@ -258,25 +258,6 @@ const $accentColorDark = #6B0A1DFF
% ====== ListView ======
(list) {
(item) {
defaultTextColor = #202020FF
defaultFontSize = 16
defaultPadding = rect(5, 15, 20, 40)
defaultSelectionColor = $accentColor
defaultSelectionTextColor = color_white
}
backgroundColor = rgb(160, 160, 160)
borderColor = rgb(50, 50, 50)
separatorLineColor = rgb(40, 40, 40)
showSeparatorLine = true
}
% ======================
% ====== Context Menu ======
(context) {
padding = rect(16, 0, 16, 0)

View file

@ -254,25 +254,6 @@ const $backgroundColor = rgba(30, 30, 30, 255)
% ====== ListView ======
(list) {
(item) {
defaultTextColor = $textColor
defaultFontSize = 16
defaultPadding = rect(5, 5, 20, 0)
defaultSelectionColor = $accentColor
defaultSelectionTextColor = color_white
}
backgroundColor = $darkColor
borderColor = $borderColor
separatorLineColor = $borderColor
showSeparatorLine = true
}
% ======================
% ====== Context Menu ======
(context) {
padding = rect(16, 0, 16, 0)
@ -376,3 +357,29 @@ const $backgroundColor = rgba(30, 30, 30, 255)
borderColor = $accentColorLight
}
% ======================
% ====== TreeView ======
(tree) {
linePadding = rect(5, 5, 20, 0)
selectionColor = $accentColor
selectionTextColor = color_white
iconSpacing = 0.0
iconInset = 4.0
showIcons = true
fontSize = 16
textColor = $textColor
backgroundColor = $darkColor
borderColor = $borderColor
separatorLineColor = $borderColor
showSeparatorLine = true
iconTintColor = $accentColorLight
indentWidth = 16.0
chevronColor = $accentColorDark
chevronInset = 4.0
branchLineColor = $textColor
showBranchLines = true
branchLineThickness = 1.0
}
% ======================

View file

@ -31,10 +31,8 @@ FIX: Float values in stylesheet needing to have decimal point to be read as floa
Make parsing of vec2 and rect in stylesheet, consistent with other value functions like gradients and animations
Add buttons to scroll tabs in TabPanel
Add Font class, to handle different font attributes
Add multi-selection to ListView
Add multi-selection to TreeView
Cache getStringDimensions() call in TabPanel::draw_tabs
FIX: Padding for ListView items
Add icons to ListView
Add gradient to listbox selection
Add gradient to menubar background and selection
Add cursor stack
@ -46,11 +44,13 @@ Add Hex Editor widget
Add Message Boxes
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: TreeView last item is covered by scrollbar
FIX: TreeView 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
Add setSelectedMenuItem() function to ComboBox
FIX: ContextMenu rendering partly outside the screen when showing it close to the right Window border
Update theme for visible TreeView lines
Add ToolBar::addButton overload that takes a ogfx::Icon wrapper
@ -83,7 +83,7 @@ Implement following widgets:
***Grid
***Fill
***ComboBox
TreeView
***TreeView
Text Input
Text Area
Numeric Field

View file

@ -28,5 +28,13 @@
#include <ogfx/gui/widgets/RadioButton.hpp>
#include <ogfx/gui/widgets/ProgressBar.hpp>
#include <ogfx/gui/widgets/Slider.hpp>
#include <ogfx/gui/widgets/ListView.hpp>
#include <ogfx/gui/widgets/ComboBox.hpp>
#include <ogfx/gui/widgets/TreeView.hpp>
namespace ogfx
{
namespace gui
{
using ListView = TreeView;
}
}

View file

@ -1,272 +0,0 @@
/*
OmniaFramework - A collection of useful functionality
Copyright (C) 2025 OmniaX-Dev
This file is part of OmniaFramework.
OmniaFramework is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
OmniaFramework is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with OmniaFramework. If not, see <https://www.gnu.org/licenses/>.
*/
#include "ListView.hpp"
#include "../../render/BasicRenderer.hpp"
#include "../Window.hpp"
#include "../../../ostd/io/Memory.hpp"
namespace ogfx
{
namespace gui
{
void ListView::Item::setText(const String& text)
{
if (m_text == text)
return;
m_text = text;
update_dimensions();
if (m_parent) m_parent->m_extentsDirty = true;
}
void ListView::Item::setFontSize(i32 fontSize)
{
if (m_fontSize == fontSize)
return;
m_fontSize = fontSize;
update_dimensions();
if (m_parent) m_parent->m_extentsDirty = true;
}
void ListView::Item::update_dimensions(void)
{
if (!isValid())
return;
m_dimensions = m_parent->getWindow().getGFX().getStringDimensions(m_text, m_fontSize);
m_dimensions += Vec2 { m_padding.x + m_padding.w, m_padding.y + m_padding.h };
}
void ListView::Item::set_selected(stdvec<Item*>& selectionList)
{
for (auto& sel : selectionList)
sel->m_selected = false;
selectionList.clear();
selectionList.push_back(this);
m_selected = true;
}
void ListView::Item::add_selected(stdvec<Item*>& selectionList)
{
if (STDVEC_CONTAINS(selectionList, this))
return;
selectionList.push_back(this);
m_selected = true;
}
void ListView::Item::remove_selected(stdvec<Item*>& selectionList)
{
if (!STDVEC_CONTAINS(selectionList, this))
return;
STDVEC_REMOVE(selectionList, this);
m_selected = false;
}
ListView& ListView::create(void)
{
setPadding({ 0, 0, 0, 0 });
setTypeName("ogfx::gui::ListView");
enableStopEvents();
enableBackground();
enableBorder();
setBackgroundColor({ 160, 160, 160 });
setBorderColor({ 50, 50, 50 });
setStylesheetCategoryName("list");
validate();
return *this;
}
void ListView::applyTheme(const ostd::Stylesheet& theme)
{
setSeparatorLineColor(getThemeValue<Color>(theme, "separatorLineColor", getSeparatorLineColor()));
enableShowSeparatorLine(getThemeValue<bool>(theme, "showSeparatorLine", isShowSeparatorLineEnabled()));
setDefaultLineTextColor(getThemeValue<Color>(theme, "item.defaultTextColor", getDefaultLineTextColor()));
setDefaultLineFontSize(getThemeValue<i32>(theme, "item.defaultFontSize", getDefaultLineFontSize()));
setDefaultLinePadding(getThemeValue<Rectangle>(theme, "item.defaultPadding", getDefaultLinePadding()));
setDefaultLineSelectionColor(getThemeValue<Color>(theme, "item.defaultSelectionColor", getDefaultLineSelectionColor()));
setDefaultLineSelectionTextColor(getThemeValue<Color>(theme, "item.defaultSelectionTextColor", getDefaultLineSelectionTextColor()));
}
void ListView::onDraw(ogfx::BasicRenderer2D& gfx)
{
const auto& bounds = getGlobalBounds();
const auto& content = getContentExtents();
const f32 scrollY = -getScrollOffset().y;
const f32 visibleH = getContentBounds().h;
const f32 visibleStart = scrollY;
const f32 visibleEnd = scrollY + visibleH;
Color textColor;
f32 y = 0;
i32 startIdx = 0;
for (auto& item : m_list)
{
if (!item.isValid()) { startIdx++; continue; }
f32 itemH = item.getDimensions().y;
if (y + itemH > visibleStart) break;
y += itemH;
startIdx++;
}
for (i32 i = startIdx; i < (i32)m_list.size(); i++)
{
auto& item = m_list[i];
if (!item.isValid()) continue;
f32 itemH = item.getDimensions().y;
if (y > visibleEnd) break;
f32 lineW = content.w;
if (content.w < bounds.w)
lineW = bounds.w;
if (item.isSelected())
{
textColor = item.getSelectedTextColor();
gfx.fillRect({ Vec2 { bounds.x, bounds.y + y + item.getPadding().h } + getScrollOffset(), { lineW, itemH } }, item.getSelectedColor());
}
else
textColor = item.getTextColor();
gfx.drawLine({ Vec2 { bounds.x, bounds.y + y + itemH + item.getPadding().h } + getScrollOffset(), Vec2 { bounds.x + lineW, bounds.y + y + itemH + item.getPadding().h } + getScrollOffset() }, getSeparatorLineColor(), 1);
gfx.drawString(item, bounds.getPosition() + Vec2 { 0, y } + getScrollOffset() + item.getPadding().getPosition(), textColor, item.getFontSize());
y += itemH;
}
}
void ListView::afterDraw(ogfx::BasicRenderer2D& gfx)
{
drawScrollbars(gfx);
}
void ListView::onMouseReleased(const Event& event)
{
if (!isMouseInside())
return;
if (event.mouse->button != ogfx::MouseEventData::eButton::Left)
return;
if (isMouseInsideAnyScrollbar())
return;
const Vec2 mousePos { event.mouse->position_x, event.mouse->position_y };
const Vec2 origin = getGlobalBounds().getPosition();
const Vec2 scroll = getScrollOffset();
const f32 localY = mousePos.y - origin.y - scroll.y;
f32 y = 0;
for (auto& item : m_list)
{
if (!item.isValid()) continue;
const f32 itemH = item.getDimensions().y;
if (localY >= y && localY < y + itemH)
{
bool wasSelected = item.isSelected();
item.set_selected(m_selectedList);
if (!wasSelected && callback_onSelectionChanged)
callback_onSelectionChanged(m_selectedList);
event.handle();
break;
}
y += itemH;
}
}
Rectangle ListView::getContentExtents(void) const
{
if (!m_extentsDirty)
return m_cachedExtents;
f32 maxX = 0, maxY = 0;
for (auto& item : m_list)
{
if (!item.isValid()) continue;
Vec2 size = item.getDimensions();
maxX = std::max(maxX, size.x);
maxY += size.y;
}
m_cachedExtents = { 0, 0, maxX, maxY };
m_extentsDirty = false;
return m_cachedExtents;
}
ListView::Item& ListView::getLine(const String& text)
{
for (auto& item : m_list)
if (item.getText() == text)
return item;
return InvalidItem;
}
ListView::Item& ListView::getLine(u32 index)
{
if (index < m_list.size())
return m_list[index];
return InvalidItem;
}
ListView::Item& ListView::addLine(const String& text)
{
Item item { *this };
item.setTextColor(m_defaultLinetextColor);
item.setPadding(m_defaultLinepadding);
item.setFontSize(m_defaultLinefontSize);
item.setSelectedColor(m_defaultSelectionColor);
item.setSelectedTextColor(m_defaultSelectionTextColor);
item.setText(text);
m_list.push_back(item);
if (m_list.size() > 0)
m_list[0].set_selected(m_selectedList);
m_extentsDirty = true;
return m_list.back();
}
bool ListView::removeLine(const String& text)
{
auto it = std::find_if(m_list.begin(), m_list.end(), [&](const Item& item) {
return item.getText() == text;
});
if (it != m_list.end())
{
m_list.erase(it);
return true;
}
return false;
}
bool ListView::removeLine(u32 index)
{
if (!hasLine(index))
return false;
m_list.erase(m_list.begin() + index);
return true;
}
bool ListView::hasLine(const String& text)
{
for (auto& item : m_list)
if (item.getText() == text)
return true;
return false;
}
bool ListView::hasLine(u32 index)
{
return m_list.size() > index;
}
}
}

View file

@ -1,116 +0,0 @@
/*
OmniaFramework - A collection of useful functionality
Copyright (C) 2025 OmniaX-Dev
This file is part of OmniaFramework.
OmniaFramework is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
OmniaFramework is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with OmniaFramework. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <ogfx/gui/widgets/Widget.hpp>
#include <ogfx/gui/widgets/Scrollbar.hpp>
#include <deque>
namespace ogfx
{
namespace gui
{
class ListView : public ScrollableWidget
{
public: class Item : public ostd::__i_stringeable
{
public:
inline Item(ListView& parent) { m_parent = &parent; }
void setText(const String& text);
void setFontSize(i32 fontSize);
inline String toString(void) const override { return m_text; }
inline bool isValid(void) const { return m_parent != nullptr && m_text.new_trim() != ""; }
inline Vec2 getDimensions(void) const { return m_dimensions; }
inline i32 getFontSize(void) const { return m_fontSize; }
inline String getText(void) const { return m_text; }
inline bool isSelected(void) const { return m_selected; }
OSTD_PARAM_GETSET(Color, TextColor, m_textColor);
OSTD_PARAM_GETSET(Rectangle, Padding, m_padding);
OSTD_PARAM_GETSET(Color, SelectedTextColor, m_selectedTextColor);
OSTD_PARAM_GETSET(Color, SelectedColor, m_selectedColor);
private:
void update_dimensions(void);
void set_selected(stdvec<Item*>& selectionList);
void add_selected(stdvec<Item*>& selectionList);
void remove_selected(stdvec<Item*>& selectionList);
private:
Vec2 m_dimensions { 0, 0 };
bool m_selected { false };
ListView* m_parent { nullptr };
String m_text { "" };
i32 m_fontSize { 16 };
Color m_textColor { 30, 30, 30 };
Color m_selectedTextColor { Colors::White };
Color m_selectedColor { Colors::Crimson };
Rectangle m_padding { 5, 5, 20, 0 };
friend class ListView;
};
public:
inline ListView(WindowCore& window) : ScrollableWidget(window) { create(); }
ListView& create(void);
void applyTheme(const ostd::Stylesheet& theme) override;
void onDraw(ogfx::BasicRenderer2D& gfx) override;
void afterDraw(ogfx::BasicRenderer2D& gfx) override;
void onMouseReleased(const Event& event) override;
Rectangle getContentExtents(void) const override;
Item& getLine(const String& text);
Item& getLine(u32 index);
Item& addLine(const String& text);
bool removeLine(const String& text);
bool removeLine(u32 index);
bool hasLine(const String& text);
bool hasLine(u32 index);
inline void addLine(const Item& line) { m_list.push_back(line); if (m_list.size() == 1) m_list.back().set_selected(m_selectedList); }
inline stdvec<Item*>& getSelection(void) { return m_selectedList; }
inline void setSelectionChangedCallback(std::function<void(stdvec<Item*>& selection)> callback) { callback_onSelectionChanged = std::move(callback); }
OSTD_PARAM_GETSET(Color, SeparatorLineColor, m_lineColor);
OSTD_BOOL_PARAM_GETSET_E(ShowSeparatorLine, m_showLine);
OSTD_PARAM_GETSET(i32, DefaultLineFontSize, m_defaultLinefontSize);
OSTD_PARAM_GETSET(Color, DefaultLineTextColor, m_defaultLinetextColor);
OSTD_PARAM_GETSET(Rectangle, DefaultLinePadding, m_defaultLinepadding);
OSTD_PARAM_GETSET(Color, DefaultLineSelectionColor, m_defaultSelectionColor);
OSTD_PARAM_GETSET(Color, DefaultLineSelectionTextColor, m_defaultSelectionTextColor);
private:
Item InvalidItem { *this };
std::deque<Item> m_list;
stdvec<Item*> m_selectedList;
std::function<void(stdvec<Item*>& selection)> callback_onSelectionChanged { nullptr };
mutable Rectangle m_cachedExtents { 0, 0, 0, 0 };
mutable bool m_extentsDirty { true };
Color m_lineColor { 40, 40, 40 };
bool m_showLine { true };
i32 m_defaultLinefontSize { 16 };
Color m_defaultLinetextColor { 30, 30, 30 };
Rectangle m_defaultLinepadding { 5, 5, 20, 0 };
Color m_defaultSelectionColor { Colors::Crimson };
Color m_defaultSelectionTextColor { Colors::White };
};
}
}

View file

@ -0,0 +1,573 @@
/*
OmniaFramework - A collection of useful functionality
Copyright (C) 2025 OmniaX-Dev
This file is part of OmniaFramework.
OmniaFramework is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
OmniaFramework is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with OmniaFramework. If not, see <https://www.gnu.org/licenses/>.
*/
#include "TreeView.hpp"
#include "../../render/BasicRenderer.hpp"
#include "../Window.hpp"
#include "../../../ostd/io/Memory.hpp"
#include <algorithm>
namespace ogfx
{
namespace gui
{
void TreeView::Item::setText(const String& text)
{
if (m_text == text)
return;
m_text = text;
update_dimensions();
if (m_treeView) m_treeView->m_extentsDirty = true;
}
void TreeView::Item::setIcon(const Icon& icon)
{
m_icon = icon;
enableIcon();
}
void TreeView::Item::update_dimensions(void)
{
if (!isValid())
return;
auto pad = m_treeView->getLinePadding();
m_dimensions = m_treeView->getWindow().getGFX().getStringDimensions(m_text, m_treeView->getFontSize());
m_dimensions += Vec2 { pad.x + pad.w, pad.y + pad.h };
}
void TreeView::Item::set_selected(stdvec<Item*>& selectionList)
{
for (auto& sel : selectionList)
sel->m_selected = false;
selectionList.clear();
selectionList.push_back(this);
m_selected = true;
}
void TreeView::Item::add_selected(stdvec<Item*>& selectionList)
{
if (STDVEC_CONTAINS(selectionList, this))
return;
selectionList.push_back(this);
m_selected = true;
}
void TreeView::Item::remove_selected(stdvec<Item*>& selectionList)
{
if (!STDVEC_CONTAINS(selectionList, this))
return;
STDVEC_REMOVE(selectionList, this);
m_selected = false;
}
void TreeView::Item::setExpanded(bool expanded)
{
if (m_expanded == expanded)
return;
m_expanded = expanded;
if (m_treeView) m_treeView->m_extentsDirty = true;
}
bool TreeView::Item::isVisible(void) const
{
// An item is visible if every ancestor is expanded.
const Item* p = m_parentItem;
while (p != nullptr)
{
if (!p->m_expanded) return false;
p = p->m_parentItem;
}
return true;
}
stdvec<String> TreeView::Item::getFullPath(void) const
{
// Walk parent pointers up to the root, collecting text bottom-up,
// then reverse so the result reads root -> ... -> this item.
stdvec<String> path;
path.push_back(m_text);
const Item* p = m_parentItem;
while (p != nullptr)
{
path.push_back(p->m_text);
p = p->m_parentItem;
}
std::reverse(path.begin(), path.end());
return path;
}
TreeView& TreeView::create(void)
{
setPadding({ 0, 0, 0, 0 });
setTypeName("ogfx::gui::TreeView");
enableStopEvents();
enableBackground();
enableBorder();
setBackgroundColor({ 160, 160, 160 });
setBorderColor({ 50, 50, 50 });
setStylesheetCategoryName("tree");
reloadTheme();
validate();
return *this;
}
void TreeView::applyTheme(const ostd::Stylesheet& theme)
{
setSeparatorLineColor(getThemeValue<Color>(theme, "separatorLineColor", getSeparatorLineColor()));
enableShowSeparatorLine(getThemeValue<bool>(theme, "showSeparatorLine", isShowSeparatorLineEnabled()));
setLinePadding(getThemeValue<Rectangle>(theme, "linePadding", getLinePadding()));
setLineSelectionColor(getThemeValue<Color>(theme, "selectionColor", getLineSelectionColor()));
setLineSelectionTextColor(getThemeValue<Color>(theme, "selectionTextColor", getLineSelectionTextColor()));
setIconSpacing(getThemeValue<f32>(theme, "iconSpacing", getIconSpacing()));
setIconInset(getThemeValue<f32>(theme, "iconInset", getIconInset()));
enableIcons(getThemeValue<bool>(theme, "showIcons", isIconsEnabled()));
setIconTintColor(getThemeValue<Color>(theme, "iconTintColor", getIconTintColor()));
setIndentWidth(getThemeValue<f32>(theme, "indentWidth", getIndentWidth()));
setChevronColor(getThemeValue<Color>(theme, "chevronColor", getChevronColor()));
setChevronInset(getThemeValue<f32>(theme, "chevronInset", getChevronInset()));
setBranchLineColor(getThemeValue<Color>(theme, "branchLineColor", getBranchLineColor()));
enableBranchLines(getThemeValue<bool>(theme, "showBranchLines", isBranchLinesEnabled()));
setBranchLineThickness(getThemeValue<f32>(theme, "branchLineThickness", getBranchLineThickness()));
}
void TreeView::onDraw(ogfx::BasicRenderer2D& gfx)
{
const auto& bounds = getGlobalBounds();
const auto& content = getContentExtents();
const f32 scrollY = -getScrollOffset().y;
const f32 visibleH = getContentBounds().h;
const f32 visibleStart = scrollY;
const f32 visibleEnd = scrollY + visibleH;
Color textColor;
// Walk forward through visible items only, advancing y for each one we draw.
f32 y = 0;
i32 startIdx = 0;
for (auto& itemPtr : m_list)
{
auto& item = *itemPtr;
if (!item.isValid() || !item.isVisible()) { startIdx++; continue; }
f32 itemH = item.getDimensions().y;
if (y + itemH > visibleStart) break;
y += itemH;
startIdx++;
}
for (i32 i = startIdx; i < (i32)m_list.size(); i++)
{
auto& item = *m_list[i];
if (!item.isValid()) continue;
if (!item.isVisible()) continue;
f32 itemH = item.getDimensions().y;
if (y > visibleEnd) break;
f32 lineW = content.w;
if (content.w < bounds.w)
lineW = bounds.w;
auto pad = getLinePadding();
Rectangle lineRect = { Vec2 { bounds.x, bounds.y + y + pad.h } + getScrollOffset(), { lineW, itemH } };
if (item.isSelected())
{
textColor = getLineSelectionTextColor();
gfx.fillRect(lineRect, getLineSelectionColor());
}
else
textColor = getTextColor();
// Branch lines (drawn under everything else for this row).
if (isBranchLinesEnabled())
render_branch_lines(gfx, item, lineRect);
// The chevron occupies one slot at column == depth.
// Text/icon start one slot further right (column == depth + 1).
const f32 indent = getIndentWidth();
const f32 chevronX = bounds.x + item.getDepth() * indent + getScrollOffset().x;
const f32 contentLeftX = chevronX + indent; // where icon (if any) or text starts
if (item.hasChildren())
{
Rectangle chevronBounds {
chevronX,
bounds.y + y + pad.h + getScrollOffset().y,
indent,
itemH
};
if (m_chevronDrawCallback)
{
gfx.pushClippingRect(chevronBounds);
m_chevronDrawCallback(*this, item, chevronBounds, gfx);
gfx.popClippingRect();
}
else
{
render_chevron(gfx, item, chevronBounds);
}
}
f32 textX = contentLeftX - bounds.x - getScrollOffset().x;
if (item.isIconEnabled() && isIconsEnabled())
{
gfx.drawAnimation(item.getIcon(),
Vec2 { contentLeftX + getIconInset(), bounds.y + getIconInset() + y + pad.h + getScrollOffset().y },
{ itemH - (getIconInset() * 2), itemH - (getIconInset() * 2) },
getIconTintColor());
textX += itemH + getIconSpacing();
}
gfx.drawLine({ Vec2 { bounds.x, bounds.y + y + itemH + pad.h } + getScrollOffset(), Vec2 { bounds.x + lineW, bounds.y + y + itemH + pad.h } + getScrollOffset() }, getSeparatorLineColor(), 1);
gfx.drawVCenteredString(item, lineRect + Rectangle { textX, 0, -(textX * 2), 0 }, textColor, getFontSize());
y += itemH;
}
}
void TreeView::afterDraw(ogfx::BasicRenderer2D& gfx)
{
drawScrollbars(gfx);
}
void TreeView::onMouseReleased(const Event& event)
{
if (!isMouseInside())
return;
if (event.mouse->button != ogfx::MouseEventData::eButton::Left)
return;
if (isMouseInsideAnyScrollbar())
return;
const Vec2 mousePos { event.mouse->position_x, event.mouse->position_y };
const Vec2 origin = getGlobalBounds().getPosition();
const Vec2 scroll = getScrollOffset();
const f32 localY = mousePos.y - origin.y - scroll.y;
const f32 localX = mousePos.x - origin.x - scroll.x;
const f32 indent = getIndentWidth();
f32 y = 0;
for (auto& itemPtr : m_list)
{
auto& item = *itemPtr;
if (!item.isValid()) continue;
if (!item.isVisible()) continue;
const f32 itemH = item.getDimensions().y;
if (localY >= y && localY < y + itemH)
{
// Chevron hit-test takes priority over selection.
if (item.hasChildren())
{
const f32 chevronXLocal = item.getDepth() * indent;
if (localX >= chevronXLocal && localX < chevronXLocal + indent)
{
item.toggleExpanded();
event.handle();
return;
}
}
bool wasSelected = item.isSelected();
item.set_selected(m_selectedList);
if (!wasSelected && callback_onSelectionChanged)
callback_onSelectionChanged(m_selectedList);
event.handle();
break;
}
y += itemH;
}
}
Rectangle TreeView::getContentExtents(void) const
{
if (!m_extentsDirty)
return m_cachedExtents;
f32 maxX = 0, maxY = 0;
const f32 indent = m_indentWidth;
for (auto& itemPtr : m_list)
{
auto& item = *itemPtr;
if (!item.isValid()) continue;
if (!item.isVisible()) continue;
Vec2 size = item.getDimensions();
// Width of a row is its text/icon width plus the indent + chevron-slot space to its left.
f32 rowW = size.x + (item.getDepth() + 1) * indent;
maxX = std::max(maxX, rowW);
maxY += size.y;
}
m_cachedExtents = { 0, 0, maxX, maxY };
m_extentsDirty = false;
return m_cachedExtents;
}
TreeView::Item& TreeView::getLine(const String& text)
{
for (auto& itemPtr : m_list)
if (itemPtr->getText() == text)
return *itemPtr;
return InvalidItem;
}
TreeView::Item& TreeView::getLine(u32 index)
{
if (index < m_list.size())
return *m_list[index];
return InvalidItem;
}
TreeView::Item& TreeView::addRoot(const String& text)
{
// Flip the previously-last root's flag, then become the new last root.
if (m_lastRoot) m_lastRoot->m_isLastChild = false;
auto item = std::make_unique<Item>(*this);
item->setText(text);
item->m_parentItem = nullptr;
item->m_depth = 0;
item->m_isLastChild = true;
Item* itemPtr = item.get();
m_list.push_back(std::move(item));
m_lastRoot = itemPtr;
if (m_selectedList.empty())
itemPtr->set_selected(m_selectedList);
m_extentsDirty = true;
return *itemPtr;
}
TreeView::Item& TreeView::addRoot(const String& text, const Icon& icon)
{
auto& item = addRoot(text);
item.setIcon(icon);
return item;
}
TreeView::Item& TreeView::addChild(Item& parentItem, const String& text)
{
// Locate the parent's slot in m_list (needed for the insertion offset).
i32 parentIdx = -1;
for (i32 i = 0; i < (i32)m_list.size(); i++)
{
if (m_list[i].get() == &parentItem) { parentIdx = i; break; }
}
if (parentIdx < 0)
return InvalidItem;
// Insert right after the parent's last descendant so display order == deque order.
u32 insertIdx = get_last_descendant_index((u32)parentIdx) + 1;
// The previously-last direct child (if any) loses its last-child flag.
if (parentItem.m_lastChild)
parentItem.m_lastChild->m_isLastChild = false;
auto newItem = std::make_unique<Item>(*this);
newItem->setText(text);
newItem->m_parentItem = &parentItem;
newItem->m_depth = parentItem.m_depth + 1;
newItem->m_isLastChild = true;
Item* newPtr = newItem.get();
// std::deque::insert moves the pointer slots around, but the Item objects
// themselves are heap-allocated and stay put. Pointers stored on other Items
// (m_parentItem, m_lastChild) and in m_selectedList remain valid.
m_list.insert(m_list.begin() + insertIdx, std::move(newItem));
parentItem.m_hasChildren = true;
parentItem.m_lastChild = newPtr;
m_extentsDirty = true;
return *newPtr;
}
TreeView::Item& TreeView::addChild(Item& parentItem, const String& text, const Icon& icon)
{
auto& item = addChild(parentItem, text);
item.setIcon(icon);
return item;
}
u32 TreeView::get_last_descendant_index(u32 itemIndex) const
{
// Walk forward while items are deeper than the anchor; the last such index is the answer.
if (itemIndex >= m_list.size()) return itemIndex;
const u32 anchorDepth = m_list[itemIndex]->m_depth;
u32 last = itemIndex;
for (u32 i = itemIndex + 1; i < m_list.size(); i++)
{
if (m_list[i]->m_depth > anchorDepth)
last = i;
else
break;
}
return last;
}
void TreeView::recompute_has_children_and_last_sibling(Item* parentItem)
{
// Recomputes m_hasChildren on parentItem (or m_lastRoot if null) and
// m_isLastChild flags + cached last-child pointer for the children of that parent.
Item* lastSeen = nullptr;
bool any = false;
for (auto& itPtr : m_list)
{
auto& it = *itPtr;
if (!it.isValid()) continue;
if (it.m_parentItem == parentItem)
{
it.m_isLastChild = false;
lastSeen = &it;
any = true;
}
}
if (lastSeen) lastSeen->m_isLastChild = true;
if (parentItem)
{
parentItem->m_hasChildren = any;
parentItem->m_lastChild = lastSeen;
}
else
{
m_lastRoot = lastSeen;
}
}
bool TreeView::removeLine(const String& text)
{
i32 idx = -1;
for (i32 i = 0; i < (i32)m_list.size(); i++)
{
if (m_list[i]->getText() == text) { idx = i; break; }
}
if (idx < 0) return false;
return removeLine((u32)idx);
}
bool TreeView::removeLine(u32 index)
{
if (!hasLine(index))
return false;
Item* removedParent = m_list[index]->m_parentItem;
// Remove the item plus its entire descendant range.
u32 lastDesc = get_last_descendant_index(index);
// Drop any selection pointers that fall inside the removed range.
for (u32 i = index; i <= lastDesc; i++)
{
Item* p = m_list[i].get();
STDVEC_REMOVE(m_selectedList, p);
}
m_list.erase(m_list.begin() + index, m_list.begin() + lastDesc + 1);
// Fix up parent flags / sibling last-child markers.
recompute_has_children_and_last_sibling(removedParent);
m_extentsDirty = true;
return true;
}
bool TreeView::hasLine(const String& text)
{
for (auto& itemPtr : m_list)
if (itemPtr->getText() == text)
return true;
return false;
}
bool TreeView::hasLine(u32 index)
{
return m_list.size() > index;
}
void TreeView::render_chevron(ogfx::BasicRenderer2D& gfx, const Item& item, const Rectangle& bounds)
{
// Default chevron: a small triangle. Right-pointing when collapsed, down-pointing when expanded.
const f32 inset = getChevronInset();
const f32 cx = bounds.x + bounds.w * 0.5f;
const f32 cy = bounds.y + bounds.h * 0.5f;
// Half-extents inside the slot.
const f32 hx = std::max(2.0f, bounds.w * 0.5f - inset);
const f32 hy = std::max(2.0f, bounds.h * 0.25f);
Vec2 p0, p1, p2;
if (item.isExpanded())
{
// Down-pointing: ▼
p0 = { cx - hx, cy - hy };
p1 = { cx + hx, cy - hy };
p2 = { cx, cy + hy };
}
else
{
// Right-pointing: ▶
p0 = { cx - hy, cy - hx };
p1 = { cx - hy, cy + hx };
p2 = { cx + hy, cy };
}
gfx.fillTriangle(p0, p1, p2, getChevronColor());
}
void TreeView::render_branch_lines(ogfx::BasicRenderer2D& gfx, const Item& item, const Rectangle& rowRect)
{
const f32 indent = getIndentWidth();
const f32 thick = getBranchLineThickness();
const Color col = getBranchLineColor();
const u32 d = item.getDepth();
if (d == 0) return; // roots have no parent line / no elbow
// Collect ancestor "isLastChild" flags. ancestorIsLast[0] = immediate parent (depth d-1),
// ancestorIsLast[k] = ancestor at depth (d-1-k).
std::vector<bool> ancestorIsLast;
ancestorIsLast.reserve(d);
const Item* p = item.getParentItem();
while (p != nullptr)
{
ancestorIsLast.push_back(p->isLastChild());
p = p->getParentItem();
}
// For each "passing-through" column a in [0, d-1): draw a vertical line spanning the
// full row IF the ancestor at depth (a+1) is NOT the last child of its parent.
// (Column 'a' carries the sibling line for depth a+1; if the depth-a+1 ancestor has
// no later sibling, the line ended above us and shouldn't be drawn here.)
// The column for our own row (a == d-1) is handled by the elbow logic below.
for (u32 a = 0; a + 1 < d; a++)
{
const u32 ancestorDepth = a + 1;
const u32 ancestorIdx = d - 1 - ancestorDepth; // index into ancestorIsLast
if (ancestorIsLast[ancestorIdx]) continue; // no continuing line in this column
const f32 colX = rowRect.x + a * indent + indent * 0.5f;
gfx.drawLine({ Vec2 { colX, rowRect.y }, Vec2 { colX, rowRect.y + rowRect.h } }, col, thick);
}
// Elbow in column d-1 connecting our row to the chevron slot at column d.
const f32 colX = rowRect.x + (d - 1) * indent + indent * 0.5f;
const f32 midY = rowRect.y + rowRect.h * 0.5f;
// If this item has no chevron drawn, extend the horizontal arm all the way to where
// the icon/text starts (the right edge of the chevron slot, i.e. column d+1's left).
// Otherwise stop at the left edge of the chevron slot so the chevron sits clean.
const f32 armEndX = item.hasChildren()
? rowRect.x + d * indent
: rowRect.x + (d + 1) * indent;
// Vertical: top -> middle of the row (always)
gfx.drawLine({ Vec2 { colX, rowRect.y }, Vec2 { colX, midY } }, col, thick);
// Vertical: middle -> bottom (only if there is a sibling below us at this depth)
if (!item.isLastChild())
gfx.drawLine({ Vec2 { colX, midY }, Vec2 { colX, rowRect.y + rowRect.h } }, col, thick);
// Horizontal arm
gfx.drawLine({ Vec2 { colX, midY }, Vec2 { armEndX, midY } }, col, thick);
}
}
}

View file

@ -0,0 +1,161 @@
/*
OmniaFramework - A collection of useful functionality
Copyright (C) 2025 OmniaX-Dev
This file is part of OmniaFramework.
OmniaFramework is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
OmniaFramework is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with OmniaFramework. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <ogfx/gui/widgets/Widget.hpp>
#include <ogfx/gui/widgets/Scrollbar.hpp>
#include <ogfx/utils/Animation.hpp>
#include <deque>
#include <memory>
namespace ogfx
{
namespace gui
{
class TreeView : public ScrollableWidget
{
public: class Item : public ostd::__i_stringeable
{
public:
inline Item(TreeView& parent) { m_treeView = &parent; }
void setText(const String& text);
void setIcon(const Icon& icon);
inline Animation& getIcon(void) { return m_icon; }
inline String toString(void) const override { return m_text; }
inline bool isValid(void) const { return m_treeView != nullptr && m_text.new_trim() != ""; }
inline Vec2 getDimensions(void) const { return m_dimensions; }
inline String getText(void) const { return m_text; }
inline bool isSelected(void) const { return m_selected; }
inline bool isExpanded(void) const { return m_expanded; }
inline bool hasChildren(void) const { return m_hasChildren; }
inline u32 getDepth(void) const { return m_depth; }
inline Item* getParentItem(void) { return m_parentItem; }
inline const Item* getParentItem(void) const { return m_parentItem; }
inline bool isLastChild(void) const { return m_isLastChild; }
void setExpanded(bool expanded);
inline void toggleExpanded(void) { setExpanded(!m_expanded); }
bool isVisible(void) const;
stdvec<String> getFullPath(void) const;
OSTD_BOOL_PARAM_GETSET_E(Icon, m_showIcon);
private:
void update_dimensions(void);
void set_selected(stdvec<Item*>& selectionList);
void add_selected(stdvec<Item*>& selectionList);
void remove_selected(stdvec<Item*>& selectionList);
private:
Vec2 m_dimensions { 0, 0 };
bool m_selected { false };
TreeView* m_treeView { nullptr };
String m_text { "" };
Animation m_icon;
bool m_showIcon { false };
// Tree state
Item* m_parentItem { nullptr };
Item* m_lastChild { nullptr };
u32 m_depth { 0 };
bool m_expanded { true };
bool m_hasChildren { false };
bool m_isLastChild { true };
friend class TreeView;
};
public:
using ChevronDrawCallback = std::function<void(TreeView& sender, const Item& item, const Rectangle& bounds, BasicRenderer2D& gfx)>;
inline TreeView(WindowCore& window) : ScrollableWidget(window) { create(); }
TreeView& create(void);
void applyTheme(const ostd::Stylesheet& theme) override;
void onDraw(ogfx::BasicRenderer2D& gfx) override;
void afterDraw(ogfx::BasicRenderer2D& gfx) override;
void onMouseReleased(const Event& event) override;
Rectangle getContentExtents(void) const override;
Item& getLine(const String& text);
Item& getLine(u32 index);
Item& addRoot(const String& text);
Item& addRoot(const String& text, const Icon& icon);
Item& addChild(Item& parentItem, const String& text);
Item& addChild(Item& parentItem, const String& text, const Icon& icon);
bool removeLine(const String& text);
bool removeLine(u32 index);
bool hasLine(const String& text);
bool hasLine(u32 index);
inline stdvec<Item*>& getSelection(void) { return m_selectedList; }
inline void setSelectionChangedCallback(std::function<void(stdvec<Item*>& selection)> callback) { callback_onSelectionChanged = std::move(callback); }
inline void setChevronDrawCallback(ChevronDrawCallback cb) { m_chevronDrawCallback = std::move(cb); }
OSTD_PARAM_GETSET(Color, SeparatorLineColor, m_lineColor);
OSTD_BOOL_PARAM_GETSET_E(ShowSeparatorLine, m_showLine);
OSTD_BOOL_PARAM_GETSET_E(Icons, m_showIcons);
OSTD_PARAM_GETSET(Rectangle, LinePadding, m_linePadding);
OSTD_PARAM_GETSET(Color, LineSelectionColor, m_selectionColor);
OSTD_PARAM_GETSET(Color, LineSelectionTextColor, m_selectionTextColor);
OSTD_PARAM_GETSET(Color, IconTintColor, m_iconTintColor);
OSTD_PARAM_GETSET(f32, IconSpacing, m_iconSpacing);
OSTD_PARAM_GETSET(f32, IconInset, m_iconInset);
OSTD_PARAM_GETSET(f32, IndentWidth, m_indentWidth);
OSTD_PARAM_GETSET(Color, ChevronColor, m_chevronColor);
OSTD_PARAM_GETSET(f32, ChevronInset, m_chevronInset);
OSTD_PARAM_GETSET(Color, BranchLineColor, m_branchLineColor);
OSTD_BOOL_PARAM_GETSET_E(BranchLines, m_showBranchLines);
OSTD_PARAM_GETSET(f32, BranchLineThickness, m_branchLineThickness);
private:
void render_chevron(ogfx::BasicRenderer2D& gfx, const Item& item, const Rectangle& bounds);
void render_branch_lines(ogfx::BasicRenderer2D& gfx, const Item& item, const Rectangle& rowRect);
u32 get_last_descendant_index(u32 itemIndex) const;
void recompute_has_children_and_last_sibling(Item* parentItem);
private:
Item InvalidItem { *this };
std::deque<std::unique_ptr<Item>> m_list;
stdvec<Item*> m_selectedList;
std::function<void(stdvec<Item*>& selection)> callback_onSelectionChanged { nullptr };
ChevronDrawCallback m_chevronDrawCallback { nullptr };
mutable Rectangle m_cachedExtents { 0, 0, 0, 0 };
mutable bool m_extentsDirty { true };
Item* m_lastRoot { nullptr };
bool m_showIcons { false };
f32 m_iconSpacing { 0 };
f32 m_iconInset { 4 };
Color m_lineColor { 40, 40, 40 };
bool m_showLine { true };
Rectangle m_linePadding { 5, 5, 20, 0 };
Color m_selectionColor { Colors::Crimson };
Color m_selectionTextColor{ Colors::White };
Color m_iconTintColor { Colors::White };
f32 m_indentWidth { 16 };
Color m_chevronColor { Colors::White };
f32 m_chevronInset { 4 };
Color m_branchLineColor { 80, 80, 80 };
bool m_showBranchLines { true };
f32 m_branchLineThickness { 1 };
};
}
}

View file

@ -93,4 +93,14 @@ namespace ogfx
bool m_back { false };
Rectangle m_frameRect { 0, 0, 0, 0 };
};
struct Icon
{
Image& sheet;
AnimationData info;
Animation asAnim(void) const { return { info, sheet }; }
AnimationData asAnimData(void) const { return info; }
inline operator Animation() const { return asAnim(); }
inline operator AnimationData() const { return asAnimData(); }
};
}

View file

@ -141,30 +141,57 @@ class TestWindow : public Window
m_list.setSize(200, 300);
m_list.reloadTheme();
for (i32 i = 0; i < 10000; i++)
// for (i32 i = 0; i < 10000; i++)
// {
// m_list.addLine(ostd::Random::getString(ostd::Random::getui8(1, 40)));
// }
// m_list.addLine("Item 1");
// m_list.addLine("Item 222");
// m_list.addLine("Item 3333333");
// m_list.setSelectionChangedCallback([&](stdvec<TreeView::Item*>& selection) -> void {\
// std::cout << *(selection[0]) << "\n";
// });
auto l_randomIcon = [&](const ogfx::AnimationData& src) -> ogfx::Icon {
static ogfx::Image img("./icons.png", getGFX());
ogfx::AnimationData ad = src;
ad.columnOffset = ostd::Random::getui32(0, 7);
ad.rowOffset = ostd::Random::getui32(0, 4);
if (ad.rowOffset == 3)
ad.columnOffset = ostd::Random::getui32(0, 4);
return { img, ad };
};
i32 listLen = 20;
for (i32 i = 0; i < listLen; i++)
{
m_list.addLine(ostd::Random::getString(ostd::Random::getui8(1, 40)));
auto& item = m_list.addRoot(String("Line ").add(i + 1), l_randomIcon(iconsAD));
}
m_list.addLine("Item 1");
m_list.addLine("Item 222");
m_list.addLine("Item 3333333");
m_list.getLine(10).setFontSize(40);
m_list.getLine(160).setTextColor(Colors::Crimson);
m_list.setSelectionChangedCallback([&](stdvec<ListView::Item*>& selection) -> void {\
std::cout << *(selection[0]) << "\n";
for (i32 i = 0; i < 30; i++)
{
i32 index = ostd::Random::geti32(0, listLen - 1);
auto& item = m_list.getLine(index);
auto& child = m_list.addChild(item, String("Child ").add(i + 1), l_randomIcon(iconsAD));
}
m_list.setSelectionChangedCallback([&](stdvec<TreeView::Item*>& selection) -> void {
auto path = (selection[0])->getFullPath();
for (i32 i = 0; i < path.size(); i++)
{
std::cout << path[i];
if (i < path.size() - 1)
std::cout << " -> ";
}
std::cout << "\n";
});
for (i32 i = 0; i < 20; i++)
{
iconsAD.columnOffset = ostd::Random::getui32(0, 7);
iconsAD.rowOffset = ostd::Random::getui32(0, 4);
if (iconsAD.rowOffset == 3)
iconsAD.columnOffset = ostd::Random::getui32(0, 4);
auto icon = l_randomIcon(iconsAD);
auto& btn = getToolBar().addButton("./icons.png");
btn.enableAnimated();
btn.setAnimationData(iconsAD);
btn.setAnimationData(icon);
}
m_tabs.setSize(900, 700);
@ -194,7 +221,7 @@ class TestWindow : public Window
auto* header = new Button(*this);
header->layoutHint().preferred = { -1, 32 }; // fixed 32px height, width follows cross-stretch
auto* body = new ListView(*this);
auto* body = new TreeView(*this);
body->layoutHint().stretch = 1.0f; // takes all leftover vertical space
auto* footer = new Button(*this);
@ -214,7 +241,7 @@ class TestWindow : public Window
t2.addWidget(*body);
for (i32 i = 0; i < 100; i++)
{
body->addLine(ostd::Random::getString(ostd::Random::getui8(1, 40)));
body->addRoot(ostd::Random::getString(ostd::Random::getui8(1, 40)));
}
t2.addWidget(*footer);
@ -333,7 +360,7 @@ class TestWindow : public Window
ProgressBar m_prog { *this, 0, 100 };
Slider m_slide { *this };
Label m_slideLbl { *this };
ListView m_list { *this };
TreeView m_list { *this };
Label m_drawCallsLbl { *this };
ComboBox m_combo { *this };