From 7ad834a3a6bbb98d4a21f893f50ecbb1c58f17a1 Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 8 Oct 2026 18:35:18 +0300 Subject: [PATCH 1/9] Widgets: rebuild around measure/arrange layout, event routing and popups --- Widgets/examples/Example.cpp | 227 -------------- Widgets/private/Layout.cpp | 96 ------ Widgets/private/RootWidget.cpp | 143 --------- Widgets/private/Widget.cpp | 147 --------- Widgets/private/WidgetApplication.cpp | 20 -- Widgets/private/core/Popup.cpp | 81 +++++ Widgets/private/core/SpaceDistribution.cpp | 85 +++++ Widgets/private/layouts/BasicLayout.cpp | 149 --------- Widgets/private/layouts/BoxLayout.cpp | 141 +++++++++ Widgets/private/layouts/DockLayout.cpp | 294 ------------------ Widgets/private/layouts/FloatingLayout.cpp | 52 ---- Widgets/private/layouts/ScrollableLayout.cpp | 53 ---- Widgets/private/layouts/SimpleLayouts.cpp | 32 -- Widgets/private/managers/LayoutManager.cpp | 101 ------ Widgets/private/managers/UpdateManager.cpp | 190 ----------- .../private/widgets/AnimationTestWidget.cpp | 28 -- Widgets/private/widgets/ColorPickerWidget.cpp | 154 --------- Widgets/private/widgets/DockWidget.cpp | 141 --------- Widgets/private/widgets/FloatingWidget.cpp | 74 ----- Widgets/private/widgets/ScrollableWidget.cpp | 106 ------- Widgets/private/widgets/SimpleWidgets.cpp | 172 ---------- Widgets/public/Layout.hpp | 69 ---- Widgets/public/RootWidget.hpp | 48 --- Widgets/public/Widget.hpp | 145 --------- Widgets/public/WidgetApplication.hpp | 27 -- Widgets/public/core/SpaceDistribution.hpp | 17 + Widgets/public/layouts/BasicLayout.hpp | 29 -- Widgets/public/layouts/BoxLayout.hpp | 49 +++ Widgets/public/layouts/DockLayout.hpp | 109 ------- Widgets/public/layouts/FloatingLayout.hpp | 34 -- Widgets/public/layouts/ScrollableLayout.hpp | 24 -- Widgets/public/layouts/SimpleLayouts.hpp | 31 -- Widgets/public/mangers/LayoutManager.hpp | 36 --- Widgets/public/mangers/UpdateManager.hpp | 43 --- .../public/widgets/AnimationTestWidget.hpp | 20 -- Widgets/public/widgets/ColorPickerWidget.hpp | 40 --- Widgets/public/widgets/DockWidget.hpp | 44 --- Widgets/public/widgets/FloatingPanel.hpp | 50 +++ Widgets/public/widgets/FloatingWidget.hpp | 61 ---- Widgets/public/widgets/ScrollableWidget.hpp | 67 ---- Widgets/public/widgets/SimpleWidgets.hpp | 139 --------- Widgets/public/widgets/Toggles.hpp | 99 ++++++ 42 files changed, 522 insertions(+), 3145 deletions(-) delete mode 100644 Widgets/examples/Example.cpp delete mode 100644 Widgets/private/Layout.cpp delete mode 100644 Widgets/private/RootWidget.cpp delete mode 100644 Widgets/private/Widget.cpp delete mode 100644 Widgets/private/WidgetApplication.cpp create mode 100644 Widgets/private/core/Popup.cpp create mode 100644 Widgets/private/core/SpaceDistribution.cpp delete mode 100644 Widgets/private/layouts/BasicLayout.cpp create mode 100644 Widgets/private/layouts/BoxLayout.cpp delete mode 100644 Widgets/private/layouts/DockLayout.cpp delete mode 100644 Widgets/private/layouts/FloatingLayout.cpp delete mode 100644 Widgets/private/layouts/ScrollableLayout.cpp delete mode 100644 Widgets/private/layouts/SimpleLayouts.cpp delete mode 100644 Widgets/private/managers/LayoutManager.cpp delete mode 100644 Widgets/private/managers/UpdateManager.cpp delete mode 100644 Widgets/private/widgets/AnimationTestWidget.cpp delete mode 100644 Widgets/private/widgets/ColorPickerWidget.cpp delete mode 100644 Widgets/private/widgets/DockWidget.cpp delete mode 100644 Widgets/private/widgets/FloatingWidget.cpp delete mode 100644 Widgets/private/widgets/ScrollableWidget.cpp delete mode 100644 Widgets/private/widgets/SimpleWidgets.cpp delete mode 100644 Widgets/public/Layout.hpp delete mode 100644 Widgets/public/RootWidget.hpp delete mode 100644 Widgets/public/Widget.hpp delete mode 100644 Widgets/public/WidgetApplication.hpp create mode 100644 Widgets/public/core/SpaceDistribution.hpp delete mode 100644 Widgets/public/layouts/BasicLayout.hpp create mode 100644 Widgets/public/layouts/BoxLayout.hpp delete mode 100644 Widgets/public/layouts/DockLayout.hpp delete mode 100644 Widgets/public/layouts/FloatingLayout.hpp delete mode 100644 Widgets/public/layouts/ScrollableLayout.hpp delete mode 100644 Widgets/public/layouts/SimpleLayouts.hpp delete mode 100644 Widgets/public/mangers/LayoutManager.hpp delete mode 100644 Widgets/public/mangers/UpdateManager.hpp delete mode 100644 Widgets/public/widgets/AnimationTestWidget.hpp delete mode 100644 Widgets/public/widgets/ColorPickerWidget.hpp delete mode 100644 Widgets/public/widgets/DockWidget.hpp create mode 100644 Widgets/public/widgets/FloatingPanel.hpp delete mode 100644 Widgets/public/widgets/FloatingWidget.hpp delete mode 100644 Widgets/public/widgets/ScrollableWidget.hpp delete mode 100644 Widgets/public/widgets/SimpleWidgets.hpp create mode 100644 Widgets/public/widgets/Toggles.hpp diff --git a/Widgets/examples/Example.cpp b/Widgets/examples/Example.cpp deleted file mode 100644 index d0e7b318..00000000 --- a/Widgets/examples/Example.cpp +++ /dev/null @@ -1,227 +0,0 @@ - -#include "WidgetApplication.hpp" - -#include "RootWidget.hpp" -#include "FloatingWidget.hpp" -#include "DockWidget.hpp" -#include "ColorPickerWidget.hpp" - -#include "ScrollableLayout.hpp" - -using namespace tp; - - -class Example : public WidgetApplication { -public: - Example() { - exampleColorPicker(); - } - - void exampleAll() { - static DockWidget dock; - static LabelWidget centralWidget; - static FloatingMenu menus[4]; - static FloatingMenu nestedMenus[4]; - static FloatingMenu buttons[10]; - - dock.setCenterWidget(¢ralWidget); - - for (auto& menu : menus) { - dock.addChild(&menu); - } - - for (auto& menu : nestedMenus) { - menus[0].addToMenu(&menu); - } - - setRoot(&dock); - } - - void exampleColorPicker() { - static DockWidget dock; - static RGBPickerWidget colorPicker; - - dock.dockWidget(&colorPicker, DockLayout::RIGHT); - - setRoot(&dock); - } - - void exampleBasic() { - static Widget widget; - static LabelWidget label; - static SliderWidget slider; - static ButtonWidget button; - - widget.addChild(&label); - widget.addChild(&button); - widget.addChild(&slider); - - setRoot(&widget); - } - - void exampleScrolling() { - static ScrollableWidget widget; - static ButtonWidget buttons[10]; - - widget.setDirection(false); - - setRoot(&widget); - - for (auto& button : buttons) { - widget.getContainer()->addChild(&button); - } - - RootWidget::setWidgetArea(*widget.getContainer(), { 333, 333, 522, 522 }); - } - - void examplePopup() { - static Widget root; - static Widget popup; - - static ButtonWidget closeButton; - static ButtonWidget openButton; - static ButtonWidget buttons[3]; - - for (auto& button : buttons) { - popup.addChild(&button); - } - - popup.addChild(&closeButton); - - root.addChild(&openButton); - ((BasicLayout*)root.getLayout())->setLayoutPolicy(LayoutPolicy::Passive); - - openButton.setAction([&](){ - popup.setArea({ 50, 50, 300, 300 }); - openButton.openPopup(&popup); - }); - - closeButton.setAction([&](){ - closeButton.closePopup(&popup); - }); - - openButton.setText("open"); - closeButton.setText("close"); - - RootWidget::setWidgetArea(openButton, { 333, 333, 222, 222 }); - - setRoot(&root); - } - - void exampleNestedMenus() { - static DockWidget dock; - static FloatingMenu menu1; - static FloatingMenu menu2; - static ButtonWidget buttons[15]; - - setRoot(&dock); - - dock.addChild(&menu1); - dock.addChild(&menu2); - - dock.setCenterWidget(&buttons[5]); - dock.dockWidget(&buttons[6], DockLayout::RIGHT); - - menu1.addToMenu(&buttons[0]); - - menu2.addToMenu(&buttons[2]); - menu2.addToMenu(&buttons[3]); - menu2.addToMenu(&buttons[4]); - - menu2.addToMenu(&menu1); - - // popup - { - buttons[0].setAction([&](){ - buttons[10].setArea({ 30, 30, 100, 100 }); - buttons[0].openPopup(&buttons[10]); - }); - - buttons[0].setText("open popup"); - - buttons[10].setAction([&](){ - buttons[10].closePopup(&buttons[10]); - }); - - buttons[10].setText("close popup"); - } - - RootWidget::setWidgetArea(menu1, { 300, 100, 150, 500 }); - RootWidget::setWidgetArea(menu2, { 100, 100, 150, 300 }); - } - - void exampleLayouts() { - static Widget widgets[15]; - static ButtonWidget buttons[15]; - - setRoot(&widgets[0]); - - widgets[0].addChild(&widgets[1]); - - widgets[1].addChild(&buttons[1]); - widgets[1].addChild(&buttons[2]); - widgets[1].addChild(&widgets[2]); - - RootWidget::setWidgetArea(buttons[1], { 300, 100, 350, 800 }); - RootWidget::setWidgetArea(buttons[2], { 100, 100, 150, 300 }); - - widgets[2].addChild(&buttons[3]); - widgets[2].addChild(&buttons[4]); - widgets[2].addChild(&buttons[5]); - widgets[2].addChild(&buttons[6]); - - buttons[1].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - buttons[2].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - buttons[3].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - buttons[4].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - buttons[5].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - buttons[6].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - - widgets[2].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - ((BasicLayout*)widgets[1].getLayout())->setLayoutPolicy(LayoutPolicy::Horizontal); - } - - void exampleDock() { - static DockWidget dock; - static FloatingMenu menu1; - static FloatingMenu menu2; - static ButtonWidget buttons[15]; - - setRoot(&dock); - - dock.addChild(&menu2); - dock.addChild(&menu1); - - dock.setCenterWidget(&buttons[5]); - dock.dockWidget(&buttons[6], DockLayout::RIGHT); - - menu1.addToMenu(&buttons[0]); - menu2.addToMenu(&buttons[2]); - //menu2.addToMenu(&menu1); - menu2.addToMenu(&buttons[3]); - menu2.addToMenu(&buttons[4]); - - buttons[0].setAction([&]() { buttons[2].setColor(RGBA::random()); }); - buttons[2].setAction([&]() { buttons[0].setColor(RGBA::random()); }); - - buttons[5].setAction([&]() { dock.dockWidget(&menu1, DockLayout::LEFT); }); - buttons[6].setAction([&]() { dock.undockWidget(DockLayout::LEFT); }); - - buttons[3].setAction([&]() { dock.toggleWidgetVisibility(DockLayout::LEFT); }); - // buttons[4].setAction([this]() { mDockLayout.undockWidget(DockLayout::LEFT); }); - - buttons[3].setText("toggle"); - buttons[5].setText("dock"); - buttons[6].setText("undock"); - - RootWidget::setWidgetArea(menu2, { 300, 100, 150, 500 }); - RootWidget::setWidgetArea(menu1, { 100, 100, 150, 300 }); - } -}; - -int main() { - { - Example gui; - gui.run(); - } -} diff --git a/Widgets/private/Layout.cpp b/Widgets/private/Layout.cpp deleted file mode 100644 index bd56f95c..00000000 --- a/Widgets/private/Layout.cpp +++ /dev/null @@ -1,96 +0,0 @@ -#include -#include "Layout.hpp" - -#include "Widget.hpp" - -using namespace tp; - -const RectF& WidgetLayout::getArea() const { - return mWidget->mAreaCache; -} - -RectF WidgetLayout::getAnimatedArea() const { - return mWidget->getArea(); -} - -void WidgetLayout::setArea(const RectF& area) { - mWidget->setAreaCache(area); -} - -Widget* WidgetLayout::parent() const { return mWidget->mParent; } - -const std::vector& WidgetLayout::children() const { return mWidget->mChildren; } - -const Vec2F& WidgetLayout::getMinSize() { return mMinSize; } - -void WidgetLayout::setMinSize(const Vec2F& size) { mMinSize = size; } - -const Vec2& WidgetLayout::getSizePolicy() const { return mSizePolicy; } - -void WidgetLayout::setSizePolicy(SizePolicy x, SizePolicy y) { mSizePolicy = { x, y }; } - -RangeF WidgetLayout::pickRange(const RangeF& current, const RangeF& children, const RangeF& parent, bool vertical) const { - RangeF out; - - switch (mSizePolicy[vertical]) { - case SizePolicy::Fixed: out = current; break; - case SizePolicy::Expanding: out = parent; break; - case SizePolicy::Minimal: out = children; break; - } - - out = clampRange(out, children, parent, vertical); - return out; -} - -void WidgetLayout::clampMinMaxSize() { - auto current = getArea(); - current.size.clamp(mMinSize, mMaxSize); - setArea(current); -} - -RangeF WidgetLayout::clampRange(const RangeF& current, const RangeF& child, const RangeF& parent, bool vertical) const { - auto out = current; - - if (!children().empty()) { - auto clampedChild = child; - clampedChild.clamp(parent); - out.clamp(clampedChild, parent); - } else { - out.clamp(parent); - } - - // clamp min max sizes - auto len = clamp(current.size(), mMinSize[vertical], mMaxSize[vertical]); - if (len != current.size()) { - out.resizeFromCenter(len); - } - - return out; -} - -RectF WidgetLayout::getChildrenEnclosure() const { - RectF out; - - if (children().empty()) { - out = { getArea().center(), { 0, 0 } }; - } else { - out = children().front()->getLayout()->getArea(); - for (auto child : children()) { - out.expand(child->getLayout()->getArea()); - } - out.pos += getArea().pos; - } - - return out; -} - -RectF WidgetLayout::getAvailableChildArea() const { - return getArea().relative(); -} - -RectF WidgetLayout::getParentEnclosure() const { - DEBUG_ASSERT(parent()) - if (!parent()) return { { 0, 0 }, mMaxSize }; - auto out = parent()->getLayout()->getAvailableChildArea(); - return out; -} \ No newline at end of file diff --git a/Widgets/private/RootWidget.cpp b/Widgets/private/RootWidget.cpp deleted file mode 100644 index 103b5bfd..00000000 --- a/Widgets/private/RootWidget.cpp +++ /dev/null @@ -1,143 +0,0 @@ - -#include "RootWidget.hpp" - -#include "BasicLayout.hpp" -#include "SimpleLayouts.hpp" - -#include - -using namespace tp; - -WidgetLayout* WidgetManagerInterface::defaultLayout(Widget* widget) { - return new BasicLayout(widget); -} - -RootWidget::RootWidget() { - - addChild(&mRoot); - - mRoot.addChild(&mPopups); - mRoot.setLayout(new OverlayLayout(&mRoot)); - - dynamic_cast(mPopups.getLayout())->setLayoutPolicy(LayoutPolicy::Passive); -} - -void RootWidget::setRootWidget(Widget* widget) { - mRoot.removeChild(mUserRoot); - mRoot.addChild(widget); - - widget->bringToBack(); - - mUserRoot = widget; -} - -void RootWidget::processFrame(const InputState& input, const RectF& screenArea) { - mRoot.setArea(screenArea); - - mUpdateManager.updateTreeToProcess(&mRoot); - - updateAnimations(); - updateAreaCache(&mRoot, true); - - mUpdateManager.processWidgets(&mRoot, input); - - updateAreaCache(&mRoot, true); - - mLayoutManager.adjust(&mRoot); - - updateAreaCache(&mRoot, false); - - // trigger some widgets by moise pointer - mUpdateManager.handleFocusChanges(&mRoot, InputView(input)); - - // check triggered widgets for removal - mUpdateManager.clean(); -} - -bool RootWidget::needsUpdate() const { - return mUpdateManager.isPendingUpdates(); -} - -void RootWidget::drawFrame(Canvas& canvas) { - canvas.rect(mRoot.getAreaT(), RGBA(0, 0, 0, 1)); - drawRecursion(canvas, &mRoot, { 0, 0 }); -} - -void RootWidget::drawRecursion(Canvas& canvas, Widget* active, const Vec2F& pos) { - if (!active->mFlags.get(ENABLED)) return; - - { - const Canvas::Placement placement(canvas, pos - canvas.origin(), active->getArea().size); - if (!canvas.isClippedAway()) { - if (active->getArea().sizeVecW() > active->getArea().pos) { - active->draw(canvas); - } - - for (auto child = active->mDepthOrder.lastNode(); child; child = child->prev) { - drawRecursion(canvas, child->data, pos + child->data->getArea().pos); - } - } - } - - const Canvas::Translation overlay(canvas, pos - canvas.origin()); - active->drawOverlay(canvas); -} - -void RootWidget::setWidgetArea(Widget& widget, const RectF& rect) { - widget.mArea.setTargetRect(rect); - widget.mArea.endAnimation(); -} - -void RootWidget::updateAnimations() { - dfs(&mRoot, [](Widget* widget) { - widget->updateAnimations(); - }); -} - -void RootWidget::updateAreaCache(Widget* iter, bool read) { - if (!iter || !iter->isUpdate()) return; - - if (read) { - iter->mAreaCache = iter->getAreaT(); - } else { - iter->setArea(iter->mAreaCache); - iter->mArea.updateCurrentRect(); - } - - for (auto child : iter->mChildren) { - if (read) { - child->mAreaCache = child->getAreaT(); - } else { - child->setArea(child->mAreaCache); - child->mArea.updateCurrentRect(); - } - } - - for (auto child : iter->mDepthOrder) { - updateAreaCache(child.data(), read); - } -} - -void RootWidget::updateWidget(Widget* widget, const char* reason) { - DEBUG_ASSERT(reason) - mUpdateManager.scheduleUpdate(widget, reason); -} - -void RootWidget::openPopup(Widget* widget) { - auto relativeArea = widget->getAreaT(); - for (auto iter = widget->mParent; iter && iter->mParent; iter = iter->mParent) { - relativeArea.pos += iter->getAreaT().pos; - } - setWidgetArea(*widget, relativeArea); - - mPopups.addChild(widget); - mUpdateManager.lockFocus(widget); -} - -void RootWidget::closePopup(Widget* widget) { - mPopups.removeChild(widget); - mUpdateManager.freeFocus(widget); -} - -void RootWidget::lockFocus(Widget* widget) { mUpdateManager.lockFocus(widget); } -void RootWidget::freeFocus(Widget* widget) { mUpdateManager.freeFocus(widget); } diff --git a/Widgets/private/Widget.cpp b/Widgets/private/Widget.cpp deleted file mode 100644 index 8848dee6..00000000 --- a/Widgets/private/Widget.cpp +++ /dev/null @@ -1,147 +0,0 @@ -#include "Widget.hpp" - -#include - -using namespace tp; - -Widget::Widget() { - mArea.setTargetRect({ 100, 100, 10, 10 }); - setLayout(WidgetManagerInterface::defaultLayout(this)); - mArea.endAnimation(); - - mFlags.set(ENABLED, true); -} - -Widget::~Widget() { - delete mLayout; -} - -void Widget::addChild(Widget* child, bool front) { - if (auto node = mDepthOrder.find(child)) { - node->data->bringToFront(); - return; - } - - if (child->mParent) { - child->mParent->removeChild(child); - } - - mChildren.push_back(child); - - if (front) { - mDepthOrder.pushFront(child); - } else { - mDepthOrder.pushBack(child); - } - - child->mParent = this; - - triggerWidgetUpdate("add child"); - child->triggerWidgetUpdate("new parent"); -} - -void Widget::removeChild(Widget* child) { - auto node = mDepthOrder.find(child); - if (!node) return; - - mDepthOrder.removeNode(node); - mChildren.erase(std::remove(mChildren.begin(), mChildren.end(), child), mChildren.end()); - - triggerWidgetUpdate("removed child"); - child->triggerWidgetUpdate("parent changed"); -} - -void Widget::clear() { - mChildren.clear(); - mDepthOrder.removeAll(); -} - -void Widget::endAnimations() { mArea.endAnimation(); } -bool Widget::processesEvents() const { return false; } -void Widget::updateAnimations() { mArea.updateCurrentRect(); } -bool Widget::needsNextFrame() const { return !mArea.shouldEndTransition(); } - -void Widget::bringToFront() { - if (!mParent) return; - auto& order = mParent->mDepthOrder; - auto node = order.find(this); - DEBUG_ASSERT(node) - order.detach(node); - order.pushFront(node); -} - -void Widget::bringToBack() { - if (!mParent) return; - auto& order = mParent->mDepthOrder; - auto node = order.find(this); - DEBUG_ASSERT(node) - order.detach(node); - order.pushBack(node); -} - -void Widget::mouseEnter() { mFlags.set(IN_FOCUS, true); } -void Widget::mouseLeave() { mFlags.set(IN_FOCUS, false); } - -bool Widget::propagateEventsToChildren() const { return true; } - -void Widget::setLayout(tp::WidgetLayout* layout) { - delete mLayout; - mLayout = layout; - triggerWidgetUpdate("chane layout"); -} - -WidgetLayout* Widget::getLayout() { return mLayout; } - -const WidgetLayout* Widget::getLayout() const { return mLayout; } - -WidgetManagerInterface* Widget::getRoot() { - Widget* iter = mParent; - while (iter && iter->mParent) { - iter = iter->mParent; - } - return dynamic_cast(iter); -} - -void Widget::setAreaCache(const tp::RectF& area) { mAreaCache = area; } - -void Widget::setArea(const RectF& area) { - if (mArea.getTargetRect() == area) return; - - mArea.setTargetRect(area); - triggerWidgetUpdate("new area"); -} - -RectF Widget::getArea() const { return mArea.getCurrentRect(); } - -RectF Widget::getAreaT() const { return mArea.getTargetRect(); } - -RectF Widget::getRelativeArea() const { return { {}, mArea.getCurrentRect().size }; } - -RectF Widget::getRelativeAreaT() const { return { {}, mArea.getTargetRect().size }; } - -void Widget::setSizePolicy(SizePolicy x, SizePolicy y) { - getLayout()->setSizePolicy(x, y); -} - -void Widget::triggerWidgetUpdate(const char* reason) { - if (auto root = getRoot()) { - root->updateWidget(this, reason); - } -} - -void Widget::openPopup(Widget* widget) { - addChild(widget); - getRoot()->openPopup(widget); -} - -void Widget::closePopup(Widget* widget) { - getRoot()->closePopup(widget); -} - -void Widget::lockFocus() { - getRoot()->lockFocus(this); -} - -void Widget::freeFocus() { - getRoot()->freeFocus(this); -} \ No newline at end of file diff --git a/Widgets/private/WidgetApplication.cpp b/Widgets/private/WidgetApplication.cpp deleted file mode 100644 index 5ee14b7c..00000000 --- a/Widgets/private/WidgetApplication.cpp +++ /dev/null @@ -1,20 +0,0 @@ -#include "WidgetApplication.hpp" - -using namespace tp; - -WidgetApplication::WidgetApplication(const ApplicationOptions& options) : - mApplication(*this, options) {} - -void WidgetApplication::setRoot(Widget* widget) { - mRootWidget.setRootWidget(widget); - - tp::RootWidget::setWidgetArea(*widget, { {}, window().contentSize() }); -} - -void WidgetApplication::processFrame(const InputState& input, halnf) { - mRootWidget.processFrame(input, RectF({ 0, 0 }, window().contentSize())); -} - -void WidgetApplication::drawFrame(Canvas& canvas) { mRootWidget.drawFrame(canvas); } - -bool WidgetApplication::wantsFrame() { return mRootWidget.needsUpdate(); } diff --git a/Widgets/private/core/Popup.cpp b/Widgets/private/core/Popup.cpp new file mode 100644 index 00000000..40590e2a --- /dev/null +++ b/Widgets/private/core/Popup.cpp @@ -0,0 +1,81 @@ +#include "Popup.hpp" + +using namespace tp; +using namespace tp::ui; + +namespace { + Placement opposite(Placement placement) { + switch (placement) { + case Placement::Below: return Placement::Above; + case Placement::Above: return Placement::Below; + case Placement::Right: return Placement::Left; + case Placement::Left: return Placement::Right; + case Placement::Over: return Placement::Over; + } + return placement; + } + + Axis mainAxisOf(Placement placement) { + return (placement == Placement::Left || placement == Placement::Right) ? Axis::Horizontal : Axis::Vertical; + } + + halnf alignedStart(halnf anchorStart, halnf anchorLength, halnf popupLength, PlacementAlign align) { + switch (align) { + case PlacementAlign::Start: return anchorStart; + case PlacementAlign::Center: return anchorStart + (anchorLength - popupLength) / 2; + case PlacementAlign::End: return anchorStart + anchorLength - popupLength; + } + return anchorStart; + } + + RectF placeOnSide(const RectF& anchor, Vec2F size, Placement placement, const PopupOptions& options) { + RectF result = makeRect({ 0, 0 }, size); + + if (placement == Placement::Over) { + result.pos = { alignedStart(anchor.pos.x, anchor.size.x, size.x, options.align), anchor.pos.y }; + return result; + } + + const Axis main = mainAxisOf(placement); + const Axis cross = crossAxis(main); + const bool before = placement == Placement::Above || placement == Placement::Left; + + const halnf mainStart = before ? startAlong(anchor, main) - options.gap - along(size, main) + : startAlong(anchor, main) + lengthAlong(anchor, main) + options.gap; + const halnf crossStart = + alignedStart(startAlong(anchor, cross), lengthAlong(anchor, cross), along(size, cross), options.align); + + along(result.pos, main) = mainStart; + along(result.pos, cross) = crossStart; + return result; + } + + bool fitsAlong(const RectF& rect, const RectF& bounds, Axis axis) { + const halnf start = startAlong(rect, axis); + return start >= startAlong(bounds, axis) && + start + lengthAlong(rect, axis) <= startAlong(bounds, axis) + lengthAlong(bounds, axis); + } + + halnf slideInside(halnf start, halnf length, halnf boundsStart, halnf boundsLength) { + const halnf boundsEnd = boundsStart + boundsLength; + if (start + length > boundsEnd) start = boundsEnd - length; + if (start < boundsStart) start = boundsStart; + return start; + } +} + +RectF tp::ui::placePopup(const RectF& anchor, Vec2F popupSize, const RectF& bounds, const PopupOptions& options) { + RectF placed = placeOnSide(anchor, popupSize, options.placement, options); + + if (options.placement != Placement::Over) { + const Axis main = mainAxisOf(options.placement); + if (!fitsAlong(placed, bounds, main)) { + const RectF flipped = placeOnSide(anchor, popupSize, opposite(options.placement), options); + if (fitsAlong(flipped, bounds, main)) placed = flipped; + } + } + + placed.pos.x = slideInside(placed.pos.x, placed.size.x, bounds.pos.x, bounds.size.x); + placed.pos.y = slideInside(placed.pos.y, placed.size.y, bounds.pos.y, bounds.size.y); + return placed; +} diff --git a/Widgets/private/core/SpaceDistribution.cpp b/Widgets/private/core/SpaceDistribution.cpp new file mode 100644 index 00000000..dee5ed47 --- /dev/null +++ b/Widgets/private/core/SpaceDistribution.cpp @@ -0,0 +1,85 @@ +#include "SpaceDistribution.hpp" + +#include + +using namespace tp; +using namespace tp::ui; + +namespace { + constexpr halnf kNegligibleSpace = 0.01f; + + halnf clampToExtent(halnf length, const Extent& extent) { + if (length < extent.minimum) return extent.minimum; + if (length > extent.maximum) return extent.maximum; + return length; + } + + halnf sum(const std::vector& values) { + halnf total = 0; + for (halnf value : values) + total += value; + return total; + } + + halnf growWeight(const Extent& extent, halnf length) { + if (extent.grow <= 0 || length >= extent.maximum) return 0; + return extent.grow; + } + + halnf shrinkWeight(const Extent& extent, halnf length) { + const halnf slack = length - extent.minimum; + return slack > 0 ? slack : 0; + } + + bool distributeOnce(const std::vector& items, std::vector& lengths, halnf freeSpace) { + const bool growing = freeSpace > 0; + + std::vector weights(items.size()); + halnf totalWeight = 0; + + for (size_t index = 0; index < items.size(); index++) { + weights[index] = growing ? growWeight(items[index], lengths[index]) : shrinkWeight(items[index], lengths[index]); + totalWeight += weights[index]; + } + + if (totalWeight <= 0) return false; + + for (size_t index = 0; index < items.size(); index++) { + if (weights[index] <= 0) continue; + const halnf share = freeSpace * weights[index] / totalWeight; + lengths[index] = clampToExtent(lengths[index] + share, items[index]); + } + + return true; + } +} + +std::vector tp::ui::distributeSpace(const std::vector& items, halnf available) { + std::vector lengths(items.size()); + for (size_t index = 0; index < items.size(); index++) { + lengths[index] = clampToExtent(items[index].preferred, items[index]); + } + + for (size_t round = 0; round <= items.size(); round++) { + const halnf freeSpace = available - sum(lengths); + if (std::fabs(freeSpace) < kNegligibleSpace) break; + if (!distributeOnce(items, lengths, freeSpace)) break; + } + + return lengths; +} + +std::vector tp::ui::placeSnapped(const std::vector& lengths, halnf origin, halnf gap) { + std::vector segments(lengths.size()); + halnf cursor = origin; + + for (size_t index = 0; index < lengths.size(); index++) { + const halnf start = std::round(cursor); + const halnf end = std::round(cursor + lengths[index]); + + segments[index] = { start, end - start }; + cursor += lengths[index] + gap; + } + + return segments; +} diff --git a/Widgets/private/layouts/BasicLayout.cpp b/Widgets/private/layouts/BasicLayout.cpp deleted file mode 100644 index c02b14db..00000000 --- a/Widgets/private/layouts/BasicLayout.cpp +++ /dev/null @@ -1,149 +0,0 @@ - -#include "BasicLayout.hpp" -#include "Widget.hpp" - -#include - -using namespace tp; - - -void BasicLayout::pickRect(bool vertical) { - auto current = getArea(); - auto children = getChildrenEnclosure(); - auto parent = getParentEnclosure(); - - children.shrinkFromCenter(-mLayoutMargin, true); - - RectF newArea; - if (vertical) { - auto rangeY = pickRange(current.getRangeY(), children.getRangeY(), parent.getRangeY(), true); - newArea = RectF(current.getRangeX(), rangeY); - } else { - auto rangeX = pickRange(current.getRangeX(), children.getRangeX(), parent.getRangeX(), false); - newArea = RectF(rangeX, current.getRangeY()); - } - - setArea(newArea); -} - -void BasicLayout::clampRect() { - DEBUG_ASSERT(0) - - auto current = getArea(); - auto children = getChildrenEnclosure(); - auto parent = getParentEnclosure(); - - auto rangeX = clampRange(current.getRangeX(), children.getRangeX(), parent.getRangeX(), false); - auto rangeY = clampRange(current.getRangeY(), children.getRangeY(), parent.getRangeY(), true); - - setArea(RectF(rangeX, rangeY)); -} - -void BasicLayout::updateLayout(bool vertical) { - if (children().empty()) return; - - if (vertical) { - switch (mLayoutPolicy) { - case LayoutPolicy::Vertical: adjustLayout(true); break; - case LayoutPolicy::Passive: - case LayoutPolicy::Horizontal: break; - } - } else { - switch (mLayoutPolicy) { - case LayoutPolicy::Horizontal: adjustLayout(false); break; - case LayoutPolicy::Passive: - case LayoutPolicy::Vertical: break; - } - } -} - - -halnf BasicLayout::changeChildSize(tp::Widget* widget, halnf diff, bool vertical) { - auto prevSize = widget->getLayout()->getArea().size[vertical]; - { - auto area = widget->getLayout()->getArea(); - area.size[vertical] += diff; - widget->setAreaCache(area); - - widget->getLayout()->clampMinMaxSize(); - } - auto newSize = widget->getLayout()->getArea().size[vertical]; - return newSize - prevSize; -} - -void BasicLayout::adjustLayout(bool vertical) { - std::vector> contributors; - Vec2F contentSize = { 0, 0 }; - Vec2F availableSize = getArea().size; - - for (auto child : children()) { - child->getLayout()->pickRect(vertical); - - if (child->getLayout()->getSizePolicy()[vertical] == SizePolicy::Expanding) { - contributors.emplace_back( child, true ); - - auto area = child->getLayout()->getArea(); - area.size[vertical] = 0; - child->setAreaCache(area); - child->getLayout()->clampMinMaxSize(); - } - - contentSize += child->getLayout()->getArea().size; - } - - availableSize -= mLayoutGap * ((halnf) children().size() - 1) + mLayoutMargin * 2; - - auto diff = availableSize - contentSize; - - // expand or contract as much as possible - while (!contributors.empty() && (diff[vertical] != 0)) { - auto quota = diff / (halnf) contributors.size(); - - for (auto& contributor : contributors) { - if (!contributor.second) continue; - - // contributor.first->endAnimations(); - auto contribution = changeChildSize(contributor.first, quota[vertical], vertical); - - if (contribution == 0) { - contributor.second = false; - } - - diff[vertical] -= contribution; - } - - contributors.erase( - std::remove_if(contributors.begin(), contributors.end(), [](auto node) { return !node.second; }), - contributors.end() - ); - } - - // set opposite direction size - for (auto child : children()) { - // if (child->getLayout()->getSizePolicy()[!vertical] != SizePolicy::Minimal) { - auto area = child->getLayout()->getArea(); - area.size[!vertical] = getArea().size[!vertical] - mLayoutMargin * 2; - child->setAreaCache(area); - // } - } - - // set pos - halnf iterPos = mLayoutMargin; - for (auto child : children()) { - auto area = child->getLayout()->getArea(); - - area.pos[vertical] = iterPos; - area.pos[!vertical] = mLayoutMargin; - - iterPos += area.size[vertical] + mLayoutGap; - child->setAreaCache(area); - - // child->updateAnimations(); - // child->triggerWidgetUpdate("layout changed"); - } -} - -RectF BasicLayout::getAvailableChildArea() const { - auto out = WidgetLayout::getAvailableChildArea(); - return out.shrinkFromCenter(mLayoutMargin, true); -} \ No newline at end of file diff --git a/Widgets/private/layouts/BoxLayout.cpp b/Widgets/private/layouts/BoxLayout.cpp new file mode 100644 index 00000000..b310d936 --- /dev/null +++ b/Widgets/private/layouts/BoxLayout.cpp @@ -0,0 +1,141 @@ +#include "BoxLayout.hpp" + +#include "SpaceDistribution.hpp" + +using namespace tp; +using namespace tp::ui; + +BoxLayout::BoxLayout(Axis axis, halnf gap) : + mAxis(axis), + mGap(gap) {} + +void BoxLayout::setGap(halnf gap) { + mGap = gap; + invalidate(); +} + +void BoxLayout::setCrossAlign(CrossAlign align) { + mCrossAlign = align; + invalidate(); +} + +void BoxLayout::setMainAlign(MainAlign align) { + mMainAlign = align; + invalidate(); +} + +Extent BoxLayout::measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const { + return axis == mAxis ? measureMainAxis(context, crossLength) : measureCrossAxis(context, crossLength); +} + +Extent BoxLayout::measureMainAxis(const LayoutContext& context, halnf crossLength) const { + Extent total = { 0, 0, 0, 0 }; + + for (const Widget* child : context.children()) { + const Extent childExtent = context.measure(*child, mAxis, crossLength); + total.minimum += childExtent.minimum; + total.preferred += childExtent.preferred; + total.maximum += childExtent.maximum; + total.grow += childExtent.grow; + } + + const halnf gaps = totalGap(context); + total.minimum += gaps; + total.preferred += gaps; + total.maximum = total.maximum + gaps < kUnbounded ? total.maximum + gaps : kUnbounded; + return total; +} + +Extent BoxLayout::measureCrossAxis(const LayoutContext& context, halnf mainLength) const { + const Axis cross = crossAxis(mAxis); + Extent total = { 0, 0, kUnbounded, 0 }; + + std::vector mainExtents; + for (const Widget* child : context.children()) + mainExtents.push_back(context.measure(*child, mAxis, kUnbounded)); + + const bool mainLengthKnown = mainLength < kUnbounded; + const std::vector mainLengths = + mainLengthKnown ? distributeSpace(mainExtents, mainLength - totalGap(context)) : std::vector(); + + for (size_t index = 0; index < context.children().size(); index++) { + const halnf childMain = mainLengthKnown ? mainLengths[index] : mainExtents[index].preferred; + const Extent childCross = context.measure(*context.children()[index], cross, childMain); + + if (childCross.minimum > total.minimum) total.minimum = childCross.minimum; + if (childCross.preferred > total.preferred) total.preferred = childCross.preferred; + if (childCross.grow > total.grow) total.grow = childCross.grow; + } + + return total; +} + +void BoxLayout::arrangeChildren(LayoutContext& context, const RectF& contentArea) { + const auto& children = context.children(); + if (children.empty()) return; + + const Axis cross = crossAxis(mAxis); + const halnf available = lengthAlong(contentArea, mAxis) - totalGap(context); + const halnf crossAvailable = lengthAlong(contentArea, cross); + + std::vector mainExtents; + mainExtents.reserve(children.size()); + for (const Widget* child : children) + mainExtents.push_back(context.measure(*child, mAxis, crossAvailable)); + + const std::vector lengths = distributeSpace(mainExtents, available); + + halnf used = 0; + for (halnf length : lengths) + used += length; + const halnf leftover = available - used > 0 ? available - used : 0; + + halnf start = startAlong(contentArea, mAxis); + halnf gap = mGap; + switch (mMainAlign) { + case MainAlign::Start: break; + case MainAlign::Center: start += leftover / 2; break; + case MainAlign::End: start += leftover; break; + case MainAlign::SpaceBetween: + if (children.size() > 1) gap += leftover / (halnf) (children.size() - 1); + break; + } + + const std::vector segments = placeSnapped(lengths, start, gap); + + for (size_t index = 0; index < children.size(); index++) { + const Extent crossExtent = context.measure(*children[index], cross, segments[index].length); + const halnf crossLength = crossLengthFor(crossExtent, crossAvailable); + + RectF rect; + setSpanAlong(rect, mAxis, segments[index].start, segments[index].length); + setSpanAlong(rect, cross, startAlong(contentArea, cross) + crossStartFor(crossLength, crossAvailable), crossLength); + context.place(*children[index], rect); + } +} + +halnf BoxLayout::totalGap(const LayoutContext& context) const { + const size_t count = context.children().size(); + return count > 1 ? mGap * (halnf) (count - 1) : 0; +} + +halnf BoxLayout::crossLengthFor(const Extent& extent, halnf available) const { + const bool stretches = mCrossAlign == CrossAlign::Stretch || extent.grow > 0; + halnf length = stretches ? available : extent.preferred; + + if (length > extent.maximum) length = extent.maximum; + if (length < extent.minimum) length = extent.minimum; + if (length > available) length = available; + return length; +} + +halnf BoxLayout::crossStartFor(halnf length, halnf available) const { + const halnf leftover = available - length; + switch (mCrossAlign) { + case CrossAlign::Stretch: + case CrossAlign::Start: return 0; + case CrossAlign::Center: return leftover / 2; + case CrossAlign::End: return leftover; + } + return 0; +} diff --git a/Widgets/private/layouts/DockLayout.cpp b/Widgets/private/layouts/DockLayout.cpp deleted file mode 100644 index 62f3023a..00000000 --- a/Widgets/private/layouts/DockLayout.cpp +++ /dev/null @@ -1,294 +0,0 @@ -#include "DockLayout.hpp" - -using namespace tp; - -DockLayout::DockLayout(Widget* widget) : - WidgetLayout(widget) { - mSideWidgets[0].side = LEFT; - mSideWidgets[1].side = TOP; - mSideWidgets[2].side = RIGHT; - mSideWidgets[3].side = BOTTOM; -} - -bool DockLayout::setCenterWidget(Widget* widget) { - if (mCenterWidget) return false; - mCenterWidget = widget; - return true; -} - -bool DockLayout::dockWidget(Widget* widget, Side side) { - if (sideExists(side) || side == Side::NONE) return false; - - auto& sideWidget = mSideWidgets[side]; - sideWidget.widget = widget; - - for (auto& order : mSideWidgets) { - if (order.order == -1) { - order.order = side; - break; - } - } - - sideWidget.hidden = false; - sideWidget.areaBeforeDocking = widget->getArea(); - - return true; -} - -bool DockLayout::undockWidget(Side side) { - if (!sideExists(side) || (side == Side::NONE)) return false; - - bool removed = false; - for (ualni i = 0; i < 3; i++) { - if (mSideWidgets[i].order == side) { - removed = true; - } - if (removed) { - swapV(mSideWidgets[i].order, mSideWidgets[i + 1].order); - } - } - mSideWidgets[3].order = -1; - mSideWidgets[side].widget = nullptr; - - return true; -} - -void DockLayout::toggleWidgetVisibility(Side side) { - DEBUG_ASSERT(sideExists(side)) - mSideWidgets[side].hidden = !mSideWidgets[side].hidden; -} - -void DockLayout::calculateSideAreas() { - auto startArea = getArea().relative(); - - if (startArea.size.x <= 0 || startArea.size.y <= 0) return; - - for (auto& sideWidget : mSideWidgets) { - const auto side = sideWidget.order; - - if (side == -1) break; - if (!isSideVisible(Side(side))) continue; - - bool vertical = side == TOP || side == BOTTOM; - bool opposite = side == BOTTOM || side == RIGHT; - - auto& sideSize = mSideWidgets[side].absoluteSize; - - auto factor = sideSize / startArea.size[vertical]; - if (opposite) factor = factor * -1 + 1; - auto& area = mSideWidgets[side].area; - - if (!vertical) { - const auto first = startArea.splitByFactorHL(factor); - const auto second = startArea.splitByFactorHR(factor); - area = side == LEFT ? first : second; - startArea = side == LEFT ? second : first; - } else { - const auto first = startArea.splitByFactorVT(factor); - const auto second = startArea.splitByFactorVB(factor); - area = side == TOP ? first : second; - startArea = side == TOP ? second : first; - } - - area = area.shrink(mPadding); - } - - mCenterArea = startArea.shrink(mPadding); -} - -void DockLayout::calculateResizeHandles() { - RectF rec; - RectF area = getArea().relative(); - - for (auto& sideWidget : mSideWidgets) { - if (!sideWidget.widget) continue; - - auto& sideSize = sideWidget.absoluteSize; - auto& resizeHandle = sideWidget.resizeHandle; - - if (resizeHandle.end < mSideSizePadding * 2) { - // sideSize = resizeHandle.end / 2.f; - } else { - sideSize = clamp(sideSize, resizeHandle.start + mSideSizePadding, resizeHandle.end - mSideSizePadding); - } - } - - if (isSideVisible(LEFT)) { - auto& side = mSideWidgets[LEFT]; - rec = { side.area.p4(), { mPadding * 2, side.area.size.y } }; - side.resizeHandle.area = rec; - side.resizeHandle.start = 0; - side.resizeHandle.end = mCenterArea.p3().x; - } - if (isSideVisible(RIGHT)) { - auto& side = mSideWidgets[RIGHT]; - rec = { side.area.p1(), { mPadding * 2, side.area.size.y } }; - rec.x -= mPadding * 2; - side.resizeHandle.area = rec; - side.resizeHandle.start = 0; - side.resizeHandle.end = (area.p3() - mCenterArea.p1()).x; - } - if (isSideVisible(TOP)) { - auto& side = mSideWidgets[TOP]; - rec = { side.area.p2(), { side.area.size.x, mPadding * 2 } }; - side.resizeHandle.area = rec; - side.resizeHandle.start = 0; - side.resizeHandle.end = mCenterArea.p2().y; - } - if (isSideVisible(BOTTOM)) { - auto& side = mSideWidgets[BOTTOM]; - rec = { side.area.p1(), { side.area.size.x, mPadding * 2 } }; - rec.y -= mPadding * 2; - side.resizeHandle.area = rec; - side.resizeHandle.start = 0; - side.resizeHandle.end = (area.p3() - mCenterArea.p1()).y; - } -} - -void DockLayout::updateChildSideWidgets() { - for (ualni i = 0; i < 4; i++) { - if (!sideExists(Side(i))) continue; - auto widget = mSideWidgets[i].widget; - - if (!isSideVisible(Side(i))) { - // FIXME : widget->mEnable = false; - } else { - widget->setAreaCache(mSideWidgets[i].area); - // FIXME : widget->mEnable = true; - } - } - - if (mCenterWidget) mCenterWidget->setAreaCache(mCenterArea); -} - -void DockLayout::calculateHeaderAreas() { - for (ualni i = 0; i < 4; i++) { - if (!isSideVisible(Side(i))) continue; - auto& area = mSideWidgets[i].area; - const auto factor = mHeaderSize / area.size.y; - mSideWidgets[i].headerArea = area.splitByFactorVT(factor); - area = area.splitByFactorVB(factor); - - mSideWidgets[i].headerArea.size.y -= mPadding; - } -} - -ualni DockLayout::getVisibleSidesSize() const { - ualni out = 0; - for (ualni i = 0; i < 4; i++) { - if (isSideVisible(Side(i))) out++; - } - return out; -} - -auto DockLayout::getSideFromWidget(Widget* widget) -> Side { - for (auto& sideWidget : mSideWidgets) { - if (sideWidget.widget == widget) return sideWidget.side; - } - return DockLayout::NONE; -} - -void DockLayout::updateLayout(bool vertical) { - for (auto child : children()) { - child->getLayout()->pickRect(vertical); - } - - if (vertical) return; - adjustChildrenRect(); -} - -void DockLayout::adjustChildrenRect() { - calculateSideAreas(); - calculateResizeHandles(); - updateChildSideWidgets(); -} - -void DockLayout::updatePreviewSide(const Vec2F& pointer) { - const auto handleSize = min(mCenterArea.size.x, mCenterArea.size.y) * mHandleFactor; - - for (auto& sideWidget : mSideWidgets) { - if (sideWidget.widget) continue; - - switch (sideWidget.side) { - case TOP: sideWidget.area = mCenterArea.splitByFactorVT(mHandleSplitFactor); break; - case BOTTOM: sideWidget.area = mCenterArea.splitByFactorVB(1 - mHandleSplitFactor); break; - case LEFT: sideWidget.area = mCenterArea.splitByFactorHL(mHandleSplitFactor); break; - case RIGHT: sideWidget.area = mCenterArea.splitByFactorHR(1 - mHandleSplitFactor); break; - default: break; - } - - sideWidget.area = sideWidget.area.shrink(mPadding * 2); - sideWidget.previewHandleArea = sideWidget.area.getSizedFromCenter({ handleSize, handleSize }); - } - - mPreviewArea = {}; - mPreviewSide = NONE; - - for (auto& sideWidget : mSideWidgets) { - if (sideWidget.widget) continue; - - if (sideWidget.previewHandleArea.isInside(pointer)) { - mPreviewArea = sideWidget.area; - mPreviewSide = sideWidget.side; - } - } -} - -DockLayout::Side DockLayout::getPreviewSide() { - return mPreviewSide; -} - -void DockLayout::updateResizeHover(const Vec2F& pointer) { - for (auto& sideWidget : mSideWidgets) { - sideWidget.resizeHandle.hover = sideWidget.resizeHandle.area.isInside(pointer); - } -} - -void DockLayout::updateResize(const Vec2F& pointerDelta) { - if (!mResizing) return; - - // do the resizing - for (auto& sideWidget : mSideWidgets) { - if (!sideWidget.resizeHandle.active) continue; - halnf delta = pointerDelta[(sideWidget.side == TOP || sideWidget.side == BOTTOM)]; - if (sideWidget.side == BOTTOM || sideWidget.side == RIGHT) { - delta *= -1; - } - sideWidget.absoluteSize += delta; - } -} - -bool DockLayout::startResize(const Vec2F& pointer) { - for (auto& sideWidget : mSideWidgets) { - if (sideWidget.resizeHandle.hover) { - sideWidget.resizeHandle.active = true; - mResizing = true; - return true; - } - } - return false; -} - -void DockLayout::endResize() { - for (auto& sideWidget : mSideWidgets) { - sideWidget.resizeHandle.active = false; - } - mResizing = false; -} - -bool DockLayout::isSideVisible(DockLayout::Side side) const { - return sideExists(side) && !mSideWidgets[side].hidden; -} - -bool DockLayout::sideExists(DockLayout::Side side) const { - return mSideWidgets[side].widget; -} - -const std::vector& DockLayout::getDockedWidgets() { - mDockedWidgets.resize(5); - for (auto i = 0; i < 4; i++) { - mDockedWidgets[i] = mSideWidgets[i].widget; - } - mDockedWidgets[4] = mCenterWidget; - return mDockedWidgets; -} \ No newline at end of file diff --git a/Widgets/private/layouts/FloatingLayout.cpp b/Widgets/private/layouts/FloatingLayout.cpp deleted file mode 100644 index 229de432..00000000 --- a/Widgets/private/layouts/FloatingLayout.cpp +++ /dev/null @@ -1,52 +0,0 @@ -#include "FloatingLayout.hpp" -#include "Widget.hpp" - -using namespace tp; - -void FloatingLayout::pickRect(bool vertical) { - if (mIsFloating) { - auto area = getArea(); - - if (mIsResizing) { - mPointerCurrent.clamp(mMinSize, mMaxSize); - - area.size[vertical] = (mPointerCurrent + mHandleSize / 2.f)[vertical]; - - for (auto child : children()) { - child->triggerWidgetUpdate("floating menu resized"); - } - - } else if (mIsFloating) { - area.pos[vertical] += (mPointerCurrent - mPointerStart)[vertical]; - } - - setArea(area); - } - - clampMinMaxSize(); -} - -RectF FloatingLayout::resizeHandleRect() { - auto area = getAnimatedArea().relative(); - area.pos = area.p3() - mHandleSize; - area.size = mHandleSize; - area.shrink(mHandlePadding); - return area; -} - -void FloatingLayout::startAction(const Vec2F& pointer) { - mPointerStart = pointer; - - mIsFloating = true; - if (resizeHandleRect().isInside(mPointerStart)) { - mIsResizing = true; - } -} - -void FloatingLayout::updateAction(const Vec2F& pointer) { - mPointerCurrent = pointer; -} - -void FloatingLayout::endAction() { - mIsResizing = mIsFloating = false; -} \ No newline at end of file diff --git a/Widgets/private/layouts/ScrollableLayout.cpp b/Widgets/private/layouts/ScrollableLayout.cpp deleted file mode 100644 index dd2a1cf3..00000000 --- a/Widgets/private/layouts/ScrollableLayout.cpp +++ /dev/null @@ -1,53 +0,0 @@ -#include "ScrollableLayout.hpp" - -// fixme : this dependency should be removed -#include "ScrollableWidget.hpp" - -using namespace tp; - -void ScrollableLayout::updateLayout(bool vertical) { - if (vertical) return; - - // TODO : make better interface to get scroller and content widget - if (children().size() != 2) return; - - auto scroller = dynamic_cast(children().front()); - auto content = children().back(); - - if (!scroller || !content) return; - - updateWidgetRects(getArea().relative(), content, scroller); -} - -void ScrollableLayout::updateWidgetRects(const RectF& area, Widget* content, ScrollableBarWidget* scroller) const { - bool dir = scroller->getDirection(); - - // update content - // auto contentItems = content->getLayout()->getChildrenEnclosure(); - // content->getLayout()->setArea(contentItems.shrinkFromCenter(-10, true)); - content->getLayout()->pickRect(dir); - - auto sizeFactor = (area.size[dir] / content->getLayout()->getArea().size[dir]); - auto splitFactor = 1 - ((sizeFactor > 1.f) ? 0 : mScrollerSize / area.size[!dir]); - - auto holderArea = dir ? area.splitByFactorHL(splitFactor) : area.splitByFactorVT(splitFactor); - auto contentArea = content->getLayout()->getArea(); - auto scrollerArea = dir ? area.splitByFactorHR(splitFactor) : area.splitByFactorVB(splitFactor); - - scroller->updateSizeFactor(sizeFactor); - - contentArea.pos[dir] = -scroller->getPosFactor() * contentArea.size[dir]; - contentArea.size[!dir] = holderArea.size[!dir]; - contentArea.pos[!dir] = 0; - - scroller->getLayout()->setArea(scrollerArea); - content->getLayout()->setArea(contentArea); -} - -RectF ScrollableLayout::getAvailableChildArea() const { - auto out = getArea(); - // dont constrain on scroll axis - out.pos = -FLT_MAX / 4; - out.size = FLT_MAX / 2; - return out; -} diff --git a/Widgets/private/layouts/SimpleLayouts.cpp b/Widgets/private/layouts/SimpleLayouts.cpp deleted file mode 100644 index c65ea62d..00000000 --- a/Widgets/private/layouts/SimpleLayouts.cpp +++ /dev/null @@ -1,32 +0,0 @@ - -#include "SimpleLayouts.hpp" -#include "Widget.hpp" - - -using namespace tp; - -void OverlayLayout::updateLayout(bool vertical) { - if (vertical) return; - - if (children().empty()) return; - - for (auto child : children()) { - child->getLayout()->setArea(getArea().relative()); - } -} - -void ToolBarLayout::updateLayout(bool vertical) { - if (vertical) return; - - if (children().size() != 2) return; - - auto toolbar = children().back(); - auto content = children().front(); - - auto factor = mToolBarHeight / getArea().w; - - auto area = getArea().relative(); - - toolbar->getLayout()->setArea(area.splitByFactorVT(factor)); - content->getLayout()->setArea(area); -} \ No newline at end of file diff --git a/Widgets/private/managers/LayoutManager.cpp b/Widgets/private/managers/LayoutManager.cpp deleted file mode 100644 index 59dcd0da..00000000 --- a/Widgets/private/managers/LayoutManager.cpp +++ /dev/null @@ -1,101 +0,0 @@ -#include "LayoutManager.hpp" - -#include "DockLayout.hpp" -#include "ScrollableLayout.hpp" - -#include - -using namespace tp; - -void LayoutManager::adjust(Widget* root) { - for (auto i : IterRange(2)) { - mVertical = bool(i); - - mDepGraph.clear(); - mLayOrder.clear(); - mRoots.clear(); - - findDependencies(root); - - // TODO : implement better topological-sort - for (auto iterRoot : mRoots) { - topologicalSort(iterRoot); - } - - adjustLayouts(); - } -} - -void LayoutManager::findDependencies(Widget* root) { - mDepGraph.insert({ root, {} }); - - Widget::dfs(root, [this](Widget* widget) { mDepGraph.insert({ widget, {} }); }); - - for (auto& [widget, deps] : mDepGraph) { - if (!widget->isUpdate()) continue; - - for (auto child : widget->mChildren) { - // if (!child->isUpdate()) continue; - - mDepGraph.insert({ child, {} }); - - if (auto depOrder = getLayoutOrder(widget->getLayout(), child->getLayout())) { - if (depOrder > 0) { - deps.depends.push_back(child); - mDepGraph[child].references++; - } else { - mDepGraph[child].depends.push_back(widget); - mDepGraph[widget].references++; - } - } - } - } - - // find root - for (auto& node : mDepGraph) { - if (node.second.references == 0) { - mRoots.push_back(node.first); - } - } -} - -void LayoutManager::topologicalSort(Widget* root, int depth) { - mDepGraph.at(root).depth = max(mDepGraph.at(root).depth, depth); - - for (auto& dep : mDepGraph.at(root).depends) { - topologicalSort(dep, depth + 1); - } -} - -void LayoutManager::adjustLayouts() { - for (auto& iter : mDepGraph) { - mLayOrder.emplace_back(iter.first, iter.second.depth); - } - - std::sort(mLayOrder.begin(), mLayOrder.end(), [](auto first, auto second){ - return first.second > second.second; - }); - - for (auto& [iter, _] : mLayOrder) { - iter->getLayout()->updateLayout(mVertical); - } -} - -static int sizePolicyDep[3][3] = { - // f s e child - { 1, 1, 1 }, // parent fixed - { 1, 1, 1 }, // parent shrink - { 1, 1, 1 }, // parent expand -}; - -int LayoutManager::getLayoutOrder(WidgetLayout* parent, WidgetLayout* child) const { - if (!parent || !child) return 0; - - auto policyParent = parent->getSizePolicy()[mVertical]; - auto policyChild = parent->getSizePolicy()[mVertical]; - - if (dynamic_cast(parent)) return -1; - // if (dynamic_cast(parent)) return -1; - - return sizePolicyDep[int(policyParent)][int(policyChild)]; -} diff --git a/Widgets/private/managers/UpdateManager.cpp b/Widgets/private/managers/UpdateManager.cpp deleted file mode 100644 index 9040df59..00000000 --- a/Widgets/private/managers/UpdateManager.cpp +++ /dev/null @@ -1,190 +0,0 @@ -#include "UpdateManager.hpp" - -using namespace tp; - -void UpdateManager::processWidgets(Widget* root, const InputState& input) { - processFocusItems(input); - processActiveTree(root, input, { 0, 0 }); -} - -void UpdateManager::clean() { - erase_if(mTriggeredWidgets, [](auto iter) { - auto widget = iter.first; - auto flag = iter.second; - - if (!flag) return false; - - // if (mWidgetsToProcess.find(widget) == mWidgetsToProcess.end()) return false; - widget->updateAnimations(); - auto end = !widget->needsNextFrame(); - if (end) { - widget->endAnimations(); - } - return end; - }); -} - -void UpdateManager::scheduleUpdate(Widget* widget, const char*) { - mTriggeredWidgets.insert({ widget, false }); -} - -void UpdateManager::updateTreeToProcess(Widget* root) { - Widget::dfs(root, [](Widget* widget) { - widget->mFlags.set(Widget::NEEDS_UPDATE, false); - }); - - for (auto& [widget, flag] : mTriggeredWidgets) { - flag = true; - - for (auto iter = widget; iter && iter->mParent; iter = iter->mParent) { - iter->mFlags.set(Widget::NEEDS_UPDATE, true); - } - } -} - -void UpdateManager::getWidgetPath(Widget* widget, std::vector& out) { - if (!widget) return; - - for (auto iter = widget; iter && iter->mParent; iter = iter->mParent) { - out.push_back(iter); - } - - for (auto i = 0; i < out.size() / 2; i++) { - swapV(out[i], out[out.size() - i - 1]); - } -} - -void UpdateManager::handleFocusChanges(Widget* root, const InputView& input) { - auto prevFocus = mInFocusWidget; - - mInFocusWidget = nullptr; - - findFocusWidget(root, &mInFocusWidget, input.getPointer()); - - if (mFocusLockWidget) { - bool hasLockedWidget = false; - for (auto iter = mInFocusWidget; iter; iter = iter->mParent) { - if (iter == mFocusLockWidget) { - hasLockedWidget = true; - break; - } - } - if (!hasLockedWidget) { - mInFocusWidget = mFocusLockWidget; - } - } - - // if (mInFocusWidget == prevFocus) return; - if (mInFocusWidget) scheduleUpdate(mInFocusWidget, "focus entered"); - - if (!mInFocusWidget && !prevFocus) return; - - std::vector path2; - getWidgetPath(mInFocusWidget, path2); - size_t propLen2 = path2.size(); - for (auto i = 0; i < path2.size(); i++) { - if (!path2[i]->propagateEventsToChildren()) { - propLen2 = i + 1; - break; - } - } - - std::vector path1; - getWidgetPath(prevFocus, path1); - size_t propLen1 = path1.size(); - for (auto i = 0; i < path1.size(); i++) { - if (!path1[i]->propagateEventsToChildren()) { - propLen1 = i + 1; - break; - } - } - - int mostCommonIdx = 0; - if (!(path1.empty() || path2.empty())) { - while (mostCommonIdx < min(path1.size(), path2.size()) && path1[mostCommonIdx] == path2[mostCommonIdx]) { - mostCommonIdx++; - } - } - mostCommonIdx--; - - for (auto i = 0; i < path1.size(); i++) { - path1[i]->mFlags.set(Widget::IN_FOCUS, false); - if (i > mostCommonIdx && i < propLen1) path1[i]->mouseLeave(); - } - - for (auto i = 0; i < path2.size(); i++) { - path2[i]->mFlags.set(Widget::IN_FOCUS, true); - if (i > mostCommonIdx && i < propLen2) path2[i]->mouseEnter(); - } -} - -void UpdateManager::findFocusWidget(Widget* iter, Widget** focus, const Vec2F& pointer) { - if (!iter->mArea.getTargetRect().isInside(pointer) || !iter->mFlags.get(Widget::ENABLED)) return; - - if (iter->processesEvents()) { - *focus = iter; - } - - for (auto child = iter->mDepthOrder.lastNode(); child; child = child->prev) { - findFocusWidget(child->data, focus, pointer - iter->mArea.getTargetRect().pos); - } -} - -void UpdateManager::processActiveTree(Widget* iter, const InputState& input, Vec2F parent) { - if (!iter || !iter->isUpdate()) return; - - auto current = parent + iter->getAreaT().pos; - - if (!iter->mFlags.get(Widget::IN_FOCUS)) { - procWidget(iter, InputView(input, current, InputView::Buttons::Hidden)); - } - - for (auto child : iter->mDepthOrder) { - processActiveTree(child.data(), input, current); - } -} - -void UpdateManager::processFocusItems(const InputState& input) { - if (!mInFocusWidget) return; - - std::vector path; - getWidgetPath(mInFocusWidget, path); - - size_t len = path.size(); - for (auto i = 0; i < len; i++) { - if (!path[i]->propagateEventsToChildren()) { - len = i + 1; - break; - } - } - - std::vector widgetGlobalPos(path.size()); - - widgetGlobalPos[0] = 0; - for (auto widget = 0; widget < path.size() - 1; widget++) { - widgetGlobalPos[widget + 1] = widgetGlobalPos[widget] + path[widget + 1]->getAreaT().pos; - // path[widget]->mGlobalPoint = widgetGlobalPos[widget]; - } - - bool eventsProcessed = false; - - for (int iter = (int) len - 1; iter >= 0; iter--) { - auto widget = path[iter]; - - const bool receivesButtons = !eventsProcessed && widget->processesEvents(); - const InputView::Buttons buttons = receivesButtons ? InputView::Buttons::Visible : InputView::Buttons::Hidden; - procWidget(widget, InputView(input, widgetGlobalPos[iter], buttons)); - eventsProcessed = eventsProcessed || receivesButtons; - } -} - -void UpdateManager::procWidget(Widget* widget, const InputView& input) { widget->process(input); } - -void UpdateManager::lockFocus(tp::Widget* widget) { - mFocusLockWidget = widget; -} - -void UpdateManager::freeFocus(tp::Widget* widget) { - // DEBUG_ASSERT(mFocusLockWidget == widget) - mFocusLockWidget = nullptr; -} \ No newline at end of file diff --git a/Widgets/private/widgets/AnimationTestWidget.cpp b/Widgets/private/widgets/AnimationTestWidget.cpp deleted file mode 100644 index 6c93c50f..00000000 --- a/Widgets/private/widgets/AnimationTestWidget.cpp +++ /dev/null @@ -1,28 +0,0 @@ -#include "AnimationTestWidget.hpp" - -using namespace tp; - -void AnimationTestWidget::process(const InputView& events) { - if (events.isDown(PointerButton::Left)) { - mTestSpring.getStart().setTargetPosition(events.getPointer()); - } - - if (events.isDown(PointerButton::Right)) { - mTestSpring.getEnd().setTargetPosition(events.getPointer()); - } - - mTestSpring.updateCurrentRect(); - - if (mTestSpring.shouldEndTransition()) { - mTestSpring.endAnimation(); - } -} - -void AnimationTestWidget::draw(Canvas& canvas) { - canvas.rect(getRelativeArea(), { 0, 0, 0, 1 }, 10); - canvas.rect(mTestSpring.getCurrentRect(), RGBA(1.f), 10); -} - -bool AnimationTestWidget::needsNextFrame() const { - return Widget::needsNextFrame() || !mTestSpring.shouldEndTransition(); -} diff --git a/Widgets/private/widgets/ColorPickerWidget.cpp b/Widgets/private/widgets/ColorPickerWidget.cpp deleted file mode 100644 index 08a598c7..00000000 --- a/Widgets/private/widgets/ColorPickerWidget.cpp +++ /dev/null @@ -1,154 +0,0 @@ -#include "ColorPickerWidget.hpp" - -#include -#include -#include - -using namespace tp; - -namespace { - constexpr halnf kTwoPi = 2 * std::numbers::pi_v; - constexpr halnf kThirdTurn = kTwoPi / 3; - constexpr halnf kRingThickness = 20; - constexpr halnf kWheelMargin = 5; - constexpr halnf kTriangleInset = 6; - constexpr int kRingSegments = 6; - - const RGBA kEdgeShade = { 0, 0, 0, 0.25f }; - const RGBA kMarkerColor = { 1, 1, 1, 0.75f }; - const RGBA kMarkerShadow = { 0, 0, 0, 0.25f }; - const RGBA kTransparent = { 0, 0, 0, 0 }; - - RGBA colorOf(halnf hue, halnf saturation, halnf value) { - RGBA color; - color = HSV(hue, saturation, value); - return color; - } - - Vec2F onCircle(Vec2F center, halnf radius, halnf angle) { return center + Vec2F(radius, 0.0f).rotated(angle); } - - Vec2F saturationValueAt(Vec2F unitTrianglePoint) { - const halnf whiteToHue = std::clamp((1 - unitTrianglePoint.x / std::cos(kThirdTurn)) / 3, 0.0f, 1.0f); - const halnf blackToWhite = (unitTrianglePoint.y / std::sin(kThirdTurn) + 1) / 2; - const halnf insideTriangle = std::clamp(blackToWhite, whiteToHue / 2, 1 - whiteToHue / 2); - - const halnf value = insideTriangle + whiteToHue / 2; - const halnf saturation = value > 0 ? whiteToHue / value : 0; - return { std::clamp(saturation, 0.0f, 1.0f), std::clamp(value, 0.0f, 1.0f) }; - } - - Vec2F unitTrianglePointOf(halnf saturation, halnf value) { - const halnf whiteToHue = saturation * value; - const halnf blackToWhite = value - whiteToHue / 2; - return { (1 - 3 * whiteToHue) * std::cos(kThirdTurn), (2 * blackToWhite - 1) * std::sin(kThirdTurn) }; - } -} - -halnf RGBPickerWidget::Geometry::innerRadius() const { return outerRadius - kRingThickness; } - -halnf RGBPickerWidget::Geometry::triangleRadius() const { return innerRadius() - kTriangleInset; } - -RGBPickerWidget::Geometry RGBPickerWidget::geometry() const { - const RectF area = getArea().relative(); - return { area.center(), std::min(area.z, area.w) / 2 - kWheelMargin }; -} - -RGBPickerWidget::Part RGBPickerWidget::partAt(Vec2F point) const { - const Geometry wheel = geometry(); - const halnf distance = (halnf) (point - wheel.center).length(); - - if (distance > wheel.outerRadius) { - return Part::Outside; - } - if (distance >= wheel.innerRadius()) { - return Part::HueRing; - } - return Part::SaturationValueTriangle; -} - -void RGBPickerWidget::pick(Vec2F point) { - const Geometry wheel = geometry(); - const Vec2F offset = point - wheel.center; - - if (mDragging == Part::HueRing) { - const halnf angle = offset.angle(); - mColor.h = angle >= 0 ? angle : angle + kTwoPi; - } else if (mDragging == Part::SaturationValueTriangle) { - const Vec2F saturationValue = saturationValueAt(offset.rotated(-mColor.h) / wheel.triangleRadius()); - mColor.s = saturationValue.x; - mColor.v = saturationValue.y; - } -} - -void RGBPickerWidget::process(const InputView& events) { - if (events.isPressed(PointerButton::Left)) { - mDragging = partAt(events.getPointer()); - pick(events.getPointer()); - lockFocus(); - } - - if (events.isReleased(PointerButton::Left)) { - mDragging = Part::Outside; - freeFocus(); - } - - if (events.getPointerDelta().length() > EPSILON && events.isDown(PointerButton::Left)) { - pick(events.getPointer()); - } -} - -void RGBPickerWidget::draw(Canvas& canvas) { - const Geometry wheel = geometry(); - - drawHueRing(canvas, wheel); - drawHueMarker(canvas, wheel); - drawSaturationValueTriangle(canvas, wheel); - drawSaturationValueMarker(canvas, wheel); -} - -void RGBPickerWidget::drawHueRing(Canvas& canvas, const Geometry& wheel) const { - const halnf seamOverlap = 0.5f / wheel.outerRadius; - const halnf middleRadius = (wheel.innerRadius() + wheel.outerRadius) / 2; - - for (int segment = 0; segment < kRingSegments; segment++) { - const halnf from = (halnf) segment / kRingSegments * kTwoPi; - const halnf to = (halnf) (segment + 1) / kRingSegments * kTwoPi; - const Canvas::LinearGradient hues = { - onCircle(wheel.center, middleRadius, from), - onCircle(wheel.center, middleRadius, to), - colorOf(from, 1, 1), - colorOf(to, 1, 1), - }; - canvas.ringSegment(wheel.center, wheel.innerRadius(), wheel.outerRadius, from - seamOverlap, to + seamOverlap, hues); - } - - canvas.circleOutline(wheel.center, wheel.innerRadius(), kEdgeShade, 1); - canvas.circleOutline(wheel.center, wheel.outerRadius + 1, kEdgeShade, 1); -} - -void RGBPickerWidget::drawHueMarker(Canvas& canvas, const Geometry& wheel) const { - const halnf middleRadius = (wheel.innerRadius() + wheel.outerRadius) / 2; - const Vec2F marker = onCircle(wheel.center, middleRadius, mColor.h); - - canvas.radialGlow(marker, kRingThickness / 2, kRingThickness / 2 + 4, kMarkerShadow, kTransparent); - canvas.circleOutline(marker, kRingThickness / 2, kMarkerColor, 2); -} - -void RGBPickerWidget::drawSaturationValueTriangle(Canvas& canvas, const Geometry& wheel) const { - const halnf radius = wheel.triangleRadius(); - const Vec2F hueCorner = onCircle(wheel.center, radius, mColor.h); - const Vec2F whiteCorner = onCircle(wheel.center, radius, mColor.h + kThirdTurn); - const Vec2F blackCorner = onCircle(wheel.center, radius, mColor.h - kThirdTurn); - const Vec2F triangle[] = { hueCorner, whiteCorner, blackCorner }; - - canvas.polygon(triangle, 3, Canvas::LinearGradient{ hueCorner, whiteCorner, colorOf(mColor.h, 1, 1), { 1, 1, 1, 1 } }); - canvas.polygon(triangle, 3, Canvas::LinearGradient{ (hueCorner + whiteCorner) / 2, blackCorner, kTransparent, { 0, 0, 0, 1 } }); -} - -void RGBPickerWidget::drawSaturationValueMarker(Canvas& canvas, const Geometry& wheel) const { - const Vec2F unitPoint = unitTrianglePointOf(mColor.s, mColor.v); - const Vec2F marker = wheel.center + (unitPoint * wheel.triangleRadius()).rotated(mColor.h); - - canvas.radialGlow(marker, 7, 9, kMarkerShadow, kTransparent); - canvas.circleOutline(marker, 5, kMarkerColor, 2); -} diff --git a/Widgets/private/widgets/DockWidget.cpp b/Widgets/private/widgets/DockWidget.cpp deleted file mode 100644 index ec1527cc..00000000 --- a/Widgets/private/widgets/DockWidget.cpp +++ /dev/null @@ -1,141 +0,0 @@ -#include "DockWidget.hpp" -#include "FloatingWidget.hpp" - -using namespace tp; - -DockWidget::DockWidget() : Widget() { - setLayout(new DockLayout(this)); -} - -void DockWidget::dockWidget(Widget* widget, DockLayout::Side side) { - if (layout()->dockWidget(widget, side)) { - addChild(widget); - - for (auto child : layout()->getDockedWidgets()) { - if (child) child->bringToBack(); - } - } -} - -void DockWidget::undockWidget(DockLayout::Side side, bool restoreArea) { - if (layout()->sideExists(side) && side != DockLayout::NONE) { - auto widget = layout()->getSideWidget(side); - - widget->bringToFront(); - if (restoreArea) { - widget->setArea(layout()->getRectBeforeDocked(side)); - } else { - // FIXME : widget->endAnimations(); - } - - layout()->undockWidget(side); - } -} - -void DockWidget::setCenterWidget(Widget* widget) { - if (layout()->setCenterWidget(widget)) { - addChild(widget); - } -} - -void DockWidget::toggleWidgetVisibility(DockLayout::Side side) { - if (layout()->sideExists(side)) { - auto widget = layout()->getSideWidget(side); - widget->setEnabled(!layout()->isSideVisible(side)); - widget->bringToBack(); - - layout()->toggleWidgetVisibility(side); - - widget->triggerWidgetUpdate("dock visibility changed"); - } -} - -void DockWidget::process(const InputView& events) { - // calculateHeaderAreas(); - - Widget* floater = nullptr; - for (auto child : mChildren) { - if (auto iter = dynamic_cast(child)) { - if (iter->isFloating()) { - floater = iter; - break; - } - } - } - - if (floater) undockWidget(layout()->getSideFromWidget(floater), false); - - if (floater) { - layout()->updatePreviewSide(events.getPointer()); - } - - if (mPreviewWidget && !floater) { - dockWidget(mPreviewWidget, layout()->getPreviewSide()); - } - - mPreviewWidget = floater; - - if (!mPreviewWidget) { - layout()->updateResizeHover(events.getPointer()); - - if (events.isPressed(PointerButton::Left)) { - if (layout()->startResize(events.getPointer())) { - triggerWidgetUpdate("dock layout resizing"); - lockFocus(); - } - } else if (events.isReleased(PointerButton::Left)) { - layout()->endResize(); - freeFocus(); - } - - if (layout()->isResizing()) { - layout()->updateResize(events.getPointerDelta()); - } - } -} - -bool DockWidget::propagateEventsToChildren() const { - return !layout()->isResizing(); -} - -bool DockWidget::needsNextFrame() const { - return Widget::needsNextFrame() || layout()->isResizing(); -} - -void DockWidget::draw(Canvas& canvas) { - canvas.rect(getRelativeArea(), mBackgroundColor, 0); -} - -void DockWidget::drawSide(DockLayout::Side side, tp::Canvas& canvas) { - auto lay = layout(); - - if (lay->isSideVisible(side)) { - // header - canvas.rect(lay->getHeaderArea(side), mResizeHandleColorActive, 0); - - // resize - if (lay->isResizing(side)) { - canvas.rect(lay->getResizeHandleArea(side).shrink(mPadding / 1.5f), mResizeHandleColorActive, 0); - } else if (lay->isResizeHandleHover(side)) { - canvas.rect(lay->getResizeHandleArea(side).shrink(mPadding / 1.5f), mResizeHandleColorHovered, 0); - } - - } else { - // preview handles - if (mPreviewWidget) { - // preview active - if (lay->getPreviewSide() == side) { - canvas.rect(lay->getPreviewArea().shrink(mPadding * 2), mPreviewColor, mRounding); - } else { - canvas.rect(lay->getPreviewHandleArea(side), mPreviewColor, mRounding); - } - } - } -} - -void DockWidget::drawOverlay(Canvas& canvas) { - drawSide(DockLayout::Side::LEFT, canvas); - drawSide(DockLayout::Side::RIGHT, canvas); - drawSide(DockLayout::Side::TOP, canvas); - drawSide(DockLayout::Side::BOTTOM, canvas); -} \ No newline at end of file diff --git a/Widgets/private/widgets/FloatingWidget.cpp b/Widgets/private/widgets/FloatingWidget.cpp deleted file mode 100644 index 78e93f71..00000000 --- a/Widgets/private/widgets/FloatingWidget.cpp +++ /dev/null @@ -1,74 +0,0 @@ -#include "FloatingWidget.hpp" -#include "ScrollableLayout.hpp" - -using namespace tp; - -void FloatingWidget::process(const InputView& events) { - const auto pointer = events.getPointer(); - - if (getRelativeAreaT().isInside(pointer) && events.isPressed(PointerButton::Left)) { - layout()->startAction(pointer); - - bringToFront(); - lockFocus(); - } - - if (layout()->isFloating() && events.isReleased(PointerButton::Left)) { - layout()->endAction(); - freeFocus(); - } - - layout()->updateAction(pointer); -} - -void FloatingWidget::draw(Canvas& canvas) { - canvas.rect(layout()->resizeHandleRect(), RGBA(0.7f), 2); - canvas.rect(getRelativeArea(), RGBA(0.5f), 10); -} - -bool FloatingWidget::processesEvents() const { - return true; -} - -bool FloatingWidget::propagateEventsToChildren() const { - return !layout()->isFloating(); -} - -bool FloatingWidget::isFloating() const { - return layout()->isFloating(); -} - -bool FloatingWidget::needsNextFrame() const { - return Widget::needsNextFrame() || isFloating(); -} - -FloatingLayout* FloatingWidget::layout() { return dynamic_cast(Widget::getLayout()); } - -const FloatingLayout* FloatingWidget::layout() const { - return dynamic_cast(Widget::getLayout()); -} - -FloatingMenu::FloatingMenu() : FloatingWidget() { - // addChild(&mMenuLayout); - - addChild(&mHeader); - addChild(&mBodyLayout); - - mHeader.setText("Menu"); - - mHeader.setSizePolicy(SizePolicy::Expanding, SizePolicy::Minimal); - mBodyLayout.setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - - mBodyLayout.setLayout(new ScrollableLayout(&mBodyLayout)); - mBodyLayout.addChild(&mScrollBar); - mBodyLayout.addChild(&mContentWidget); - - mContentWidget.setSizePolicy(SizePolicy::Minimal, SizePolicy::Minimal); - - // getLayout()->setLayoutPolicy(LayoutPolicy::Vertical); - // mBodyLayout.getLayout()->setLayoutPolicy(LayoutPolicy::Vertical); -} - -void FloatingMenu::setText(const std::string& text) { - mHeader.setText(text); -} diff --git a/Widgets/private/widgets/ScrollableWidget.cpp b/Widgets/private/widgets/ScrollableWidget.cpp deleted file mode 100644 index fe6d669c..00000000 --- a/Widgets/private/widgets/ScrollableWidget.cpp +++ /dev/null @@ -1,106 +0,0 @@ -#include "ScrollableWidget.hpp" -#include "ScrollableLayout.hpp" -#include "BasicLayout.hpp" - -using namespace tp; - -void ScrollableBarWidget::process(const InputView& events) { - // all content is visible no need to process anything - if (mSizeFactor >= 1) { - mScrolling = false; - mPosFactor = mSizeFactor / 2.f; - freeFocus(); - return; - } - - updateHandleRect(); - - auto pointer = events.getPointer(); - auto size = getArea().size; - - mHandleHovered = getHandleRect().isInside(pointer); - - if (getRelativeAreaT().isInside(pointer) && events.isPressed(PointerButton::Left)) { - if (!mHandleHovered) { - jumpTo((pointer / size)[mVertical]); - mStartPos = { 0, 0 }; - } else { - mStartPos = pointer - getHandleRect().center(); - } - mScrolling = true; - lockFocus(); - } - - if (mScrolling && events.isReleased(PointerButton::Left)) { - mScrolling = false; - freeFocus(); - } - - if (mScrolling) { - updateHandleRect(); - auto dragDelta = (pointer - getHandleRect().center()) - mStartPos; - moveBy((dragDelta / size)[mVertical]); - } -} - -void ScrollableBarWidget::jumpTo(halnf pos) { - mPosFactor = pos; - clamp(); -} - -void ScrollableBarWidget::moveBy(halnf delta) { - mPosFactor += delta; - clamp(); -} - -void ScrollableBarWidget::updateSizeFactor(halnf factor) { - mSizeFactor = factor; - clamp(); -} - -void ScrollableBarWidget::clamp() { - mSizeFactor = ::clamp(mSizeFactor, 0.f, 1.f); - mPosFactor = ::clamp(mPosFactor, mSizeFactor / 2, 1.f - mSizeFactor / 2); -} - -void ScrollableBarWidget::draw(Canvas& canvas) { - updateHandleRect(); - - canvas.rect(getArea().relative(), mBGColor, mRounding); - - auto& handleColor = mScrolling ? mHandleSlideColor : (mHandleHovered ? mHandleHoverColor : mHandleColor); - canvas.rect(getHandleRect(), handleColor, mRounding); -} - -void ScrollableBarWidget::updateHandleRect() { - auto area = getArea().relative(); - - mHandleRect = area; - mHandleRect.size[mVertical] = area.size[mVertical] * mSizeFactor; - mHandleRect.pos[mVertical] = area.size[mVertical] * mPosFactor; - - mHandleRect.pos[mVertical] -= mHandleRect.size[mVertical] / 2; - - mHandleRect.shrinkFromCenter(mHandlePadding, true); -} - -const RectF& ScrollableBarWidget::getHandleRect() const { - return mHandleRect; -} - -ScrollableWidget::ScrollableWidget() { - addChild(&mScroller); - addChild(&mContent); - - setLayout(new ScrollableLayout(this)); - - mContent.setSizePolicy(SizePolicy::Minimal, SizePolicy::Minimal); -} - -void ScrollableWidget::setDirection(bool direction) { - if (auto lay = dynamic_cast(mContent.getLayout())) { - lay->setLayoutPolicy(direction ? LayoutPolicy::Vertical : LayoutPolicy::Horizontal); - } - - mScroller.setDirection(direction); -} diff --git a/Widgets/private/widgets/SimpleWidgets.cpp b/Widgets/private/widgets/SimpleWidgets.cpp deleted file mode 100644 index 1998990c..00000000 --- a/Widgets/private/widgets/SimpleWidgets.cpp +++ /dev/null @@ -1,172 +0,0 @@ - -#include "SimpleWidgets.hpp" - -using namespace tp; - -void LabelWidget::setText(const std::string& text) { - mText = text; -} - -const std::string& LabelWidget::getText() const { - return mText; -} - -void LabelWidget::draw(Canvas& canvas) { - canvas.text(mText.c_str(), getRelativeArea(), { .size = mSize, .color = mColor, .padding = mPadding }); -} - -ButtonWidget::ButtonWidget() { - mAction = []() { - printf("Button Pressed!\n"); - }; - - mColorAnimated.setTargetColor(mColor); - mColorAnimated.endAnimation(); -} - -void ButtonWidget::setAction(const std::function& action) { - mAction = action; -} - -void ButtonWidget::setColor(const RGBA& in) { - mColor = in; - mColorAnimated.setTargetColor(mColor); - triggerWidgetUpdate("color changed"); -} - -void ButtonWidget::process(const InputView& inputState) { - if (getRelativeArea().isInside(inputState.getPointer())) { - if (inputState.isPressed(PointerButton::Left)) { - mAction(); - } - } -} - -void ButtonWidget::draw(Canvas& canvas) { - canvas.rect(getRelativeArea(), mColorAnimated.getCurrentColor(), mRounding); - LabelWidget::draw(canvas); -} - -bool ButtonWidget::needsNextFrame() const { - return LabelWidget::needsNextFrame() || !mColorAnimated.shouldEndTransition(); -} - -void ButtonWidget::endAnimations() { - mColorAnimated.endAnimation(); - LabelWidget::endAnimations(); -} - -void ButtonWidget::updateAnimations() { - mColorAnimated.updateCurrentRect(); - LabelWidget::updateAnimations(); -} - -void ButtonWidget::mouseEnter() { - mColorAnimated.setTargetColor(mColor); - mColorAnimated.endAnimation(); - - mColorAnimated.setTargetColor(mColorHovered); - - // mColorAnimated.updateCurrentRect(); - triggerWidgetUpdate("button hovered"); -} - -void ButtonWidget::mouseLeave() { - mColorAnimated.setTargetColor(mColor); - //mColorAnimated.updateCurrentRect(); - - triggerWidgetUpdate("button out of focus"); -} - - -void SliderWidget::process(const InputView& events) { - const auto pointer = events.getPointer(); - - switch (mState) { - case SLIDING: - if (events.isReleased(PointerButton::Left)) { - mState = IDLE; - freeFocus(); - } - - mFactor = ((pointer - mHandleSize / 2) / (getArea().relative().size - mHandleSize)).x; - mFactor = clamp(mFactor, 0.f, 1.f); - break; - - case IDLE: - case HOVER: - mState = getHandleArea().isInside(pointer) ? HOVER : IDLE; - if (getRelativeAreaT().isInside(pointer) && events.isPressed(PointerButton::Left)) { - mState = SLIDING; - lockFocus(); - } - break; - } -} - -void SliderWidget::draw(Canvas& canvas) { - canvas.rect(getArea().relative(), mColorBG, mRounding); - - switch (mState) { - case IDLE: canvas.rect(getHandleArea(), mColorIdle, mRounding); break; - case HOVER: canvas.rect(getHandleArea(), mColorHovered, mRounding); break; - case SLIDING: canvas.rect(getHandleArea(), mColorActive, mRounding); break; - } -} - -RectF SliderWidget::getHandleArea() const { - auto area = getArea().relative(); - - const auto& size = area.size; - auto center = area.center(); - auto tilt = -(mFactor - 0.5f) * 2; - - area.pos.x = center.x - (tilt * ((size.x - mHandleSize) / 2)) - mHandleSize / 2; - area.size.x = mHandleSize; - - return area; -} - -void PopupWidget::open(Widget* parent, const RectF& at) { - mParentArea = parent->getArea().relative(); - mParentArea.pos = at.pos * -1; - mParentArea.shrinkFromCenter(-10, true); - - setArea(at); - parent->openPopup(this); -} - -void PopupWidget::process(const InputView& events) { - bool insideParent = mParentArea.isInside(events.getPointer()); - bool insideMenu = getArea().relative().isInside(events.getPointer()); - - if (!(insideMenu || insideParent)) { - closePopup(this); - } -} - -void PopupWidget::draw(Canvas& canvas) { - canvas.rect(getArea().relative(), col, rounding); -} - -void HoverPopupTriggerWidget::mouseEnter() { - auto area = getArea().relative(); - auto size = Vec2F(300, 400); - - if (mDirection == Right) { - auto popupArea = RectF{ area.p4() + Vec2F{ gap, 0.f }, size }; - mPopup.open(this, popupArea); - } else { - auto popupArea = RectF{ area.p2() + Vec2F{ 0.f, gap }, size }; - mPopup.open(this, popupArea); - } -} - -PopupWidget* HoverPopupTriggerWidget::getPopup() { - return &mPopup; -} - -void HoverPopupTriggerWidget::draw(Canvas& canvas) { - canvas.rect(getArea().relative(), col, rounding); - LabelWidget::draw(canvas); -} diff --git a/Widgets/public/Layout.hpp b/Widgets/public/Layout.hpp deleted file mode 100644 index 80872586..00000000 --- a/Widgets/public/Layout.hpp +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include "Rect.hpp" -#include - -namespace tp { - class Widget; - - enum class SizePolicy { - Fixed, - Expanding, - Minimal, - }; - - enum class LayoutPolicy { - Passive, - Vertical, - Horizontal, - }; - - class WidgetLayout { - friend class LayoutManager; - - public: - explicit WidgetLayout(Widget* widget) { mWidget = widget; } - virtual ~WidgetLayout() = default; - - virtual void updateLayout(bool vertical) {} - - virtual void pickRect(bool vertical) {} - virtual void clampRect() {} - [[nodiscard]] virtual RectF getAvailableChildArea() const; - - public: - const Vec2F& getMinSize(); - void setMinSize(const Vec2F& size); - - [[nodiscard]] const Vec2& getSizePolicy() const; - void setSizePolicy(SizePolicy x, SizePolicy y); - - public: - [[nodiscard]] const RectF& getArea() const; - [[nodiscard]] RectF getAnimatedArea() const; - - void setArea(const RectF& area); - [[nodiscard]] Widget* parent() const; - [[nodiscard]] const std::vector& children() const; - - public: - void clampMinMaxSize(); - - [[nodiscard]] RangeF pickRange(const RangeF& current, const RangeF& child, const RangeF& parent, bool v) const; - [[nodiscard]] RangeF clampRange(const RangeF& current, const RangeF& child, const RangeF& parent, bool v) const; - - [[nodiscard]] RectF getChildrenEnclosure() const; - [[nodiscard]] RectF getParentEnclosure() const; - - private: - Widget* mWidget = nullptr; - - protected: - Vec2 mSizePolicy = { SizePolicy::Fixed, SizePolicy::Fixed }; - Vec2F mMinSize = { 30, 30 }; - Vec2F mMaxSize = { FLT_MAX / 2, FLT_MAX / 2 }; - - private: - // RectF mPrevArea{}; - }; -} \ No newline at end of file diff --git a/Widgets/public/RootWidget.hpp b/Widgets/public/RootWidget.hpp deleted file mode 100644 index 12272641..00000000 --- a/Widgets/public/RootWidget.hpp +++ /dev/null @@ -1,48 +0,0 @@ -#pragma once - -#include "Widget.hpp" - -#include "UpdateManager.hpp" -#include "LayoutManager.hpp" - -namespace tp { - class RootWidget : public WidgetManagerInterface { - public: - RootWidget(); - - // User Interface - public: - void setRootWidget(Widget* widget); - static void setWidgetArea(Widget& widget, const RectF& rect); - - // Graphic Application Interface - public: - void processFrame(const InputState& input, const RectF& screenArea); - void drawFrame(Canvas& canvas); - [[nodiscard]] bool needsUpdate() const; - - // Internals - private: - void drawRecursion(Canvas& canvas, Widget* active, const Vec2F& pos); - - void updateWidget(Widget*, const char* reason = nullptr) override; - - void openPopup(Widget*) override; - void closePopup(Widget*) override; - - void lockFocus(Widget*) override; - void freeFocus(Widget*) override; - - void updateAnimations(); - void updateAreaCache(Widget* iter, bool read); - - private: - Widget mRoot; - Widget mPopups; - - Widget* mUserRoot = nullptr; - - LayoutManager mLayoutManager; - UpdateManager mUpdateManager; - }; -} \ No newline at end of file diff --git a/Widgets/public/Widget.hpp b/Widgets/public/Widget.hpp deleted file mode 100644 index fb581561..00000000 --- a/Widgets/public/Widget.hpp +++ /dev/null @@ -1,145 +0,0 @@ -#pragma once - -#include "SpringAnimations.hpp" - -#include "Layout.hpp" - -#include "InputState.hpp" -#include "Canvas.hpp" -#include "List.hpp" - -#include -#include - -namespace tp { - class WidgetLayout; - - class LayoutManager; - class UpdateManager; - - class WidgetManagerInterface; - class RootWidget; - - class Widget { - friend RootWidget; - - friend LayoutManager; - friend UpdateManager; - - using BitField = Bits; - - using DFSAction = std::function; - - enum Flags : int1 { - ENABLED = 0, - NEEDS_UPDATE, - IN_FOCUS, - TRIGGERED, - }; - - public: - Widget(const Widget&&) = delete; - Widget(const Widget&) = delete; - void operator=(const Widget&) = delete; - - Widget(); - virtual ~Widget(); - - void addChild(Widget* child, bool front = false); - void removeChild(Widget* child); - void clear(); - - const List& getChildren() { return mDepthOrder; } - - void bringToFront(); - void bringToBack(); - - void setLayout(WidgetLayout* layout); - void setSizePolicy(SizePolicy x, SizePolicy y); - - void setEnabled(bool val) { mFlags.set(ENABLED, val); } - [[nodiscard]] bool getEnabled() const { return mFlags.get(ENABLED); } - - WidgetLayout* getLayout(); - [[nodiscard]] const WidgetLayout* getLayout() const; - - void triggerWidgetUpdate(const char* reason = nullptr); - - void openPopup(Widget*); - void closePopup(Widget*); - - void lockFocus(); - void freeFocus(); - - protected: - virtual void process(const InputView& events) {} - virtual void draw(Canvas& canvas) {} - virtual void drawOverlay(Canvas& canvas) {} - virtual void endAnimations(); - virtual void updateAnimations(); - - [[nodiscard]] virtual bool processesEvents() const; - [[nodiscard]] virtual bool propagateEventsToChildren() const; - [[nodiscard]] virtual bool needsNextFrame() const; - - virtual void mouseEnter(); - virtual void mouseLeave(); - - protected: - WidgetManagerInterface* getRoot(); - - public: - [[nodiscard]] RectF getArea() const; - [[nodiscard]] RectF getAreaT() const; - - [[nodiscard]] RectF getRelativeArea() const; - [[nodiscard]] RectF getRelativeAreaT() const; - - void setArea(const RectF& area); - void setAreaCache(const RectF& area); - - private: - [[nodiscard]] bool isUpdate() const { return mFlags.get(ENABLED) && mFlags.get(NEEDS_UPDATE); } - [[nodiscard]] bool isDraw() const { return mFlags.get(ENABLED); } - - static void dfs(Widget* iter, const DFSAction& before, const DFSAction& after = [](auto){}) { - if (!iter->isUpdate()) return; - - before(iter); - - for (auto child : iter->mDepthOrder) { - dfs(child.data(), before, after); - } - - after(iter); - } - - protected: - friend WidgetLayout; - - Widget* mParent = nullptr; - - std::vector mChildren; - List mDepthOrder; - - // relative to the parent - SpringRect mArea; - RectF mAreaCache; - - WidgetLayout* mLayout = nullptr; - - BitField mFlags; - }; - - struct WidgetManagerInterface : public Widget { - virtual void updateWidget(Widget*, const char* reason) = 0; - - virtual void openPopup(Widget*) = 0; - virtual void closePopup(Widget*) = 0; - - virtual void lockFocus(Widget*) = 0; - virtual void freeFocus(Widget*) = 0; - - static WidgetLayout* defaultLayout(Widget* widget); - }; -} \ No newline at end of file diff --git a/Widgets/public/WidgetApplication.hpp b/Widgets/public/WidgetApplication.hpp deleted file mode 100644 index 557c476e..00000000 --- a/Widgets/public/WidgetApplication.hpp +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include "GraphicApplication.hpp" -#include "RootWidget.hpp" - -namespace tp { - class WidgetApplication : public ApplicationDelegate { - public: - explicit WidgetApplication(const ApplicationOptions& options = {}); - - void run() { mApplication.run(); } - [[nodiscard]] Application& application() { return mApplication; } - [[nodiscard]] Window& window() { return mApplication.window(); } - [[nodiscard]] Canvas& canvas() { return mApplication.canvas(); } - - void setRoot(Widget* widget); - - private: - void processFrame(const InputState& input, halnf deltaMs) override; - void drawFrame(Canvas& canvas) override; - bool wantsFrame() override; - - private: - RootWidget mRootWidget; - Application mApplication; - }; -} diff --git a/Widgets/public/core/SpaceDistribution.hpp b/Widgets/public/core/SpaceDistribution.hpp new file mode 100644 index 00000000..560663a1 --- /dev/null +++ b/Widgets/public/core/SpaceDistribution.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include "Sizing.hpp" + +#include + +namespace tp::ui { + + std::vector distributeSpace(const std::vector& items, halnf available); + + struct Segment { + halnf start = 0; + halnf length = 0; + }; + + std::vector placeSnapped(const std::vector& lengths, halnf origin, halnf gap); +} diff --git a/Widgets/public/layouts/BasicLayout.hpp b/Widgets/public/layouts/BasicLayout.hpp deleted file mode 100644 index 840c9744..00000000 --- a/Widgets/public/layouts/BasicLayout.hpp +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include "Layout.hpp" - -namespace tp { - - class BasicLayout : public WidgetLayout { - - public: - explicit BasicLayout(Widget* widget) : WidgetLayout(widget) {} - - void updateLayout(bool vertical) override; - - void pickRect(bool vertical) override; - void clampRect() override; - [[nodiscard]] RectF getAvailableChildArea() const override; - - void setLayoutPolicy(LayoutPolicy layout) { mLayoutPolicy = layout; } - - private: - void adjustLayout(bool vertical); - static halnf changeChildSize(Widget*, halnf diff, bool vertical); - - private: - LayoutPolicy mLayoutPolicy = LayoutPolicy::Vertical; - halnf mLayoutGap = 5; - halnf mLayoutMargin = 9; - }; -} \ No newline at end of file diff --git a/Widgets/public/layouts/BoxLayout.hpp b/Widgets/public/layouts/BoxLayout.hpp new file mode 100644 index 00000000..32da8e44 --- /dev/null +++ b/Widgets/public/layouts/BoxLayout.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "Layout.hpp" + +namespace tp::ui { + + enum class CrossAlign { Stretch, Start, Center, End }; + enum class MainAlign { Start, Center, End, SpaceBetween }; + + class BoxLayout : public Layout { + public: + explicit BoxLayout(Axis axis, halnf gap = 0); + + void setGap(halnf gap); + void setCrossAlign(CrossAlign align); + void setMainAlign(MainAlign align); + + [[nodiscard]] Axis axis() const { return mAxis; } + [[nodiscard]] halnf gap() const { return mGap; } + + protected: + [[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override; + void arrangeChildren(LayoutContext& context, const RectF& contentArea) override; + + private: + [[nodiscard]] Extent measureMainAxis(const LayoutContext& context, halnf crossLength) const; + [[nodiscard]] Extent measureCrossAxis(const LayoutContext& context, halnf mainLength) const; + [[nodiscard]] halnf totalGap(const LayoutContext& context) const; + [[nodiscard]] halnf crossLengthFor(const Extent& extent, halnf available) const; + [[nodiscard]] halnf crossStartFor(halnf length, halnf available) const; + + Axis mAxis; + halnf mGap; + CrossAlign mCrossAlign = CrossAlign::Stretch; + MainAlign mMainAlign = MainAlign::Start; + }; + + class RowLayout : public BoxLayout { + public: + explicit RowLayout(halnf gap = 0) : + BoxLayout(Axis::Horizontal, gap) {} + }; + + class ColumnLayout : public BoxLayout { + public: + explicit ColumnLayout(halnf gap = 0) : + BoxLayout(Axis::Vertical, gap) {} + }; +} diff --git a/Widgets/public/layouts/DockLayout.hpp b/Widgets/public/layouts/DockLayout.hpp deleted file mode 100644 index 47de3460..00000000 --- a/Widgets/public/layouts/DockLayout.hpp +++ /dev/null @@ -1,109 +0,0 @@ -#include "Widget.hpp" - -namespace tp { - class DockLayout : public WidgetLayout { - public: - enum Side { LEFT, TOP, RIGHT, BOTTOM, NONE }; - - private: - struct ResizeHandle { - RectF area{ 0, 0, 0, 0 }; - halnf start{ 0 }; - halnf end{ 0 }; - bool active = false; - bool hover = false; - }; - - struct SideWidgetData { - Widget* widget = nullptr; - bool hidden = false; - halnf absoluteSize = 300; - alni order = -1; - - Side side = { TOP }; - - RectF area = {}; - RectF headerArea = {}; - RectF previewHandleArea = {}; - - ResizeHandle resizeHandle; - - RectF areaBeforeDocking = {}; - }; - - public: - explicit DockLayout(Widget* widget); - - void updateResizeHover(const Vec2F& pointer); - bool startResize(const Vec2F& pointer); - void updateResize(const Vec2F& pointerDelta); - void endResize(); - - void updatePreviewSide(const Vec2F& pointer); - - public: - void pickRect(bool vertical) override {} - void clampRect() override {}; - void updateLayout(bool vertical) override; - void adjustChildrenRect(); - - public: - bool setCenterWidget(Widget* widget); - - bool dockWidget(Widget* widget, Side side); - bool undockWidget(Side side); - void toggleWidgetVisibility(Side side); - - public: - [[nodiscard]] bool isSideVisible(Side side) const; - [[nodiscard]] bool sideExists(Side side) const; - - [[nodiscard]] bool isResizing() const { return mResizing; } - [[nodiscard]] bool isResizing(Side side) const { return mSideWidgets[side].resizeHandle.active; } - [[nodiscard]] bool isResizeHandleHover(Side side) const { return mSideWidgets[side].resizeHandle.hover; } - - Side getSideFromWidget(Widget*); - Side getPreviewSide(); - - [[nodiscard]] RectF getRectBeforeDocked(Side side) const { return mSideWidgets[side].areaBeforeDocking; } - [[nodiscard]] RectF getResizeHandleArea(Side side) const { return mSideWidgets[side].resizeHandle.area; } - [[nodiscard]] RectF getHeaderArea(Side side) const { return mSideWidgets[side].headerArea; } - [[nodiscard]] RectF getPreviewHandleArea(Side side) const { return mSideWidgets[side].previewHandleArea; } - [[nodiscard]] RectF getPreviewArea() const { return mPreviewArea; } - - Widget* getSideWidget(Side side) { return mSideWidgets[side].widget; } - - const std::vector& getDockedWidgets(); - - private: - ualni getVisibleSidesSize() const; - - void calculateSideAreas(); - void calculateResizeHandles(); - void updateChildSideWidgets(); - void calculateHeaderAreas(); - - private: - RectF mPreviewArea = {}; - Side mPreviewSide = NONE; - int resizeType[2] = { 0, 0 }; - - SideWidgetData mSideWidgets[4]; - - std::vector mDockedWidgets; - - private: - bool mResizing = false; - - Widget* mCenterWidget = nullptr; - RectF mCenterArea { 0, 0, 10, 10 }; - - // Parameters - const halnf mHandleSplitFactor = 0.3; - const halnf mHandleFactor = 0.1; - - halnf mSideSizePadding = 150.f; - halnf mPadding = 4; - halnf mHeaderSize = 27; - }; -} \ No newline at end of file diff --git a/Widgets/public/layouts/FloatingLayout.hpp b/Widgets/public/layouts/FloatingLayout.hpp deleted file mode 100644 index c1418fe2..00000000 --- a/Widgets/public/layouts/FloatingLayout.hpp +++ /dev/null @@ -1,34 +0,0 @@ -#pragma once - -#include "BasicLayout.hpp" - -namespace tp { - class FloatingLayout : public BasicLayout { - public: - explicit FloatingLayout(Widget* widget) : - BasicLayout(widget) {} - - public: - void startAction(const Vec2F& pointer); - void updateAction(const Vec2F& pointer); - void endAction(); - - bool isFloating() const { return mIsFloating; } - - RectF resizeHandleRect(); - - public: - void pickRect(bool vertical) override; - - private: - bool mIsFloating = false; - bool mIsResizing = false; - - Vec2F mPointerStart; - Vec2F mPointerCurrent; - - // TODO : remove? - halnf mHandleSize = 10; - halnf mHandlePadding = 2; - }; -} \ No newline at end of file diff --git a/Widgets/public/layouts/ScrollableLayout.hpp b/Widgets/public/layouts/ScrollableLayout.hpp deleted file mode 100644 index 329131ec..00000000 --- a/Widgets/public/layouts/ScrollableLayout.hpp +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include "Layout.hpp" - -namespace tp { - class ScrollableBarWidget; - - class ScrollableLayout : public WidgetLayout { - public: - explicit ScrollableLayout(Widget* widget) : - WidgetLayout(widget) { - setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - } - - void updateLayout(bool vertical) override; - [[nodiscard]] RectF getAvailableChildArea() const override; - - private: - void updateWidgetRects(const RectF& area, Widget* content, ScrollableBarWidget* scroller) const; - - private: - halnf mScrollerSize = 15; - }; -} \ No newline at end of file diff --git a/Widgets/public/layouts/SimpleLayouts.hpp b/Widgets/public/layouts/SimpleLayouts.hpp deleted file mode 100644 index 23866f47..00000000 --- a/Widgets/public/layouts/SimpleLayouts.hpp +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include "Layout.hpp" - -namespace tp { - - class OverlayLayout : public WidgetLayout { - - public: - explicit OverlayLayout(Widget* widget) : WidgetLayout(widget) {} - - void updateLayout(bool vertical) override; - - void pickRect(bool vertical) override {} - void clampRect() override {} - }; - - class ToolBarLayout : public WidgetLayout { - - public: - explicit ToolBarLayout(Widget* widget) : WidgetLayout(widget) {} - - void updateLayout(bool vertical) override; - - void pickRect(bool vertical) override {} - void clampRect() override {} - - private: - halnf mToolBarHeight = 55; - }; -} \ No newline at end of file diff --git a/Widgets/public/mangers/LayoutManager.hpp b/Widgets/public/mangers/LayoutManager.hpp deleted file mode 100644 index 7a55546e..00000000 --- a/Widgets/public/mangers/LayoutManager.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include "Layout.hpp" - -#include -#include - -namespace tp { - class LayoutManager { - struct DepNode { - std::vector depends; - int references = 0; - int depth = 0; - }; - - public: - LayoutManager() = default; - - void adjust(Widget* root); - - private: - void findDependencies(Widget* root); - void topologicalSort(Widget* root, int depth = 0); - void adjustLayouts(); - - private: - int getLayoutOrder(WidgetLayout* parent, WidgetLayout* child) const; - - private: - std::map mDepGraph; - std::vector mRoots; - - std::vector> mLayOrder; - bool mVertical = false; - }; -} \ No newline at end of file diff --git a/Widgets/public/mangers/UpdateManager.hpp b/Widgets/public/mangers/UpdateManager.hpp deleted file mode 100644 index b5dc4b68..00000000 --- a/Widgets/public/mangers/UpdateManager.hpp +++ /dev/null @@ -1,43 +0,0 @@ -#pragma once - -#include "Widget.hpp" - -#include - -namespace tp { - // FIXME : desperately needs refactor - class UpdateManager { - public: - UpdateManager() = default; - - void scheduleUpdate(Widget* widget, const char* reason); - - void lockFocus(Widget* widget); - void freeFocus(Widget* widget); - - [[nodiscard]] bool isPendingUpdates() const { - return !mTriggeredWidgets.empty(); - } - - void processWidgets(Widget* root, const InputState& input); - void clean(); - - void updateTreeToProcess(Widget* root); - void handleFocusChanges(Widget* root, const InputView& input); - - void findFocusWidget(Widget* iter, Widget** focus, const Vec2F& pointer); - static void getWidgetPath(Widget* widget, std::vector& out); - void processActiveTree(Widget* iter, const InputState& input, Vec2F pos); - void processFocusItems(const InputState& input); - - private: - static void procWidget(Widget* widget, const InputView& input); - - private: - std::map mTriggeredWidgets; - Widget* mInFocusWidget = nullptr; - Widget* mFocusLockWidget = nullptr; - - private: - }; -} \ No newline at end of file diff --git a/Widgets/public/widgets/AnimationTestWidget.hpp b/Widgets/public/widgets/AnimationTestWidget.hpp deleted file mode 100644 index 0c5851d2..00000000 --- a/Widgets/public/widgets/AnimationTestWidget.hpp +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "Widget.hpp" - -namespace tp { - class AnimationTestWidget : public Widget { - - public: - AnimationTestWidget() = default; - ~AnimationTestWidget() override = default; - - void draw(Canvas& canvas) override; - void process(const InputView& events) override; - - [[nodiscard]] bool needsNextFrame() const override; - - private: - mutable SpringRect mTestSpring; - }; -} \ No newline at end of file diff --git a/Widgets/public/widgets/ColorPickerWidget.hpp b/Widgets/public/widgets/ColorPickerWidget.hpp deleted file mode 100644 index a6b40410..00000000 --- a/Widgets/public/widgets/ColorPickerWidget.hpp +++ /dev/null @@ -1,40 +0,0 @@ -#pragma once - -#include "Widget.hpp" - -namespace tp { - class RGBPickerWidget : public Widget { - public: - RGBPickerWidget() = default; - - void process(const InputView& events) override; - void draw(Canvas& canvas) override; - - [[nodiscard]] bool processesEvents() const override { return true; } - - [[nodiscard]] const HSV& color() const { return mColor; } - - private: - enum class Part { Outside, HueRing, SaturationValueTriangle }; - - struct Geometry { - Vec2F center; - halnf outerRadius = 0; - - [[nodiscard]] halnf innerRadius() const; - [[nodiscard]] halnf triangleRadius() const; - }; - - [[nodiscard]] Geometry geometry() const; - [[nodiscard]] Part partAt(Vec2F point) const; - void pick(Vec2F point); - - void drawHueRing(Canvas& canvas, const Geometry& wheel) const; - void drawHueMarker(Canvas& canvas, const Geometry& wheel) const; - void drawSaturationValueTriangle(Canvas& canvas, const Geometry& wheel) const; - void drawSaturationValueMarker(Canvas& canvas, const Geometry& wheel) const; - - HSV mColor = { 0.13f, 0.5f, 1 }; - Part mDragging = Part::Outside; - }; -} diff --git a/Widgets/public/widgets/DockWidget.hpp b/Widgets/public/widgets/DockWidget.hpp deleted file mode 100644 index 91ff0a8f..00000000 --- a/Widgets/public/widgets/DockWidget.hpp +++ /dev/null @@ -1,44 +0,0 @@ -#include "DockLayout.hpp" - -namespace tp { - class DockWidget : public Widget { - public: - DockWidget(); - - public: - void process(const InputView& events) override; - void draw(Canvas& canvas) override; - void drawOverlay(Canvas& canvas) override; - - [[nodiscard]] bool propagateEventsToChildren() const override; - [[nodiscard]] bool needsNextFrame() const override; - [[nodiscard]]bool processesEvents() const override { return true; } - - void drawSide(DockLayout::Side side, Canvas& canvas); - - public: - DockLayout::Side getSide(Widget* widget) { return layout()->getSideFromWidget(widget); } - - void setCenterWidget(Widget* widget); - - void dockWidget(Widget* widget, DockLayout::Side side); - void undockWidget(DockLayout::Side side, bool restoreArea = true); - void toggleWidgetVisibility(DockLayout::Side side); - - private: - DockLayout* layout() { return dynamic_cast(mLayout); } - const DockLayout* layout() const { return dynamic_cast(mLayout); } - - private: - Widget* mPreviewWidget = nullptr; - - private: - halnf mRounding = 10; - halnf mPadding = 4; - - RGBA mResizeHandleColorHovered = RGBA(0.3, 0.3, 0.3, 1); - RGBA mResizeHandleColorActive = RGBA(0.6, 0.6, 0.6, 1); - RGBA mBackgroundColor = RGBA(0, 0, 0, 1); - RGBA mPreviewColor = RGBA(0.6, 0.6, 0.6, 0.2); - }; -} \ No newline at end of file diff --git a/Widgets/public/widgets/FloatingPanel.hpp b/Widgets/public/widgets/FloatingPanel.hpp new file mode 100644 index 00000000..43197573 --- /dev/null +++ b/Widgets/public/widgets/FloatingPanel.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "Containers.hpp" +#include "Label.hpp" + +#include + +namespace tp::ui { + + class FloatingPanel : public Panel { + public: + explicit FloatingPanel(std::string title = {}); + + void setTitle(std::string title) { mTitle.setText(std::move(title)); } + [[nodiscard]] const std::string& title() const { return mTitle.text(); } + + void setContent(Widget* content); + [[nodiscard]] Widget* content() const { return mContent; } + + void setPanelSize(Vec2F size); + [[nodiscard]] Vec2F panelSize() const { return mPanelSize; } + void setMinimumPanelSize(Vec2F size) { mMinimumSize = size; } + + [[nodiscard]] bool isMoving() const { return mInteraction == Interaction::Moving; } + [[nodiscard]] bool isResizing() const { return mInteraction == Interaction::Resizing; } + + [[nodiscard]] RectF titleBarRect() const; + [[nodiscard]] RectF resizeGripRect() const; + + protected: + void onEvent(Event& event) override; + void paintOverlay(Painter& painter) override; + + private: + enum class Interaction { None, Moving, Resizing }; + + void applySize(); + + Label mTitle; + Widget mTitleBar; + Widget mBody; + Widget* mContent = nullptr; + + Vec2F mPanelSize = { 240, 200 }; + Vec2F mMinimumSize = { 120, 80 }; + + Interaction mInteraction = Interaction::None; + Vec2F mGrabOffset = { 0, 0 }; + }; +} diff --git a/Widgets/public/widgets/FloatingWidget.hpp b/Widgets/public/widgets/FloatingWidget.hpp deleted file mode 100644 index 12978550..00000000 --- a/Widgets/public/widgets/FloatingWidget.hpp +++ /dev/null @@ -1,61 +0,0 @@ -#pragma once - -#include "SimpleWidgets.hpp" -#include "FloatingLayout.hpp" -#include "ScrollableWidget.hpp" - -namespace tp { - class FloatingWidget : public Widget { - public: - FloatingWidget() : Widget() { - setLayout(new FloatingLayout(this)); - } - - void process(const InputView& events) override; - - void draw(Canvas& canvas) override; - - - [[nodiscard]] bool needsNextFrame() const override; - - [[nodiscard]] bool propagateEventsToChildren() const override; - [[nodiscard]] bool processesEvents() const override; - - [[nodiscard]] bool isFloating() const; - - private: - FloatingLayout* layout(); - [[nodiscard]] const FloatingLayout* layout() const; - }; - - class FloatingMenu : public FloatingWidget { - public: - FloatingMenu(); - - public: - void addToMenu(Widget* widget) { - widget->setSizePolicy(SizePolicy::Expanding, SizePolicy::Minimal); - mContentWidget.addChild(widget); - } - - const List& getContent() { - return mContentWidget.getChildren(); - } - - void clearChildren() { - mContentWidget.clear(); - } - - void setText(const std::string& text); - - private: - // VerticalLayout mMenuLayout; - Widget mBodyLayout; - Widget mContentWidget; - ScrollableBarWidget mScrollBar; - - LabelWidget mHeader; - - // ButtonWidget mTestButton; - }; -} \ No newline at end of file diff --git a/Widgets/public/widgets/ScrollableWidget.hpp b/Widgets/public/widgets/ScrollableWidget.hpp deleted file mode 100644 index bcb67ad1..00000000 --- a/Widgets/public/widgets/ScrollableWidget.hpp +++ /dev/null @@ -1,67 +0,0 @@ -#pragma once - -#include "Widget.hpp" - -namespace tp { - // just the scrolling bar - class ScrollableBarWidget : public Widget { - public: - ScrollableBarWidget() = default; - - [[nodiscard]] bool processesEvents() const override { return true; } - [[nodiscard]] bool propagateEventsToChildren() const override { return false; } - [[nodiscard]] bool needsNextFrame() const override { return mScrolling; } - - void process(const InputView& events) override; - void draw(Canvas& canvas) override; - - void updateSizeFactor(halnf factor); // holder-size divided by content-size - - [[nodiscard]] bool getDirection() const { return mVertical; } - [[nodiscard]] halnf getPosFactor() const { return mPosFactor - mSizeFactor / 2; } - - void setDirection(bool direction) { mVertical = direction; } - - private: - [[nodiscard]] const RectF& getHandleRect() const; - void updateHandleRect(); - void jumpTo(halnf); - void moveBy(halnf); - void clamp(); - - private: // state - bool mVertical = true; - bool mScrolling = false; - bool mHandleHovered = false; - - Vec2F mStartPos = {}; - - halnf mPosFactor = 0.2f; // (center-of-holder - content-start) / content-size - halnf mSizeFactor = 0.3f; - - private: // TODO : make params static - halnf mRounding = 5; - halnf mHandlePadding = 0; - - RGBA mBGColor = { 0.1, 0.1, 0.1, 0.0 }; - RGBA mHandleColor = { 0.2, 0.2, 0.2, 1 }; - RGBA mHandleHoverColor = { 0.3, 0.3, 0.3, 1 }; - RGBA mHandleSlideColor = { 0.5, 0.5, 0.5, 1 }; - - private: // cache - RectF mHandleRect; - }; - - class ScrollableWidget : public Widget { - public: - ScrollableWidget(); - - void setDirection(bool direction); - - Widget* getContainer() { return &mContent; } - - private: - Widget mContent; - ScrollableBarWidget mScroller; - }; -} \ No newline at end of file diff --git a/Widgets/public/widgets/SimpleWidgets.hpp b/Widgets/public/widgets/SimpleWidgets.hpp deleted file mode 100644 index c2878a1b..00000000 --- a/Widgets/public/widgets/SimpleWidgets.hpp +++ /dev/null @@ -1,139 +0,0 @@ -#pragma once - -#include "Widget.hpp" - -#include - -namespace tp { - class LabelWidget : public Widget { - public: - LabelWidget() : Widget() { - } - - void setText(const std::string& text); - - [[nodiscard]] const std::string& getText() const; - - void draw(Canvas& canvas) override; - - [[nodiscard]] bool processesEvents() const override { return false; } - - private: - std::string mText = "Text"; - - halnf mPadding = 5; - RGBA mColor = 1.f; - halnf mSize = 17.f; - }; - - class ButtonWidget : public LabelWidget { - public: - ButtonWidget(); - - void setAction(const std::function& action); - - void process(const InputView& inputState) override; - void draw(Canvas& canvas) override; - - void mouseEnter() override; - void mouseLeave() override; - - [[nodiscard]] bool processesEvents() const override { return true; } - [[nodiscard]] bool needsNextFrame() const override; - - void endAnimations() override; - void updateAnimations() override; - - void setColor(const RGBA& in); - - private: - std::function mAction; - - SpringRect mColorAnimated; - - halnf mRounding = 5; - RGBA mColorHovered = { 0.0f, 0.4f, 0.4f, 1.f }; - RGBA mColor = { 0.13f, 0.13f, 0.13f, 1.f }; - }; - - class SliderWidget : public Widget { - enum State { - IDLE, - HOVER, - SLIDING, - }; - - public: - SliderWidget() = default; - - void process(const InputView& inputState) override; - void draw(Canvas& canvas) override; - - [[nodiscard]] bool processesEvents() const override { return true; } - [[nodiscard]] bool needsNextFrame() const override { return mState != IDLE; } - - [[nodiscard]] halnf val() const { return mFactor; } - - private: - [[nodiscard]] RectF getHandleArea() const; - - private: - halnf mFactor = 0; - State mState = IDLE; - - private: - halnf mHandleSize = 20; - halnf mRounding = 5; - - RGBA mColorActive = { 0.99f, 0.99f, 0.99f, 1.f }; - RGBA mColorHovered = { 0.9f, 0.9f, 0.9f, 1.f }; - RGBA mColorIdle = { 0.8f, 0.8f, 0.8f, 1.f }; - RGBA mColorBG = { 0.3f, 0.3f, 0.3f, 1.0f }; - }; - - class PopupWidget : public Widget { - public: - PopupWidget() = default; - - public: - void process(const InputView& events) override; - void draw(Canvas& canvas) override; - - void open(Widget* parent, const RectF& at); - [[nodiscard]] bool processesEvents() const override { return true; } - - private: - RectF mParentArea {}; - RGBA col = RGBA(0.03f, 0.03f, 0.03f, 0.9f); - halnf rounding = 5; - halnf borders = 2; - }; - - class HoverPopupTriggerWidget : public LabelWidget { - public: - enum ExpandDirection { - Right, - Bottom, - }; - - public: - HoverPopupTriggerWidget() = default; - - [[nodiscard]] bool processesEvents() const override { return true; } - void mouseEnter() override; - - void draw(Canvas& canvas) override; - - PopupWidget* getPopup(); - void setDirection(ExpandDirection dir) { mDirection = dir; } - - private: - PopupWidget mPopup; - - private: - ExpandDirection mDirection = Bottom; - RGBA col = RGBA(0.03f, 0.03f, 0.03f, 0.07f); - halnf rounding = 5; - halnf gap = 7; - }; -} \ No newline at end of file diff --git a/Widgets/public/widgets/Toggles.hpp b/Widgets/public/widgets/Toggles.hpp new file mode 100644 index 00000000..7fa758b2 --- /dev/null +++ b/Widgets/public/widgets/Toggles.hpp @@ -0,0 +1,99 @@ +#pragma once + +#include "Control.hpp" + +#include +#include + +namespace tp::ui { + + class ToggleControl : public Control { + public: + explicit ToggleControl(std::string text); + + void setChecked(bool checked); + [[nodiscard]] bool isChecked() const { return mChecked; } + + void setOnChanged(std::function onChanged) { mOnChanged = std::move(onChanged); } + + void setText(std::string text); + [[nodiscard]] const std::string& text() const { return mText; } + + protected: + [[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override; + void onActivated() override; + void paint(Painter& painter) override; + + virtual void paintIndicator(Painter& painter, const RectF& box, halnf checkedAmount) = 0; + [[nodiscard]] virtual halnf indicatorWidth() const; + [[nodiscard]] virtual bool togglesOnActivate() const { return true; } + + [[nodiscard]] RectF indicatorBox() const; + + private: + std::string mText; + bool mChecked = false; + AnimatedFloat mCheckedAmount; + std::function mOnChanged; + }; + + class Checkbox : public ToggleControl { + public: + explicit Checkbox(std::string text = {}); + + protected: + void paintIndicator(Painter& painter, const RectF& box, halnf checkedAmount) override; + }; + + class Switch : public ToggleControl { + public: + explicit Switch(std::string text = {}); + + protected: + void paintIndicator(Painter& painter, const RectF& box, halnf checkedAmount) override; + [[nodiscard]] halnf indicatorWidth() const override; + }; + + class RadioGroup; + + class RadioButton : public ToggleControl { + public: + explicit RadioButton(std::string text = {}); + ~RadioButton() override; + + [[nodiscard]] RadioGroup* group() const { return mGroup; } + + protected: + void paintIndicator(Painter& painter, const RectF& box, halnf checkedAmount) override; + [[nodiscard]] bool togglesOnActivate() const override { return false; } + void onActivated() override; + + private: + friend class RadioGroup; + + RadioGroup* mGroup = nullptr; + }; + + class RadioGroup { + public: + RadioGroup() = default; + ~RadioGroup(); + + RadioGroup(const RadioGroup&) = delete; + RadioGroup& operator=(const RadioGroup&) = delete; + + void add(RadioButton& button); + void remove(RadioButton& button); + + void select(RadioButton& button); + void selectIndex(size_t index); + [[nodiscard]] long selectedIndex() const; + [[nodiscard]] RadioButton* selected() const; + + void setOnChanged(std::function onChanged) { mOnChanged = std::move(onChanged); } + + private: + std::vector mButtons; + std::function mOnChanged; + }; +} -- 2.49.1 From a3c4cdfe55238601c046086aaf55153dbe997b44 Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 8 Oct 2026 18:36:18 +0300 Subject: [PATCH 2/9] Widgets: layout engine, popup stack, shortcuts and window chrome --- Widgets/examples/ThemeEditor.hpp | 102 +++ Widgets/private/app/AppDriver.cpp | 138 ++++ Widgets/private/core/LayoutEngine.cpp | 59 ++ Widgets/private/core/LayoutEngine.hpp | 24 + Widgets/private/core/PopupStack.cpp | 95 +++ Widgets/private/core/PopupStack.hpp | 44 ++ Widgets/private/core/Shortcut.cpp | 90 +++ Widgets/private/core/Sizing.cpp | 64 ++ Widgets/private/core/TickScheduler.cpp | 50 ++ Widgets/private/core/TickScheduler.hpp | 33 + Widgets/private/widgets/ApplicationFrame.cpp | 28 + Widgets/private/widgets/FloatingPanel.cpp | 106 +++ Widgets/private/widgets/MediaControls.cpp | 384 ++++++++++ Widgets/private/widgets/Notice.cpp | 49 ++ Widgets/private/widgets/ResizeHandle.cpp | 84 +++ Widgets/private/widgets/Toggles.cpp | 186 +++++ Widgets/private/widgets/WindowControls.cpp | 131 ++++ Widgets/public/app/AppDriver.hpp | 50 ++ Widgets/public/app/InputTranslator.hpp | 52 ++ Widgets/public/core/Diagnostics.hpp | 56 ++ Widgets/public/core/Shortcut.hpp | 76 ++ Widgets/public/core/Sizing.hpp | 60 ++ Widgets/public/widgets/ApplicationFrame.hpp | 26 + Widgets/public/widgets/Dialog.hpp | 62 ++ Widgets/public/widgets/MediaControls.hpp | 144 ++++ Widgets/public/widgets/Notice.hpp | 28 + Widgets/public/widgets/ResizeHandle.hpp | 42 ++ Widgets/public/widgets/WindowControls.hpp | 47 ++ Widgets/tests/AlgorithmTests.cpp | 256 +++++++ Widgets/tests/DockingTests.cpp | 750 +++++++++++++++++++ Widgets/tests/LayoutTests.cpp | 653 ++++++++++++++++ Widgets/tests/MediaControlsTests.cpp | 283 +++++++ Widgets/tests/NoticeTests.cpp | 83 ++ Widgets/tests/ShortcutTests.cpp | 82 ++ Widgets/tests/StyleTests.cpp | 184 +++++ Widgets/tests/ThemeEditorTests.cpp | 160 ++++ Widgets/tests/app/DebugInspectorTests.cpp | 103 +++ Widgets/tests/support/Probes.hpp | 71 ++ 38 files changed, 4935 insertions(+) create mode 100644 Widgets/examples/ThemeEditor.hpp create mode 100644 Widgets/private/app/AppDriver.cpp create mode 100644 Widgets/private/core/LayoutEngine.cpp create mode 100644 Widgets/private/core/LayoutEngine.hpp create mode 100644 Widgets/private/core/PopupStack.cpp create mode 100644 Widgets/private/core/PopupStack.hpp create mode 100644 Widgets/private/core/Shortcut.cpp create mode 100644 Widgets/private/core/Sizing.cpp create mode 100644 Widgets/private/core/TickScheduler.cpp create mode 100644 Widgets/private/core/TickScheduler.hpp create mode 100644 Widgets/private/widgets/ApplicationFrame.cpp create mode 100644 Widgets/private/widgets/FloatingPanel.cpp create mode 100644 Widgets/private/widgets/MediaControls.cpp create mode 100644 Widgets/private/widgets/Notice.cpp create mode 100644 Widgets/private/widgets/ResizeHandle.cpp create mode 100644 Widgets/private/widgets/Toggles.cpp create mode 100644 Widgets/private/widgets/WindowControls.cpp create mode 100644 Widgets/public/app/AppDriver.hpp create mode 100644 Widgets/public/app/InputTranslator.hpp create mode 100644 Widgets/public/core/Diagnostics.hpp create mode 100644 Widgets/public/core/Shortcut.hpp create mode 100644 Widgets/public/core/Sizing.hpp create mode 100644 Widgets/public/widgets/ApplicationFrame.hpp create mode 100644 Widgets/public/widgets/Dialog.hpp create mode 100644 Widgets/public/widgets/MediaControls.hpp create mode 100644 Widgets/public/widgets/Notice.hpp create mode 100644 Widgets/public/widgets/ResizeHandle.hpp create mode 100644 Widgets/public/widgets/WindowControls.hpp create mode 100644 Widgets/tests/AlgorithmTests.cpp create mode 100644 Widgets/tests/DockingTests.cpp create mode 100644 Widgets/tests/LayoutTests.cpp create mode 100644 Widgets/tests/MediaControlsTests.cpp create mode 100644 Widgets/tests/NoticeTests.cpp create mode 100644 Widgets/tests/ShortcutTests.cpp create mode 100644 Widgets/tests/StyleTests.cpp create mode 100644 Widgets/tests/ThemeEditorTests.cpp create mode 100644 Widgets/tests/app/DebugInspectorTests.cpp create mode 100644 Widgets/tests/support/Probes.hpp diff --git a/Widgets/examples/ThemeEditor.hpp b/Widgets/examples/ThemeEditor.hpp new file mode 100644 index 00000000..45921ea1 --- /dev/null +++ b/Widgets/examples/ThemeEditor.hpp @@ -0,0 +1,102 @@ +#pragma once + +#include "BoxLayout.hpp" +#include "Button.hpp" +#include "Containers.hpp" +#include "Label.hpp" +#include "Menus.hpp" +#include "Slider.hpp" +#include "Theme.hpp" +#include "Views.hpp" + +#include +#include +#include + +namespace tp::ui { + + struct ThemeColorField { + const char* name; + RGBA Palette::* member; + }; + + struct ThemeMetricField { + const char* name; + halnf Metrics::* member; + halnf minimum; + halnf maximum; + }; + + const std::vector& themeColorFields(); + const std::vector& themeMetricFields(); + std::string colorHex(const RGBA& color); + std::string themeSource(const Theme& theme); + + class ColorSwatch : public Control { + public: + ColorSwatch(); + + void setColor(const RGBA& color); + [[nodiscard]] const RGBA& color() const { return mColor; } + + protected: + [[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override; + void paint(Painter& painter) override; + + private: + RGBA mColor; + }; + + class ThemeColorRow : public Widget { + public: + explicit ThemeColorRow(const ThemeColorField& field); + + void refresh(const Theme& theme); + void setSelected(bool selected); + + ColorSwatch swatch; + Button name; + Label hex; + + private: + const ThemeColorField& mField; + }; + + class ThemeEditorPage : public Widget { + public: + ThemeEditorPage(); + + [[nodiscard]] size_t selectedColor() const { return mSelected; } + void selectColor(size_t index); + [[nodiscard]] Theme editedTheme() const; + + ScrollView colorScroll; + Widget colorList; + std::vector> colorRows; + + Label editingTitle; + ColorWheel wheel; + LabeledSlider alpha{ "Alpha", 0, 1, 1 }; + std::vector> metricSliders; + Dropdown accentPreset{ { "Blue", "Purple", "Green", "Orange", "Pink" } }; + Button reset{ "Reset to standard" }; + Button copySource{ "Copy as C++" }; + Label status; + + protected: + void onAttached() override; + void onThemeChanged() override; + + private: + template + void edit(Edit&& change); + void refreshEditors(); + + size_t mSelected = 0; + bool mRefreshing = false; + Widget mColorColumn; + Widget mEditorColumn; + Widget mActionRow; + Separator mSeparator; + }; +} diff --git a/Widgets/private/app/AppDriver.cpp b/Widgets/private/app/AppDriver.cpp new file mode 100644 index 00000000..1bd00ef3 --- /dev/null +++ b/Widgets/private/app/AppDriver.cpp @@ -0,0 +1,138 @@ +#include "AppDriver.hpp" + +#include "Widget.hpp" + +using namespace tp; +using namespace tp::ui; + +AppDriver::AppDriver(WidgetApplication& application) : + mApplication(application), + mInput(application.window().injectInput()) { + mApplication.window().setPlatformInputEnabled(false); + mApplication.start(); + mInput.onKeyboardFocus(true); + frames(2); +} + +AppDriver::~AppDriver() { + mApplication.finish(); + mApplication.window().setPlatformInputEnabled(true); +} + +void AppDriver::frame(halnf deltaMs) { mApplication.step(deltaMs); } + +void AppDriver::frames(int count) { + for (int index = 0; index < count; index++) + frame(); +} + +void AppDriver::advance(halnf milliseconds) { + while (milliseconds > 0) { + const halnf delta = milliseconds < kFrameMs ? milliseconds : kFrameMs; + frame(delta); + milliseconds -= delta; + } +} + +void AppDriver::moveTo(Vec2F windowPoint) { + mInput.onPointerMoved(windowPoint); + frame(); +} + +void AppDriver::press(InputID button) { + mInput.onPointerButton(button, true); + frame(); +} + +void AppDriver::release(InputID button) { + mInput.onPointerButton(button, false); + frame(); +} + +void AppDriver::click(Vec2F windowPoint, InputID button) { + moveTo(windowPoint); + press(button); + release(button); + advance(kClickGapMs); +} + +void AppDriver::click(const Widget& widget) { + settle(widget); + click(centerOf(widget)); +} + +void AppDriver::settle(const Widget& widget) { + for (int index = 0; index < kSettleFrameLimit; index++) { + frame(); + if (!isMoving(widget)) return; + } +} + +bool AppDriver::isMoving(const Widget& widget) { + for (const Widget* current = &widget; current; current = current->parent()) { + if (current->isGeometryMoving() || current->isTransitioning()) return true; + } + return false; +} + +void AppDriver::penStroke(const std::vector& points, const std::vector& samples) { + if (points.empty() || points.size() != samples.size()) return; + + mInput.onPointerSample(samples.front()); + mInput.onPointerMoved(points.front()); + mInput.onPointerButton(InputID::MOUSE1, true); + frame(); + for (size_t index = 1; index < points.size(); index++) { + mInput.onPointerSample(samples[index]); + mInput.onPointerMoved(points[index]); + frame(); + } + mInput.onPointerButton(InputID::MOUSE1, false); + mInput.onPointerSample(PointerSample{}); + frame(); +} + +void AppDriver::drag(Vec2F from, Vec2F to, int steps) { + moveTo(from); + press(); + for (int step = 1; step <= steps; step++) { + const halnf amount = (halnf) step / (halnf) steps; + moveTo(from + (to - from) * amount); + } + release(); + advance(kClickGapMs); +} + +void AppDriver::scroll(Vec2F windowPoint, halnf amount) { + moveTo(windowPoint); + mInput.onScrolled(Vec2F(0.0f, amount)); + frame(); +} + +void AppDriver::keyDown(InputID key) { + mInput.onKey(key, true); + frame(); +} + +void AppDriver::keyUp(InputID key) { + mInput.onKey(key, false); + frame(); +} + +void AppDriver::key(InputID key) { + keyDown(key); + keyUp(key); +} + +void AppDriver::shortcut(InputID modifier, InputID key) { + keyDown(modifier); + this->key(key); + keyUp(modifier); +} + +void AppDriver::type(const std::string& utf8) { + mInput.onText(utf8); + frame(); +} + +Vec2F AppDriver::centerOf(const Widget& widget) { return widget.windowRect().center(); } diff --git a/Widgets/private/core/LayoutEngine.cpp b/Widgets/private/core/LayoutEngine.cpp new file mode 100644 index 00000000..0860f684 --- /dev/null +++ b/Widgets/private/core/LayoutEngine.cpp @@ -0,0 +1,59 @@ +#include "LayoutEngine.hpp" + +#include "Layout.hpp" +#include "WidgetInternals.hpp" + +using namespace tp; +using namespace tp::ui; + +Extent LayoutEngine::measure(const Widget& widget, Axis axis, halnf crossLength) { + Extent cached; + if (WidgetInternals::cachedMeasure(widget, axis, crossLength, cached)) return cached; + + mDiagnostics.stats.measuredWidgets++; + + Extent content; + if (widget.layout()) { + LayoutContext context(*this, const_cast(widget)); + content = widget.layout()->measure(context, axis, crossLength); + } else { + content = WidgetInternals::measureContent(widget, axis, crossLength); + } + + const Extent resolved = widget.sizing().along(axis).resolve(content); + WidgetInternals::storeMeasure(const_cast(widget), axis, crossLength, resolved); + return resolved; +} + +Vec2F LayoutEngine::preferredSize(const Widget& widget) { + const halnf width = measure(widget, Axis::Horizontal, kUnbounded).preferred; + const halnf height = measure(widget, Axis::Vertical, width).preferred; + return { width, height }; +} + +void LayoutEngine::layoutRoot(Widget& root, const RectF& area) { + mDiagnostics.stats.layoutPasses++; + if (mDiagnostics.observer) mDiagnostics.observer->onLayoutPassStarted(); + place(root, area); +} + +void LayoutEngine::place(Widget& widget, const RectF& rect) { + const bool rectChanged = WidgetInternals::assignTarget(widget, rect); + + if (!rectChanged && !WidgetInternals::needsLayout(widget)) { + mDiagnostics.stats.skippedWidgets++; + return; + } + + mDiagnostics.stats.arrangedWidgets++; + if (mDiagnostics.observer) mDiagnostics.observer->onWidgetArranged(widget); + arrangeChildren(widget); + WidgetInternals::markLaidOut(widget); +} + +void LayoutEngine::arrangeChildren(Widget& widget) { + if (!widget.layout()) return; + + LayoutContext context(*this, widget); + widget.layout()->arrange(context, widget.localRect()); +} diff --git a/Widgets/private/core/LayoutEngine.hpp b/Widgets/private/core/LayoutEngine.hpp new file mode 100644 index 00000000..0775eea3 --- /dev/null +++ b/Widgets/private/core/LayoutEngine.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "Ui.hpp" +#include "Widget.hpp" + +namespace tp::ui { + + class LayoutEngine { + public: + explicit LayoutEngine(Diagnostics& diagnostics) : + mDiagnostics(diagnostics) {} + + [[nodiscard]] Extent measure(const Widget& widget, Axis axis, halnf crossLength); + [[nodiscard]] Vec2F preferredSize(const Widget& widget); + + void layoutRoot(Widget& root, const RectF& area); + void place(Widget& widget, const RectF& rect); + + private: + void arrangeChildren(Widget& widget); + + Diagnostics& mDiagnostics; + }; +} diff --git a/Widgets/private/core/PopupStack.cpp b/Widgets/private/core/PopupStack.cpp new file mode 100644 index 00000000..a72d96d6 --- /dev/null +++ b/Widgets/private/core/PopupStack.cpp @@ -0,0 +1,95 @@ +#include "PopupStack.hpp" + +#include "Widget.hpp" + +using namespace tp; +using namespace tp::ui; + +void PopupStack::closeAll() { + mClosingAll = true; + closeFrom(0); + mClosingAll = false; +} + +bool PopupStack::open(const PopupRequest& request) { + if (mClosingAll || !request.content) return false; + + if (isOpen(*request.content)) close(*request.content); + closeTransient(); + + if (request.options.interactive) { + const long ownerPopup = request.owner ? indexContaining(*request.owner) : -1; + closeFrom(ownerPopup + 1); + } + + mEntries.push_back({ request, makeRect(0, 0, 0, 0) }); + return true; +} + +void PopupStack::closeTransient() { + if (!mEntries.empty() && !mEntries.back().request.options.interactive) closeFrom(mEntries.size() - 1); +} + +void PopupStack::close(const Widget& content) { + const long index = indexOf(content); + if (index >= 0) closeFrom((size_t) index); +} + +void PopupStack::closeOwnedBy(const Widget& detached) { + for (size_t index = 0; index < mEntries.size(); index++) { + const PopupRequest& request = mEntries[index].request; + const bool ownerDetached = request.owner && detached.isSelfOrAncestorOf(*request.owner); + const bool contentDetached = request.content == &detached; + + if (ownerDetached || contentDetached) { + closeFrom(index); + return; + } + } +} + +long PopupStack::indexOf(const Widget& content) const { + for (size_t index = 0; index < mEntries.size(); index++) { + if (mEntries[index].request.content == &content) return (long) index; + } + return -1; +} + +long PopupStack::indexContaining(const Widget& widget) const { + for (long index = (long) mEntries.size() - 1; index >= 0; index--) { + if (mEntries[index].request.content->isSelfOrAncestorOf(widget)) return index; + } + return -1; +} + +long PopupStack::topmostModal() const { + for (long index = (long) mEntries.size() - 1; index >= 0; index--) { + if (mEntries[index].request.options.modal) return index; + } + return -1; +} + +long PopupStack::chainRootOf(const Widget& content) const { + long index = indexOf(content); + + while (index > 0) { + const Widget* owner = mEntries[(size_t) index].request.owner; + const long ownerPopup = owner ? indexContaining(*owner) : -1; + if (ownerPopup < 0 || ownerPopup >= index) break; + index = ownerPopup; + } + + return index; +} + +void PopupStack::closeFrom(size_t index) { + if (index >= mEntries.size()) return; + + std::vector closing(mEntries.begin() + (ptrdiff_t) index, mEntries.end()); + mEntries.erase(mEntries.begin() + (ptrdiff_t) index, mEntries.end()); + for (auto entry = closing.rbegin(); entry != closing.rend(); ++entry) + mOnClosed(entry->request); + for (auto entry = closing.rbegin(); entry != closing.rend(); ++entry) { + if (entry->request.options.onClosed) entry->request.options.onClosed(); + } +} diff --git a/Widgets/private/core/PopupStack.hpp b/Widgets/private/core/PopupStack.hpp new file mode 100644 index 00000000..067be99e --- /dev/null +++ b/Widgets/private/core/PopupStack.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "Popup.hpp" + +#include +#include + +namespace tp::ui { + + class Widget; + + class PopupStack { + public: + struct Entry { + PopupRequest request; + RectF rect; + }; + + explicit PopupStack(std::function onClosed) : + mOnClosed(std::move(onClosed)) {} + + bool open(const PopupRequest& request); + void close(const Widget& content); + void closeTransient(); + void closeFrom(size_t index); + void closeAll(); + void closeOwnedBy(const Widget& detached); + + [[nodiscard]] bool isOpen(const Widget& content) const { return indexOf(content) >= 0; } + [[nodiscard]] long indexOf(const Widget& content) const; + [[nodiscard]] long indexContaining(const Widget& widget) const; + [[nodiscard]] long topmostModal() const; + [[nodiscard]] long chainRootOf(const Widget& content) const; + + [[nodiscard]] std::vector& entries() { return mEntries; } + [[nodiscard]] const std::vector& entries() const { return mEntries; } + [[nodiscard]] bool empty() const { return mEntries.empty(); } + + private: + std::function mOnClosed; + std::vector mEntries; + bool mClosingAll = false; + }; +} diff --git a/Widgets/private/core/Shortcut.cpp b/Widgets/private/core/Shortcut.cpp new file mode 100644 index 00000000..b42921f6 --- /dev/null +++ b/Widgets/private/core/Shortcut.cpp @@ -0,0 +1,90 @@ +#include "Shortcut.hpp" + +using namespace tp; +using namespace tp::ui; + +namespace { + Shortcut make(InputID key, bool control, bool shift, bool alt) { + Shortcut shortcut; + shortcut.key = key; + shortcut.modifiers.control = control; + shortcut.modifiers.shift = shift; + shortcut.modifiers.alt = alt; + return shortcut; + } + + bool sameModifiers(const Modifiers& left, const Modifiers& right) { + return left.control == right.control && left.shift == right.shift && left.alt == right.alt; + } +} + +bool Shortcut::matches(const Event& event) const { + return event.keyCode() == key && sameModifiers(event.modifiers(), modifiers); +} + +bool Shortcut::operator==(const Shortcut& other) const { + return key == other.key && sameModifiers(modifiers, other.modifiers); +} + +std::string Shortcut::text() const { + std::string text; + if (modifiers.control) text += "Ctrl+"; + if (modifiers.shift) text += "Shift+"; + if (modifiers.alt) text += "Alt+"; + return text + keyName(key); +} + +Shortcut tp::ui::ctrl(InputID key) { return make(key, true, false, false); } +Shortcut tp::ui::ctrlShift(InputID key) { return make(key, true, true, false); } +Shortcut tp::ui::ctrlAlt(InputID key) { return make(key, true, false, true); } +Shortcut tp::ui::shift(InputID key) { return make(key, false, true, false); } +Shortcut tp::ui::alt(InputID key) { return make(key, false, false, true); } +Shortcut tp::ui::plain(InputID key) { return make(key, false, false, false); } + +std::string tp::ui::keyName(InputID key) { + const auto code = (ualni) key; + if (code >= (ualni) InputID::A && code <= (ualni) InputID::Z) return std::string(1, (char) code); + if (code >= (ualni) InputID::N0 && code <= (ualni) InputID::N9) return std::string(1, (char) code); + if (code >= (ualni) InputID::F1 && code <= (ualni) InputID::F25) { + return "F" + std::to_string(code - (ualni) InputID::F1 + 1); + } + if (code >= (ualni) InputID::KP_0 && code <= (ualni) InputID::KP_9) { + return "Num" + std::to_string(code - (ualni) InputID::KP_0); + } + + switch (key) { + case InputID::SPACE: return "Space"; + case InputID::APOSTROPHE: return "'"; + case InputID::COMMA: return ","; + case InputID::MINUS: return "-"; + case InputID::PERIOD: return "."; + case InputID::SLASH: return "/"; + case InputID::SEMICOLON: return ";"; + case InputID::EQUAL: return "="; + case InputID::LEFT_BRACKET: return "["; + case InputID::BACKSLASH: return "\\"; + case InputID::RIGHT_BRACKET: return "]"; + case InputID::GRAVE_ACCENT: return "`"; + case InputID::ESCAPE: return "Esc"; + case InputID::ENTER: return "Enter"; + case InputID::TAB: return "Tab"; + case InputID::BACKSPACE: return "Back"; + case InputID::INSERT: return "Insert"; + case InputID::DELETE_KEY: return "Delete"; + case InputID::RIGHT: return "Right"; + case InputID::LEFT: return "Left"; + case InputID::DOWN: return "Down"; + case InputID::UP: return "Up"; + case InputID::PAGE_UP: return "PgUp"; + case InputID::PAGE_DOWN: return "PgDn"; + case InputID::HOME: return "Home"; + case InputID::END: return "End"; + case InputID::KP_DECIMAL: return "Num,"; + case InputID::KP_DIVIDE: return "Num/"; + case InputID::KP_MULTIPLY: return "Num*"; + case InputID::KP_SUBTRACT: return "Num-"; + case InputID::KP_ADD: return "Num+"; + case InputID::KP_ENTER: return "NumEnter"; + default: return "?"; + } +} diff --git a/Widgets/private/core/Sizing.cpp b/Widgets/private/core/Sizing.cpp new file mode 100644 index 00000000..13306645 --- /dev/null +++ b/Widgets/private/core/Sizing.cpp @@ -0,0 +1,64 @@ +#include "Sizing.hpp" + +using namespace tp; +using namespace tp::ui; + +namespace { + halnf clampLength(halnf value, halnf minimum, halnf maximum) { + if (value < minimum) return minimum; + if (value > maximum) return maximum; + return value; + } +} + +AxisSizing::AxisSizing(Mode mode, halnf length, halnf weight) : + mMode(mode), + mLength(length), + mWeight(weight) {} + +AxisSizing AxisSizing::atLeast(halnf minimum) const { + AxisSizing copy = *this; + copy.mMinimum = minimum; + return copy; +} + +AxisSizing AxisSizing::atMost(halnf maximum) const { + AxisSizing copy = *this; + copy.mMaximum = maximum; + return copy; +} + +Extent AxisSizing::resolve(const Extent& content) const { + Extent result = content; + + switch (mMode) { + case Mode::Fixed: result = Extent::rigid(mLength); break; + + case Mode::Preferred: + result.preferred = mLength; + result.maximum = mLength; + result.grow = 0; + break; + + case Mode::Fit: result.grow = 0; break; + + case Mode::Fill: + result.grow = mWeight; + result.maximum = kUnbounded; + break; + } + + const halnf lowerBound = mMinimum > result.minimum ? mMinimum : result.minimum; + const halnf upperBound = mMaximum < result.maximum ? mMaximum : result.maximum; + + result.minimum = clampLength(lowerBound, 0, mMaximum); + result.maximum = upperBound < result.minimum ? result.minimum : upperBound; + result.preferred = clampLength(result.preferred, result.minimum, result.maximum); + + return result; +} + +bool AxisSizing::operator==(const AxisSizing& other) const { + return mMode == other.mMode && mLength == other.mLength && mWeight == other.mWeight && mMinimum == other.mMinimum && + mMaximum == other.mMaximum; +} diff --git a/Widgets/private/core/TickScheduler.cpp b/Widgets/private/core/TickScheduler.cpp new file mode 100644 index 00000000..eea00a20 --- /dev/null +++ b/Widgets/private/core/TickScheduler.cpp @@ -0,0 +1,50 @@ +#include "TickScheduler.hpp" + +#include "WidgetInternals.hpp" + +#include + +using namespace tp; +using namespace tp::ui; + +void TickScheduler::start(Widget& widget) { insertUnique(mTicking, widget); } + +void TickScheduler::stop(const Widget& widget) { erase(mTicking, widget); } + +void TickScheduler::startGeometry(Widget& widget) { insertUnique(mAnimatingGeometry, widget); } + +void TickScheduler::forget(const Widget& widget) { + erase(mTicking, widget); + erase(mAnimatingGeometry, widget); +} + +void TickScheduler::advance(halnf deltaMs) { + advanceTicks(deltaMs); + advanceGeometry(deltaMs); +} + +void TickScheduler::advanceTicks(halnf deltaMs) { + const std::vector snapshot = mTicking; + for (Widget* widget : snapshot) { + const bool stillScheduled = std::find(mTicking.begin(), mTicking.end(), widget) != mTicking.end(); + if (stillScheduled) WidgetInternals::tick(*widget, deltaMs); + } +} + +void TickScheduler::advanceGeometry(halnf deltaMs) { + const std::vector snapshot = mAnimatingGeometry; + for (Widget* widget : snapshot) { + const bool keepsMoving = WidgetInternals::stepGeometry(*widget, deltaMs); + + widget->requestPaint(); + if (!keepsMoving) erase(mAnimatingGeometry, *widget); + } +} + +void TickScheduler::insertUnique(std::vector& widgets, Widget& widget) { + if (std::find(widgets.begin(), widgets.end(), &widget) == widgets.end()) widgets.push_back(&widget); +} + +void TickScheduler::erase(std::vector& widgets, const Widget& widget) { + widgets.erase(std::remove(widgets.begin(), widgets.end(), &widget), widgets.end()); +} diff --git a/Widgets/private/core/TickScheduler.hpp b/Widgets/private/core/TickScheduler.hpp new file mode 100644 index 00000000..588de5c2 --- /dev/null +++ b/Widgets/private/core/TickScheduler.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include "Environment.hpp" + +#include + +namespace tp::ui { + + class Widget; + + class TickScheduler { + public: + void start(Widget& widget); + void stop(const Widget& widget); + void startGeometry(Widget& widget); + void forget(const Widget& widget); + + void advance(halnf deltaMs); + [[nodiscard]] bool isActive() const { return !mTicking.empty() || !mAnimatingGeometry.empty(); } + [[nodiscard]] const std::vector& ticking() const { return mTicking; } + [[nodiscard]] const std::vector& animatingGeometry() const { return mAnimatingGeometry; } + + private: + static void insertUnique(std::vector& widgets, Widget& widget); + static void erase(std::vector& widgets, const Widget& widget); + + void advanceTicks(halnf deltaMs); + void advanceGeometry(halnf deltaMs); + + std::vector mTicking; + std::vector mAnimatingGeometry; + }; +} diff --git a/Widgets/private/widgets/ApplicationFrame.cpp b/Widgets/private/widgets/ApplicationFrame.cpp new file mode 100644 index 00000000..5e2961fc --- /dev/null +++ b/Widgets/private/widgets/ApplicationFrame.cpp @@ -0,0 +1,28 @@ +#include "ApplicationFrame.hpp" + +#include "BoxLayout.hpp" + +using namespace tp; +using namespace tp::ui; + +ApplicationFrame::ApplicationFrame() : + Panel(Surface::Window) { + setDebugName("application frame"); + setSizing(AxisSizing::fill(), AxisSizing::fill()); + setCornerRadius(0); + emplaceLayout(); + addChild(menuBar); +} + +void ApplicationFrame::setContent(Widget* content) { + if (content == mContent) return; + if (mContent) removeChild(*mContent); + + mContent = content; + if (!mContent) return; + + mContent->setSizing(AxisSizing::fill(), AxisSizing::fill()); + addChild(*mContent); +} + +void ApplicationFrame::setMenuBarVisible(bool visible) { menuBar.setVisible(visible); } diff --git a/Widgets/private/widgets/FloatingPanel.cpp b/Widgets/private/widgets/FloatingPanel.cpp new file mode 100644 index 00000000..6f727e8b --- /dev/null +++ b/Widgets/private/widgets/FloatingPanel.cpp @@ -0,0 +1,106 @@ +#include "FloatingPanel.hpp" + +#include "BoxLayout.hpp" +#include "Event.hpp" +#include "StackLayout.hpp" +#include "Theme.hpp" +#include "Ui.hpp" + +using namespace tp; +using namespace tp::ui; + +namespace { + constexpr halnf kGripSize = 14; +} + +FloatingPanel::FloatingPanel(std::string title) : + Panel(Surface::Raised), + mTitle(std::move(title)) { + setDebugName("floating panel"); + setBorder(true); + + emplaceLayout(0); + + mTitleBar.setSizing(AxisSizing::fill(), AxisSizing::fixed(Theme::standard().metrics.controlHeight)); + mTitleBar.emplaceLayout().setPadding(Insets::symmetric(8, 0)); + mTitle.setSizing(AxisSizing::fill(), AxisSizing::fill()); + mTitleBar.addChild(mTitle); + + mBody.setSizing(AxisSizing::fill(), AxisSizing::fill()); + mBody.emplaceLayout().setPadding(Insets::uniform(6)); + + addChild(mTitleBar); + addChild(mBody); + applySize(); +} + +void FloatingPanel::setContent(Widget* content) { + if (mContent) mBody.removeChild(*mContent); + + mContent = content; + if (mContent) mBody.addChild(*mContent); +} + +void FloatingPanel::setPanelSize(Vec2F size) { + mPanelSize = { size.x > mMinimumSize.x ? size.x : mMinimumSize.x, size.y > mMinimumSize.y ? size.y : mMinimumSize.y }; + applySize(); +} + +RectF FloatingPanel::titleBarRect() const { return mTitleBar.rect(); } + +RectF FloatingPanel::resizeGripRect() const { + return makeRect(size().x - kGripSize, size().y - kGripSize, kGripSize, kGripSize); +} + +void FloatingPanel::onEvent(Event& event) { + switch (event.type()) { + case Event::Type::PointerDown: + { + if (event.button() != PointerButton::Primary) return; + + const Vec2F local = event.position(); + if (contains(resizeGripRect(), local)) mInteraction = Interaction::Resizing; + else if (contains(titleBarRect(), local)) mInteraction = Interaction::Moving; + else return; + + mGrabOffset = mInteraction == Interaction::Moving ? local : local - size(); + bringToFront(); + ui()->capturePointer(*this); + event.accept(); + break; + } + + case Event::Type::PointerMove: + { + if (mInteraction == Interaction::None || !parent()) return; + + const Vec2F inParent = event.windowPosition() - parent()->windowPosition(); + if (mInteraction == Interaction::Moving) requestPosition(inParent - mGrabOffset); + else setPanelSize(event.position() - mGrabOffset); + event.accept(); + break; + } + + case Event::Type::PointerUp: + if (mInteraction == Interaction::None) return; + mInteraction = Interaction::None; + ui()->releasePointer(*this); + event.accept(); + break; + + default: return; + } + + requestPaint(); +} + +void FloatingPanel::paintOverlay(Painter& painter) { + const Theme& style = theme(); + const RectF grip = resizeGripRect(); + const RGBA color = isResizing() ? style.palette.accent : style.palette.mutedText; + + painter.line(grip.pos + Vec2F(grip.size.x, 4.0f), grip.pos + Vec2F(4.0f, grip.size.y), color, 1); + painter.line(grip.pos + Vec2F(grip.size.x, 9.0f), grip.pos + Vec2F(9.0f, grip.size.y), color, 1); +} + +void FloatingPanel::applySize() { setSizing(AxisSizing::preferred(mPanelSize.x), AxisSizing::preferred(mPanelSize.y)); } diff --git a/Widgets/private/widgets/MediaControls.cpp b/Widgets/private/widgets/MediaControls.cpp new file mode 100644 index 00000000..0f403a18 --- /dev/null +++ b/Widgets/private/widgets/MediaControls.cpp @@ -0,0 +1,384 @@ +#include "MediaControls.hpp" + +#include "BoxLayout.hpp" +#include "Event.hpp" +#include "Ui.hpp" +#include "Theme.hpp" + +#include +#include +#include + +using namespace tp; +using namespace tp::ui; + +namespace { + constexpr halnf kTransportSize = 32; + constexpr halnf kPrimaryTransportSize = 40; + constexpr halnf kSeekHeight = 30; + constexpr halnf kSeekTrackThickness = 4; + constexpr halnf kSeekTimeWidth = 44; + constexpr halnf kSeekKeyStepSeconds = 5; + constexpr halnf kVolumeSliderWidth = 96; + + std::vector triangle(Vec2F center, halnf size, bool pointsRight) { + const halnf half = size / 2; + const halnf direction = pointsRight ? 1.0f : -1.0f; + return { center + Vec2F(-half * 0.8f * direction, -half), + center + Vec2F(-half * 0.8f * direction, half), + center + Vec2F(half * direction, 0.0f) }; + } +} + +std::string tp::ui::formatDuration(halnf seconds) { + const long total = std::max(0L, (long) std::floor(seconds)); + char text[32]; + if (total >= 3600) std::snprintf(text, sizeof(text), "%ld:%02ld:%02ld", total / 3600, total / 60 % 60, total % 60); + else std::snprintf(text, sizeof(text), "%ld:%02ld", total / 60, total % 60); + return text; +} + +TransportButton::TransportButton(Glyph glyph, bool primary) : + Button(""), + mGlyph(glyph), + mPrimary(primary) { + setDebugName("transport button"); + setStyle(Style::Flat); +} + +void TransportButton::setGlyph(Glyph glyph) { + if (glyph == mGlyph) return; + mGlyph = glyph; + requestPaint(); +} + +Extent TransportButton::measureContent(Axis, halnf) const { + return Extent::rigid(mPrimary ? kPrimaryTransportSize : kTransportSize); +} + +RGBA TransportButton::backgroundTarget() const { + if (!mPrimary) return Button::backgroundTarget(); + + const Palette& palette = theme().palette; + if (!isEffectivelyEnabled()) return palette.control; + if (isPressed()) return palette.sliderFill; + if (isHovered()) return palette.toggleOnHovered; + return palette.toggleOn; +} + +void TransportButton::paint(Painter& painter) { + const Theme& style = theme(); + const RectF box = localRect(); + const halnf radius = box.size.x / 2; + + painter.fillRect(box, background(), radius); + if (showsFocusRing()) painter.strokeRect(insetRect(box, -2), style.palette.focusRing, radius + 2, 2); + + RGBA glyphColor = mPrimary ? style.palette.onToggle : style.palette.text; + if (!isEffectivelyEnabled()) glyphColor = style.palette.disabledText; + paintGlyph(painter, box.center(), box.size.x * 0.36f, glyphColor); +} + +void TransportButton::paintGlyph(Painter& painter, Vec2F center, halnf size, const RGBA& color) const { + const halnf half = size / 2; + + switch (mGlyph) { + case Glyph::Play: painter.fillPolygon(triangle(center + Vec2F(size * 0.08f, 0.0f), size, true), color); return; + + case Glyph::Pause: + { + const halnf bar = size * 0.32f; + painter.fillRect(makeRect(center.x - half * 0.8f, center.y - half, bar, size), color, bar / 3); + painter.fillRect(makeRect(center.x + half * 0.8f - bar, center.y - half, bar, size), color, bar / 3); + return; + } + + case Glyph::Previous: + case Glyph::Next: + { + const bool next = mGlyph == Glyph::Next; + const halnf bar = size * 0.16f; + const halnf barX = next ? center.x + half - bar : center.x - half; + painter.fillPolygon(triangle(center + Vec2F(next ? -bar : bar, 0.0f), size * 0.85f, next), color); + painter.fillRect(makeRect(barX, center.y - half, bar, size), color, bar / 2); + return; + } + + case Glyph::Volume: + case Glyph::Muted: + { + const halnf body = size * 0.3f; + const halnf left = center.x - half; + painter.fillPolygon( + { { left, center.y - body / 2 }, + { left + body, center.y - body / 2 }, + { left + body * 2, center.y - half }, + { left + body * 2, center.y + half }, + { left + body, center.y + body / 2 }, + { left, center.y + body / 2 } }, + color + ); + const halnf waveX = left + body * 2.4f; + if (mGlyph == Glyph::Muted) { + painter.line({ waveX, center.y - body }, { waveX + body * 1.6f, center.y + body }, color, 1.5f); + painter.line({ waveX, center.y + body }, { waveX + body * 1.6f, center.y - body }, color, 1.5f); + return; + } + painter.polyline( + { { waveX, center.y - body }, { waveX + body * 0.7f, center.y }, { waveX, center.y + body } }, color, 1.5f + ); + painter.polyline( + { { waveX + body * 0.8f, center.y - half * 0.9f }, + { waveX + body * 1.7f, center.y }, + { waveX + body * 0.8f, center.y + half * 0.9f } }, + color, + 1.5f + ); + return; + } + } +} + +SeekBar::SeekBar() { + setDebugName("seek bar"); + setSizing(AxisSizing::fill(), AxisSizing::fit()); +} + +void SeekBar::setDuration(halnf seconds) { + mDuration = std::max(0.0f, seconds); + requestPaint(); +} + +void SeekBar::setPosition(halnf fraction) { + mPosition = std::clamp(fraction, 0.0f, 1.0f); + requestPaint(); +} + +void SeekBar::setBuffered(halnf fraction) { + mBuffered = std::clamp(fraction, 0.0f, 1.0f); + requestPaint(); +} + +Extent SeekBar::measureContent(Axis axis, halnf) const { + if (axis == Axis::Vertical) return Extent::rigid(kSeekHeight); + return Extent::shrinkable(kSeekTimeWidth * 2 + 200, kSeekTimeWidth * 2 + 40); +} + +RectF SeekBar::trackRect() const { + const halnf inset = kSeekTimeWidth + theme().metrics.spacing; + return makeRect( + inset, (size().y - kSeekTrackThickness) / 2, std::max(0.0f, size().x - inset * 2), kSeekTrackThickness + ); +} + +halnf SeekBar::positionAt(halnf localX) const { + const RectF track = trackRect(); + if (track.size.x <= 0) return 0; + return std::clamp((localX - track.pos.x) / track.size.x, 0.0f, 1.0f); +} + +Vec2F SeekBar::windowPointForPosition(halnf fraction) const { + const RectF track = trackRect(); + return windowPosition() + Vec2F(track.pos.x + track.size.x * std::clamp(fraction, 0.0f, 1.0f), size().y / 2); +} + +void SeekBar::seekBy(halnf seconds) { + if (mDuration <= 0) return; + setPosition(mPosition + seconds / mDuration); + if (mOnSeek) mOnSeek(mPosition); +} + +void SeekBar::onEvent(Event& event) { + if (!isEffectivelyEnabled()) return; + + switch (event.type()) { + case Event::Type::PointerDown: + if (event.button() != PointerButton::Primary) return; + mScrubbing = true; + mScrubPosition = positionAt(event.position().x); + if (ui()) ui()->capturePointer(*this); + requestPaint(); + event.accept(); + return; + + case Event::Type::PointerMove: + if (!mScrubbing) return; + mScrubPosition = positionAt(event.position().x); + requestPaint(); + event.accept(); + return; + + case Event::Type::PointerUp: + if (!mScrubbing || event.button() != PointerButton::Primary) return; + mScrubbing = false; + if (ui()) ui()->releasePointer(*this); + setPosition(mScrubPosition); + if (mOnSeek) mOnSeek(mPosition); + event.accept(); + return; + + case Event::Type::KeyDown: + if (event.keyCode() == InputID::RIGHT) seekBy(kSeekKeyStepSeconds); + else if (event.keyCode() == InputID::LEFT) seekBy(-kSeekKeyStepSeconds); + else return; + event.accept(); + return; + + default: Control::onEvent(event); return; + } +} + +void SeekBar::paint(Painter& painter) { + const Theme& style = theme(); + const RectF track = trackRect(); + const halnf radius = track.size.y / 2; + const halnf shown = shownPosition(); + const bool known = mDuration > 0; + + const RectF elapsedBox = makeRect(0, 0, kSeekTimeWidth, size().y); + const RectF totalBox = makeRect(size().x - kSeekTimeWidth, 0, kSeekTimeWidth, size().y); + const RGBA timeColor = known ? style.palette.mutedText : style.palette.disabledText; + painter.text( + known ? formatDuration(shown * mDuration) : "-:--", + elapsedBox, + style.metrics.smallFontSize, + TextAlign::End, + timeColor + ); + painter.text( + known ? formatDuration(mDuration) : "-:--", totalBox, style.metrics.smallFontSize, TextAlign::Start, timeColor + ); + + painter.fillRect(track, style.palette.track, radius); + if (mBuffered > 0) { + painter.fillRect( + makeRect(track.pos, { track.size.x * mBuffered, track.size.y }), style.palette.controlBorder, radius + ); + } + if (shown > 0) { + painter.fillRect(makeRect(track.pos, { track.size.x * shown, track.size.y }), style.palette.sliderFill, radius); + } + + if (!known || !(isHovered() || mScrubbing || showsFocusRing())) return; + const halnf handleRadius = style.metrics.sliderHandleSize / 2; + const Vec2F handle = { track.pos.x + track.size.x * shown, size().y / 2 }; + if (showsFocusRing()) painter.strokeCircle(handle, handleRadius + 3, style.palette.focusRing, 2); + painter.fillCircle(handle, handleRadius, style.palette.text); +} + +VolumeControl::VolumeControl() { + setDebugName("volume control"); + setSizing(AxisSizing::fit(), AxisSizing::fit()); + emplaceLayout(4).setCrossAlign(CrossAlign::Center); + + mMute.setTooltip("Mute"); + mMute.setOnActivate([this]() { setMuted(!mMuted); }); + mSlider.setSizing(AxisSizing::fixed(kVolumeSliderWidth), AxisSizing::fit()); + mSlider.setOnChanged([this](halnf) { + mMuted = false; + mMute.setGlyph(TransportButton::Glyph::Volume); + notify(); + }); + + addChild(mMute); + addChild(mSlider); +} + +void VolumeControl::setVolume(halnf volume) { mSlider.setValue(std::clamp(volume, 0.0f, 1.0f)); } + +void VolumeControl::setMuted(bool muted) { + mMuted = muted; + mMute.setGlyph(muted ? TransportButton::Glyph::Muted : TransportButton::Glyph::Volume); + mMute.setTooltip(muted ? "Unmute" : "Mute"); + notify(); +} + +void VolumeControl::notify() { + if (mOnChanged) mOnChanged(mMuted ? 0.0f : mSlider.value()); +} + +namespace { + constexpr halnf kPlayerArtSize = 48; + constexpr halnf kPlayerSideWidth = 260; + constexpr halnf kPlayerCenterMaxWidth = 640; +} + +PlayerBar::PlayerBar() : + Panel(Surface::Base) { + setDebugName("player bar"); + setSizing(AxisSizing::fill(), AxisSizing::fit()); + RowLayout& layout = emplaceLayout(16); + layout.setPadding(Insets::symmetric(14, 8)); + layout.setCrossAlign(CrossAlign::Center); + + art.setSizing(AxisSizing::fixed(kPlayerArtSize), AxisSizing::fixed(kPlayerArtSize)); + title.setSizing(AxisSizing::fill(), AxisSizing::fit()); + artist.setSizing(AxisSizing::fill(), AxisSizing::fit()); + artist.setTone(Label::Tone::Muted); + artist.setFontSize(theme().metrics.smallFontSize); + + mText.setSizing(AxisSizing::fill(), AxisSizing::fit()); + mText.emplaceLayout(2); + mText.addChild(title); + mText.addChild(artist); + + mInfo.setSizing(AxisSizing::fixed(kPlayerSideWidth), AxisSizing::fit()); + mInfo.emplaceLayout(10).setCrossAlign(CrossAlign::Center); + mInfo.addChild(art); + mInfo.addChild(mText); + + previous.setTooltip("Previous"); + next.setTooltip("Next"); + playPause.setTooltip("Play"); + previous.setOnActivate([this]() { + if (onPrevious) onPrevious(); + }); + next.setOnActivate([this]() { + if (onNext) onNext(); + }); + playPause.setOnActivate([this]() { + if (onPlayPause) onPlayPause(); + }); + + mTransport.setSizing(AxisSizing::fit(), AxisSizing::fit()); + mTransport.emplaceLayout(10).setCrossAlign(CrossAlign::Center); + mTransport.addChild(previous); + mTransport.addChild(playPause); + mTransport.addChild(next); + + mCenter.setSizing(AxisSizing::fill().atMost(kPlayerCenterMaxWidth), AxisSizing::fit()); + ColumnLayout& center = mCenter.emplaceLayout(2); + center.setCrossAlign(CrossAlign::Center); + mCenter.addChild(mTransport); + mCenter.addChild(seek); + + mVolumeColumn.setSizing(AxisSizing::fixed(kPlayerSideWidth), AxisSizing::fit()); + RowLayout& volumeColumn = mVolumeColumn.emplaceLayout(); + volumeColumn.setMainAlign(MainAlign::End); + volumeColumn.setCrossAlign(CrossAlign::Center); + mVolumeColumn.addChild(volume); + + addChild(mInfo); + addChild(mCenter); + addChild(mVolumeColumn); + setTrack(nullptr); +} + +void PlayerBar::setTrack(const NowPlaying* track) { + mHasTrack = track != nullptr; + title.setText(track ? track->title : "Nothing playing"); + artist.setText(track ? track->artist : ""); + seek.setDuration(track ? track->durationSeconds : 0); + seek.setEnabled(mHasTrack); + if (!track) { + seek.setPosition(0); + seek.setBuffered(0); + setPlaying(false); + } +} + +void PlayerBar::setPlaying(bool playing) { + mPlaying = playing; + playPause.setGlyph(playing ? TransportButton::Glyph::Pause : TransportButton::Glyph::Play); + playPause.setTooltip(playing ? "Pause" : "Play"); +} diff --git a/Widgets/private/widgets/Notice.cpp b/Widgets/private/widgets/Notice.cpp new file mode 100644 index 00000000..1b460fa3 --- /dev/null +++ b/Widgets/private/widgets/Notice.cpp @@ -0,0 +1,49 @@ +#include "Notice.hpp" + +#include "Ui.hpp" + +using namespace tp; +using namespace tp::ui; + +Notice::Notice() { setDebugName("notice"); } + +Notice::~Notice() { hide(); } + +void Notice::show(Widget& anchor, std::string text, halnf durationMs) { + Ui* ui = anchor.ui(); + if (!ui) return; + + setText(std::move(text)); + mRemainingMs = durationMs; + + if (!ui->isPopupOpen(*this)) { + const RectF area = anchor.windowRect(); + PopupRequest request; + request.content = this; + request.anchor = makeRect(area.pos.x, area.pos.y + area.size.y / 3, area.size.x, 0); + request.options.placement = Placement::Over; + request.options.align = PlacementAlign::Center; + request.options.interactive = false; + request.options.closeOnOutsidePress = false; + request.options.closeOnEscape = false; + ui->openPopup(request); + } + + startTicking(); + requestPaint(); +} + +void Notice::hide() { + mRemainingMs = 0; + if (ui() && ui()->isPopupOpen(*this)) ui()->closePopup(*this); +} + +bool Notice::isShown() const { return ui() && ui()->isPopupOpen(*this); } + +void Notice::onTick(halnf deltaMs) { + mRemainingMs -= deltaMs; + if (mRemainingMs > 0) return; + + stopTicking(); + hide(); +} diff --git a/Widgets/private/widgets/ResizeHandle.cpp b/Widgets/private/widgets/ResizeHandle.cpp new file mode 100644 index 00000000..d9547415 --- /dev/null +++ b/Widgets/private/widgets/ResizeHandle.cpp @@ -0,0 +1,84 @@ +#include "ResizeHandle.hpp" + +#include "Event.hpp" +#include "Painter.hpp" +#include "Theme.hpp" +#include "Ui.hpp" + +using namespace tp; +using namespace tp::ui; + +namespace { + constexpr halnf kLineThickness = 2; + constexpr halnf kLineInset = 8; +} + +ResizeHandle::ResizeHandle(Axis dragAxis) : + mDragAxis(dragAxis) { + setDebugName("resize handle"); + setGeometryAnimated(false); +} + +RectF ResizeHandle::lineRect() const { + const Axis lengthAxis = crossAxis(mDragAxis); + const halnf available = lengthAlong(localRect(), lengthAxis); + const halnf inset = available > kLineInset * 4 ? kLineInset : 0; + + RectF line; + setSpanAlong(line, mDragAxis, (lengthAlong(localRect(), mDragAxis) - kLineThickness) / 2, kLineThickness); + setSpanAlong(line, lengthAxis, inset, available - inset * 2); + return line; +} + +void ResizeHandle::onEvent(Event& event) { + switch (event.type()) { + case Event::Type::PointerDown: + if (event.button() != PointerButton::Primary) return; + mDragging = true; + mGrabOffset = along(event.position(), mDragAxis) - lengthAlong(localRect(), mDragAxis) / 2; + ui()->capturePointer(*this); + if (mOnStarted) mOnStarted(); + break; + + case Event::Type::PointerMove: + if (!mDragging) return; + if (mOnDragged) mOnDragged(along(event.windowPosition(), mDragAxis) - mGrabOffset); + break; + + case Event::Type::PointerUp: + if (!mDragging || event.button() != PointerButton::Primary) return; + mDragging = false; + ui()->releasePointer(*this); + if (mOnFinished) mOnFinished(); + break; + + default: return; + } + + event.accept(); + refreshReveal(); +} + +void ResizeHandle::onHoverChanged() { refreshReveal(); } + +void ResizeHandle::refreshReveal() { + const halnf target = isHovered() || mDragging ? 1.0f : 0.0f; + if (mReveal.target() == target && !mReveal.isAnimating()) return; + + mReveal.animateTo(target); + startTicking(); +} + +void ResizeHandle::onTick(halnf deltaMs) { + if (!mReveal.advance(deltaMs)) stopTicking(); + requestPaint(); +} + +void ResizeHandle::paint(Painter& painter) { + const halnf reveal = mReveal.value(); + if (reveal <= 0.01f) return; + + const Palette& palette = theme().palette; + const RGBA base = mDragging ? palette.text : palette.mutedText; + painter.fillRect(lineRect(), { base.r, base.g, base.b, base.a * reveal }, kLineThickness / 2); +} diff --git a/Widgets/private/widgets/Toggles.cpp b/Widgets/private/widgets/Toggles.cpp new file mode 100644 index 00000000..a8bc54f9 --- /dev/null +++ b/Widgets/private/widgets/Toggles.cpp @@ -0,0 +1,186 @@ +#include "Toggles.hpp" + +#include "Theme.hpp" + +#include + +using namespace tp; +using namespace tp::ui; + +namespace { + RGBA mix(const RGBA& from, const RGBA& to, halnf amount) { return Palette::mix(from, to, amount); } + + RGBA withAlpha(const RGBA& color, halnf alpha) { return { color.r, color.g, color.b, color.a * alpha }; } +} + +ToggleControl::ToggleControl(std::string text) : + mText(std::move(text)), + mCheckedAmount(0) {} + +void ToggleControl::setChecked(bool checked) { + if (checked == mChecked) return; + + mChecked = checked; + animate(mCheckedAmount, checked ? 1.0f : 0.0f); + if (mOnChanged) mOnChanged(checked); +} + +void ToggleControl::setText(std::string text) { + mText = std::move(text); + requestLayout(); +} + +Extent ToggleControl::measureContent(Axis axis, halnf) const { + const Metrics& metrics = theme().metrics; + + if (axis == Axis::Vertical) return Extent::natural(metrics.controlHeight); + + const halnf label = mText.empty() ? 0 : metrics.spacing + textWidth(mText, metrics.fontSize); + return Extent::natural(indicatorWidth() + label); +} + +void ToggleControl::onActivated() { + if (togglesOnActivate()) setChecked(!mChecked); +} + +void ToggleControl::paint(Painter& painter) { + const Theme& style = theme(); + + paintIndicator(painter, indicatorBox(), mCheckedAmount.value()); + + if (mText.empty()) return; + + const halnf labelLeft = indicatorWidth() + style.metrics.spacing; + const RectF labelBox = makeRect(labelLeft, 0, size().x - labelLeft, size().y); + const RGBA color = isEffectivelyEnabled() ? style.palette.text : style.palette.disabledText; + painter.text(mText, labelBox, style.metrics.fontSize, TextAlign::Start, color); +} + +halnf ToggleControl::indicatorWidth() const { return theme().metrics.indicatorSize; } + +RectF ToggleControl::indicatorBox() const { + const halnf height = theme().metrics.indicatorSize; + return makeRect(0, (size().y - height) / 2, indicatorWidth(), height); +} + +Checkbox::Checkbox(std::string text) : + ToggleControl(std::move(text)) { + setDebugName("checkbox"); +} + +void Checkbox::paintIndicator(Painter& painter, const RectF& box, halnf checkedAmount) { + const Palette& palette = theme().palette; + const halnf radius = theme().metrics.indicatorCornerRadius + 1; + const RGBA uncheckedFill = isHovered() ? palette.controlHovered : palette.field; + const RGBA checkedFill = isHovered() ? palette.toggleOnHovered : palette.toggleOn; + const RGBA uncheckedBorder = isHovered() ? palette.mutedText : palette.controlBorder; + + if (showsFocusRing()) painter.strokeRect(insetRect(box, -3), palette.focusRing, radius + 3, 2); + painter.fillRect(box, mix(uncheckedFill, checkedFill, checkedAmount), radius); + painter.strokeRect(box, withAlpha(uncheckedBorder, 1 - checkedAmount), radius); + + if (checkedAmount <= 0.01f) return; + + const Vec2F origin = box.pos; + const halnf unit = box.size.x; + const halnf grow = 0.6f + 0.4f * checkedAmount; + const Vec2F center = box.center(); + const auto point = [&](halnf x, halnf y) { return center + (origin + Vec2F(unit * x, unit * y) - center) * grow; }; + painter.polyline( + { point(0.27f, 0.52f), point(0.44f, 0.68f), point(0.74f, 0.35f) }, withAlpha(palette.onToggle, checkedAmount), 2 + ); +} + +Switch::Switch(std::string text) : + ToggleControl(std::move(text)) { + setDebugName("switch"); +} + +void Switch::paintIndicator(Painter& painter, const RectF& box, halnf checkedAmount) { + const Palette& palette = theme().palette; + const halnf radius = box.size.y / 2; + const RGBA offTrack = isHovered() ? palette.controlPressed : palette.track; + const RGBA onTrack = isHovered() ? palette.toggleOnHovered : palette.toggleOn; + + if (showsFocusRing()) painter.strokeRect(insetRect(box, -3), palette.focusRing, radius + 3, 2); + painter.fillRect(box, mix(offTrack, onTrack, checkedAmount), radius); + + const halnf knobRadius = radius - 3; + const halnf travel = box.size.x - box.size.y; + const Vec2F knobCenter = { box.pos.x + radius + travel * checkedAmount, box.pos.y + radius }; + const RGBA offKnob = palette.mutedText; + painter.fillCircle(knobCenter, knobRadius, mix(offKnob, palette.onToggle, checkedAmount)); +} + +halnf Switch::indicatorWidth() const { return theme().metrics.indicatorSize * 1.9f; } + +RadioButton::RadioButton(std::string text) : + ToggleControl(std::move(text)) { + setDebugName("radio"); +} + +RadioButton::~RadioButton() { + if (mGroup) mGroup->remove(*this); +} + +void RadioButton::paintIndicator(Painter& painter, const RectF& box, halnf checkedAmount) { + const Palette& palette = theme().palette; + const Vec2F center = box.center(); + const halnf radius = box.size.y / 2; + const RGBA uncheckedFill = isHovered() ? palette.controlHovered : palette.field; + const RGBA checkedFill = isHovered() ? palette.toggleOnHovered : palette.toggleOn; + const RGBA uncheckedBorder = isHovered() ? palette.mutedText : palette.controlBorder; + + if (showsFocusRing()) painter.strokeCircle(center, radius + 3, palette.focusRing, 2); + painter.fillCircle(center, radius, mix(uncheckedFill, checkedFill, checkedAmount)); + painter.strokeCircle(center, radius, withAlpha(uncheckedBorder, 1 - checkedAmount)); + + if (checkedAmount > 0.01f) painter.fillCircle(center, radius * 0.4f * checkedAmount, palette.onToggle); +} + +void RadioButton::onActivated() { + if (mGroup) mGroup->select(*this); + else setChecked(true); +} + +RadioGroup::~RadioGroup() { + for (RadioButton* button : mButtons) + button->mGroup = nullptr; +} + +void RadioGroup::add(RadioButton& button) { + if (button.mGroup) button.mGroup->remove(button); + + button.mGroup = this; + mButtons.push_back(&button); +} + +void RadioGroup::remove(RadioButton& button) { + mButtons.erase(std::remove(mButtons.begin(), mButtons.end(), &button), mButtons.end()); + button.mGroup = nullptr; +} + +void RadioGroup::select(RadioButton& button) { + if (button.mGroup != this) return; + if (button.isChecked()) return; + + for (RadioButton* other : mButtons) + other->setChecked(other == &button); + if (mOnChanged) mOnChanged((size_t) selectedIndex()); +} + +void RadioGroup::selectIndex(size_t index) { + if (index < mButtons.size()) select(*mButtons[index]); +} + +long RadioGroup::selectedIndex() const { + for (size_t index = 0; index < mButtons.size(); index++) { + if (mButtons[index]->isChecked()) return (long) index; + } + return -1; +} + +RadioButton* RadioGroup::selected() const { + const long index = selectedIndex(); + return index >= 0 ? mButtons[(size_t) index] : nullptr; +} diff --git a/Widgets/private/widgets/WindowControls.cpp b/Widgets/private/widgets/WindowControls.cpp new file mode 100644 index 00000000..3b6792ec --- /dev/null +++ b/Widgets/private/widgets/WindowControls.cpp @@ -0,0 +1,131 @@ +#include "WindowControls.hpp" + +#include "BoxLayout.hpp" +#include "Theme.hpp" +#include "Ui.hpp" +#include "WindowHost.hpp" + +using namespace tp; +using namespace tp::ui; + +namespace { + constexpr halnf kGlyphHalfSize = 4.5f; + constexpr halnf kGlyphThickness = 1.2f; + constexpr halnf kRestoreOffset = 2; + constexpr halnf kControlsGap = 2; + + RGBA withAlpha(const RGBA& color, halnf alpha) { return { color.r, color.g, color.b, alpha }; } + + const char* debugNameOf(WindowButton::Kind kind) { + switch (kind) { + case WindowButton::Kind::Minimize: return "minimize button"; + case WindowButton::Kind::Maximize: return "maximize button"; + case WindowButton::Kind::Close: return "close button"; + } + return "window button"; + } +} + +WindowButton::WindowButton(Kind kind) : + mKind(kind) { + setStyle(Style::Flat); + setDebugName(debugNameOf(kind)); +} + +Extent WindowButton::measureContent(Axis axis, halnf) const { + const Theme& style = theme(); + return Extent::rigid(axis == Axis::Horizontal ? style.metrics.windowButtonWidth : style.metrics.iconButtonSize); +} + +RGBA WindowButton::backgroundTarget() const { + const Palette& palette = theme().palette; + + if (mKind == Kind::Close) { + if (isPressed()) return palette.windowClosePressed; + if (isHovered()) return palette.windowCloseHovered; + return withAlpha(palette.windowCloseHovered, 0); + } + + if (isPressed()) return palette.controlPressed; + if (isHovered()) return palette.controlHovered; + return withAlpha(palette.controlHovered, 0); +} + +void WindowButton::paint(Painter& painter) { + const Theme& style = theme(); + const bool closeHighlighted = mKind == Kind::Close && (isHovered() || isPressed()); + const RGBA glyph = closeHighlighted ? style.palette.onWindowClose + : isHovered() ? style.palette.text + : style.palette.mutedText; + + painter.fillRect(localRect(), background(), style.metrics.cornerRadius); + paintGlyph(painter, localRect().center(), glyph); +} + +void WindowButton::paintGlyph(Painter& painter, Vec2F center, const RGBA& color) const { + const Vec2F half = { kGlyphHalfSize, kGlyphHalfSize }; + + switch (mKind) { + case Kind::Minimize: + painter.line(center + Vec2F(-kGlyphHalfSize, 0.0f), center + Vec2F(kGlyphHalfSize, 0.0f), color, kGlyphThickness); + return; + + case Kind::Maximize: + { + const WindowHost* host = ui() ? ui()->windowHost() : nullptr; + const bool maximized = host && host->isWindowMaximized(); + const RectF box = makeRect(center - half, half * 2.0f); + + if (!maximized) { + painter.strokeRect(box, color, 2, kGlyphThickness); + return; + } + + const Vec2F shift = { kRestoreOffset, -kRestoreOffset }; + painter.strokeRect(translated(insetRect(box, 1), shift), color, 2, kGlyphThickness); + painter.fillRect(insetRect(box, 1), background(), 2); + painter.strokeRect(insetRect(box, 1), color, 2, kGlyphThickness); + return; + } + + case Kind::Close: + painter.line(center - half, center + half, color, kGlyphThickness); + painter.line( + center + Vec2F(-kGlyphHalfSize, kGlyphHalfSize), + center + Vec2F(kGlyphHalfSize, -kGlyphHalfSize), + color, + kGlyphThickness + ); + return; + } +} + +void WindowButton::onActivated() { + WindowHost* host = ui() ? ui()->windowHost() : nullptr; + if (!host) return; + + switch (mKind) { + case Kind::Minimize: host->minimizeWindow(); break; + case Kind::Maximize: host->toggleMaximizeWindow(); break; + case Kind::Close: host->closeWindow(); break; + } +} + +WindowControls::WindowControls() { + setDebugName("window controls"); + setSizing(AxisSizing::fit(), AxisSizing::fit()); + emplaceLayout(kControlsGap).setCrossAlign(CrossAlign::Center); + + addChild(mMinimize); + addChild(mMaximize); + addChild(mClose); +} + +void WindowControls::onAttached() { followWindowHost(); } + +void WindowControls::onWindowHostChanged() { followWindowHost(); } + +void WindowControls::followWindowHost() { + const WindowHost* host = ui() ? ui()->windowHost() : nullptr; + setVisible(host && host->platformInfo().hasWindowControls); +} diff --git a/Widgets/public/app/AppDriver.hpp b/Widgets/public/app/AppDriver.hpp new file mode 100644 index 00000000..aea9d186 --- /dev/null +++ b/Widgets/public/app/AppDriver.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "WidgetApplication.hpp" + +#include +#include + +namespace tp::ui { + + class AppDriver { + public: + static constexpr halnf kFrameMs = 16; + static constexpr halnf kClickGapMs = 600; + static constexpr int kSettleFrameLimit = 120; + + explicit AppDriver(WidgetApplication& application); + ~AppDriver(); + + AppDriver(const AppDriver&) = delete; + AppDriver& operator=(const AppDriver&) = delete; + + void frame(halnf deltaMs = kFrameMs); + void frames(int count); + void advance(halnf milliseconds); + + void moveTo(Vec2F windowPoint); + void press(InputID button = InputID::MOUSE1); + void release(InputID button = InputID::MOUSE1); + void click(Vec2F windowPoint, InputID button = InputID::MOUSE1); + void click(const Widget& widget); + void drag(Vec2F from, Vec2F to, int steps = 8); + void scroll(Vec2F windowPoint, halnf amount); + void penStroke(const std::vector& points, const std::vector& samples); + + void key(InputID key); + void keyDown(InputID key); + void keyUp(InputID key); + void shortcut(InputID modifier, InputID key); + void type(const std::string& utf8); + + void settle(const Widget& widget); + + [[nodiscard]] static Vec2F centerOf(const Widget& widget); + [[nodiscard]] static bool isMoving(const Widget& widget); + + private: + WidgetApplication& mApplication; + WindowInputObserver& mInput; + }; +} diff --git a/Widgets/public/app/InputTranslator.hpp b/Widgets/public/app/InputTranslator.hpp new file mode 100644 index 00000000..08d3596d --- /dev/null +++ b/Widgets/public/app/InputTranslator.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include "EventHandler.hpp" +#include "Event.hpp" +#include "WindowInput.hpp" + +#include +#include + +namespace tp::ui { + + struct InputRouting { + bool pointerBlocked = false; + bool keyboardBlocked = false; + }; + + class InputTranslator : public WindowInputObserver { + public: + [[nodiscard]] std::vector translate(const EventHandler& input, const InputRouting& routing); + + void onKey(InputID key, bool pressed) override; + void onKeyRepeat(InputID key) override; + void onText(const std::string& utf8) override; + void onKeyboardFocus(bool focused) override; + void onGesture(const TouchGesture& gesture) override; + + [[nodiscard]] const Modifiers& modifiers() const { return mModifiers; } + + private: + void translatePointer(const EventHandler& input, std::vector& events); + void translateButtons(const EventHandler& input, std::vector& events, bool pressesBlocked); + void translateWheel(const EventHandler& input, std::vector& events); + void translateKeys(std::vector& events, bool pressesBlocked); + void translateGestures(std::vector& events, bool pointerBlocked); + void updateModifiers(InputID key, bool pressed); + + struct PendingKey { + Event event; + bool press; + }; + + Vec2F mLastPointer = { -1.0e6f, -1.0e6f }; + bool mPointerKnown = false; + PointerSample mLastSample; + bool mButtonDelivered[3] = {}; + std::vector mPendingKeys; + std::vector mPendingGestures; + std::set mModifiersDown; + std::set mDeliveredKeys; + Modifiers mModifiers; + }; +} diff --git a/Widgets/public/core/Diagnostics.hpp b/Widgets/public/core/Diagnostics.hpp new file mode 100644 index 00000000..2490eafb --- /dev/null +++ b/Widgets/public/core/Diagnostics.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include "Environment.hpp" +#include "Timing.hpp" + +namespace tp::ui { + + class Widget; + class Event; + + struct FrameStats { + ualni layoutPasses = 0; + ualni measuredWidgets = 0; + ualni arrangedWidgets = 0; + ualni skippedWidgets = 0; + ualni deliveredEvents = 0; + ualni paintedWidgets = 0; + + time_ns inputNs = 0; + time_ns animationNs = 0; + time_ns layoutNs = 0; + time_ns paintNs = 0; + }; + + class UiObserver { + public: + virtual ~UiObserver() = default; + + virtual void onEventDelivering(const Widget& receiver, const Event& event) {} + virtual void onEventDelivered(const Widget& receiver, const Event& event) {} + virtual void onLayoutPassStarted() {} + virtual void onWidgetArranged(const Widget& widget) {} + virtual void onWidgetDetached(const Widget& widget) {} + }; + + struct Diagnostics { + FrameStats stats; + UiObserver* observer = nullptr; + }; + + class ScopedNanoseconds { + public: + explicit ScopedNanoseconds(time_ns& accumulator) : + mAccumulator(accumulator), + mStart(get_time_ns()) {} + + ~ScopedNanoseconds() { mAccumulator += get_time_ns() - mStart; } + + ScopedNanoseconds(const ScopedNanoseconds&) = delete; + ScopedNanoseconds& operator=(const ScopedNanoseconds&) = delete; + + private: + time_ns& mAccumulator; + time_ns mStart; + }; +} diff --git a/Widgets/public/core/Shortcut.hpp b/Widgets/public/core/Shortcut.hpp new file mode 100644 index 00000000..90e2dd8c --- /dev/null +++ b/Widgets/public/core/Shortcut.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include "Event.hpp" + +#include +#include + +namespace tp::ui { + + struct Shortcut { + InputID key = InputID::SPACE; + Modifiers modifiers; + + [[nodiscard]] bool matches(const Event& event) const; + [[nodiscard]] std::string text() const; + [[nodiscard]] bool operator==(const Shortcut& other) const; + }; + + Shortcut ctrl(InputID key); + Shortcut ctrlShift(InputID key); + Shortcut ctrlAlt(InputID key); + Shortcut shift(InputID key); + Shortcut alt(InputID key); + Shortcut plain(InputID key); + + [[nodiscard]] std::string keyName(InputID key); + + template + class ShortcutMap { + public: + struct Binding { + Shortcut shortcut; + CommandId command; + bool repeats = false; + }; + + void bind(const Shortcut& shortcut, CommandId command, bool repeats = false) { + unbind(shortcut); + mBindings.push_back({ shortcut, command, repeats }); + } + + void unbind(const Shortcut& shortcut) { + std::erase_if(mBindings, [&](const Binding& binding) { return binding.shortcut == shortcut; }); + } + + void clear() { mBindings.clear(); } + + [[nodiscard]] const Binding* find(const Event& event) const { + if (event.type() != Event::Type::KeyDown) return nullptr; + for (const Binding& binding : mBindings) { + if (!binding.shortcut.matches(event)) continue; + if (event.isRepeat() && !binding.repeats) return nullptr; + return &binding; + } + return nullptr; + } + + [[nodiscard]] std::vector shortcutsFor(CommandId command) const { + std::vector shortcuts; + for (const Binding& binding : mBindings) { + if (binding.command == command) shortcuts.push_back(binding.shortcut); + } + return shortcuts; + } + + [[nodiscard]] std::string textFor(CommandId command) const { + const std::vector shortcuts = shortcutsFor(command); + return shortcuts.empty() ? std::string() : shortcuts.front().text(); + } + + [[nodiscard]] const std::vector& bindings() const { return mBindings; } + + private: + std::vector mBindings; + }; +} diff --git a/Widgets/public/core/Sizing.hpp b/Widgets/public/core/Sizing.hpp new file mode 100644 index 00000000..b72a5db8 --- /dev/null +++ b/Widgets/public/core/Sizing.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include "Geometry.hpp" + +namespace tp::ui { + + constexpr halnf kUnbounded = 1.0e9f; + + struct Extent { + halnf minimum = 0; + halnf preferred = 0; + halnf maximum = kUnbounded; + halnf grow = 0; + + static Extent rigid(halnf length) { return { length, length, length, 0 }; } + static Extent natural(halnf length) { return { length, length, kUnbounded, 0 }; } + static Extent shrinkable(halnf preferred, halnf minimum = 0) { return { minimum, preferred, kUnbounded, 0 }; } + }; + + class AxisSizing { + public: + enum class Mode { Fit, Fixed, Preferred, Fill }; + + static AxisSizing fit() { return AxisSizing(Mode::Fit, 0, 0); } + static AxisSizing fixed(halnf length) { return AxisSizing(Mode::Fixed, length, 0); } + static AxisSizing preferred(halnf length) { return AxisSizing(Mode::Preferred, length, 0); } + static AxisSizing fill(halnf weight = 1) { return AxisSizing(Mode::Fill, 0, weight); } + + [[nodiscard]] AxisSizing atLeast(halnf minimum) const; + [[nodiscard]] AxisSizing atMost(halnf maximum) const; + + [[nodiscard]] Mode mode() const { return mMode; } + [[nodiscard]] halnf fixedLength() const { return mLength; } + [[nodiscard]] halnf fillWeight() const { return mWeight; } + [[nodiscard]] halnf minimum() const { return mMinimum; } + [[nodiscard]] halnf maximum() const { return mMaximum; } + + [[nodiscard]] Extent resolve(const Extent& content) const; + + bool operator==(const AxisSizing& other) const; + + private: + AxisSizing(Mode mode, halnf length, halnf weight); + + Mode mMode; + halnf mLength; + halnf mWeight; + halnf mMinimum = 0; + halnf mMaximum = kUnbounded; + }; + + struct Sizing { + AxisSizing horizontal = AxisSizing::fit(); + AxisSizing vertical = AxisSizing::fit(); + + [[nodiscard]] const AxisSizing& along(Axis axis) const { return axis == Axis::Horizontal ? horizontal : vertical; } + + AxisSizing& along(Axis axis) { return axis == Axis::Horizontal ? horizontal : vertical; } + }; +} diff --git a/Widgets/public/widgets/ApplicationFrame.hpp b/Widgets/public/widgets/ApplicationFrame.hpp new file mode 100644 index 00000000..5d2b6095 --- /dev/null +++ b/Widgets/public/widgets/ApplicationFrame.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "Containers.hpp" +#include "Menus.hpp" + +namespace tp::ui { + + class ApplicationFrame : public Panel { + public: + ApplicationFrame(); + + void setContent(Widget* content); + [[nodiscard]] Widget* content() const { return mContent; } + + void setMenuBarVisible(bool visible); + [[nodiscard]] bool isMenuBarVisible() const { return menuBar.isVisible(); } + + MenuBar menuBar; + + protected: + [[nodiscard]] Widget* defaultKeyTarget() override { return mContent ? mContent : this; } + + private: + Widget* mContent = nullptr; + }; +} diff --git a/Widgets/public/widgets/Dialog.hpp b/Widgets/public/widgets/Dialog.hpp new file mode 100644 index 00000000..ff505889 --- /dev/null +++ b/Widgets/public/widgets/Dialog.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include "Button.hpp" +#include "Containers.hpp" +#include "Label.hpp" + +#include +#include +#include +#include + +namespace tp::ui { + + class Dialog : public Panel { + public: + explicit Dialog(std::string title = {}); + ~Dialog() override; + + void setTitle(std::string title); + void setMessage(std::string message); + [[nodiscard]] const std::string& title() const { return mTitle.text(); } + [[nodiscard]] const std::string& message() const { return mMessage.text(); } + + [[nodiscard]] Widget& body() { return mBody; } + + Button& addButton(std::string text, std::function action, bool isDefault = false); + Button& addCancelButton(std::string text = "Cancel"); + [[nodiscard]] const std::vector>& buttons() const { return mButtons; } + void setOnCancel(std::function onCancel) { mOnCancel = std::move(onCancel); } + + void open(Ui& ui, Widget* owner = nullptr); + void close(); + [[nodiscard]] bool isOpen() const; + + void accept(); + void cancel(); + + protected: + void onEvent(Event& event) override; + + private: + Label mTitle; + Label mMessage; + Widget mBody; + Widget mButtonRow; + Spacer mButtonSpacer; + std::vector> mButtons; + Button* mDefault = nullptr; + std::function mOnCancel; + }; + + class ConfirmDialog : public Dialog { + public: + ConfirmDialog(); + + void ask(Ui& ui, std::string title, std::string message, std::string confirmText, std::function onConfirm); + + private: + std::function mOnConfirm; + Button* mConfirm = nullptr; + }; +} diff --git a/Widgets/public/widgets/MediaControls.hpp b/Widgets/public/widgets/MediaControls.hpp new file mode 100644 index 00000000..8b80b3a0 --- /dev/null +++ b/Widgets/public/widgets/MediaControls.hpp @@ -0,0 +1,144 @@ +#pragma once + +#include "Button.hpp" +#include "Containers.hpp" +#include "Control.hpp" +#include "Label.hpp" +#include "Slider.hpp" + +#include +#include + +namespace tp::ui { + + class TransportButton : public Button { + public: + enum class Glyph { Play, Pause, Previous, Next, Volume, Muted }; + + explicit TransportButton(Glyph glyph, bool primary = false); + + void setGlyph(Glyph glyph); + [[nodiscard]] Glyph glyph() const { return mGlyph; } + [[nodiscard]] bool isPrimary() const { return mPrimary; } + + protected: + [[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override; + void paint(Painter& painter) override; + [[nodiscard]] RGBA backgroundTarget() const override; + + private: + void paintGlyph(Painter& painter, Vec2F center, halnf size, const RGBA& color) const; + + Glyph mGlyph; + bool mPrimary; + }; + + std::string formatDuration(halnf seconds); + + class SeekBar : public Control { + public: + SeekBar(); + + void setDuration(halnf seconds); + [[nodiscard]] halnf duration() const { return mDuration; } + + void setPosition(halnf fraction); + [[nodiscard]] halnf position() const { return mPosition; } + + void setBuffered(halnf fraction); + [[nodiscard]] halnf buffered() const { return mBuffered; } + + [[nodiscard]] bool isScrubbing() const { return mScrubbing; } + [[nodiscard]] halnf shownPosition() const { return mScrubbing ? mScrubPosition : mPosition; } + [[nodiscard]] Vec2F windowPointForPosition(halnf fraction) const; + + void setOnSeek(std::function onSeek) { mOnSeek = std::move(onSeek); } + + protected: + [[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override; + void onEvent(Event& event) override; + void paint(Painter& painter) override; + + private: + [[nodiscard]] RectF trackRect() const; + [[nodiscard]] halnf positionAt(halnf localX) const; + void seekBy(halnf seconds); + + halnf mDuration = 0; + halnf mPosition = 0; + halnf mBuffered = 0; + halnf mScrubPosition = 0; + bool mScrubbing = false; + std::function mOnSeek; + }; + + class VolumeControl : public Widget { + public: + VolumeControl(); + + void setVolume(halnf volume); + [[nodiscard]] halnf volume() const { return mSlider.value(); } + [[nodiscard]] bool isMuted() const { return mMuted; } + void setMuted(bool muted); + + void setOnChanged(std::function onChanged) { mOnChanged = std::move(onChanged); } + + TransportButton& muteButton() { return mMute; } + Slider& slider() { return mSlider; } + + private: + void notify(); + + TransportButton mMute{ TransportButton::Glyph::Volume }; + Slider mSlider{ 0, 1, 1 }; + bool mMuted = false; + std::function mOnChanged; + }; +} + +namespace tp::ui { + + struct NowPlaying { + std::string title; + std::string artist; + std::string album; + halnf durationSeconds = 0; + bool loved = false; + }; + + class PlayerBar : public Panel { + public: + PlayerBar(); + + void setTrack(const NowPlaying* track); + [[nodiscard]] bool hasTrack() const { return mHasTrack; } + + void setPlaying(bool playing); + [[nodiscard]] bool isPlaying() const { return mPlaying; } + + void setPosition(halnf fraction) { seek.setPosition(fraction); } + void setBuffered(halnf fraction) { seek.setBuffered(fraction); } + + std::function onPlayPause; + std::function onPrevious; + std::function onNext; + + Panel art{ Surface::Raised }; + Label title; + Label artist; + TransportButton previous{ TransportButton::Glyph::Previous }; + TransportButton playPause{ TransportButton::Glyph::Play, true }; + TransportButton next{ TransportButton::Glyph::Next }; + SeekBar seek; + VolumeControl volume; + + private: + bool mHasTrack = false; + bool mPlaying = false; + Widget mInfo; + Widget mText; + Widget mCenter; + Widget mTransport; + Widget mVolumeColumn; + }; +} diff --git a/Widgets/public/widgets/Notice.hpp b/Widgets/public/widgets/Notice.hpp new file mode 100644 index 00000000..e20ac9ac --- /dev/null +++ b/Widgets/public/widgets/Notice.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "Animation.hpp" +#include "Label.hpp" + +#include + +namespace tp::ui { + + class Notice : public TooltipBubble { + public: + static constexpr halnf kDefaultDurationMs = 2500; + + Notice(); + ~Notice() override; + + void show(Widget& anchor, std::string text, halnf durationMs = kDefaultDurationMs); + void hide(); + [[nodiscard]] bool isShown() const; + [[nodiscard]] halnf remainingMs() const { return mRemainingMs; } + + protected: + void onTick(halnf deltaMs) override; + + private: + halnf mRemainingMs = 0; + }; +} diff --git a/Widgets/public/widgets/ResizeHandle.hpp b/Widgets/public/widgets/ResizeHandle.hpp new file mode 100644 index 00000000..581df01e --- /dev/null +++ b/Widgets/public/widgets/ResizeHandle.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "Animation.hpp" +#include "Widget.hpp" + +#include + +namespace tp::ui { + + class ResizeHandle : public Widget { + public: + explicit ResizeHandle(Axis dragAxis); + + void setOnDragStarted(std::function onStarted) { mOnStarted = std::move(onStarted); } + void setOnDragged(std::function onDragged) { mOnDragged = std::move(onDragged); } + void setOnDragFinished(std::function onFinished) { mOnFinished = std::move(onFinished); } + + [[nodiscard]] Axis dragAxis() const { return mDragAxis; } + [[nodiscard]] bool isDragging() const { return mDragging; } + [[nodiscard]] halnf revealAmount() const { return mReveal.value(); } + + [[nodiscard]] RectF lineRect() const; + + protected: + void onEvent(Event& event) override; + void onHoverChanged() override; + void onTick(halnf deltaMs) override; + void paint(Painter& painter) override; + + private: + void refreshReveal(); + + Axis mDragAxis; + bool mDragging = false; + halnf mGrabOffset = 0; + AnimatedFloat mReveal{ 0, Motion::quick() }; + + std::function mOnStarted; + std::function mOnDragged; + std::function mOnFinished; + }; +} diff --git a/Widgets/public/widgets/WindowControls.hpp b/Widgets/public/widgets/WindowControls.hpp new file mode 100644 index 00000000..279adb62 --- /dev/null +++ b/Widgets/public/widgets/WindowControls.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "Button.hpp" +#include "Widget.hpp" + +namespace tp::ui { + + class WindowButton : public Button { + public: + enum class Kind { Minimize, Maximize, Close }; + + explicit WindowButton(Kind kind); + + [[nodiscard]] Kind kind() const { return mKind; } + + protected: + [[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override; + void paint(Painter& painter) override; + [[nodiscard]] RGBA backgroundTarget() const override; + void onActivated() override; + + private: + void paintGlyph(Painter& painter, Vec2F center, const RGBA& color) const; + + Kind mKind; + }; + + class WindowControls : public Widget { + public: + WindowControls(); + + [[nodiscard]] WindowButton& minimizeButton() { return mMinimize; } + [[nodiscard]] WindowButton& maximizeButton() { return mMaximize; } + [[nodiscard]] WindowButton& closeButton() { return mClose; } + + protected: + void onAttached() override; + void onWindowHostChanged() override; + + private: + void followWindowHost(); + + WindowButton mMinimize{ WindowButton::Kind::Minimize }; + WindowButton mMaximize{ WindowButton::Kind::Maximize }; + WindowButton mClose{ WindowButton::Kind::Close }; + }; +} diff --git a/Widgets/tests/AlgorithmTests.cpp b/Widgets/tests/AlgorithmTests.cpp new file mode 100644 index 00000000..1d0743f5 --- /dev/null +++ b/Widgets/tests/AlgorithmTests.cpp @@ -0,0 +1,256 @@ +#include "Animation.hpp" +#include "Popup.hpp" +#include "SpaceDistribution.hpp" + +#include "UnitTest++/UnitTest++.h" + +using namespace tp; +using namespace tp::ui; + +namespace { + constexpr float kTolerance = 0.05f; + + Extent grower(halnf preferred, halnf weight = 1, halnf minimum = 0, halnf maximum = kUnbounded) { + return { minimum, preferred, maximum, weight }; + } +} + +SUITE(AxisSizingResolution) { + TEST(FixedIgnoresContent) { + const Extent resolved = AxisSizing::fixed(120).resolve(Extent::shrinkable(40)); + CHECK_CLOSE(120, resolved.minimum, kTolerance); + CHECK_CLOSE(120, resolved.preferred, kTolerance); + CHECK_CLOSE(120, resolved.maximum, kTolerance); + CHECK_CLOSE(0, resolved.grow, kTolerance); + } + + TEST(FitKeepsContentPreferenceWithoutGrowing) { + const Extent resolved = AxisSizing::fit().resolve(Extent::shrinkable(40)); + CHECK_CLOSE(40, resolved.preferred, kTolerance); + CHECK_CLOSE(0, resolved.grow, kTolerance); + } + + TEST(PreferredNeverDropsBelowContentMinimum) { + const Extent roomy = AxisSizing::preferred(200).resolve(Extent::shrinkable(40, 30)); + CHECK_CLOSE(30, roomy.minimum, kTolerance); + CHECK_CLOSE(200, roomy.preferred, kTolerance); + CHECK_CLOSE(200, roomy.maximum, kTolerance); + + const Extent tight = AxisSizing::preferred(10).resolve(Extent::shrinkable(40, 30)); + CHECK_CLOSE(30, tight.preferred, kTolerance); + CHECK_CLOSE(30, tight.maximum, kTolerance); + } + + TEST(FillGrowsWithWeight) { + const Extent resolved = AxisSizing::fill(3).resolve(Extent::shrinkable(40)); + CHECK_CLOSE(40, resolved.preferred, kTolerance); + CHECK_CLOSE(3, resolved.grow, kTolerance); + } + + TEST(BoundsClampPreferenceAndRange) { + const Extent resolved = AxisSizing::fill().atLeast(50).atMost(80).resolve(Extent::shrinkable(10)); + CHECK_CLOSE(50, resolved.minimum, kTolerance); + CHECK_CLOSE(50, resolved.preferred, kTolerance); + CHECK_CLOSE(80, resolved.maximum, kTolerance); + } + + TEST(MinimumAboveMaximumWins) { + const Extent resolved = AxisSizing::fit().atLeast(90).atMost(40).resolve(Extent::shrinkable(10)); + CHECK_CLOSE(40, resolved.minimum, kTolerance); + CHECK(resolved.maximum >= resolved.minimum); + } +} + +SUITE(SpaceDistribution) { + TEST(RigidItemsKeepPreferredSizes) { + const auto lengths = distributeSpace({ Extent::rigid(10), Extent::rigid(20) }, 100); + CHECK_CLOSE(10, lengths[0], kTolerance); + CHECK_CLOSE(20, lengths[1], kTolerance); + } + + TEST(EqualWeightsShareFreeSpaceEqually) { + const auto lengths = distributeSpace({ grower(0), grower(0) }, 100); + CHECK_CLOSE(50, lengths[0], kTolerance); + CHECK_CLOSE(50, lengths[1], kTolerance); + } + + TEST(WeightsSplitProportionally) { + const auto lengths = distributeSpace({ grower(0, 1), grower(0, 3) }, 100); + CHECK_CLOSE(25, lengths[0], kTolerance); + CHECK_CLOSE(75, lengths[1], kTolerance); + } + + TEST(GrowingStartsFromPreferredSizes) { + const auto lengths = distributeSpace({ grower(20), grower(40) }, 100); + CHECK_CLOSE(40, lengths[0], kTolerance); + CHECK_CLOSE(60, lengths[1], kTolerance); + } + + TEST(FrozenItemGivesSpaceToOthers) { + const auto lengths = distributeSpace({ grower(0, 1, 0, 10), grower(0) }, 100); + CHECK_CLOSE(10, lengths[0], kTolerance); + CHECK_CLOSE(90, lengths[1], kTolerance); + } + + TEST(RigidPlusGrowerFillsRemainder) { + const auto lengths = distributeSpace({ Extent::rigid(30), grower(0) }, 100); + CHECK_CLOSE(30, lengths[0], kTolerance); + CHECK_CLOSE(70, lengths[1], kTolerance); + } + + TEST(ShrinkingIsProportionalToSlackAboveMinimum) { + const auto lengths = distributeSpace({ grower(60, 0, 50), grower(60, 0, 0) }, 80); + const halnf deficit = 40; + const halnf totalSlack = 10 + 60; + CHECK_CLOSE(60 - deficit * 10 / totalSlack, lengths[0], kTolerance); + CHECK_CLOSE(60 - deficit * 60 / totalSlack, lengths[1], kTolerance); + CHECK(lengths[0] >= 50); + } + + TEST(OverflowStopsAtMinimums) { + const auto lengths = distributeSpace({ grower(40, 0, 40), grower(40, 0, 40) }, 50); + CHECK_CLOSE(40, lengths[0], kTolerance); + CHECK_CLOSE(40, lengths[1], kTolerance); + } + + TEST(FractionalSpaceTerminates) { + std::vector items(7, grower(0, 1, 0, 13.37f)); + const auto lengths = distributeSpace(items, 333.333f); + for (halnf length : lengths) + CHECK_CLOSE(13.37f, length, kTolerance); + } + + TEST(EmptyInputReturnsEmpty) { CHECK(distributeSpace({}, 100).empty()); } +} + +SUITE(PixelSnapping) { + TEST(EdgesAreRoundedSoSegmentsTile) { + const auto segments = placeSnapped({ 33.3f, 33.3f, 33.4f }, 0, 0); + CHECK_EQUAL(0, segments[0].start); + CHECK_EQUAL(segments[0].start + segments[0].length, segments[1].start); + CHECK_EQUAL(segments[1].start + segments[1].length, segments[2].start); + CHECK_EQUAL(100, segments[2].start + segments[2].length); + } + + TEST(GapsSeparateSegments) { + const auto segments = placeSnapped({ 10, 10 }, 5, 4); + CHECK_EQUAL(5, segments[0].start); + CHECK_EQUAL(19, segments[1].start); + } +} + +SUITE(PopupPlacement) { + const RectF kScreen = makeRect(0, 0, 400, 300); + const RectF kAnchor = makeRect(100, 100, 80, 20); + + TEST(BelowAlignedToStart) { + PopupOptions options; + options.gap = 2; + const RectF placed = placePopup(kAnchor, { 120, 50 }, kScreen, options); + CHECK_CLOSE(100, placed.pos.x, kTolerance); + CHECK_CLOSE(122, placed.pos.y, kTolerance); + } + + TEST(RightOfAnchor) { + PopupOptions options; + options.placement = Placement::Right; + options.gap = 0; + const RectF placed = placePopup(kAnchor, { 50, 50 }, kScreen, options); + CHECK_CLOSE(180, placed.pos.x, kTolerance); + CHECK_CLOSE(100, placed.pos.y, kTolerance); + } + + TEST(CenterAlignment) { + PopupOptions options; + options.align = PlacementAlign::Center; + options.gap = 0; + const RectF placed = placePopup(kAnchor, { 40, 10 }, kScreen, options); + CHECK_CLOSE(120, placed.pos.x, kTolerance); + } + + TEST(FlipsAboveWhenNoRoomBelow) { + PopupOptions options; + options.gap = 0; + const RectF lowAnchor = makeRect(100, 270, 80, 20); + const RectF placed = placePopup(lowAnchor, { 100, 100 }, kScreen, options); + CHECK_CLOSE(170, placed.pos.y, kTolerance); + } + + TEST(FlipsLeftWhenNoRoomRight) { + PopupOptions options; + options.placement = Placement::Right; + options.gap = 0; + const RectF edgeAnchor = makeRect(350, 50, 40, 20); + const RectF placed = placePopup(edgeAnchor, { 100, 50 }, kScreen, options); + CHECK_CLOSE(250, placed.pos.x, kTolerance); + } + + TEST(SlidesInsideScreenOnCrossAxis) { + PopupOptions options; + options.gap = 0; + const RectF edgeAnchor = makeRect(380, 10, 20, 20); + const RectF placed = placePopup(edgeAnchor, { 100, 50 }, kScreen, options); + CHECK_CLOSE(300, placed.pos.x, kTolerance); + } + + TEST(OversizedPopupPinnedToOrigin) { + PopupOptions options; + const RectF placed = placePopup(kAnchor, { 1000, 1000 }, kScreen, options); + CHECK_CLOSE(0, placed.pos.x, kTolerance); + CHECK_CLOSE(0, placed.pos.y, kTolerance); + } +} + +SUITE(SpringAnimation) { + TEST(ConvergesToTarget) { + AnimatedFloat value(0, Motion::quick()); + value.animateTo(100); + + int frames = 0; + while (value.advance(16) && frames < 1000) + frames++; + + CHECK_CLOSE(100, value.value(), 0.01f); + CHECK(frames < 120); + CHECK(!value.isAnimating()); + } + + TEST(MovesTowardTargetMonotonicallyAtFirst) { + AnimatedFloat value(0, Motion::smooth()); + value.animateTo(10); + value.advance(16); + const halnf first = value.value(); + value.advance(16); + CHECK(first > 0); + CHECK(value.value() > first); + } + + TEST(JumpAndFinishAreImmediate) { + AnimatedRect rect(makeRect(0, 0, 10, 10)); + rect.animateTo(makeRect(50, 50, 20, 20)); + CHECK(rect.isAnimating()); + + rect.finish(); + CHECK(!rect.isAnimating()); + CHECK(rect.value() == makeRect(50, 50, 20, 20)); + + rect.jumpTo(makeRect(1, 2, 3, 4)); + CHECK(rect.value() == makeRect(1, 2, 3, 4)); + } + + TEST(ColorChannelsAnimateTogether) { + AnimatedColor color(RGBA(0, 0, 0, 1)); + color.animateTo(RGBA(1, 0.5f, 0, 1)); + while (color.advance(16)) { + } + CHECK_CLOSE(1, color.value().r, 0.01f); + CHECK_CLOSE(0.5f, color.value().g, 0.01f); + } + + TEST(LargeTimeStepIsStable) { + AnimatedFloat value(0, Motion::quick()); + value.animateTo(1); + value.advance(5000); + CHECK_CLOSE(1, value.value(), 0.01f); + } +} diff --git a/Widgets/tests/DockingTests.cpp b/Widgets/tests/DockingTests.cpp new file mode 100644 index 00000000..a21eb6d0 --- /dev/null +++ b/Widgets/tests/DockingTests.cpp @@ -0,0 +1,750 @@ +#include "Docking.hpp" +#include "Probes.hpp" +#include "Theme.hpp" +#include "Ui.hpp" +#include "UiDriver.hpp" + +#include "UnitTest++/UnitTest++.h" + +#include +#include + +using namespace tp; +using namespace tp::ui; +using namespace tp::ui::testing; + +namespace { + MenuItem& menuItem(Menu& menu, const std::string& text) { + for (const auto& item : menu.items()) { + if (item->entry().text == text) return *item; + } + throw std::runtime_error("no menu item " + text); + } + + void chooseViewMode(UiDriver& driver, DockPanel& panel, const std::string& mode) { + driver.click(panel.optionsButton()); + driver.hoverFor(menuItem(panel.optionsMenu(), "View Mode"), 50); + Menu& modes = *menuItem(panel.optionsMenu(), "View Mode").entry().submenu; + driver.click(menuItem(modes, mode)); + } + + struct DockScene { + DockArea area; + EventProbe editor{ { 100, 100 }, "editor" }; + DockPanel project{ "Project", DockSide::Left }; + DockPanel structure{ "Structure", DockSide::Left }; + DockPanel terminal{ "Terminal", DockSide::Bottom }; + DockPanel inspector{ "Inspector", DockSide::Right }; + Box projectContent; + Box terminalContent; + + DockScene() { + editor.setSizing(AxisSizing::fill(), AxisSizing::fill()); + area.setCenter(&editor); + + project.setContent(&projectContent); + terminal.setContent(&terminalContent); + + for (DockPanel* panel : { &project, &structure, &terminal, &inspector }) + area.addPanel(*panel); + } + + [[nodiscard]] Vec2F dropPoint(DockSide side) const { + return area.windowPosition() + area.dropTarget(side).center(); + } + + [[nodiscard]] Vec2F headerPoint(const DockPanel& panel) const { + return panel.windowPosition() + Vec2F(20.0f, panel.headerRect().pos.y + panel.headerRect().size.y / 2); + } + }; + + const Vec2F kWindow = { 1200, 800 }; + const halnf kGap = Theme::standard().metrics.surfaceGap; +} + +SUITE(DockStripes) { + TEST(StripesListPanelsAndPanelsStartHidden) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + + CHECK(scene.area.stripeRect(DockSide::Left).size.x > 0); + CHECK(scene.area.stripeRect(DockSide::Right).size.x > 0); + CHECK(!scene.project.isVisible()); + CHECK(scene.area.stripeButton(scene.project) != nullptr); + CHECK_CLOSE(scene.area.stripeRect(DockSide::Left).size.x, scene.editor.rect().pos.x, 0.01f); + } + + TEST(ClickingStripeButtonTogglesPanel) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + Button& button = *scene.area.stripeButton(scene.project); + + driver.click(button); + CHECK(scene.project.isOpen()); + CHECK(scene.project.isVisible()); + CHECK(button.isSelected()); + + driver.click(button); + CHECK(!scene.project.isOpen()); + CHECK(!button.isSelected()); + } + + TEST(BottomPanelsStackAtTheBottomOfTheLeftStripe) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + + const RectF projectButton = scene.area.stripeButton(scene.project)->rect(); + const RectF terminalButton = scene.area.stripeButton(scene.terminal)->rect(); + + CHECK(projectButton.pos.y < 100); + CHECK(terminalButton.pos.y > kWindow.y - 100); + } + + TEST(StripeButtonShowsFullTitleVertically) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + StripeButton& button = *scene.area.stripeButton(scene.project); + + CHECK_EQUAL("Project", button.text()); + CHECK_EQUAL("Project", button.tooltip()); + CHECK(button.size().y > button.size().x); + CHECK(button.size().y >= button.textWidth("Project", Theme::standard().metrics.smallFontSize)); + CHECK(driver.paint().hasText("Project")); + } + + TEST(StripeButtonsOnOneRailDoNotOverlap) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + + const RectF project = scene.area.stripeButton(scene.project)->rect(); + const RectF structure = scene.area.stripeButton(scene.structure)->rect(); + + CHECK(structure.pos.y >= project.pos.y + project.size.y); + } + + TEST(StripeButtonsHaveFillAndBorder) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + const RectF button = scene.area.stripeButton(scene.project)->windowRect(); + + bool filled = false; + bool bordered = false; + for (const PaintCommand& command : driver.paint().commands()) { + if (!(command.rect == button)) continue; + filled |= command.kind == PaintCommand::Kind::FillRect && command.color.a > 0; + bordered |= command.kind == PaintCommand::Kind::StrokeRect; + } + + CHECK(filled); + CHECK(bordered); + } +} + +SUITE(DockedPanels) { + TEST(PinnedPanelPushesMainViewAside) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + const halnf stripe = scene.area.stripeRect(DockSide::Left).size.x; + + scene.area.openPanel(scene.project); + driver.layout(); + + CHECK_CLOSE(stripe, scene.project.rect().pos.x, 0.01f); + CHECK_CLOSE(260, scene.project.size().x, 0.01f); + CHECK_CLOSE(stripe + 260 + kGap, scene.editor.rect().pos.x, 0.01f); + } + + TEST(OnlyOnePanelPerSideIsOpen) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + + scene.area.openPanel(scene.project); + scene.area.openPanel(scene.structure); + + CHECK(!scene.project.isOpen()); + CHECK(scene.structure.isOpen()); + } + + TEST(PanelsOnDifferentSidesCoexist) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + + scene.area.openPanel(scene.project); + scene.area.openPanel(scene.terminal); + scene.area.openPanel(scene.inspector); + driver.layout(); + + const RectF editor = scene.editor.windowRect(); + CHECK(scene.project.isOpen() && scene.terminal.isOpen() && scene.inspector.isOpen()); + CHECK_CLOSE(scene.project.windowRect().pos.x + scene.project.size().x + kGap, editor.pos.x, 0.01f); + CHECK_CLOSE(scene.inspector.windowRect().pos.x, editor.pos.x + editor.size.x + kGap, 0.01f); + CHECK_CLOSE(scene.terminal.windowRect().pos.y, editor.pos.y + editor.size.y + kGap, 0.01f); + CHECK_CLOSE(editor.pos.x, scene.terminal.windowRect().pos.x, 0.01f); + } + + TEST(DraggingEdgeResizesDockedPanel) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + driver.layout(); + + const RectF panel = scene.project.windowRect(); + const Vec2F edge = { panel.pos.x + panel.size.x + kGap / 2, panel.center().y }; + driver.drag(edge, edge + Vec2F(140.0f, 0.0f)); + + CHECK_CLOSE(400, scene.project.size().x, 1); + CHECK_CLOSE(scene.project.windowRect().pos.x + 400 + kGap, scene.editor.windowRect().pos.x, 1); + } + + TEST(ResizeIsLimitedSoMainViewStaysVisible) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.terminal); + driver.layout(); + + const RectF panel = scene.terminal.windowRect(); + const Vec2F edge = { panel.center().x, panel.pos.y - kGap / 2 }; + driver.drag(edge, Vec2F(edge.x, -500.0f)); + + CHECK(scene.editor.size().y > 0); + CHECK(scene.terminal.size().y <= kWindow.y * 0.86f); + } + + TEST(HideButtonClosesPanel) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + + driver.click(scene.project.hideButton()); + + CHECK(!scene.project.isOpen()); + } +} + +SUITE(ResizeHandles) { + TEST(EdgeHandleSitsCenteredInTheGapAndIsHiddenUntilHovered) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + driver.settle(); + + ResizeHandle& handle = scene.area.edgeHandle(DockSide::Left); + const RectF panel = scene.project.windowRect(); + const RectF line = translated(handle.lineRect(), handle.windowPosition()); + CHECK_CLOSE(panel.pos.x + panel.size.x + kGap / 2, line.center().x, 0.01f); + CHECK_CLOSE(0, handle.revealAmount(), 0.01f); + + driver.moveTo(handle.windowRect().center()); + driver.settle(); + CHECK_CLOSE(1, handle.revealAmount(), 0.01f); + + driver.moveTo(scene.editor.windowRect().center()); + driver.settle(); + CHECK(!handle.isHovered()); + CHECK_CLOSE(0, handle.revealAmount(), 0.01f); + } + + TEST(EdgeHandleStaysLitWhileDraggingOutsideItAndFadesAfter) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + driver.settle(); + ResizeHandle& handle = scene.area.edgeHandle(DockSide::Left); + + driver.moveTo(handle.windowRect().center()); + driver.press(); + driver.moveTo(handle.windowRect().center() + Vec2F(120.0f, 0.0f)); + driver.settle(); + CHECK_CLOSE(1, handle.revealAmount(), 0.01f); + + driver.release(); + driver.moveTo(scene.editor.windowRect().center()); + driver.settle(); + CHECK_CLOSE(0, handle.revealAmount(), 0.01f); + CHECK(!driver.ui().needsFrame()); + } + + TEST(OnlyPinnedDockedSidesHaveHandles) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + driver.settle(); + CHECK(scene.area.edgeHandle(DockSide::Left).isVisible()); + CHECK(!scene.area.edgeHandle(DockSide::Right).isVisible()); + + chooseViewMode(driver, scene.project, "Float"); + CHECK(!scene.area.edgeHandle(DockSide::Left).isVisible()); + CHECK(scene.project.cornerGrip().isVisible()); + } +} + +SUITE(UnpinnedPanels) { + TEST(UnpinnedPanelOverlaysMainViewInsteadOfPushingIt) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + const halnf editorLeft = scene.editor.rect().pos.x; + scene.area.openPanel(scene.project); + + chooseViewMode(driver, scene.project, "Dock Unpinned"); + driver.layout(); + + CHECK(scene.project.viewMode() == DockPanel::ViewMode::Undocked); + CHECK(scene.project.isOpen()); + CHECK_CLOSE(editorLeft, scene.editor.rect().pos.x, 0.01f); + CHECK(driver.ui().widgetAt(scene.project.windowRect().center()) != &scene.editor); + } + + TEST(UnpinnedPanelHidesWhenFocusMovesToMainView) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + chooseViewMode(driver, scene.project, "Dock Unpinned"); + + driver.click(scene.editor.windowRect().pos + scene.editor.size() - Vec2F(20.0f, 20.0f)); + + CHECK(!scene.project.isOpen()); + CHECK_EQUAL(1, scene.editor.count(Event::Type::Click)); + } + + TEST(UnpinnedPanelStaysOpenWhileUsingIt) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + chooseViewMode(driver, scene.project, "Dock Unpinned"); + + driver.click(scene.projectContent); + + CHECK(scene.project.isOpen()); + } + + TEST(StripeButtonTogglesUnpinnedPanelWithoutFlicker) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + chooseViewMode(driver, scene.project, "Dock Unpinned"); + driver.click(scene.editor); + REQUIRE CHECK(!scene.project.isOpen()); + + driver.click(*scene.area.stripeButton(scene.project)); + CHECK(scene.project.isOpen()); + + driver.click(*scene.area.stripeButton(scene.project)); + CHECK(!scene.project.isOpen()); + } + + TEST(PinningAgainRestoresDockedLayout) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + chooseViewMode(driver, scene.project, "Dock Unpinned"); + + chooseViewMode(driver, scene.project, "Dock Pinned"); + driver.layout(); + + CHECK(scene.project.viewMode() == DockPanel::ViewMode::Docked); + CHECK_CLOSE( + scene.project.windowRect().pos.x + scene.project.size().x + kGap, scene.editor.windowRect().pos.x, 0.01f + ); + } +} + +SUITE(PanelOptionsMenu) { + TEST(HeaderHasOnlyTitleOptionsAndHide) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + driver.layout(); + + CHECK(scene.project.optionsButton().size().x <= 24); + CHECK(scene.project.hideButton().size().x <= 24); + CHECK(driver.paint().hasText("Project")); + CHECK(!driver.paint().hasText("Pin")); + CHECK(!driver.paint().hasText("Float")); + } + + TEST(OptionsMenuMarksCurrentViewModeAndSide) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + + driver.click(scene.project.optionsButton()); + CHECK(driver.ui().isPopupOpen(scene.project.optionsMenu())); + + Menu& modes = *menuItem(scene.project.optionsMenu(), "View Mode").entry().submenu; + Menu& sides = *menuItem(scene.project.optionsMenu(), "Move To").entry().submenu; + CHECK(menuItem(modes, "Dock Pinned").entry().isChecked()); + CHECK(!menuItem(modes, "Float").entry().isChecked()); + CHECK(menuItem(sides, "Left").entry().isChecked()); + } + + TEST(MoveToRightDocksPanelOnRight) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + + driver.click(scene.project.optionsButton()); + driver.hoverFor(menuItem(scene.project.optionsMenu(), "Move To"), 50); + Menu& sides = *menuItem(scene.project.optionsMenu(), "Move To").entry().submenu; + driver.click(menuItem(sides, "Right")); + driver.settle(); + + CHECK(scene.project.side() == DockSide::Right); + CHECK(!driver.ui().isPopupOpen(scene.project.optionsMenu())); + CHECK_CLOSE( + scene.project.windowRect().pos.x, scene.editor.windowRect().pos.x + scene.editor.size().x + kGap, 0.01f + ); + } + + TEST(UsingOptionsMenuDoesNotHideUnpinnedPanel) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + chooseViewMode(driver, scene.project, "Dock Unpinned"); + REQUIRE CHECK(scene.project.isOpen()); + + driver.click(scene.project.optionsButton()); + driver.hoverFor(menuItem(scene.project.optionsMenu(), "Move To"), 50); + + CHECK(scene.project.isOpen()); + } +} + +SUITE(DraggingPanels) { + TEST(DraggingHeaderUndocksAndShowsDropPreviewOverTargetSide) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + driver.layout(); + + driver.moveTo(scene.headerPoint(scene.project)); + driver.press(); + driver.moveTo(Vec2F(600.0f, 300.0f)); + + CHECK(scene.project.isDragging()); + CHECK(scene.area.isDraggingPanel()); + CHECK(!scene.area.isPreviewingDrop()); + CHECK(scene.editor.rect().pos.x < 100); + + driver.moveTo(scene.dropPoint(DockSide::Right)); + REQUIRE CHECK(scene.area.isPreviewingDrop()); + driver.settle(); + CHECK(scene.area.dropPreview().pos.x > kWindow.x / 2); + CHECK(scene.area.dropPreview().size.y > kWindow.y / 2); + + driver.release(); + CHECK(scene.project.side() == DockSide::Right); + CHECK(scene.project.viewMode() == DockPanel::ViewMode::Docked); + CHECK(!scene.area.isPreviewingDrop()); + driver.layout(); + CHECK_CLOSE( + scene.project.windowRect().pos.x, scene.editor.windowRect().pos.x + scene.editor.size().x + kGap, 0.01f + ); + } + + TEST(DroppingOnSideMovesStripeButtonAndReplacesOpenPanel) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.terminal); + scene.area.openPanel(scene.project); + driver.layout(); + + driver.drag(scene.headerPoint(scene.project), scene.dropPoint(DockSide::Bottom), 8); + + CHECK(scene.project.side() == DockSide::Bottom); + CHECK(scene.project.isOpen()); + CHECK(!scene.terminal.isOpen()); + CHECK(scene.area.stripeButton(scene.project)->rect().pos.y > kWindow.y / 2); + } + + TEST(DroppingInTheMiddleFloatsThePanel) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + driver.layout(); + + driver.drag(scene.headerPoint(scene.project), Vec2F(600.0f, 300.0f), 8); + driver.layout(); + + CHECK(scene.project.viewMode() == DockPanel::ViewMode::Float); + CHECK(scene.project.isOpen()); + CHECK(contains(scene.project.windowRect(), Vec2F(600.0f, 300.0f))); + CHECK_CLOSE(scene.area.stripeRect(DockSide::Left).size.x, scene.editor.rect().pos.x, 0.01f); + } + + TEST(ShortPressOnHeaderDoesNotUndock) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + driver.layout(); + + const Vec2F point = scene.headerPoint(scene.project); + driver.drag(point, point + Vec2F(2.0f, 1.0f), 1); + + CHECK(scene.project.viewMode() == DockPanel::ViewMode::Docked); + CHECK(scene.project.side() == DockSide::Left); + } +} + +SUITE(FloatingDockPanels) { + TEST(FloatButtonFloatsAndMovingKeepsItFloating) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + chooseViewMode(driver, scene.project, "Float"); + driver.layout(); + + CHECK(scene.project.viewMode() == DockPanel::ViewMode::Float); + const Vec2F start = scene.project.windowPosition(); + + const Vec2F grab = scene.headerPoint(scene.project); + driver.drag(grab, grab + Vec2F(120.0f, 80.0f), 6); + + CHECK(scene.project.viewMode() == DockPanel::ViewMode::Float); + CHECK_CLOSE(start.x + 120, scene.project.windowPosition().x, 1); + CHECK_CLOSE(start.y + 80, scene.project.windowPosition().y, 1); + } + + TEST(CornerGripResizesFloatingPanelWithMinimum) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + chooseViewMode(driver, scene.project, "Float"); + driver.layout(); + const Vec2F before = scene.project.size(); + + const Vec2F corner = scene.project.windowPosition() + before - Vec2F(3.0f, 3.0f); + driver.drag(corner, corner + Vec2F(60.0f, 40.0f)); + CHECK_CLOSE(before.x + 60, scene.project.size().x, 1); + CHECK_CLOSE(before.y + 40, scene.project.size().y, 1); + + const Vec2F newCorner = scene.project.windowPosition() + scene.project.size() - Vec2F(3.0f, 3.0f); + driver.drag(newCorner, newCorner - Vec2F(2000.0f, 2000.0f)); + CHECK_CLOSE(160, scene.project.size().x, 1); + CHECK_CLOSE(120, scene.project.size().y, 1); + } + + TEST(FloatingPanelDoesNotCloseDockedPanelOnSameSide) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + chooseViewMode(driver, scene.project, "Float"); + + scene.area.openPanel(scene.structure); + + CHECK(scene.project.isOpen()); + CHECK(scene.structure.isOpen()); + } + + TEST(DockingFromFloatReturnsToZone) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + chooseViewMode(driver, scene.project, "Float"); + driver.layout(); + + chooseViewMode(driver, scene.project, "Dock Pinned"); + driver.layout(); + + CHECK(scene.project.viewMode() == DockPanel::ViewMode::Docked); + CHECK_CLOSE( + scene.project.windowRect().pos.x + scene.project.size().x + kGap, scene.editor.windowRect().pos.x, 0.01f + ); + } +} + +SUITE(DockAnimation) { + TEST(OpeningDockedPanelAnimatesLayoutOfPanelAndMainView) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + driver.setAutoSettle(false); + driver.settle(); + const RectF editorBefore = scene.editor.rect(); + + scene.area.openPanel(scene.project); + driver.ui().updateLayout(); + driver.advance(16); + + CHECK(scene.project.rect().size.x > 0); + CHECK(scene.project.rect().size.x < scene.project.targetRect().size.x); + CHECK(scene.editor.rect().pos.x > editorBefore.pos.x); + CHECK(scene.editor.rect().pos.x < scene.editor.targetRect().pos.x); + CHECK_CLOSE(scene.project.rect().pos.x + scene.project.rect().size.x + kGap, scene.editor.rect().pos.x, 40); + + driver.settle(); + CHECK(scene.project.rect() == scene.project.targetRect()); + CHECK(scene.editor.rect() == scene.editor.targetRect()); + CHECK_CLOSE(scene.project.rect().pos.x + scene.project.rect().size.x + kGap, scene.editor.rect().pos.x, 0.01f); + } + + TEST(ChildrenFollowAnimatedParentGeometry) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + driver.setAutoSettle(false); + driver.settle(); + + scene.area.openPanel(scene.project); + driver.ui().updateLayout(); + driver.advance(32); + + const RectF panel = scene.project.windowRect(); + const RectF content = scene.projectContent.windowRect(); + CHECK(content.pos.x + content.size.x <= panel.pos.x + panel.size.x + 0.01f); + } + + TEST(GeometrySpringMatchesPreviousLibraryCurve) { + AnimatedRect rect(makeRect(0, 0, 0, 0), Motion::fluid(), AnimatedRect::kGeometryTolerance); + rect.animateTo(makeRect(100, 0, 0, 0)); + + halnf peak = 0; + halnf elapsed = 0; + halnf ninetyPercentAt = -1; + while (rect.advance(1) && elapsed < 2000) { + elapsed += 1; + peak = std::max(peak, rect.value().pos.x); + if (ninetyPercentAt < 0 && rect.value().pos.x >= 90) ninetyPercentAt = elapsed; + } + + CHECK(peak <= 100.5f); + CHECK(ninetyPercentAt > 100 && ninetyPercentAt < 170); + CHECK(elapsed < 600); + } + + TEST(DraggedPanelGlidesTowardsPointer) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + driver.settle(); + driver.setAutoSettle(false); + + driver.moveTo(scene.headerPoint(scene.project)); + driver.press(); + driver.moveTo(Vec2F(600.0f, 300.0f)); + driver.ui().updateLayout(); + driver.advance(16); + + CHECK(!(scene.project.rect() == scene.project.targetRect())); + + driver.settle(); + CHECK(scene.project.rect() == scene.project.targetRect()); + driver.release(); + } + + TEST(DroppedPanelSpringsIntoItsZone) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + driver.settle(); + driver.setAutoSettle(false); + + driver.drag(scene.headerPoint(scene.project), scene.dropPoint(DockSide::Right), 8); + driver.ui().updateLayout(); + driver.advance(16); + + CHECK(!(scene.project.rect() == scene.project.targetRect())); + CHECK(driver.ui().needsFrame()); + + driver.settle(); + CHECK(scene.project.rect() == scene.project.targetRect()); + CHECK(scene.project.side() == DockSide::Right); + } + + TEST(EdgeResizeTracksPointerExactly) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + driver.settle(); + driver.setAutoSettle(false); + + const RectF panel = scene.project.windowRect(); + const Vec2F edge = { panel.pos.x + panel.size.x + kGap / 2, panel.center().y }; + driver.moveTo(edge); + driver.press(); + driver.moveTo(edge + Vec2F(100.0f, 0.0f)); + driver.ui().updateLayout(); + + CHECK(scene.project.rect() == scene.project.targetRect()); + CHECK(scene.editor.rect() == scene.editor.targetRect()); + driver.release(); + } +} + +SUITE(DockLifecycle) { + TEST(DestroyingPanelRemovesItsStripeButton) { + DockArea area; + Box center; + area.setCenter(¢er); + UiDriver driver(area, kWindow); + { + DockPanel temporary("Temporary"); + area.addPanel(temporary); + area.openPanel(temporary); + driver.layout(); + CHECK_EQUAL(1u, area.panels().size()); + } + CHECK(area.panels().empty()); + driver.layout(); + driver.paint(); + } + + TEST(PaintingIsBalancedInEveryMode) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + scene.area.openPanel(scene.terminal); + CHECK(driver.paint().isBalanced()); + + chooseViewMode(driver, scene.project, "Float"); + CHECK(driver.paint().isBalanced()); + CHECK(driver.paint().hasText("Project")); + } +} + +SUITE(UnpinnedPanelResize) { + TEST(UnpinnedPanelEdgeCanBeDraggedWithoutPushingMainView) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + chooseViewMode(driver, scene.project, "Dock Unpinned"); + driver.settle(); + const halnf editorLeft = scene.editor.windowRect().pos.x; + const halnf width = scene.project.size().x; + + ResizeHandle& handle = scene.area.edgeHandle(DockSide::Left); + CHECK(handle.isVisible()); + const RectF panel = scene.project.windowRect(); + CHECK_CLOSE(panel.pos.x + panel.size.x + kGap / 2, handle.windowRect().center().x, 1.0f); + + driver.drag(handle.windowRect().center(), handle.windowRect().center() + Vec2F(80.0f, 0.0f)); + driver.settle(); + CHECK(scene.project.isOpen()); + CHECK_CLOSE(width + 80, scene.project.size().x, 1.0f); + CHECK_CLOSE(editorLeft, scene.editor.windowRect().pos.x, 0.01f); + } + + TEST(UnpinnedHiddenPanelHasNoHandle) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + chooseViewMode(driver, scene.project, "Dock Unpinned"); + scene.area.hidePanel(scene.project); + driver.settle(); + CHECK(!scene.area.edgeHandle(DockSide::Left).isVisible()); + } + + TEST(ClickingOutsideAfterResizingHidesUnpinnedPanel) { + DockScene scene; + UiDriver driver(scene.area, kWindow); + scene.area.openPanel(scene.project); + chooseViewMode(driver, scene.project, "Dock Unpinned"); + driver.settle(); + + ResizeHandle& handle = scene.area.edgeHandle(DockSide::Left); + driver.drag(handle.windowRect().center(), handle.windowRect().center() + Vec2F(60.0f, 0.0f)); + driver.settle(); + CHECK(scene.project.isOpen()); + + driver.click(scene.editor.windowRect().center() + Vec2F(200.0f, 0.0f)); + driver.settle(); + CHECK(!scene.project.isOpen()); + } +} diff --git a/Widgets/tests/LayoutTests.cpp b/Widgets/tests/LayoutTests.cpp new file mode 100644 index 00000000..37659721 --- /dev/null +++ b/Widgets/tests/LayoutTests.cpp @@ -0,0 +1,653 @@ +#include "BoxLayout.hpp" +#include "Containers.hpp" +#include "Label.hpp" +#include "Probes.hpp" +#include "StackLayout.hpp" +#include "UiDriver.hpp" + +#include "UnitTest++/UnitTest++.h" + +#include +#include +#include + +using namespace tp; +using namespace tp::ui; +using namespace tp::ui::testing; + +namespace { + void checkRect(const RectF& expected, const RectF& actual) { + CHECK_CLOSE(expected.pos.x, actual.pos.x, 0.01f); + CHECK_CLOSE(expected.pos.y, actual.pos.y, 0.01f); + CHECK_CLOSE(expected.size.x, actual.size.x, 0.01f); + CHECK_CLOSE(expected.size.y, actual.size.y, 0.01f); + } +} + +SUITE(BoxLayoutArrangement) { + TEST(ColumnStacksChildrenWithGapAndPadding) { + Widget root; + root.emplaceLayout(5).setPadding(Insets::uniform(10)); + Box first({ 40, 20 }); + Box second({ 60, 30 }); + root.addChild(first); + root.addChild(second); + + UiDriver driver(root, { 200, 200 }); + + checkRect(makeRect(0, 0, 200, 200), root.rect()); + checkRect(makeRect(10, 10, 180, 20), first.rect()); + checkRect(makeRect(10, 35, 180, 30), second.rect()); + } + + TEST(RowLaysOutHorizontallyAndStretchesCrossAxis) { + Widget root; + root.emplaceLayout(4); + Box first({ 40, 20 }); + Box second({ 60, 30 }); + root.addChild(first); + root.addChild(second); + + UiDriver driver(root, { 300, 100 }); + + checkRect(makeRect(0, 0, 40, 100), first.rect()); + checkRect(makeRect(44, 0, 60, 100), second.rect()); + } + + TEST(CrossAlignCenterUsesPreferredCrossSize) { + Widget root; + root.emplaceLayout().setCrossAlign(CrossAlign::Center); + Box child({ 40, 20 }); + root.addChild(child); + + UiDriver driver(root, { 300, 100 }); + + checkRect(makeRect(0, 40, 40, 20), child.rect()); + } + + TEST(CrossAlignEnd) { + Widget root; + root.emplaceLayout().setCrossAlign(CrossAlign::End); + Box child({ 40, 20 }); + root.addChild(child); + + UiDriver driver(root, { 300, 100 }); + + checkRect(makeRect(260, 0, 40, 20), child.rect()); + } + + TEST(FillChildrenShareRemainingSpaceByWeight) { + Widget root; + root.emplaceLayout(); + Box fixed({ 100, 10 }); + Box single; + Box triple; + fixed.setSizing(AxisSizing::fixed(100), AxisSizing::fit()); + single.setSizing(AxisSizing::fill(1), AxisSizing::fit()); + triple.setSizing(AxisSizing::fill(3), AxisSizing::fit()); + root.addChild(fixed); + root.addChild(single); + root.addChild(triple); + + UiDriver driver(root, { 500, 50 }); + + CHECK_CLOSE(100, fixed.rect().size.x, 0.01f); + CHECK_CLOSE(100, single.rect().size.x, 0.01f); + CHECK_CLOSE(300, triple.rect().size.x, 0.01f); + CHECK_CLOSE(200, triple.rect().pos.x, 0.01f); + } + + TEST(FillRespectsMaximumAndRedistributes) { + Widget root; + root.emplaceLayout(); + Box capped; + Box free; + capped.setSizing(AxisSizing::fit(), AxisSizing::fill().atMost(50)); + free.setSizing(AxisSizing::fit(), AxisSizing::fill()); + root.addChild(capped); + root.addChild(free); + + UiDriver driver(root, { 100, 400 }); + + CHECK_CLOSE(50, capped.rect().size.y, 0.01f); + CHECK_CLOSE(350, free.rect().size.y, 0.01f); + } + + TEST(MainAlignCenterAndEnd) { + Widget root; + BoxLayout& layout = root.emplaceLayout(); + Box child({ 100, 10 }); + root.addChild(child); + UiDriver driver(root, { 400, 50 }); + + layout.setMainAlign(MainAlign::Center); + driver.layout(); + CHECK_CLOSE(150, child.rect().pos.x, 0.01f); + + layout.setMainAlign(MainAlign::End); + driver.layout(); + CHECK_CLOSE(300, child.rect().pos.x, 0.01f); + } + + TEST(SpaceBetweenSpreadsChildren) { + Widget root; + root.emplaceLayout().setMainAlign(MainAlign::SpaceBetween); + Box first({ 50, 10 }); + Box second({ 50, 10 }); + Box third({ 50, 10 }); + root.addChild(first); + root.addChild(second); + root.addChild(third); + + UiDriver driver(root, { 350, 50 }); + + CHECK_CLOSE(0, first.rect().pos.x, 0.01f); + CHECK_CLOSE(150, second.rect().pos.x, 0.01f); + CHECK_CLOSE(300, third.rect().pos.x, 0.01f); + } + + TEST(HiddenChildrenTakeNoSpace) { + Widget root; + root.emplaceLayout(10); + Box first({ 10, 20 }); + Box hidden; + Box last({ 10, 20 }); + hidden.setSizing(AxisSizing::fit(), AxisSizing::fixed(100)); + root.addChild(first); + root.addChild(hidden); + root.addChild(last); + hidden.setVisible(false); + + UiDriver driver(root, { 100, 300 }); + + CHECK_CLOSE(30, last.rect().pos.y, 0.01f); + + hidden.setVisible(true); + driver.layout(); + CHECK_CLOSE(140, last.rect().pos.y, 0.01f); + } + + TEST(OverflowShrinksFlexibleChildrenButNotRigidOnes) { + Widget root; + root.emplaceLayout(); + Box rigid; + Box flexible({ 10, 400 }); + rigid.setSizing(AxisSizing::fit(), AxisSizing::fixed(100)); + root.addChild(rigid); + root.addChild(flexible); + + UiDriver driver(root, { 100, 300 }); + + CHECK_CLOSE(100, rigid.rect().size.y, 0.01f); + CHECK_CLOSE(200, flexible.rect().size.y, 0.01f); + } + + TEST(OverflowTakesSpaceFromScrollableContentNotFromControls) { + Widget root; + root.emplaceLayout(); + Label header("Header"); + Widget content; + content.setSizing(AxisSizing::fill(), AxisSizing::fill()); + content.emplaceLayout(); + Box tall({ 10, 2000 }); + content.addChild(tall); + root.addChild(header); + root.addChild(content); + + UiDriver driver(root, { 200, 300 }); + + CHECK_CLOSE(driver.ui().theme().metrics.lineHeight, header.size().y, 0.01f); + CHECK_CLOSE(300 - header.size().y, content.size().y, 0.01f); + } + + TEST(LayoutChangesAnimateWidgetsAndTheirSiblings) { + Widget root; + root.emplaceLayout(); + Box first({ 100, 50 }); + Box second({ 100, 50 }); + root.addChild(first); + root.addChild(second); + UiDriver driver(root, { 600, 100 }); + driver.setAutoSettle(false); + + first.setPreferred({ 300, 50 }); + driver.ui().updateLayout(); + driver.advance(32); + + CHECK(first.size().x > 100 && first.size().x < 300); + CHECK(second.rect().pos.x > 100 && second.rect().pos.x < 300); + CHECK(driver.ui().needsFrame()); + + driver.settle(); + CHECK_CLOSE(300, first.size().x, 0.01f); + CHECK_CLOSE(300, second.rect().pos.x, 0.01f); + } + + TEST(ShownWidgetGrowsIntoPlace) { + Widget root; + root.emplaceLayout(); + Box header({ 100, 30 }); + Box details({ 100, 80 }); + details.setVisible(false); + root.addChild(header); + root.addChild(details); + UiDriver driver(root, { 200, 300 }); + driver.setAutoSettle(false); + + details.setVisible(true); + driver.ui().updateLayout(); + CHECK(details.size().y < 1); + + driver.advance(48); + CHECK(details.size().y > 1 && details.size().y < 80); + + driver.settle(); + CHECK_CLOSE(80, details.size().y, 0.01f); + } + + TEST(DisablingGeometryAnimationAppliesLayoutImmediately) { + Widget root; + root.emplaceLayout(); + Box first({ 100, 50 }); + first.setGeometryAnimated(false); + root.addChild(first); + UiDriver driver(root, { 600, 100 }); + driver.setAutoSettle(false); + + first.setPreferred({ 300, 50 }); + driver.ui().updateLayout(); + + CHECK_CLOSE(300, first.size().x, 0.01f); + } + + TEST(EmptyContainerIsHarmless) { + Widget root; + root.emplaceLayout(10); + UiDriver driver(root, { 100, 100 }); + checkRect(makeRect(0, 0, 100, 100), root.rect()); + } +} + +SUITE(FitSizing) { + TEST(FitContainerWrapsItsChildren) { + Widget root; + root.emplaceLayout(); + + Widget card; + card.emplaceLayout(4).setPadding(Insets::uniform(8)); + Box a({ 100, 20 }); + Box b({ 60, 30 }); + card.addChild(a); + card.addChild(b); + root.addChild(card); + + UiDriver driver(root, { 800, 600 }); + + CHECK_CLOSE(116, card.rect().size.x, 0.01f); + CHECK_CLOSE(8 + 20 + 4 + 30 + 8, card.rect().size.y, 0.01f); + } + + TEST(FitContainerFollowsChildGrowthInSameFrame) { + Widget root; + root.emplaceLayout(); + Widget card; + card.emplaceLayout(); + Box child({ 50, 20 }); + card.addChild(child); + root.addChild(card); + UiDriver driver(root); + + child.setPreferred({ 70, 90 }); + driver.layout(); + + CHECK_CLOSE(70, card.rect().size.x, 0.01f); + CHECK_CLOSE(90, card.rect().size.y, 0.01f); + } + + TEST(NestedFillInsideFitInsideFixedResolvesInOnePass) { + Widget root; + root.emplaceLayout(); + + Widget fixedFrame; + fixedFrame.setSizing(AxisSizing::fixed(300), AxisSizing::fixed(200)); + fixedFrame.emplaceLayout(); + + Widget fitColumn; + fitColumn.emplaceLayout(); + Box content({ 80, 40 }); + Box stretch; + stretch.setSizing(AxisSizing::fill(), AxisSizing::fill()); + fitColumn.addChild(content); + fitColumn.addChild(stretch); + + Box sibling; + sibling.setSizing(AxisSizing::fill(), AxisSizing::fill()); + + fixedFrame.addChild(fitColumn); + fixedFrame.addChild(sibling); + root.addChild(fixedFrame); + + UiDriver driver(root); + + CHECK_CLOSE(80, fitColumn.rect().size.x, 0.01f); + CHECK_CLOSE(200, fitColumn.rect().size.y, 0.01f); + CHECK_CLOSE(160, stretch.rect().size.y, 0.01f); + CHECK_CLOSE(220, sibling.rect().size.x, 0.01f); + } +} + +SUITE(OtherLayouts) { + TEST(StackPlacesEveryChildOverWholeArea) { + Widget root; + root.emplaceLayout().setPadding(Insets::uniform(5)); + Box bottom; + Box top; + root.addChild(bottom); + root.addChild(top); + + UiDriver driver(root, { 100, 80 }); + + checkRect(makeRect(5, 5, 90, 70), bottom.rect()); + checkRect(makeRect(5, 5, 90, 70), top.rect()); + } + + TEST(FreeLayoutUsesRequestedPositionAndPreferredSize) { + Widget root; + root.emplaceLayout(); + Box child({ 30, 40 }); + child.requestPosition({ 25, 35 }); + root.addChild(child); + + UiDriver driver(root); + + checkRect(makeRect(25, 35, 30, 40), child.rect()); + + child.requestPosition({ 100, 10 }); + driver.layout(); + checkRect(makeRect(100, 10, 30, 40), child.rect()); + } + + TEST(GridPlacesChildrenInRowsOfEqualColumns) { + Widget root; + root.emplaceLayout(2, 10); + Box cells[3] = { Box({ 10, 20 }), Box({ 10, 40 }), Box({ 10, 30 }) }; + for (Box& cell : cells) + root.addChild(cell); + + UiDriver driver(root, { 210, 300 }); + + checkRect(makeRect(0, 0, 100, 40), cells[0].rect()); + checkRect(makeRect(110, 0, 100, 40), cells[1].rect()); + checkRect(makeRect(0, 50, 100, 30), cells[2].rect()); + } +} + +SUITE(IncrementalLayout) { + TEST(SecondPassWithoutChangesArrangesNothing) { + Widget root; + root.emplaceLayout(); + Box children[10]; + for (Box& child : children) { + child.setPreferred({ 10, 10 }); + root.addChild(child); + } + UiDriver driver(root); + + driver.ui().resetStats(); + driver.layout(); + CHECK_EQUAL(0u, driver.ui().stats().arrangedWidgets); + CHECK_EQUAL(0u, driver.ui().stats().layoutPasses); + } + + TEST(ChangingOneLeafDoesNotRemeasureSiblingsSubtrees) { + Widget root; + root.emplaceLayout(); + + Widget left; + left.emplaceLayout(); + Box leftLeaves[20]; + for (Box& leaf : leftLeaves) { + leaf.setPreferred({ 5, 5 }); + left.addChild(leaf); + } + + Box changing({ 10, 10 }); + root.addChild(left); + root.addChild(changing); + + UiDriver driver(root); + driver.ui().resetStats(); + + changing.setPreferred({ 10, 20 }); + driver.layout(); + + CHECK(driver.ui().stats().measuredWidgets < 6); + CHECK_CLOSE(20, changing.rect().size.y, 0.01f); + } + + TEST(ViewportResizeRelayouts) { + Widget root; + root.emplaceLayout(); + Box fill; + fill.setSizing(AxisSizing::fill(), AxisSizing::fill()); + root.addChild(fill); + UiDriver driver(root, { 100, 100 }); + + driver.resize({ 640, 480 }); + checkRect(makeRect(0, 0, 640, 480), fill.rect()); + } + + TEST(LayoutChangeIsVisibleImmediatelyAfterDispatch) { + Widget root; + root.emplaceLayout(); + EventProbe probe; + Box other({ 10, 10 }); + root.addChild(probe); + root.addChild(other); + UiDriver driver(root); + + probe.setPreferred({ 50, 100 }); + driver.moveTo({ 5, 105 }); + + CHECK_CLOSE(100, other.rect().pos.y, 0.01f); + CHECK(driver.ui().hoveredWidget() == &other); + } +} + +SUITE(TreeOwnership) { + TEST(DestroyingChildDetachesItFromParent) { + Widget root; + root.emplaceLayout(); + UiDriver driver(root); + { + Box temporary({ 10, 10 }); + root.addChild(temporary); + driver.layout(); + CHECK_EQUAL(1u, root.children().size()); + } + CHECK_EQUAL(0u, root.children().size()); + driver.layout(); + } + + TEST(ReparentingMovesChild) { + Widget first; + Widget second; + Box child; + first.addChild(child); + second.addChild(child); + + CHECK(child.parent() == &second); + CHECK(first.children().empty()); + } + + TEST(CannotCreateCycles) { + Widget parent; + Widget child; + parent.addChild(child); + child.addChild(parent); + + CHECK(parent.parent() == nullptr); + CHECK(child.children().empty()); + } + + TEST(AttachedSubtreeKnowsItsUi) { + Widget root; + Widget middle; + Box leaf; + middle.addChild(leaf); + UiDriver driver(root); + + CHECK(leaf.ui() == nullptr); + root.addChild(middle); + CHECK(leaf.ui() == &driver.ui()); + root.removeChild(middle); + CHECK(leaf.ui() == nullptr); + } + + TEST(WindowPositionAccumulatesAncestors) { + Widget root; + root.emplaceLayout().setPadding(Insets::uniform(10)); + Widget inner; + inner.emplaceLayout().setPadding(Insets::uniform(7)); + Box leaf({ 5, 5 }); + inner.addChild(leaf); + root.addChild(inner); + UiDriver driver(root); + + CHECK_CLOSE(17, leaf.windowPosition().x, 0.01f); + CHECK_CLOSE(17, leaf.windowPosition().y, 0.01f); + } +} + +SUITE(PaintCulling) { + TEST(OnlyVisibleRowsOfLongScrolledListArePainted) { + Widget root; + root.emplaceLayout(); + ScrollView scroll; + scroll.setSizing(AxisSizing::fill(), AxisSizing::fill()); + Widget list; + list.emplaceLayout(4); + std::vector> rows; + for (int index = 0; index < 3000; index++) { + rows.push_back(std::make_unique(Vec2F(200.0f, 70.0f), "row " + std::to_string(index))); + list.addChild(*rows.back()); + } + scroll.setContent(&list); + root.addChild(scroll); + UiDriver driver(root, { 600, 700 }); + + driver.paint(); + const ualni painted = driver.ui().stats().paintedWidgets; + CHECK(painted < 40); + + scroll.scrollTo(scroll.maximumOffset()); + driver.settle(); + const ualni before = driver.ui().stats().paintedWidgets; + driver.paint(); + const ualni paintedAtEnd = driver.ui().stats().paintedWidgets - before; + CHECK(paintedAtEnd < 40); + CHECK(paintedAtEnd > 5); + CHECK(overlaps(rows.back()->windowRect(), scroll.windowRect())); + } + + TEST(OverhangingDecorationsNearTheEdgeStillPaint) { + Widget root; + root.emplaceLayout(); + ScrollView scroll; + scroll.setSizing(AxisSizing::fill(), AxisSizing::fixed(100)); + Widget list; + list.emplaceLayout(); + Box first(Vec2F(200.0f, 100.0f), "first"); + Box second(Vec2F(200.0f, 100.0f), "second"); + list.addChild(first); + list.addChild(second); + scroll.setContent(&list); + root.addChild(scroll); + UiDriver driver(root, { 400, 300 }); + driver.paint(); + + const ualni before = driver.ui().stats().paintedWidgets; + driver.paint(); + CHECK(driver.ui().stats().paintedWidgets - before >= 4); + } +} + +SUITE(FillSizing) { + TEST(FillGrowsPastContentMaximum) { + Widget root; + root.emplaceLayout(); + Widget row; + row.setSizing(AxisSizing::fill(), AxisSizing::fit()); + RowLayout& rowLayout = row.emplaceLayout(); + Box fixedA(Vec2F(100.0f, 20.0f), "a"); + fixedA.setSizing(AxisSizing::fixed(100), AxisSizing::fixed(20)); + Box fixedB(Vec2F(100.0f, 20.0f), "b"); + fixedB.setSizing(AxisSizing::fixed(100), AxisSizing::fixed(20)); + row.addChild(fixedA); + row.addChild(fixedB); + rowLayout.setMainAlign(MainAlign::SpaceBetween); + root.addChild(row); + UiDriver driver(root, { 800, 200 }); + + CHECK_CLOSE(800, row.size().x, 0.5f); + CHECK_CLOSE(700, fixedB.windowRect().pos.x, 0.5f); + } + + TEST(FillingScrollViewTakesRemainingHeightEvenWhenContentIsShort) { + Widget root; + root.emplaceLayout(); + ScrollView scroll; + scroll.setSizing(AxisSizing::fill(), AxisSizing::fill()); + Box item(Vec2F(100.0f, 50.0f), "item"); + scroll.setContent(&item); + Box footer(Vec2F(100.0f, 60.0f), "footer"); + footer.setSizing(AxisSizing::fill(), AxisSizing::fixed(60)); + root.addChild(scroll); + root.addChild(footer); + UiDriver driver(root, { 800, 600 }); + + CHECK_CLOSE(540, scroll.size().y, 0.5f); + CHECK_CLOSE(600, footer.windowRect().pos.y + footer.size().y, 0.5f); + } + + TEST(ExplicitMaximumStillCapsFill) { + Widget root; + root.emplaceLayout(); + Box capped(Vec2F(10.0f, 10.0f), "capped"); + capped.setSizing(AxisSizing::fill().atMost(300), AxisSizing::fixed(10)); + root.addChild(capped); + UiDriver driver(root, { 800, 100 }); + CHECK_CLOSE(300, capped.size().x, 0.5f); + } +} + +SUITE(SurfaceClipping) { + TEST(OversizedContentDoesNotPaintOutsideItsPanel) { + Widget root; + root.emplaceLayout().setCrossAlign(CrossAlign::Start); + Panel panel; + panel.setSizing(AxisSizing::fixed(200), AxisSizing::fixed(120)); + panel.emplaceLayout(); + Panel big(Panel::Surface::Raised); + big.setSizing(AxisSizing::fixed(200), AxisSizing::fixed(300)); + panel.addChild(big); + root.addChild(panel); + UiDriver driver(root, { 600, 600 }); + + CHECK(panel.clipsChildren()); + const RectF panelRect = panel.windowRect(); + const RGBA raised = Theme::standard().palette.raisedSurface; + const PaintCommand* oversized = nullptr; + for (const PaintCommand& command : driver.paint().commands()) { + if (command.kind == PaintCommand::Kind::FillRect && command.color == raised) oversized = &command; + } + REQUIRE CHECK(oversized != nullptr); + CHECK_CLOSE(300, oversized->rect.size.y, 0.5f); + CHECK(oversized->clip.pos.y + oversized->clip.size.y <= panelRect.pos.y + panelRect.size.y + 0.5f); + } + + TEST(SurfacelessPanelsLeaveOverhangAlone) { + Panel plain(Panel::Surface::None); + CHECK(!plain.clipsChildren()); + } +} diff --git a/Widgets/tests/MediaControlsTests.cpp b/Widgets/tests/MediaControlsTests.cpp new file mode 100644 index 00000000..ca447a14 --- /dev/null +++ b/Widgets/tests/MediaControlsTests.cpp @@ -0,0 +1,283 @@ +#include "ApplicationFrame.hpp" +#include "BoxLayout.hpp" +#include "Gallery.hpp" +#include "MediaControls.hpp" +#include "Probes.hpp" +#include "UiDriver.hpp" + +#include "UnitTest++/UnitTest++.h" + +using namespace tp; +using namespace tp::ui; +using namespace tp::ui::gallery; +using namespace tp::ui::testing; + +namespace { + constexpr size_t kMediaTab = 7; + + struct BarScene { + Widget root; + PlayerBar bar; + NowPlaying track{ "Jumpsuit", "twenty one pilots", "Trench", 238, true }; + UiDriver driver{ root, { 1100, 300 } }; + + BarScene() { + root.emplaceLayout(); + root.addChild(bar); + driver.settle(); + } + }; +} + +SUITE(DurationFormat) { + TEST(MinutesAndHours) { + CHECK_EQUAL("0:00", formatDuration(0)); + CHECK_EQUAL("3:58", formatDuration(238.9f)); + CHECK_EQUAL("1:02:05", formatDuration(3725)); + CHECK_EQUAL("0:00", formatDuration(-4)); + } +} + +SUITE(TransportButtons) { + TEST(GlyphsPaintAsShapesAndPrimaryIsLight) { + Widget root; + root.emplaceLayout(); + TransportButton play(TransportButton::Glyph::Play, true); + TransportButton next(TransportButton::Glyph::Next); + root.addChild(play); + root.addChild(next); + UiDriver driver(root); + + CHECK(play.size().x > next.size().x); + RecordingPainter& painter = driver.paint(); + int polygons = 0; + for (const PaintCommand& command : painter.commands()) + polygons += command.kind == PaintCommand::Kind::Polygon ? 1 : 0; + CHECK_EQUAL(2, polygons); + + int clicks = 0; + play.setOnActivate([&clicks]() { clicks++; }); + driver.click(play); + CHECK_EQUAL(1, clicks); + } +} + +SUITE(SeekBarInteraction) { + TEST(ClickSeeksAndShowsTimes) { + Widget root; + root.emplaceLayout(); + SeekBar seek; + seek.setDuration(200); + root.addChild(seek); + UiDriver driver(root, { 600, 100 }); + halnf reported = -1; + seek.setOnSeek([&reported](halnf position) { reported = position; }); + + driver.click(seek.windowPointForPosition(0.25f)); + CHECK_CLOSE(0.25f, reported, 0.01f); + CHECK_CLOSE(0.25f, seek.position(), 0.01f); + CHECK(driver.paint().hasText("0:50")); + CHECK(driver.paint().hasText("3:20")); + } + + TEST(ScrubbingPreviewsUntilRelease) { + Widget root; + root.emplaceLayout(); + SeekBar seek; + seek.setDuration(100); + root.addChild(seek); + UiDriver driver(root, { 600, 100 }); + int seeks = 0; + seek.setOnSeek([&seeks](halnf) { seeks++; }); + + driver.moveTo(seek.windowPointForPosition(0.1f)); + driver.press(); + driver.moveTo(seek.windowPointForPosition(0.7f)); + CHECK(seek.isScrubbing()); + CHECK_CLOSE(0.7f, seek.shownPosition(), 0.01f); + CHECK_CLOSE(0, seek.position(), 0.001f); + seek.setPosition(0.05f); + CHECK_CLOSE(0.7f, seek.shownPosition(), 0.01f); + CHECK_EQUAL(0, seeks); + + driver.release(); + CHECK_EQUAL(1, seeks); + CHECK_CLOSE(0.7f, seek.position(), 0.01f); + } + + TEST(ArrowKeysSkipFiveSeconds) { + Widget root; + root.emplaceLayout(); + SeekBar seek; + seek.setDuration(100); + root.addChild(seek); + UiDriver driver(root, { 600, 100 }); + driver.click(seek.windowPointForPosition(0.5f)); + driver.key(InputID::RIGHT); + CHECK_CLOSE(0.55f, seek.position(), 0.01f); + driver.key(InputID::LEFT); + driver.key(InputID::LEFT); + CHECK_CLOSE(0.45f, seek.position(), 0.01f); + } + + TEST(UnknownDurationShowsPlaceholders) { + Widget root; + root.emplaceLayout(); + SeekBar seek; + root.addChild(seek); + UiDriver driver(root, { 600, 100 }); + CHECK(driver.paint().hasText("-:--")); + } +} + +SUITE(VolumeControlBehaviour) { + TEST(SliderAndMuteReportEffectiveVolume) { + Widget root; + root.emplaceLayout(); + VolumeControl volume; + root.addChild(volume); + UiDriver driver(root); + halnf reported = -1; + volume.setOnChanged([&reported](halnf value) { reported = value; }); + + driver.setSlider(volume.slider(), 0.4f); + CHECK_CLOSE(0.4f, reported, 0.03f); + + driver.click(volume.muteButton()); + CHECK(volume.isMuted()); + CHECK_CLOSE(0, reported, 0.001f); + CHECK(volume.muteButton().glyph() == TransportButton::Glyph::Muted); + + driver.click(volume.muteButton()); + CHECK(!volume.isMuted()); + CHECK_CLOSE(0.4f, reported, 0.03f); + } + + TEST(MovingSliderUnmutes) { + Widget root; + root.emplaceLayout(); + VolumeControl volume; + root.addChild(volume); + UiDriver driver(root); + volume.setMuted(true); + driver.setSlider(volume.slider(), 0.8f); + CHECK(!volume.isMuted()); + } +} + +SUITE(PlayerBarBehaviour) { + TEST(EmptyBarIsInert) { + BarScene scene; + CHECK(!scene.bar.hasTrack()); + CHECK_EQUAL("Nothing playing", scene.bar.title.text()); + CHECK(!scene.bar.seek.isEnabled()); + } + + TEST(TrackFillsInfoAndTransportForwardsClicks) { + BarScene scene; + scene.bar.setTrack(&scene.track); + CHECK_EQUAL("Jumpsuit", scene.bar.title.text()); + CHECK_EQUAL("twenty one pilots", scene.bar.artist.text()); + CHECK_CLOSE(238, scene.bar.seek.duration(), 0.01f); + + int playPause = 0; + int next = 0; + int previous = 0; + scene.bar.onPlayPause = [&playPause]() { playPause++; }; + scene.bar.onNext = [&next]() { next++; }; + scene.bar.onPrevious = [&previous]() { previous++; }; + scene.driver.click(scene.bar.playPause); + scene.driver.click(scene.bar.next); + scene.driver.click(scene.bar.previous); + CHECK_EQUAL(1, playPause); + CHECK_EQUAL(1, next); + CHECK_EQUAL(1, previous); + + scene.bar.setPlaying(true); + CHECK(scene.bar.playPause.glyph() == TransportButton::Glyph::Pause); + CHECK_EQUAL("Pause", scene.bar.playPause.tooltip()); + } + + TEST(LayoutKeepsTransportCenteredAndVolumeRight) { + BarScene scene; + scene.bar.setTrack(&scene.track); + scene.driver.settle(); + const RectF bar = scene.bar.windowRect(); + const RectF play = scene.bar.playPause.windowRect(); + const RectF volume = scene.bar.volume.windowRect(); + CHECK_CLOSE(bar.center().x, play.center().x, 2.0f); + CHECK(volume.pos.x + volume.size.x > bar.pos.x + bar.size.x - 20); + CHECK(scene.bar.art.windowRect().pos.x < play.pos.x); + } +} + +SUITE(ApplicationFrameMenu) { + TEST(FrameShowsMenuBarAboveContentAndCanHideIt) { + ApplicationFrame frame; + Box content(Vec2F(100.0f, 100.0f), "content"); + frame.setContent(&content); + Menu file; + file.addItem("Open", {}); + frame.menuBar.addMenu("File", file); + UiDriver driver(frame, { 800, 600 }); + + CHECK(frame.isMenuBarVisible()); + CHECK(content.windowRect().pos.y >= frame.menuBar.windowRect().pos.y + frame.menuBar.size().y - 0.5f); + CHECK(driver.paint().hasText("File")); + + frame.setMenuBarVisible(false); + driver.settle(); + CHECK_CLOSE(0, content.windowRect().pos.y, 0.5f); + CHECK(!driver.paint().hasText("File")); + } + + TEST(SwappingContentReplacesIt) { + ApplicationFrame frame; + Box first(Vec2F(100.0f, 100.0f), "first"); + Box second(Vec2F(100.0f, 100.0f), "second"); + frame.setContent(&first); + frame.setContent(&second); + CHECK(first.parent() == nullptr); + CHECK(second.parent() == &frame); + frame.setContent(nullptr); + CHECK(second.parent() == nullptr); + } +} + +SUITE(GalleryMediaPage) { + TEST(PlaybackAdvancesAndSkipsTracks) { + Gallery gallery; + UiDriver driver(gallery, { 1200, 800 }); + driver.click(gallery.tabs.tabButton(kMediaTab)); + MediaPage& page = gallery.mediaPage; + CHECK_EQUAL("Media", gallery.tabs.tabButton(kMediaTab).text()); + CHECK_EQUAL("Jumpsuit", page.player.title.text()); + + driver.click(page.player.playPause); + CHECK(page.player.isPlaying()); + driver.advance(2000); + CHECK_CLOSE(2.0f / 238.5f, page.player.seek.position(), 0.004f); + + driver.click(page.player.next); + CHECK_EQUAL("Mystery Lady", page.player.title.text()); + driver.click(page.player.previous); + driver.click(page.player.previous); + CHECK_EQUAL("No Idea", page.player.title.text()); + + driver.click(page.player.playPause); + CHECK(!page.player.isPlaying()); + CHECK_EQUAL("Paused", page.status.text()); + } +} + +SUITE(ApplicationFrameKeys) { + TEST(UnfocusedKeysReachContentThroughFrame) { + ApplicationFrame frame; + EventProbe content(Vec2F(100.0f, 100.0f), "content"); + frame.setContent(&content); + UiDriver driver(frame, { 800, 600 }); + + driver.key(InputID::Z); + CHECK_EQUAL(1, content.count(Event::Type::KeyDown)); + } +} diff --git a/Widgets/tests/NoticeTests.cpp b/Widgets/tests/NoticeTests.cpp new file mode 100644 index 00000000..5c12b212 --- /dev/null +++ b/Widgets/tests/NoticeTests.cpp @@ -0,0 +1,83 @@ +#include "BoxLayout.hpp" +#include "Notice.hpp" +#include "Probes.hpp" +#include "Ui.hpp" +#include "UiDriver.hpp" + +#include "UnitTest++/UnitTest++.h" + +using namespace tp; +using namespace tp::ui; +using namespace tp::ui::testing; + +SUITE(NoticeBalloon) { + TEST(ShowsCenteredOverAnchorAndHidesAfterDuration) { + Widget root; + EventProbe area({ 600, 400 }, "area"); + root.emplaceLayout(); + root.addChild(area); + Notice notice; + UiDriver driver(root, { 600, 400 }); + + notice.show(area, "You can't edit a locked layer", 1000); + driver.advance(16); + CHECK(notice.isShown()); + driver.advance(300); + CHECK(driver.paint().hasText("You can't edit a locked layer")); + const RectF rect = notice.windowRect(); + CHECK_CLOSE(300, rect.pos.x + rect.size.x / 2, 1.0f); + + driver.advance(300); + CHECK(notice.isShown()); + driver.advance(600); + CHECK(!notice.isShown()); + } + + TEST(ShowingAgainRestartsTimerAndReplacesText) { + Widget root; + EventProbe area({ 600, 400 }, "area"); + root.emplaceLayout(); + root.addChild(area); + Notice notice; + UiDriver driver(root, { 600, 400 }); + + notice.show(area, "first", 1000); + driver.advance(800); + notice.show(area, "second", 1000); + driver.advance(800); + CHECK(notice.isShown()); + CHECK_EQUAL("second", notice.text()); + } + + TEST(NoticeDoesNotBlockInput) { + Widget root; + EventProbe area({ 600, 400 }, "area"); + root.emplaceLayout(); + root.addChild(area); + Notice notice; + UiDriver driver(root, { 600, 400 }); + + notice.show(area, "message", 1000); + driver.advance(300); + driver.click(driver.centerOf(notice)); + CHECK_EQUAL(1, area.count(Event::Type::Click)); + } +} + +SUITE(NoticeKeyboard) { + TEST(KeysStillReachRootWhileNoticeIsShown) { + Widget root; + EventProbe area({ 600, 400 }, "area"); + root.emplaceLayout(); + root.addChild(area); + Notice notice; + UiDriver driver(root, { 600, 400 }); + + notice.show(area, "message", 1000); + const int before = area.count(Event::Type::KeyDown); + driver.ui().setKeyboardFocus(&area); + driver.key(InputID::K); + CHECK_EQUAL(before + 1, area.count(Event::Type::KeyDown)); + CHECK(notice.isShown()); + } +} diff --git a/Widgets/tests/ShortcutTests.cpp b/Widgets/tests/ShortcutTests.cpp new file mode 100644 index 00000000..a48f8046 --- /dev/null +++ b/Widgets/tests/ShortcutTests.cpp @@ -0,0 +1,82 @@ +#include "Shortcut.hpp" + +#include "UnitTest++/UnitTest++.h" + +using namespace tp; +using namespace tp::ui; + +namespace { + enum class TestCommand { Undo, Redo, Zoom }; + + Event keyDown(InputID key, Modifiers modifiers = {}) { + return Event::key(Event::Type::KeyDown, key).withModifiers(modifiers); + } + + Modifiers control(bool shift = false) { + Modifiers modifiers; + modifiers.control = true; + modifiers.shift = shift; + return modifiers; + } +} + +SUITE(Shortcuts) { + TEST(MatchingRequiresExactModifiers) { + CHECK(ctrl(InputID::Z).matches(keyDown(InputID::Z, control()))); + CHECK(!ctrl(InputID::Z).matches(keyDown(InputID::Z))); + CHECK(!ctrl(InputID::Z).matches(keyDown(InputID::Z, control(true)))); + CHECK(ctrlShift(InputID::Z).matches(keyDown(InputID::Z, control(true)))); + } + + TEST(TextListsModifiersInFixedOrder) { + CHECK_EQUAL("Ctrl+Shift+Z", ctrlShift(InputID::Z).text()); + CHECK_EQUAL("Ctrl+Alt+A", ctrlAlt(InputID::A).text()); + CHECK_EQUAL("Alt+`", alt(InputID::GRAVE_ACCENT).text()); + CHECK_EQUAL("F11", plain(InputID::F11).text()); + CHECK_EQUAL("Ctrl+Num0", ctrl(InputID::KP_0).text()); + CHECK_EQUAL("Delete", plain(InputID::DELETE_KEY).text()); + CHECK_EQUAL("[", plain(InputID::LEFT_BRACKET).text()); + } + + TEST(MapFindsCommandForKeyDownOnly) { + ShortcutMap shortcuts; + shortcuts.bind(ctrl(InputID::Z), TestCommand::Undo); + + const auto* binding = shortcuts.find(keyDown(InputID::Z, control())); + CHECK(binding != nullptr); + CHECK(binding->command == TestCommand::Undo); + CHECK(shortcuts.find(Event::key(Event::Type::KeyUp, InputID::Z).withModifiers(control())) == nullptr); + CHECK(shortcuts.find(keyDown(InputID::Y, control())) == nullptr); + } + + TEST(RepeatOnlyTriggersRepeatingBindings) { + ShortcutMap shortcuts; + shortcuts.bind(ctrl(InputID::Z), TestCommand::Undo, true); + shortcuts.bind(plain(InputID::Z), TestCommand::Zoom); + + CHECK(shortcuts.find(Event::keyRepeat(InputID::Z).withModifiers(control())) != nullptr); + CHECK(shortcuts.find(Event::keyRepeat(InputID::Z)) == nullptr); + } + + TEST(RebindingAShortcutReplacesItsCommand) { + ShortcutMap shortcuts; + shortcuts.bind(ctrl(InputID::Y), TestCommand::Undo); + shortcuts.bind(ctrl(InputID::Y), TestCommand::Redo); + + CHECK_EQUAL(1u, shortcuts.bindings().size()); + CHECK(shortcuts.find(keyDown(InputID::Y, control()))->command == TestCommand::Redo); + } + + TEST(CommandsCanHaveSeveralShortcutsAndReportTheFirst) { + ShortcutMap shortcuts; + shortcuts.bind(ctrl(InputID::Z), TestCommand::Undo); + shortcuts.bind(ctrl(InputID::Y), TestCommand::Undo); + + CHECK_EQUAL(2u, shortcuts.shortcutsFor(TestCommand::Undo).size()); + CHECK_EQUAL("Ctrl+Z", shortcuts.textFor(TestCommand::Undo)); + CHECK_EQUAL("", shortcuts.textFor(TestCommand::Redo)); + + shortcuts.unbind(ctrl(InputID::Z)); + CHECK_EQUAL("Ctrl+Y", shortcuts.textFor(TestCommand::Undo)); + } +} diff --git a/Widgets/tests/StyleTests.cpp b/Widgets/tests/StyleTests.cpp new file mode 100644 index 00000000..f4980d63 --- /dev/null +++ b/Widgets/tests/StyleTests.cpp @@ -0,0 +1,184 @@ +#include "BoxLayout.hpp" +#include "Gallery.hpp" +#include "Button.hpp" +#include "Menus.hpp" +#include "Slider.hpp" +#include "Theme.hpp" +#include "Toggles.hpp" +#include "Ui.hpp" +#include "UiDriver.hpp" +#include "Views.hpp" + +#include "UnitTest++/UnitTest++.h" + +using namespace tp; +using namespace tp::ui; +using namespace tp::ui::testing; + +namespace { + bool sameColor(const RGBA& first, const RGBA& second) { + const halnf tolerance = 0.002f; + return std::abs(first.r - second.r) < tolerance && std::abs(first.g - second.g) < tolerance && + std::abs(first.b - second.b) < tolerance && std::abs(first.a - second.a) < tolerance; + } + + struct ControlsScene { + Widget root; + Button button{ "Button" }; + Button primary{ "Primary" }; + Checkbox checkbox{ "Checkbox" }; + Dropdown dropdown{ { "One", "Two" } }; + + ControlsScene() { + root.emplaceLayout(8).setCrossAlign(CrossAlign::Start); + primary.setStyle(Button::Style::Accent); + checkbox.setChecked(true); + for (Widget* widget : std::initializer_list{ &button, &primary, &checkbox, &dropdown }) { + root.addChild(*widget); + } + } + }; +} + +SUITE(Style) { + TEST(ControlsPaintRoundedShapesOnly) { + ControlsScene scene; + UiDriver driver(scene.root); + driver.settle(); + + size_t shapes = 0; + for (const PaintCommand& command : driver.paint().commands()) { + const bool box = command.kind == PaintCommand::Kind::FillRect || command.kind == PaintCommand::Kind::StrokeRect; + if (!box || command.color.a <= 0.01f) continue; + shapes++; + CHECK(command.cornerRadius > 0); + } + CHECK(shapes >= 5); + } + + TEST(PopupMenusUseLargerRadiusThanControls) { + const Metrics& metrics = Theme::standard().metrics; + CHECK(metrics.popupCornerRadius > metrics.cornerRadius); + CHECK(metrics.cornerRadius >= 3); + } + + TEST(AccentDerivesAllAccentShades) { + const Theme purple = Theme::standard().withAccent(accents::purple); + + CHECK(sameColor(accents::purple, purple.palette.accent)); + CHECK(purple.palette.accentHovered.r > purple.palette.accent.r); + CHECK(purple.palette.accentPressed.r < purple.palette.accent.r); + CHECK_CLOSE(accents::purple.r, purple.palette.focusRing.r, 0.001f); + CHECK(sameColor(Theme::standard().palette.window, purple.palette.window)); + } + + TEST(ChangingAccentRepaintsControlsInNewColor) { + ControlsScene scene; + UiDriver driver(scene.root); + driver.settle(); + + driver.ui().setTheme(Theme::standard().withAccent(accents::green)); + driver.settle(); + + bool primaryIsGreen = false; + for (const PaintCommand& command : driver.paint().commands()) { + const bool onPrimary = + command.kind == PaintCommand::Kind::FillRect && contains(command.rect, scene.primary.windowRect().center()); + primaryIsGreen = primaryIsGreen || (onPrimary && sameColor(command.color, accents::green)); + } + CHECK(primaryIsGreen); + } + + TEST(ControlsStartInTheirRestingColorWithoutAnimating) { + Widget root; + root.emplaceLayout(8); + Button button("Button"); + Button primary("Primary"); + primary.setStyle(Button::Style::Accent); + root.addChild(button); + root.addChild(primary); + UiDriver driver(root); + + driver.ui().updateLayout(); + + CHECK(driver.ui().animatingWidgets().empty()); + } + + TEST(TabsDockingMenusAndSlidersUseNoAccentColor) { + gallery::Gallery gallery; + UiDriver driver(gallery, { 1200, 800 }); + const RGBA accent = Theme::standard().palette.accent; + const auto accentPainted = [&driver, &accent]() { + for (const PaintCommand& command : driver.paint().commands()) { + if (command.color.a > 0.01f && sameColor(command.color, accent)) return true; + } + return false; + }; + + driver.click(gallery.tabs.tabButton(3)); + driver.settle(); + CHECK(!accentPainted()); + + driver.click(gallery.dockPage.project.optionsButton()); + driver.moveTo(*gallery.dockPage.project.optionsMenu().items()[2]); + driver.settle(); + CHECK(!accentPainted()); + driver.key(InputID::ESCAPE); + + driver.click(gallery.tabs.tabButton(0)); + driver.moveTo(gallery.controls.volume); + driver.settle(); + bool sliderUsesAccent = false; + const RectF slider = gallery.controls.volume.windowRect(); + for (const PaintCommand& command : driver.paint().commands()) { + const bool onSlider = contains(slider, command.rect.center()); + sliderUsesAccent = sliderUsesAccent || (onSlider && sameColor(command.color, accent)); + } + CHECK(!sliderUsesAccent); + } + + TEST(SelectedTabIsAGrayFill) { + TabView tabs; + Widget first; + Widget second; + tabs.addTab("First", first); + tabs.addTab("Second", second); + UiDriver driver(tabs, Vec2F(400.0f, 200.0f)); + driver.moveTo(Vec2F(-50.0f, -50.0f)); + driver.settle(); + + const RectF tab = tabs.tabButton(0).windowRect(); + bool grayFill = false; + for (const PaintCommand& command : driver.paint().commands()) { + const bool coversTab = command.kind == PaintCommand::Kind::FillRect && command.rect.size.y >= tab.size.y - 1 && + contains(command.rect, tab.center()); + grayFill = grayFill || (coversTab && sameColor(command.color, Theme::standard().palette.neutralSelection)); + } + CHECK(grayFill); + } + + TEST(CheckedTogglesAreMonochrome) { + Widget root; + root.emplaceLayout(8); + Checkbox checkbox("Checkbox"); + Switch toggle("Switch"); + RadioButton radio("Radio"); + for (ToggleControl* control : std::initializer_list{ &checkbox, &toggle, &radio }) { + control->setChecked(true); + root.addChild(*control); + } + UiDriver driver(root); + driver.settle(); + + const Palette& palette = Theme::standard().palette; + bool usedToggleOn = false; + for (const PaintCommand& command : driver.paint().commands()) { + CHECK(!sameColor(command.color, palette.accent)); + usedToggleOn = usedToggleOn || sameColor(command.color, palette.toggleOn); + const bool gray = + std::abs(command.color.r - command.color.g) < 0.03f && std::abs(command.color.g - command.color.b) < 0.05f; + CHECK(gray); + } + CHECK(usedToggleOn); + } +} diff --git a/Widgets/tests/ThemeEditorTests.cpp b/Widgets/tests/ThemeEditorTests.cpp new file mode 100644 index 00000000..d4334d6d --- /dev/null +++ b/Widgets/tests/ThemeEditorTests.cpp @@ -0,0 +1,160 @@ +#include "FakeWindowHost.hpp" +#include "Gallery.hpp" +#include "ThemeEditor.hpp" +#include "UiDriver.hpp" + +#include "UnitTest++/UnitTest++.h" + +#include +#include + +using namespace tp; +using namespace tp::ui; +using namespace tp::ui::gallery; +using namespace tp::ui::testing; + +namespace { + constexpr size_t kThemeTab = 6; + + size_t colorIndex(const std::string& name) { + const auto& fields = themeColorFields(); + for (size_t index = 0; index < fields.size(); index++) { + if (name == fields[index].name) return index; + } + return fields.size(); + } + + struct ThemeScene { + Gallery gallery; + UiDriver driver{ gallery, { 1200, 800 } }; + ThemeEditorPage& page = gallery.themePage; + + ThemeScene() { driver.click(gallery.tabs.tabButton(kThemeTab)); } + }; +} + +SUITE(ThemeEditorFields) { + TEST(EveryPaletteColorIsListedOnce) { + std::set names; + for (const ThemeColorField& field : themeColorFields()) + names.insert(field.name); + CHECK_EQUAL(themeColorFields().size(), names.size()); + CHECK_EQUAL(sizeof(Palette) / sizeof(RGBA), themeColorFields().size()); + } + + TEST(HexAndSourceDescribeTheTheme) { + CHECK_EQUAL("#FF8000FF", colorHex({ 1, 0.5f, 0, 1 })); + CHECK_EQUAL("#00000059", colorHex(Theme::standard().palette.scrim)); + + Theme theme; + theme.palette.accent = { 1, 0, 0, 1 }; + theme.metrics.cornerRadius = 3; + const std::string source = themeSource(theme); + CHECK(source.find("theme.palette.accent = { 1.000f, 0.000f, 0.000f, 1.000f };") != std::string::npos); + CHECK(source.find("theme.metrics.cornerRadius = 3.000f;") != std::string::npos); + } +} + +SUITE(ThemeEditorPage) { + TEST(TabShowsEveryColorWithSwatchAndHex) { + ThemeScene scene; + CHECK_EQUAL("Theme", scene.gallery.tabs.tabButton(kThemeTab).text()); + CHECK_EQUAL(themeColorFields().size(), scene.page.colorRows.size()); + CHECK_EQUAL("Editing window", scene.page.editingTitle.text()); + CHECK_EQUAL(colorHex(Theme::standard().palette.window), scene.page.colorRows[0]->hex.text()); + CHECK(scene.driver.paint().hasText("accent")); + } + + TEST(SelectingColorLoadsItIntoEditors) { + ThemeScene scene; + const size_t scrim = colorIndex("scrim"); + scene.page.selectColor(scrim); + CHECK_EQUAL("Editing scrim", scene.page.editingTitle.text()); + CHECK_CLOSE(Theme::standard().palette.scrim.a, scene.page.alpha.value(), 0.001f); + CHECK(scene.page.colorRows[scrim]->name.isSelected()); + CHECK(!scene.page.colorRows[0]->name.isSelected()); + + scene.driver.click(scene.page.colorRows[0]->name); + CHECK_EQUAL(0u, scene.page.selectedColor()); + } + + TEST(WheelAndAlphaEditTheLiveTheme) { + ThemeScene scene; + const size_t text = colorIndex("text"); + scene.page.selectColor(text); + + scene.driver.settle(); + const RectF wheel = scene.page.wheel.windowRect(); + scene.driver.click(wheel.center()); + const RGBA edited = scene.driver.ui().theme().palette.text; + CHECK(!(edited == Theme::standard().palette.text)); + CHECK_EQUAL(colorHex(edited), scene.page.colorRows[text]->hex.text()); + + scene.driver.setSlider(scene.page.alpha.slider(), 0.5f); + CHECK_CLOSE(0.5f, scene.driver.ui().theme().palette.text.a, 0.03f); + CHECK_CLOSE(edited.r, scene.driver.ui().theme().palette.text.r, 0.001f); + } + + TEST(MetricSlidersEditTheLiveTheme) { + ThemeScene scene; + LabeledSlider& corner = *scene.page.metricSliders[1]; + CHECK_CLOSE(Theme::standard().metrics.cornerRadius, corner.value(), 0.001f); + + scene.driver.setSlider(corner.slider(), 2); + CHECK_CLOSE(2, scene.driver.ui().theme().metrics.cornerRadius, 0.6f); + } + + TEST(AccentPresetAndReset) { + ThemeScene scene; + scene.driver.click(scene.page.accentPreset); + scene.driver.click(*scene.page.accentPreset.menu().items()[2]); + CHECK(scene.driver.ui().theme().palette.accent == accents::green); + + scene.driver.click(scene.page.reset); + CHECK(scene.driver.ui().theme().palette.accent == Theme::standard().palette.accent); + CHECK_EQUAL("Reset to the standard theme", scene.page.status.text()); + } + + TEST(CopyPutsThemeSourceOnClipboard) { + ThemeScene scene; + FakeWindowHost host; + scene.driver.ui().setWindowHost(&host); + scene.driver.click(scene.page.copySource); + CHECK_EQUAL(themeSource(scene.page.editedTheme()), host.clipboard); + CHECK_EQUAL("Copied theme as C++", scene.page.status.text()); + scene.driver.ui().setWindowHost(nullptr); + } + + TEST(ControlsPageAccentIsPickedUpByEditor) { + ThemeScene scene; + scene.driver.click(scene.gallery.tabs.tabButton(0)); + scene.driver.ui().setTheme(Theme::standard().withAccent(accents::orange)); + scene.driver.click(scene.gallery.tabs.tabButton(kThemeTab)); + scene.page.selectColor(colorIndex("text")); + scene.driver.setSlider(scene.page.alpha.slider(), 0.9f); + CHECK(scene.driver.ui().theme().palette.accent == accents::orange); + } +} + +SUITE(ThemeScaling) { + TEST(ScalingMultipliesLengthsAndKeepsTimesAndHairlines) { + const Theme base = Theme::standard(); + const Theme half = base.scaled(0.5f); + + CHECK_CLOSE(base.metrics.fontSize * 0.5f, half.metrics.fontSize, 0.001f); + CHECK_CLOSE(base.metrics.controlHeight * 0.5f, half.metrics.controlHeight, 0.001f); + CHECK_CLOSE(base.metrics.menuItemHeight * 0.5f, half.metrics.menuItemHeight, 0.001f); + CHECK_CLOSE(base.metrics.popupCornerRadius * 0.5f, half.metrics.popupCornerRadius, 0.001f); + CHECK_CLOSE(base.metrics.controlPadding.left * 0.5f, half.metrics.controlPadding.left, 0.001f); + CHECK_CLOSE(base.metrics.menuPadding.top * 0.5f, half.metrics.menuPadding.top, 0.001f); + CHECK_CLOSE(base.metrics.tooltipDelayMs, half.metrics.tooltipDelayMs, 0.001f); + CHECK_CLOSE(base.metrics.borderWidth, half.metrics.borderWidth, 0.001f); + CHECK_CLOSE(base.metrics.separatorThickness, half.metrics.separatorThickness, 0.001f); + } + + TEST(ScalingKeepsThePalette) { + const Theme base = Theme::standard().withAccent(accents::green); + + CHECK(base.scaled(1.5f).palette.accent == base.palette.accent); + } +} diff --git a/Widgets/tests/app/DebugInspectorTests.cpp b/Widgets/tests/app/DebugInspectorTests.cpp new file mode 100644 index 00000000..d109e554 --- /dev/null +++ b/Widgets/tests/app/DebugInspectorTests.cpp @@ -0,0 +1,103 @@ +#include "BoxLayout.hpp" +#include "DebugInspector.hpp" +#include "Probes.hpp" +#include "UiDriver.hpp" + +#include "UnitTest++/UnitTest++.h" + +using namespace tp; +using namespace tp::ui; +using namespace tp::ui::testing; + +SUITE(DebugInspectorLayout) { + TEST(HiddenPanelLeavesWholeWindowToUi) { + Widget root; + UiDriver driver(root); + DebugInspector inspector(driver.ui()); + + const DebugInspector::WindowSplit split = inspector.splitWindow({ 1500, 900 }); + + CHECK(split.content == makeRect(0, 0, 1500, 900)); + CHECK_CLOSE(0, split.panel.size.x, 0.01f); + } + + TEST(VisiblePanelTakesRightColumnWithoutOverlap) { + Widget root; + UiDriver driver(root); + DebugInspector inspector(driver.ui()); + inspector.setVisible(true); + + const DebugInspector::WindowSplit split = inspector.splitWindow({ 1500, 900 }); + + CHECK_CLOSE(0, split.content.pos.x, 0.01f); + CHECK_CLOSE(split.content.size.x, split.panel.pos.x, 0.01f); + CHECK_CLOSE(1500, split.content.size.x + split.panel.size.x, 0.01f); + CHECK_CLOSE(900, split.panel.size.y, 0.01f); + CHECK(split.panel.size.x >= 320); + } + + TEST(NarrowWindowKeepsUsablePanelWidth) { + Widget root; + UiDriver driver(root); + DebugInspector inspector(driver.ui()); + inspector.setVisible(true); + + const DebugInspector::WindowSplit split = inspector.splitWindow({ 600, 400 }); + + CHECK_CLOSE(320, split.panel.size.x, 0.01f); + CHECK_CLOSE(280, split.content.size.x, 0.01f); + } + + TEST(UiLaidOutInContentAreaNeverReachesPanel) { + Widget root; + root.emplaceLayout(); + Box fill; + fill.setSizing(AxisSizing::fill(), AxisSizing::fill()); + root.addChild(fill); + UiDriver driver(root, { 1500, 900 }); + DebugInspector inspector(driver.ui()); + inspector.setVisible(true); + + const DebugInspector::WindowSplit split = inspector.splitWindow({ 1500, 900 }); + driver.ui().setViewport(split.content); + driver.layout(); + + const RectF area = fill.windowRect(); + CHECK(area.pos.x + area.size.x <= split.panel.pos.x + 0.01f); + CHECK(driver.ui().widgetAt(split.panel.center()) == nullptr); + } +} + +SUITE(DebugInspectorSizingEditor) { + TEST(EveryModeMapsToItsOwnSizing) { + CHECK(DebugInspector::editedSizing(AxisSizing::Mode::Fit, 0, 0, 0, -1).mode() == AxisSizing::Mode::Fit); + CHECK(DebugInspector::editedSizing(AxisSizing::Mode::Fixed, 120, 0, 0, -1) == AxisSizing::fixed(120)); + CHECK(DebugInspector::editedSizing(AxisSizing::Mode::Preferred, 80, 0, 0, -1) == AxisSizing::preferred(80)); + CHECK(DebugInspector::editedSizing(AxisSizing::Mode::Fill, 0, 2, 0, -1) == AxisSizing::fill(2)); + CHECK(DebugInspector::editedSizing(AxisSizing::Mode::Fill, 0, 0, 0, -1) == AxisSizing::fill(1)); + } + + TEST(MinimumAndMaximumApplyAndNegativeMaximumMeansNone) { + const AxisSizing bounded = DebugInspector::editedSizing(AxisSizing::Mode::Fill, 0, 1, 50, 300); + CHECK_CLOSE(50, bounded.minimum(), 0.01f); + CHECK_CLOSE(300, bounded.maximum(), 0.01f); + CHECK(DebugInspector::editedSizing(AxisSizing::Mode::Fill, 0, 1, 0, -1).maximum() >= kUnbounded); + } + + TEST(EditedSizingReflowsTheWidget) { + Widget root; + root.emplaceLayout(); + Box box(Vec2F(100.0f, 40.0f), "box"); + root.addChild(box); + UiDriver driver(root, { 800, 200 }); + CHECK_CLOSE(100, box.size().x, 0.5f); + + box.setSizing(Axis::Horizontal, DebugInspector::editedSizing(AxisSizing::Mode::Fill, 0, 1, 0, -1)); + driver.settle(); + CHECK_CLOSE(800, box.size().x, 0.5f); + + box.setSizing(Axis::Horizontal, DebugInspector::editedSizing(AxisSizing::Mode::Fixed, 240, 0, 0, -1)); + driver.settle(); + CHECK_CLOSE(240, box.size().x, 0.5f); + } +} diff --git a/Widgets/tests/support/Probes.hpp b/Widgets/tests/support/Probes.hpp new file mode 100644 index 00000000..96fa020c --- /dev/null +++ b/Widgets/tests/support/Probes.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include "Event.hpp" +#include "Painter.hpp" +#include "Widget.hpp" + +#include +#include + +namespace tp::ui::testing { + + class Box : public Widget { + public: + explicit Box(Vec2F preferred = { 0, 0 }, std::string name = "box") : + mPreferred(preferred) { + setDebugName(std::move(name)); + } + + void setPreferred(Vec2F preferred) { + mPreferred = preferred; + requestLayout(); + } + + protected: + [[nodiscard]] Extent measureContent(Axis axis, halnf) const override { + return Extent::shrinkable(along(mPreferred, axis)); + } + + private: + Vec2F mPreferred; + }; + + class EventProbe : public Box { + public: + explicit EventProbe(Vec2F preferred = { 50, 30 }, std::string name = "probe") : + Box(preferred, std::move(name)) {} + + std::vector received; + std::vector localPositions; + int hoverChanges = 0; + int ticks = 0; + int paints = 0; + bool acceptEvents = true; + bool focusable = false; + + [[nodiscard]] int count(Event::Type type) const { + int total = 0; + for (Event::Type receivedType : received) + total += receivedType == type ? 1 : 0; + return total; + } + + void beginTicking() { startTicking(); } + void endTicking() { stopTicking(); } + + protected: + void onEvent(Event& event) override { + received.push_back(event.type()); + localPositions.push_back(event.position()); + if (acceptEvents) event.accept(); + } + + void onHoverChanged() override { hoverChanges++; } + void onTick(halnf) override { ticks++; } + void paint(Painter& painter) override { + paints++; + painter.fillRect(localRect(), RGBA(1, 0, 0, 1)); + } + [[nodiscard]] bool acceptsKeyboardFocus() const override { return focusable; } + }; +} -- 2.49.1 From bb7fa3239fd6f9197b813d38429bca0bd6594309 Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 8 Oct 2026 18:37:18 +0300 Subject: [PATCH 3/9] Widgets: input routing, tooltips and the reusable control set --- Widgets/examples/ThemeEditor.cpp | 272 ++++++++ Widgets/private/app/DebugInspector.cpp | 609 +++++++++++++++++ Widgets/private/core/InputRouter.cpp | 429 ++++++++++++ Widgets/private/core/InputRouter.hpp | 108 +++ Widgets/private/core/TooltipController.cpp | 87 +++ Widgets/private/core/TooltipController.hpp | 39 ++ Widgets/private/core/WidgetInternals.hpp | 179 +++++ Widgets/private/layouts/StackLayout.cpp | 153 +++++ Widgets/private/nodes/NodeGraph.cpp | 230 +++++++ Widgets/private/widgets/Collapsible.cpp | 249 +++++++ Widgets/private/widgets/ColorPicker.cpp | 185 ++++++ Widgets/private/widgets/Control.cpp | 109 +++ Widgets/private/widgets/Dialog.cpp | 141 ++++ Widgets/private/widgets/ReorderableList.cpp | 399 +++++++++++ Widgets/private/widgets/SettingsView.cpp | 245 +++++++ Widgets/private/widgets/TextEditBuffer.cpp | 180 +++++ Widgets/public/app/DebugInspector.hpp | 142 ++++ Widgets/public/core/Geometry.hpp | 73 ++ Widgets/public/core/Popup.hpp | 37 ++ Widgets/public/layouts/StackLayout.hpp | 43 ++ Widgets/public/nodes/NodeGraph.hpp | 133 ++++ Widgets/public/widgets/Collapsible.hpp | 73 ++ Widgets/public/widgets/ColorPicker.hpp | 96 +++ Widgets/public/widgets/Control.hpp | 50 ++ Widgets/public/widgets/ReorderableList.hpp | 99 +++ Widgets/public/widgets/SettingsView.hpp | 98 +++ Widgets/public/widgets/TextEditBuffer.hpp | 58 ++ Widgets/tests/CollapsibleTests.cpp | 234 +++++++ Widgets/tests/ColorSquareTests.cpp | 96 +++ Widgets/tests/DialogTests.cpp | 202 ++++++ Widgets/tests/InputTests.cpp | 693 +++++++++++++++++++ Widgets/tests/Main.cpp | 36 + Widgets/tests/PopupTests.cpp | 504 ++++++++++++++ Widgets/tests/ReorderableListTests.cpp | 477 ++++++++++++++ Widgets/tests/SettingsViewTests.cpp | 246 +++++++ Widgets/tests/TextAreaTests.cpp | 696 ++++++++++++++++++++ Widgets/tests/TextWrapTests.cpp | 190 ++++++ Widgets/tests/WindowChromeTests.cpp | 409 ++++++++++++ 38 files changed, 8299 insertions(+) create mode 100644 Widgets/examples/ThemeEditor.cpp create mode 100644 Widgets/private/app/DebugInspector.cpp create mode 100644 Widgets/private/core/InputRouter.cpp create mode 100644 Widgets/private/core/InputRouter.hpp create mode 100644 Widgets/private/core/TooltipController.cpp create mode 100644 Widgets/private/core/TooltipController.hpp create mode 100644 Widgets/private/core/WidgetInternals.hpp create mode 100644 Widgets/private/layouts/StackLayout.cpp create mode 100644 Widgets/private/nodes/NodeGraph.cpp create mode 100644 Widgets/private/widgets/Collapsible.cpp create mode 100644 Widgets/private/widgets/ColorPicker.cpp create mode 100644 Widgets/private/widgets/Control.cpp create mode 100644 Widgets/private/widgets/Dialog.cpp create mode 100644 Widgets/private/widgets/ReorderableList.cpp create mode 100644 Widgets/private/widgets/SettingsView.cpp create mode 100644 Widgets/private/widgets/TextEditBuffer.cpp create mode 100644 Widgets/public/app/DebugInspector.hpp create mode 100644 Widgets/public/core/Geometry.hpp create mode 100644 Widgets/public/core/Popup.hpp create mode 100644 Widgets/public/layouts/StackLayout.hpp create mode 100644 Widgets/public/nodes/NodeGraph.hpp create mode 100644 Widgets/public/widgets/Collapsible.hpp create mode 100644 Widgets/public/widgets/ColorPicker.hpp create mode 100644 Widgets/public/widgets/Control.hpp create mode 100644 Widgets/public/widgets/ReorderableList.hpp create mode 100644 Widgets/public/widgets/SettingsView.hpp create mode 100644 Widgets/public/widgets/TextEditBuffer.hpp create mode 100644 Widgets/tests/CollapsibleTests.cpp create mode 100644 Widgets/tests/ColorSquareTests.cpp create mode 100644 Widgets/tests/DialogTests.cpp create mode 100644 Widgets/tests/InputTests.cpp create mode 100644 Widgets/tests/Main.cpp create mode 100644 Widgets/tests/PopupTests.cpp create mode 100644 Widgets/tests/ReorderableListTests.cpp create mode 100644 Widgets/tests/SettingsViewTests.cpp create mode 100644 Widgets/tests/TextAreaTests.cpp create mode 100644 Widgets/tests/TextWrapTests.cpp create mode 100644 Widgets/tests/WindowChromeTests.cpp diff --git a/Widgets/examples/ThemeEditor.cpp b/Widgets/examples/ThemeEditor.cpp new file mode 100644 index 00000000..84aea066 --- /dev/null +++ b/Widgets/examples/ThemeEditor.cpp @@ -0,0 +1,272 @@ +#include "ThemeEditor.hpp" + +#include "Ui.hpp" +#include "WindowHost.hpp" + +#include +#include + +using namespace tp; +using namespace tp::ui; + +namespace { + constexpr halnf kSwatchSize = 18; + constexpr halnf kColorColumnWidth = 300; + constexpr halnf kEditorColumnWidth = 300; + + std::string number(halnf value) { + char text[32]; + std::snprintf(text, sizeof(text), "%.3ff", value); + return text; + } + + std::string twoDecimals(halnf value) { + char text[32]; + std::snprintf(text, sizeof(text), "%.2f", value); + return text; + } + + const RGBA kAccentPresets[] = { accents::blue, accents::purple, accents::green, accents::orange, accents::pink }; +} + +const std::vector& tp::ui::themeColorFields() { + static const std::vector fields = { + { "window", &Palette::window }, + { "surface", &Palette::surface }, + { "raisedSurface", &Palette::raisedSurface }, + { "border", &Palette::border }, + { "focusRing", &Palette::focusRing }, + { "control", &Palette::control }, + { "controlHovered", &Palette::controlHovered }, + { "controlPressed", &Palette::controlPressed }, + { "controlBorder", &Palette::controlBorder }, + { "field", &Palette::field }, + { "track", &Palette::track }, + { "accent", &Palette::accent }, + { "accentHovered", &Palette::accentHovered }, + { "accentPressed", &Palette::accentPressed }, + { "onAccent", &Palette::onAccent }, + { "selection", &Palette::selection }, + { "neutralSelection", &Palette::neutralSelection }, + { "menuHighlight", &Palette::menuHighlight }, + { "sliderFill", &Palette::sliderFill }, + { "toggleOn", &Palette::toggleOn }, + { "toggleOnHovered", &Palette::toggleOnHovered }, + { "onToggle", &Palette::onToggle }, + { "text", &Palette::text }, + { "mutedText", &Palette::mutedText }, + { "disabledText", &Palette::disabledText }, + { "warningText", &Palette::warningText }, + { "separator", &Palette::separator }, + { "tooltip", &Palette::tooltip }, + { "scrim", &Palette::scrim }, + { "shadow", &Palette::shadow }, + { "windowCloseHovered", &Palette::windowCloseHovered }, + { "windowClosePressed", &Palette::windowClosePressed }, + { "onWindowClose", &Palette::onWindowClose }, + }; + return fields; +} + +const std::vector& tp::ui::themeMetricFields() { + static const std::vector fields = { + { "fontSize", &Metrics::fontSize, 9, 20 }, + { "cornerRadius", &Metrics::cornerRadius, 0, 16 }, + { "popupCornerRadius", &Metrics::popupCornerRadius, 0, 20 }, + { "surfaceCornerRadius", &Metrics::surfaceCornerRadius, 0, 24 }, + { "controlHeight", &Metrics::controlHeight, 18, 40 }, + { "spacing", &Metrics::spacing, 0, 16 }, + }; + return fields; +} + +std::string tp::ui::colorHex(const RGBA& color) { + const auto channel = [](halnf value) { return (int) std::lround(std::fmin(std::fmax(value, 0.0f), 1.0f) * 255); }; + char text[16]; + std::snprintf( + text, sizeof(text), "#%02X%02X%02X%02X", channel(color.r), channel(color.g), channel(color.b), channel(color.a) + ); + return text; +} + +std::string tp::ui::themeSource(const Theme& theme) { + std::string source = "Theme theme;\n"; + for (const ThemeColorField& field : themeColorFields()) { + const RGBA& color = theme.palette.*field.member; + source += "theme.palette." + std::string(field.name) + " = { " + number(color.r) + ", " + number(color.g) + ", " + + number(color.b) + ", " + number(color.a) + " };\n"; + } + for (const ThemeMetricField& field : themeMetricFields()) + source += "theme.metrics." + std::string(field.name) + " = " + number(theme.metrics.*field.member) + ";\n"; + return source; +} + +ColorSwatch::ColorSwatch() { setDebugName("color swatch"); } + +void ColorSwatch::setColor(const RGBA& color) { + mColor = color; + requestPaint(); +} + +Extent ColorSwatch::measureContent(Axis, halnf) const { return Extent::rigid(kSwatchSize); } + +void ColorSwatch::paint(Painter& painter) { + const RectF box = localRect(); + const halnf radius = theme().metrics.indicatorCornerRadius; + const halnf half = box.size.x / 2; + painter.fillRect(box, { 1, 1, 1, 1 }, radius); + painter.fillRect(makeRect(0, 0, half, half), { 0.75f, 0.75f, 0.75f, 1 }, radius / 2); + painter.fillRect(makeRect(half, half, half, half), { 0.75f, 0.75f, 0.75f, 1 }, radius / 2); + painter.fillRect(box, mColor, radius); + painter.strokeRect(box, theme().palette.controlBorder, theme().metrics.indicatorCornerRadius); +} + +ThemeColorRow::ThemeColorRow(const ThemeColorField& field) : + mField(field) { + setDebugName(std::string("theme color: ") + field.name); + setSizing(AxisSizing::fill(), AxisSizing::fit()); + emplaceLayout(6).setCrossAlign(CrossAlign::Center); + + name.setText(field.name); + name.setStyle(Button::Style::Flat); + name.setSizing(AxisSizing::fill(), AxisSizing::fit()); + hex.setTone(Label::Tone::Muted); + + addChild(swatch); + addChild(name); + addChild(hex); +} + +void ThemeColorRow::refresh(const Theme& theme) { + const RGBA& color = theme.palette.*mField.member; + swatch.setColor(color); + hex.setText(colorHex(color)); +} + +void ThemeColorRow::setSelected(bool selected) { name.setSelected(selected); } + +ThemeEditorPage::ThemeEditorPage() { + setDebugName("theme editor page"); + RowLayout& layout = emplaceLayout(16); + layout.setPadding(Insets::uniform(16)); + + colorList.setSizing(AxisSizing::fill(), AxisSizing::fit()); + colorList.emplaceLayout(1); + for (size_t index = 0; index < themeColorFields().size(); index++) { + colorRows.push_back(std::make_unique(themeColorFields()[index])); + colorRows.back()->name.setOnActivate([this, index]() { selectColor(index); }); + colorRows.back()->swatch.setOnActivate([this, index]() { selectColor(index); }); + colorList.addChild(*colorRows.back()); + } + colorScroll.setContent(&colorList); + colorScroll.setSizing(AxisSizing::fill(), AxisSizing::fill()); + + mColorColumn.setSizing(AxisSizing::fixed(kColorColumnWidth), AxisSizing::fill()); + mColorColumn.emplaceLayout(6); + mColorColumn.addChild(colorScroll); + + wheel.setSizing(AxisSizing::fill(), AxisSizing::fixed(200)); + wheel.setOnChanged([this](const RGBA& color) { + edit([this, &color](Theme& theme) { + RGBA& target = theme.palette.*themeColorFields()[mSelected].member; + target = { color.r, color.g, color.b, target.a }; + }); + }); + alpha.setSizing(AxisSizing::fill(), AxisSizing::fit()); + alpha.setValueFormatter(twoDecimals); + alpha.setOnChanged([this](halnf value) { + edit([this, value](Theme& theme) { (theme.palette.*themeColorFields()[mSelected].member).a = value; }); + }); + + mEditorColumn.setSizing(AxisSizing::fixed(kEditorColumnWidth), AxisSizing::fit()); + mEditorColumn.emplaceLayout(8); + mEditorColumn.addChild(editingTitle); + mEditorColumn.addChild(wheel); + mEditorColumn.addChild(alpha); + mEditorColumn.addChild(mSeparator); + + for (const ThemeMetricField& field : themeMetricFields()) { + metricSliders.push_back(std::make_unique(field.name, field.minimum, field.maximum, 0)); + LabeledSlider& slider = *metricSliders.back(); + slider.setSizing(AxisSizing::fill(), AxisSizing::fit()); + slider.setValueFormatter([](halnf value) { return std::to_string((int) std::lround(value)); }); + slider.slider().setStep(1); + halnf Metrics::* member = field.member; + slider.setOnChanged([this, member](halnf value) { + edit([member, value](Theme& theme) { theme.metrics.*member = value; }); + }); + mEditorColumn.addChild(slider); + } + + accentPreset.setTooltip("Accent preset"); + accentPreset.setOnSelected([this](long index) { + if (index < 0 || index >= 5) return; + edit([index](Theme& theme) { theme.palette.setAccent(kAccentPresets[index]); }); + }); + reset.setOnActivate([this]() { + edit([](Theme& theme) { theme = Theme::standard(); }); + status.setText("Reset to the standard theme"); + }); + copySource.setOnActivate([this]() { + if (ui() && ui()->windowHost()) ui()->windowHost()->setClipboardText(themeSource(editedTheme())); + status.setText("Copied theme as C++"); + }); + status.setTone(Label::Tone::Muted); + + mActionRow.setSizing(AxisSizing::fill(), AxisSizing::fit()); + mActionRow.emplaceLayout(6).setCrossAlign(CrossAlign::Center); + mActionRow.addChild(accentPreset); + mActionRow.addChild(reset); + mActionRow.addChild(copySource); + mEditorColumn.addChild(mActionRow); + mEditorColumn.addChild(status); + + addChild(mColorColumn); + addChild(mEditorColumn); + refreshEditors(); +} + +Theme ThemeEditorPage::editedTheme() const { return ui() ? ui()->theme() : Theme::standard(); } + +void ThemeEditorPage::onAttached() { refreshEditors(); } + +void ThemeEditorPage::onThemeChanged() { + Widget::onThemeChanged(); + refreshEditors(); +} + +void ThemeEditorPage::selectColor(size_t index) { + if (index >= colorRows.size()) return; + mSelected = index; + refreshEditors(); +} + +template +void ThemeEditorPage::edit(Edit&& change) { + if (mRefreshing || !ui()) return; + + Theme theme = ui()->theme(); + change(theme); + ui()->setTheme(theme); +} + +void ThemeEditorPage::refreshEditors() { + const Theme theme = editedTheme(); + mRefreshing = true; + + for (size_t index = 0; index < colorRows.size(); index++) { + colorRows[index]->refresh(theme); + colorRows[index]->setSelected(index == mSelected); + } + + const ThemeColorField& field = themeColorFields()[mSelected]; + const RGBA& color = theme.palette.*field.member; + editingTitle.setText(std::string("Editing ") + field.name); + wheel.setColor(color); + alpha.setValue(color.a); + + for (size_t index = 0; index < metricSliders.size(); index++) + metricSliders[index]->setValue(theme.metrics.*themeMetricFields()[index].member); + + mRefreshing = false; +} diff --git a/Widgets/private/app/DebugInspector.cpp b/Widgets/private/app/DebugInspector.cpp new file mode 100644 index 00000000..d9df793d --- /dev/null +++ b/Widgets/private/app/DebugInspector.cpp @@ -0,0 +1,609 @@ +#include "DebugInspector.hpp" + +#include "Assert.hpp" +#include "Layout.hpp" +#include "Widget.hpp" + +#include "imgui.h" +#include "implot.h" + +#include + +using namespace tp; +using namespace tp::ui; + +namespace { + constexpr size_t kMaximumEventRecords = 400; + constexpr size_t kMaximumLayoutRecords = 400; + constexpr float kMinimumPanelFraction = 0.2f; + constexpr float kMaximumPanelFraction = 0.6f; + constexpr float kMinimumPanelWidth = 320; + + const RGBA kOutlineColor = { 0.2f, 0.8f, 1.0f, 0.35f }; + const RGBA kHoverOutlineColor = { 1.0f, 0.75f, 0.1f, 0.9f }; + const RGBA kSelectionColor = { 1.0f, 0.25f, 0.35f, 0.95f }; + const RGBA kSelectionFill = { 1.0f, 0.25f, 0.35f, 0.12f }; + + float nanosecondsToMs(time_ns nanoseconds) { return (float) ((double) nanoseconds / 1.0e6); } + + constexpr AxisSizing::Mode kSizingModes[] = { + AxisSizing::Mode::Fit, AxisSizing::Mode::Fixed, AxisSizing::Mode::Preferred, AxisSizing::Mode::Fill + }; + + const char* sizingModeName(AxisSizing::Mode mode) { + switch (mode) { + case AxisSizing::Mode::Fit: return "fit"; + case AxisSizing::Mode::Fixed: return "fixed"; + case AxisSizing::Mode::Preferred: return "preferred"; + case AxisSizing::Mode::Fill: return "fill"; + } + return "?"; + } + + void plotSeries(const char* label, const TimeSeries& series) { + ImPlot::PlotLine(label, series.data(), series.count(), 1.0, 0.0, 0, series.offset()); + } + + void statisticsRow(const char* name, const TimeSeries& series, const char* unit) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(name); + ImGui::TableNextColumn(); + ImGui::Text("%.3f %s", series.latest(), unit); + ImGui::TableNextColumn(); + ImGui::Text("%.3f", series.average()); + ImGui::TableNextColumn(); + ImGui::Text("%.3f", series.maximum()); + } +} + +void TimeSeries::add(float value) { + mValues[mNext] = value; + mNext = (mNext + 1) % kCapacity; +} + +float TimeSeries::latest() const { return mValues[(mNext + kCapacity - 1) % kCapacity]; } + +float TimeSeries::average() const { + float total = 0; + for (float value : mValues) + total += value; + return total / (float) kCapacity; +} + +float TimeSeries::maximum() const { + float result = 0; + for (float value : mValues) + result = value > result ? value : result; + return result; +} + +DebugInspector::DebugInspector(Ui& ui) : + mUi(ui) { + mUi.setObserver(this); +} + +DebugInspector::~DebugInspector() { mUi.setObserver(nullptr); } + +void DebugInspector::handleShortcuts(const EventHandler& input) { + if (ImGui::GetCurrentContext() && ImGui::GetIO().WantTextInput) return; + + if (mToggleKey && input.isPressed(*mToggleKey)) setVisible(!mVisible); + if (!mVisible) return; + + if (input.isPressed(InputID::K)) mFreezeProcessing = !mFreezeProcessing; + + Widget* hovered = mUi.hoveredWidget(); + if (!hovered) return; + + if (input.isPressed(InputID::S)) mSelected = hovered; + if (input.isPressed(InputID::P)) mEventBreakpoints.insert(hovered); + if (input.isPressed(InputID::L)) mLayoutBreakpoints.insert(hovered); +} + +DebugInspector::WindowSplit DebugInspector::splitWindow(Vec2F windowSize) const { + if (!mVisible) return { makeRect({ 0, 0 }, windowSize), makeRect(windowSize.x, 0, 0, windowSize.y) }; + + float panelWidth = windowSize.x * mPanelFraction; + if (panelWidth < kMinimumPanelWidth) panelWidth = kMinimumPanelWidth; + if (panelWidth > windowSize.x) panelWidth = windowSize.x; + + const float contentWidth = windowSize.x - panelWidth; + return { makeRect(0, 0, contentWidth, windowSize.y), makeRect(contentWidth, 0, panelWidth, windowSize.y) }; +} + +InputRouting DebugInspector::inputRouting(Vec2F pointer, Vec2F windowSize) const { + InputRouting routing; + if (!ImGui::GetCurrentContext()) return routing; + + const ImGuiIO& io = ImGui::GetIO(); + const bool pointerOverPanel = mVisible && contains(splitWindow(windowSize).panel, pointer); + routing.pointerBlocked = pointerOverPanel || io.WantCaptureMouse; + routing.keyboardBlocked = io.WantCaptureKeyboard; + return routing; +} + +void DebugInspector::recordFrame( + halnf processMs, halnf drawMs, halnf framesProcessedPerSecond, halnf framesDrawnPerSecond +) { + const FrameStats& stats = mUi.stats(); + + mProcessMs.add(processMs); + mDrawMs.add(drawMs); + mInputMs.add(nanosecondsToMs(stats.inputNs - mPreviousStats.inputNs)); + mAnimationMs.add(nanosecondsToMs(stats.animationNs - mPreviousStats.animationNs)); + mLayoutMs.add(nanosecondsToMs(stats.layoutNs - mPreviousStats.layoutNs)); + mPaintMs.add(nanosecondsToMs(stats.paintNs - mPreviousStats.paintNs)); + mArranged.add((float) (stats.arrangedWidgets - mPreviousStats.arrangedWidgets)); + mMeasured.add((float) (stats.measuredWidgets - mPreviousStats.measuredWidgets)); + + mFramesProcessedPerSecond = framesProcessedPerSecond; + mFramesDrawnPerSecond = framesDrawnPerSecond; + mPreviousStats = stats; + mFrame++; +} + +void DebugInspector::draw() { + if (!mVisible) return; + + const ImGuiViewport* viewport = ImGui::GetMainViewport(); + const WindowSplit split = splitWindow({ viewport->Size.x, viewport->Size.y }); + + ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + split.panel.pos.x, viewport->Pos.y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(split.panel.size.x, split.panel.size.y), ImGuiCond_Always); + + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoBringToFrontOnFocus; + if (ImGui::Begin("Widget Debugger", nullptr, flags)) { + drawPanelWidthControl(); + drawToolbar(); + + if (ImGui::BeginTabBar("debugger tabs")) { + if (ImGui::BeginTabItem("Performance")) { + drawPerformanceTab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Under Cursor")) { + drawHoverStackTab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Tree")) { + drawWidgetTreeTab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Events")) { + drawEventsTab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Layout")) { + drawLayoutTab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Popups")) { + drawPopupsTab(); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + } + ImGui::End(); +} + +void DebugInspector::paintOverlay(Painter& painter) { + if (!mVisible) return; + + if (mShowOutlines && mUi.root()) paintOutlines(painter, *mUi.root(), 0); + + if (Widget* hovered = mUi.hoveredWidget()) painter.strokeRect(hovered->windowRect(), kHoverOutlineColor); + + if (mSelected) { + const RectF rect = mSelected->windowRect(); + painter.fillRect(rect, kSelectionFill); + painter.strokeRect(rect, kSelectionColor); + painter.text( + nameOf(*mSelected), makeRect(rect.pos.x + 4, rect.pos.y - 18, 400, 18), 12, TextAlign::Start, kSelectionColor + ); + } +} + +void DebugInspector::onEventDelivering(const Widget& receiver, const Event&) { + if (mEventBreakpoints.erase(&receiver)) DEBUG_BREAK(true) +} + +void DebugInspector::onEventDelivered(const Widget& receiver, const Event& event) { + if (!mVisible) return; + if (event.type() == Event::Type::PointerMove && !mLogPointerMoves) return; + + mEvents.push_front({ mFrame, eventTypeName(event), nameOf(receiver), event.windowPosition(), event.isAccepted() }); + if (mEvents.size() > kMaximumEventRecords) mEvents.pop_back(); +} + +void DebugInspector::onLayoutPassStarted() { + if (!mCurrentLayoutOrder.empty()) mLastLayoutOrder.swap(mCurrentLayoutOrder); + mCurrentLayoutOrder.clear(); +} + +void DebugInspector::onWidgetArranged(const Widget& widget) { + if (mLayoutBreakpoints.erase(&widget)) DEBUG_BREAK(true) + if (mVisible && mCurrentLayoutOrder.size() < kMaximumLayoutRecords) mCurrentLayoutOrder.push_back(nameOf(widget)); +} + +void DebugInspector::onWidgetDetached(const Widget& widget) { + if (mSelected && widget.isSelfOrAncestorOf(*mSelected)) mSelected = nullptr; + mEventBreakpoints.erase(&widget); + mLayoutBreakpoints.erase(&widget); +} + +void DebugInspector::drawPanelWidthControl() { + ImGui::TextUnformatted("Widget Debugger"); + ImGui::SameLine(); + if (ImGui::SmallButton("Close")) mVisible = false; + ImGui::SameLine(); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + float percent = mPanelFraction * 100; + if ( + ImGui::SliderFloat( + "##panel width", &percent, kMinimumPanelFraction * 100, kMaximumPanelFraction * 100, "width %.0f%%" + ) + ) { + mPanelFraction = percent / 100; + } +} + +void DebugInspector::drawToolbar() { + ImGui::Checkbox("Freeze [K]", &mFreezeProcessing); + ImGui::SameLine(); + ImGui::Checkbox("Force redraw", &mForceRedraw); + ImGui::SameLine(); + ImGui::Checkbox("Outlines", &mShowOutlines); + + ImGui::TextDisabled("Hover a widget: [S] select [P] break on event [L] break on layout"); + + const char* state = mUi.isLayoutPending() ? "layout pending" : (mUi.needsFrame() ? "animating" : "idle"); + ImGui::Text( + "frame %llu %s popups %llu", (unsigned long long) mFrame, state, (unsigned long long) mUi.openPopupCount() + ); + ImGui::Separator(); +} + +void DebugInspector::drawPerformanceTab() { + ImGui::Text("Processed %.0f fps Drawn %.0f fps", mFramesProcessedPerSecond, mFramesDrawnPerSecond); + ImGui::Text( + "Input %llu received, %llu delivered", + (unsigned long long) mWindowCounters.inputReceived, + (unsigned long long) mWindowCounters.inputDelivered + ); + ImGui::Text( + "Resize %llu received, %llu applied Swapchain rebuilds %llu", + (unsigned long long) mWindowCounters.configuresReceived, + (unsigned long long) mWindowCounters.configuresApplied, + (unsigned long long) mWindowCounters.swapchainRecreations + ); + + if (ImPlot::BeginPlot("Frame time (ms)", ImVec2(-1, 200))) { + ImPlot::SetupAxes("frame", "ms", ImPlotAxisFlags_NoTickLabels, ImPlotAxisFlags_AutoFit); + plotSeries("process", mProcessMs); + plotSeries("draw", mDrawMs); + ImPlot::EndPlot(); + } + + if (ImPlot::BeginPlot("Ui phases (ms)", ImVec2(-1, 200))) { + ImPlot::SetupAxes("frame", "ms", ImPlotAxisFlags_NoTickLabels, ImPlotAxisFlags_AutoFit); + plotSeries("input", mInputMs); + plotSeries("animation", mAnimationMs); + plotSeries("layout", mLayoutMs); + plotSeries("paint", mPaintMs); + ImPlot::EndPlot(); + } + + if (ImPlot::BeginPlot("Layout work (widgets)", ImVec2(-1, 160))) { + ImPlot::SetupAxes("frame", "widgets", ImPlotAxisFlags_NoTickLabels, ImPlotAxisFlags_AutoFit); + plotSeries("arranged", mArranged); + plotSeries("measured", mMeasured); + ImPlot::EndPlot(); + } + + if (ImGui::BeginTable("phase table", 4, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV)) { + ImGui::TableSetupColumn("phase"); + ImGui::TableSetupColumn("last"); + ImGui::TableSetupColumn("avg"); + ImGui::TableSetupColumn("max"); + ImGui::TableHeadersRow(); + statisticsRow("process", mProcessMs, "ms"); + statisticsRow("draw", mDrawMs, "ms"); + statisticsRow("input", mInputMs, "ms"); + statisticsRow("animation", mAnimationMs, "ms"); + statisticsRow("layout", mLayoutMs, "ms"); + statisticsRow("paint", mPaintMs, "ms"); + statisticsRow("arranged", mArranged, ""); + statisticsRow("measured", mMeasured, ""); + ImGui::EndTable(); + } + + const FrameStats& stats = mUi.stats(); + ImGui::Text( + "Totals: %llu passes, %llu arranged, %llu skipped, %llu events, %llu painted", + (unsigned long long) stats.layoutPasses, + (unsigned long long) stats.arrangedWidgets, + (unsigned long long) stats.skippedWidgets, + (unsigned long long) stats.deliveredEvents, + (unsigned long long) stats.paintedWidgets + ); + if (ImGui::Button("Reset totals")) { + mUi.resetStats(); + mPreviousStats = {}; + } + + ImGui::SeparatorText("Animating"); + for (Widget* widget : mUi.animatingWidgets()) + drawWidgetLink(*widget); +} + +void DebugInspector::drawHoverStackTab() { + Widget* hovered = mUi.hoveredWidget(); + const Vec2F pointer = mUi.pointerPosition(); + ImGui::Text("pointer %.0f, %.0f", pointer.x, pointer.y); + + if (Widget* capture = mUi.pointerCapture()) ImGui::Text("pointer captured by %s", nameOf(*capture).c_str()); + if (Widget* focus = mUi.keyboardFocus()) ImGui::Text("keyboard focus: %s", nameOf(*focus).c_str()); + + if (!hovered) { + ImGui::TextDisabled("Nothing under the cursor"); + return; + } + + ImGui::SeparatorText("Stack, innermost first"); + if (ImGui::BeginChild("hover stack", ImVec2(0, 260), ImGuiChildFlags_Border)) { + for (Widget* widget = hovered; widget; widget = widget->parent()) + drawWidgetLink(*widget); + } + ImGui::EndChild(); + + drawSelectedWidget(); +} + +void DebugInspector::drawWidgetTreeTab() { + if (ImGui::BeginChild("widget tree", ImVec2(0, ImGui::GetContentRegionAvail().y * 0.55f), ImGuiChildFlags_Border)) { + if (mUi.root()) drawWidgetTreeNode(*mUi.root()); + for (Widget* popup : mUi.openPopups()) { + ImGui::TextDisabled("popup"); + drawWidgetTreeNode(*popup); + } + } + ImGui::EndChild(); + + drawSelectedWidget(); +} + +void DebugInspector::drawWidgetTreeNode(Widget& widget) { + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_SpanAvailWidth; + if (widget.children().empty()) flags |= ImGuiTreeNodeFlags_Leaf; + if (&widget == mSelected) flags |= ImGuiTreeNodeFlags_Selected; + if (mUi.hoveredWidget() && widget.isSelfOrAncestorOf(*mUi.hoveredWidget())) flags |= ImGuiTreeNodeFlags_DefaultOpen; + + const RectF& rect = widget.rect(); + const bool open = ImGui::TreeNodeEx( + &widget, + flags, + "%s%s [%.0f x %.0f]", + nameOf(widget).c_str(), + widget.isVisible() ? "" : " (hidden)", + rect.size.x, + rect.size.y + ); + + if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) mSelected = &widget; + + if (!open) return; + for (Widget* child : widget.children()) + drawWidgetTreeNode(*child); + ImGui::TreePop(); +} + +void DebugInspector::drawSelectedWidget() { + ImGui::SeparatorText("Selected"); + if (!mSelected) { + ImGui::TextDisabled("Click a widget in a list, or hover one and press [S]"); + return; + } + + Widget& widget = *mSelected; + ImGui::Text("%s", nameOf(widget).c_str()); + + const RectF rect = widget.rect(); + const RectF window = widget.windowRect(); + ImGui::Text("rect %.1f, %.1f %.1f x %.1f", rect.pos.x, rect.pos.y, rect.size.x, rect.size.y); + ImGui::Text("window %.1f, %.1f", window.pos.x, window.pos.y); + if (widget.isGeometryMoving()) { + const RectF target = widget.targetRect(); + ImGui::Text("target %.1f, %.1f %.1f x %.1f", target.pos.x, target.pos.y, target.size.x, target.size.y); + } + bool animated = widget.isGeometryAnimated(); + if (ImGui::Checkbox("animate geometry", &animated)) widget.setGeometryAnimated(animated); + + bool visible = widget.isVisible(); + if (ImGui::Checkbox("visible", &visible)) widget.setVisible(visible); + ImGui::SameLine(); + bool enabled = widget.isEnabled(); + if (ImGui::Checkbox("enabled", &enabled)) widget.setEnabled(enabled); + ImGui::SameLine(); + ImGui::Text("%s%s", widget.isHovered() ? "hovered " : "", widget.hasKeyboardFocus() ? "focused" : ""); + + drawSizingEditor(widget, Axis::Horizontal); + drawSizingEditor(widget, Axis::Vertical); + + if (Layout* layout = widget.layout()) { + Insets padding = layout->padding(); + if (ImGui::DragFloat4("padding", &padding.left, 0.5f, 0, 200)) layout->setPadding(padding); + } + + if (!widget.tooltip().empty()) ImGui::Text("tooltip: %s", widget.tooltip().c_str()); + + if (ImGui::Button("Select parent") && widget.parent()) mSelected = widget.parent(); + ImGui::SameLine(); + if (ImGui::Button("Break on event")) mEventBreakpoints.insert(&widget); + ImGui::SameLine(); + if (ImGui::Button("Break on layout")) mLayoutBreakpoints.insert(&widget); + ImGui::SameLine(); + if (ImGui::Button("Relayout")) widget.requestLayout(); +} + +void DebugInspector::drawEventsTab() { + ImGui::Checkbox("Log pointer moves", &mLogPointerMoves); + ImGui::SameLine(); + if (ImGui::Button("Clear")) mEvents.clear(); + + const ImGuiTableFlags flags = ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_BordersInnerV; + if (!ImGui::BeginTable("events", 5, flags, ImVec2(0, ImGui::GetContentRegionAvail().y))) return; + + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("frame"); + ImGui::TableSetupColumn("event"); + ImGui::TableSetupColumn("receiver"); + ImGui::TableSetupColumn("at"); + ImGui::TableSetupColumn("accepted"); + ImGui::TableHeadersRow(); + + for (const EventRecord& record : mEvents) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::Text("%llu", (unsigned long long) record.frame); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(record.type.c_str()); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(record.receiver.c_str()); + ImGui::TableNextColumn(); + ImGui::Text("%.0f, %.0f", record.position.x, record.position.y); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(record.accepted ? "yes" : ""); + } + ImGui::EndTable(); +} + +void DebugInspector::drawLayoutTab() { + ImGui::Text("Last layout pass arranged %llu widgets, in order:", (unsigned long long) mLastLayoutOrder.size()); + if (ImGui::BeginChild("layout order", ImVec2(0, 0), ImGuiChildFlags_Border)) { + for (size_t index = 0; index < mLastLayoutOrder.size(); index++) { + ImGui::Text("%3llu %s", (unsigned long long) index, mLastLayoutOrder[index].c_str()); + } + } + ImGui::EndChild(); +} + +void DebugInspector::drawPopupsTab() { + const std::vector popups = mUi.openPopups(); + if (popups.empty()) ImGui::TextDisabled("No popups open"); + + for (size_t index = 0; index < popups.size(); index++) { + ImGui::Text("%llu.", (unsigned long long) index); + ImGui::SameLine(); + drawWidgetLink(*popups[index]); + } + + if (!popups.empty() && ImGui::Button("Close all")) mUi.closeAllPopups(); + if (Widget* tooltip = mUi.openTooltip()) ImGui::Text("tooltip open: %s", nameOf(*tooltip).c_str()); +} + +AxisSizing +DebugInspector::editedSizing(AxisSizing::Mode mode, halnf length, halnf weight, halnf minimum, halnf maximum) { + AxisSizing sizing = AxisSizing::fit(); + switch (mode) { + case AxisSizing::Mode::Fit: sizing = AxisSizing::fit(); break; + case AxisSizing::Mode::Fixed: sizing = AxisSizing::fixed(length); break; + case AxisSizing::Mode::Preferred: sizing = AxisSizing::preferred(length); break; + case AxisSizing::Mode::Fill: sizing = AxisSizing::fill(weight > 0 ? weight : 1); break; + } + + sizing = sizing.atLeast(minimum); + if (maximum >= 0) sizing = sizing.atMost(maximum); + return sizing; +} + +void DebugInspector::drawSizingEditor(Widget& widget, Axis axis) { + const AxisSizing current = widget.sizing().along(axis); + const char* axisName = axis == Axis::Horizontal ? "width" : "height"; + const halnf placedLength = lengthAlong(widget.rect(), axis); + + ImGui::PushID(axisName); + + int modeIndex = 0; + for (int index = 0; index < 4; index++) { + if (kSizingModes[index] == current.mode()) modeIndex = index; + } + + AxisSizing::Mode mode = current.mode(); + halnf length = current.fixedLength() > 0 ? current.fixedLength() : placedLength; + halnf weight = current.fillWeight(); + halnf minimum = current.minimum(); + halnf maximum = current.maximum() >= kUnbounded ? -1.0f : current.maximum(); + bool changed = false; + + ImGui::SetNextItemWidth(100); + if (ImGui::Combo(axisName, &modeIndex, "fit\0fixed\0preferred\0fill\0")) { + mode = kSizingModes[modeIndex]; + changed = true; + } + + const bool usesLength = mode == AxisSizing::Mode::Fixed || mode == AxisSizing::Mode::Preferred; + ImGui::SameLine(); + ImGui::SetNextItemWidth(70); + if (usesLength) changed |= ImGui::DragFloat("len", &length, 1.0f, 0.0f, 10000.0f, "%.0f"); + else if (mode == AxisSizing::Mode::Fill) changed |= ImGui::DragFloat("weight", &weight, 0.05f, 0.05f, 20.0f, "%.2f"); + else ImGui::TextDisabled("len %.0f", placedLength); + + ImGui::SetNextItemWidth(70); + changed |= ImGui::DragFloat("min", &minimum, 1.0f, 0.0f, 10000.0f, "%.0f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(70); + changed |= ImGui::DragFloat("max (-1 none)", &maximum, 1.0f, -1.0f, 10000.0f, "%.0f"); + + if (changed) widget.setSizing(axis, DebugInspector::editedSizing(mode, length, weight, minimum, maximum)); + + ImGui::PopID(); +} + +void DebugInspector::drawWidgetLink(Widget& widget) { + ImGui::PushID(&widget); + const RectF rect = widget.windowRect(); + char label[256]; + std::snprintf( + label, + sizeof(label), + "%s [%.0f, %.0f %.0f x %.0f]", + nameOf(widget).c_str(), + rect.pos.x, + rect.pos.y, + rect.size.x, + rect.size.y + ); + if (ImGui::Selectable(label, &widget == mSelected)) mSelected = &widget; + ImGui::PopID(); +} + +void DebugInspector::paintOutlines(Painter& painter, const Widget& widget, int depth) { + if (!widget.isVisible()) return; + + painter.strokeRect(widget.windowRect(), kOutlineColor); + for (const Widget* child : widget.children()) + paintOutlines(painter, *child, depth + 1); +} + +std::string DebugInspector::nameOf(const Widget& widget) { + return widget.debugName().empty() ? std::string("widget") : widget.debugName(); +} + +const char* DebugInspector::eventTypeName(const Event& event) { + switch (event.type()) { + case Event::Type::PointerMove: return "move"; + case Event::Type::PointerDown: return "down"; + case Event::Type::PointerUp: return "up"; + case Event::Type::Click: return "click"; + case Event::Type::LongPress: return "long press"; + case Event::Type::Wheel: return "wheel"; + case Event::Type::KeyDown: return "key down"; + case Event::Type::KeyUp: return "key up"; + case Event::Type::TextInput: return "text"; + case Event::Type::Gesture: return "gesture"; + } + return "?"; +} diff --git a/Widgets/private/core/InputRouter.cpp b/Widgets/private/core/InputRouter.cpp new file mode 100644 index 00000000..7720eb63 --- /dev/null +++ b/Widgets/private/core/InputRouter.cpp @@ -0,0 +1,429 @@ +#include "InputRouter.hpp" + +#include "PopupStack.hpp" +#include "TooltipController.hpp" +#include "Ui.hpp" +#include "WidgetInternals.hpp" +#include "WindowHost.hpp" + +#include + +using namespace tp; +using namespace tp::ui; + +namespace { + constexpr halnf kMultiClickIntervalMs = 400; + constexpr halnf kMultiClickDistance = 4; +} + +InputRouter::InputRouter(Ui& ui, PopupStack& popups, TooltipController& tooltips, Diagnostics& diagnostics) : + mUi(ui), + mPopups(popups), + mTooltips(tooltips), + mDiagnostics(diagnostics) {} + +void InputRouter::dispatch(Event event) { + if (event.isPointerEvent()) { + mPointer = event.windowPosition(); + event.withButtonsDown(mButtonsDown[0], mButtonsDown[1], mButtonsDown[2]); + } + + switch (event.type()) { + case Event::Type::PointerMove: handlePointerMove(event); break; + case Event::Type::PointerDown: handlePointerDown(event); break; + case Event::Type::PointerUp: handlePointerUp(event); break; + case Event::Type::Wheel: handleWheel(event); break; + case Event::Type::KeyDown: + case Event::Type::KeyUp: + case Event::Type::TextInput: handleKey(event); break; + case Event::Type::Gesture: handleGesture(event); break; + case Event::Type::Click: + case Event::Type::LongPress: break; + } +} + +void InputRouter::advanceClock(halnf deltaMs) { + mClockMs += deltaMs; + if (mLongPressArmed && mClockMs >= mLongPressDueMs) fireLongPress(); +} + +void InputRouter::armLongPress(const Event& event) { + mLongPressConsumed = false; + mLongPressArmed = event.button() == PointerButton::Primary && mPressed != nullptr; + mLongPressDueMs = mClockMs + Ui::kLongPressMs; + mLongPressOrigin = event.windowPosition(); + mLongPressModifiers = event.modifiers(); + mLongPressSample = event.sample(); +} + +void InputRouter::fireLongPress() { + mLongPressArmed = false; + if (!mPressed) return; + + Event longPress = Event::pointer(Event::Type::LongPress, mLongPressOrigin, { 0, 0 }, PointerButton::Primary) + .withModifiers(mLongPressModifiers) + .withSample(mLongPressSample) + .withButtonsDown(mButtonsDown[0], mButtonsDown[1], mButtonsDown[2]); + bubble(longPress, mPressed); + mLongPressConsumed = longPress.isAccepted() || (mPressed && mTooltips.showFor(*mPressed)); +} + +void InputRouter::refreshHover() { + updateHover(mCapture ? mCapture : widgetAt(mPointer)); + updatePressed(); +} + +Widget* InputRouter::pressedWidget() const { + if (!mButtonsDown[(int) PointerButton::Primary] || !mPressed) return nullptr; + if (mCapture) return mCapture; + return mHovered && mPressed->isSelfOrAncestorOf(*mHovered) ? mPressed : nullptr; +} + +void InputRouter::capture(Widget& widget) { + mCapture = &widget; + if (mPressed && mPressed != &widget) { + mPressed = nullptr; + updatePressed(); + } +} + +void InputRouter::updatePressed() { + const std::vector previousPath = mPressedPath; + Widget* pressed = pressedWidget(); + + mPressedPath.clear(); + if (pressed) { + const std::vector hoverPath = + pathTo(mHovered && pressed->isSelfOrAncestorOf(*mHovered) ? mHovered : pressed); + for (Widget* widget : hoverPath) { + if (pressed->isSelfOrAncestorOf(*widget)) mPressedPath.push_back(widget); + } + } + + updatePathFlags(previousPath, mPressedPath, WidgetInternals::setPressed); +} + +void InputRouter::forget(const Widget& widget) { + auto detaches = [&widget](Widget* tracked) { return tracked && widget.isSelfOrAncestorOf(*tracked); }; + + if (detaches(mHovered)) { + for (Widget* iterator = mHovered; iterator; iterator = iterator->parent()) { + WidgetInternals::setHovered(*iterator, false); + } + mHovered = nullptr; + } + + if (detaches(mPressed)) mPressed = nullptr; + if (detaches(mCapture)) mCapture = nullptr; + if (detaches(mGestureTarget)) mGestureTarget = nullptr; + + const auto inSubtree = [&widget](Widget* tracked) { return widget.isSelfOrAncestorOf(*tracked); }; + for (Widget* tracked : mPressedPath) { + if (inSubtree(tracked)) WidgetInternals::setPressed(*tracked, false); + } + mPressedPath.erase(std::remove_if(mPressedPath.begin(), mPressedPath.end(), inSubtree), mPressedPath.end()); + if (detaches(mKeyboardFocus)) mKeyboardFocus = nullptr; + + const auto inDetachedSubtree = [&widget](Widget* tracked) { return widget.isSelfOrAncestorOf(*tracked); }; + if (std::any_of(mActivePath.begin(), mActivePath.end(), inDetachedSubtree)) { + const std::vector previousPath = mActivePath; + const auto detached = std::find_if(previousPath.begin(), previousPath.end(), inDetachedSubtree); + + mActivePath.assign(previousPath.begin(), detached); + mActive = mActivePath.empty() ? nullptr : mActivePath.back(); + updatePathFlags(previousPath, mActivePath, WidgetInternals::setContainsActive); + } +} + +void InputRouter::release(const Widget& widget) { + if (mCapture == &widget) mCapture = nullptr; + updatePressed(); +} + +void InputRouter::setKeyboardFocus(Widget* widget) { + if (mKeyboardFocus == widget) return; + + Widget* previous = mKeyboardFocus; + mKeyboardFocus = widget; + + if (previous) WidgetInternals::notifyKeyboardFocusChanged(*previous); + if (widget) WidgetInternals::notifyKeyboardFocusChanged(*widget); +} + +void InputRouter::setActiveWidget(Widget* widget) { + if (widget == mActive) return; + + const std::vector previousPath = mActivePath; + mActive = widget; + mActivePath = ownershipPathTo(widget); + updatePathFlags(previousPath, mActivePath, WidgetInternals::setContainsActive); +} + +std::vector InputRouter::ownershipPathTo(Widget* widget) const { + std::vector path; + Widget* iterator = widget; + + while (iterator) { + std::vector segment = pathTo(iterator); + path.insert(path.begin(), segment.begin(), segment.end()); + + const long popup = mPopups.indexOf(*segment.front()); + iterator = popup >= 0 ? mPopups.entries()[(size_t) popup].request.owner : nullptr; + } + + return path; +} + +Widget* InputRouter::widgetAt(Vec2F windowPoint) const { + const auto& entries = mPopups.entries(); + + for (auto entry = entries.rbegin(); entry != entries.rend(); ++entry) { + if (!entry->request.options.interactive) continue; + if (Widget* hit = hitTestSubtree(*entry->request.content, windowPoint)) return hit; + if (entry->request.options.modal) return nullptr; + } + + if (!mUi.root()) return nullptr; + return hitTestSubtree(*mUi.root(), windowPoint); +} + +void InputRouter::handlePointerMove(Event& event) { + mTooltips.onPointerActivity(); + if (mLongPressArmed) { + const Vec2F offset = event.windowPosition() - mLongPressOrigin; + if (offset.x * offset.x + offset.y * offset.y > kMultiClickDistance * kMultiClickDistance) mLongPressArmed = false; + } + updateHover(mCapture ? mCapture : widgetAt(mPointer)); + updatePressed(); + bubble(event, pointerTarget()); + applyCursor(); +} + +int InputRouter::countClick(const Event& event) { + const Vec2F offset = event.windowPosition() - mLastPressPosition; + const bool nearby = offset.x * offset.x + offset.y * offset.y <= kMultiClickDistance * kMultiClickDistance; + const bool quick = mClockMs - mLastPressMs <= kMultiClickIntervalMs; + const bool sameButton = event.button() == mLastPressButton; + + mClickCount = nearby && quick && sameButton ? mClickCount + 1 : 1; + mLastPressMs = mClockMs; + mLastPressPosition = event.windowPosition(); + mLastPressButton = event.button(); + return mClickCount; +} + +void InputRouter::applyCursor() { + Widget* target = pointerTarget(); + CursorShape cursor = CursorShape::Default; + for (Widget* widget = target; widget; widget = widget->parent()) { + if (widget->cursor() == CursorShape::Default) continue; + cursor = widget->cursor(); + break; + } + + if (cursor == mAppliedCursor) return; + mAppliedCursor = cursor; + if (WindowHost* host = mUi.windowHost()) host->setCursor(cursor); +} + +void InputRouter::handlePointerDown(Event& event) { + mButtonsDown[(int) event.button()] = true; + event.withButtonsDown(mButtonsDown[0], mButtonsDown[1], mButtonsDown[2]); + event.withClickCount(countClick(event)); + mTooltips.onPress(); + + Widget* target = widgetAt(mPointer); + dismissPopupsOutside(target); + + target = widgetAt(mPointer); + updateHover(target); + + if (event.button() == PointerButton::Primary) mPressed = target; + mKeyboardNavigating = false; + updatePressed(); + setActiveWidget(target); + setKeyboardFocus(nullptr); + for (Widget* candidate = target; candidate; candidate = candidate->parent()) { + if (WidgetInternals::acceptsKeyboardFocus(*candidate) && candidate->isEffectivelyEnabled()) { + setKeyboardFocus(candidate); + break; + } + } + + armLongPress(event); + bubble(event, target); +} + +void InputRouter::handlePointerUp(Event& event) { + mButtonsDown[(int) event.button()] = false; + event.withButtonsDown(mButtonsDown[0], mButtonsDown[1], mButtonsDown[2]); + + Widget* target = pointerTarget(); + bubble(event, target); + + if (mCapture && event.button() == PointerButton::Primary) mCapture = nullptr; + + Widget* released = widgetAt(mPointer); + const bool primary = event.button() == PointerButton::Primary; + const bool longPressed = primary && mLongPressConsumed; + if (primary) { + mLongPressArmed = false; + mLongPressConsumed = false; + } + const bool isClick = primary && !longPressed && mPressed && released && mPressed->isSelfOrAncestorOf(*released); + Widget* pressed = mPressed; + if (event.button() == PointerButton::Primary) mPressed = nullptr; + updatePressed(); + + if (isClick) { + Event click = Event::pointer(Event::Type::Click, mPointer, { 0, 0 }, PointerButton::Primary) + .withModifiers(event.modifiers()) + .withButtonsDown(mButtonsDown[0], mButtonsDown[1], mButtonsDown[2]) + .withClickCount(mClickCount); + bubble(click, pressed); + } + + updateHover(widgetAt(mPointer)); + updatePressed(); + applyCursor(); +} + +void InputRouter::handleWheel(Event& event) { + mTooltips.onPointerActivity(); + bubble(event, pointerTarget()); +} + +void InputRouter::handleGesture(Event& event) { + mTooltips.onPointerActivity(); + if (event.touchGesture().phase == GesturePhase::Begin) { + mGestureTarget = widgetAt(event.windowPosition()); + if (mGestureTarget) dismissPopupsOutside(mGestureTarget); + } + Widget* target = mGestureTarget; + if (event.touchGesture().phase == GesturePhase::End) mGestureTarget = nullptr; + bubble(event, target); +} + +void InputRouter::handleKey(Event& event) { + if (event.type() == Event::Type::KeyDown && !mKeyboardNavigating) { + mKeyboardNavigating = true; + if (mKeyboardFocus) mKeyboardFocus->requestPaint(); + } + if (event.type() == Event::Type::KeyDown) mKeysDown.insert(event.keyCode()); + else if (event.type() == Event::Type::KeyUp) mKeysDown.erase(event.keyCode()); + + const bool escape = event.type() == Event::Type::KeyDown && event.keyCode() == InputID::ESCAPE; + + if ( + escape && !mPopups.empty() && mPopups.entries().back().request.options.interactive && + mPopups.entries().back().request.options.closeOnEscape + ) { + mPopups.closeFrom(mPopups.entries().size() - 1); + updateHover(widgetAt(mPointer)); + return; + } + + Widget* target = mKeyboardFocus && !isBlockedByModal(mKeyboardFocus) ? mKeyboardFocus : nullptr; + for (auto entry = mPopups.entries().rbegin(); !target && entry != mPopups.entries().rend(); ++entry) { + if (entry->request.options.interactive) target = entry->request.content; + } + if (!target && mUi.root()) target = WidgetInternals::defaultKeyTarget(*mUi.root()); + + bubble(event, target); +} + +void InputRouter::dismissPopupsOutside(Widget* target) { + const long containing = target ? mPopups.indexContaining(*target) : -1; + long firstToClose = (long) mPopups.entries().size(); + + for (long index = firstToClose - 1; index > containing; index--) { + const PopupRequest& request = mPopups.entries()[index].request; + const bool pressedOwnTrigger = target && request.owner && request.owner->isSelfOrAncestorOf(*target); + + if (pressedOwnTrigger || !request.options.closeOnOutsidePress) break; + firstToClose = index; + } + + mPopups.closeFrom((size_t) firstToClose); +} + +void InputRouter::updateHover(Widget* target) { + if (target == mHovered) return; + + const std::vector previousPath = pathTo(mHovered); + mHovered = target; + updatePathFlags(previousPath, pathTo(target), WidgetInternals::setHovered); + mTooltips.onHoverChanged(target); +} + +void InputRouter::updatePathFlags( + const std::vector& previousPath, const std::vector& nextPath, void (*setFlag)(Widget&, bool) +) { + size_t common = 0; + while (common < previousPath.size() && common < nextPath.size() && previousPath[common] == nextPath[common]) { + common++; + } + + for (size_t index = previousPath.size(); index > common; index--) + setFlag(*previousPath[index - 1], false); + for (size_t index = common; index < nextPath.size(); index++) + setFlag(*nextPath[index], true); +} + +void InputRouter::bubble(Event& event, Widget* target) { + if (!target || isBlockedByModal(target)) return; + + const std::vector path = pathTo(target); + + event.mTarget = target; + for (size_t index = 0; index + 1 < path.size(); index++) { + WidgetInternals::filter(*path[index], event); + if (event.isAccepted()) return; + } + + for (auto receiver = path.rbegin(); receiver != path.rend(); ++receiver) { + if (!(*receiver)->isEffectivelyEnabled()) continue; + + deliverTo(event, **receiver, target); + if (event.isAccepted()) return; + } +} + +void InputRouter::deliverTo(Event& event, Widget& receiver, Widget* target) { + event.mTarget = target; + event.mReceiver = &receiver; + mDiagnostics.stats.deliveredEvents++; + if (mDiagnostics.observer) mDiagnostics.observer->onEventDelivering(receiver, event); + WidgetInternals::deliver(receiver, event); + if (mDiagnostics.observer) mDiagnostics.observer->onEventDelivered(receiver, event); +} + +Widget* InputRouter::pointerTarget() const { return mCapture ? mCapture : mHovered; } + +bool InputRouter::isBlockedByModal(const Widget* target) const { + const long modal = mPopups.topmostModal(); + if (modal < 0 || !target) return false; + return mPopups.indexContaining(*target) < modal; +} + +Widget* InputRouter::hitTestSubtree(Widget& widget, Vec2F windowPoint) { + if (!widget.isVisible()) return nullptr; + + const Vec2F local = widget.toLocal(windowPoint); + if (!contains(widget.localRect(), local)) return nullptr; + + const auto& children = widget.children(); + for (auto child = children.rbegin(); child != children.rend(); ++child) { + if (Widget* hit = hitTestSubtree(**child, windowPoint)) return hit; + } + + return WidgetInternals::hitTest(widget, local) ? &widget : nullptr; +} + +std::vector InputRouter::pathTo(Widget* widget) { + std::vector path; + for (Widget* iterator = widget; iterator; iterator = iterator->parent()) + path.push_back(iterator); + std::reverse(path.begin(), path.end()); + return path; +} diff --git a/Widgets/private/core/InputRouter.hpp b/Widgets/private/core/InputRouter.hpp new file mode 100644 index 00000000..ac9215a6 --- /dev/null +++ b/Widgets/private/core/InputRouter.hpp @@ -0,0 +1,108 @@ +#pragma once + +#include "Diagnostics.hpp" +#include "Event.hpp" +#include "WindowTypes.hpp" + +#include +#include + +namespace tp::ui { + + class Ui; + class Widget; + class PopupStack; + class TooltipController; + + class InputRouter { + public: + InputRouter(Ui& ui, PopupStack& popups, TooltipController& tooltips, Diagnostics& diagnostics); + + void dispatch(Event event); + void advanceClock(halnf deltaMs); + [[nodiscard]] bool isWaitingForLongPress() const { return mLongPressArmed; } + void refreshHover(); + void refreshCursor() { applyCursor(); } + void forget(const Widget& widget); + + void capture(Widget& widget); + void release(const Widget& widget); + [[nodiscard]] Widget* captured() const { return mCapture; } + + void setKeyboardFocus(Widget* widget); + [[nodiscard]] Widget* keyboardFocus() const { return mKeyboardFocus; } + [[nodiscard]] bool isKeyboardNavigating() const { return mKeyboardNavigating; } + + void setActiveWidget(Widget* widget); + [[nodiscard]] Widget* activeWidget() const { return mActive; } + + [[nodiscard]] Widget* hovered() const { return mHovered; } + [[nodiscard]] Widget* pressed() const { return mPressed; } + [[nodiscard]] Vec2F pointer() const { return mPointer; } + [[nodiscard]] bool isPrimaryDown() const { return mButtonsDown[(int) PointerButton::Primary]; } + [[nodiscard]] bool isKeyDown(InputID key) const { return mKeysDown.count(key) > 0; } + + [[nodiscard]] Widget* widgetAt(Vec2F windowPoint) const; + + private: + void handlePointerMove(Event& event); + void handlePointerDown(Event& event); + void handlePointerUp(Event& event); + void handleWheel(Event& event); + void handleKey(Event& event); + void handleGesture(Event& event); + + [[nodiscard]] int countClick(const Event& event); + void armLongPress(const Event& event); + void fireLongPress(); + void applyCursor(); + + void dismissPopupsOutside(Widget* target); + void updateHover(Widget* target); + void updatePressed(); + [[nodiscard]] Widget* pressedWidget() const; + static void updatePathFlags( + const std::vector& previousPath, const std::vector& nextPath, void (*setFlag)(Widget&, bool) + ); + [[nodiscard]] std::vector ownershipPathTo(Widget* widget) const; + void bubble(Event& event, Widget* target); + void deliverTo(Event& event, Widget& receiver, Widget* target); + + [[nodiscard]] Widget* pointerTarget() const; + [[nodiscard]] bool isBlockedByModal(const Widget* target) const; + [[nodiscard]] static Widget* hitTestSubtree(Widget& widget, Vec2F windowPoint); + [[nodiscard]] static std::vector pathTo(Widget* widget); + + Ui& mUi; + PopupStack& mPopups; + TooltipController& mTooltips; + Diagnostics& mDiagnostics; + + Vec2F mPointer = { -1.0e6f, -1.0e6f }; + bool mButtonsDown[3] = { false, false, false }; + std::set mKeysDown; + + Widget* mHovered = nullptr; + Widget* mGestureTarget = nullptr; + Widget* mPressed = nullptr; + Widget* mCapture = nullptr; + Widget* mKeyboardFocus = nullptr; + bool mKeyboardNavigating = false; + std::vector mPressedPath; + + halnf mClockMs = 0; + halnf mLastPressMs = -1.0e9f; + Vec2F mLastPressPosition = { -1.0e6f, -1.0e6f }; + PointerButton mLastPressButton = PointerButton::Primary; + int mClickCount = 0; + bool mLongPressArmed = false; + bool mLongPressConsumed = false; + halnf mLongPressDueMs = 0; + Vec2F mLongPressOrigin = { 0, 0 }; + Modifiers mLongPressModifiers; + PointerSample mLongPressSample; + CursorShape mAppliedCursor = CursorShape::Default; + Widget* mActive = nullptr; + std::vector mActivePath; + }; +} diff --git a/Widgets/private/core/TooltipController.cpp b/Widgets/private/core/TooltipController.cpp new file mode 100644 index 00000000..c692a5b2 --- /dev/null +++ b/Widgets/private/core/TooltipController.cpp @@ -0,0 +1,87 @@ +#include "TooltipController.hpp" + +#include "Label.hpp" +#include "Ui.hpp" + +using namespace tp; +using namespace tp::ui; + +TooltipController::TooltipController(Ui& ui) : + mUi(ui), + mBubble(std::make_unique()) {} + +TooltipController::~TooltipController() = default; + +void TooltipController::onHoverChanged(Widget* hovered) { + Widget* candidate = hovered; + while (candidate && candidate->tooltip().empty()) + candidate = candidate->parent(); + + if (candidate == mCandidate) return; + + hide(); + mCandidate = candidate; + mDwellMs = 0; +} + +void TooltipController::onPointerActivity() { + if (mPinned) return; + hide(); + mDwellMs = 0; +} + +void TooltipController::onPress() { + mPinned = false; + onPointerActivity(); +} + +bool TooltipController::showFor(Widget& widget) { + Widget* candidate = &widget; + while (candidate && candidate->tooltip().empty()) + candidate = candidate->parent(); + if (!candidate) return false; + hide(); + mCandidate = candidate; + show(); + mPinned = true; + return true; +} + +void TooltipController::advance(halnf deltaMs) { + if (!mCandidate || isShown() || mUi.isTouchMode()) return; + + mDwellMs += deltaMs; + if (mDwellMs >= mUi.theme().metrics.tooltipDelayMs) show(); +} + +void TooltipController::forget(const Widget& widget) { + if (mCandidate && widget.isSelfOrAncestorOf(*mCandidate)) { + hide(); + mCandidate = nullptr; + } +} + +bool TooltipController::isShown() const { return mUi.isPopupOpen(*mBubble); } + +Widget* TooltipController::bubble() const { return isShown() ? mBubble.get() : nullptr; } + +void TooltipController::show() { + mBubble->setText(mCandidate->tooltip()); + + PopupRequest request; + request.content = mBubble.get(); + request.owner = nullptr; + request.anchor = mCandidate->windowRect(); + request.options.placement = Placement::Below; + request.options.align = PlacementAlign::Center; + request.options.gap = mUi.theme().metrics.popupGap * 2; + request.options.closeOnOutsidePress = true; + request.options.interactive = false; + + mUi.openPopup(request); +} + +void TooltipController::hide() { + mPinned = false; + if (isShown()) mUi.closePopup(*mBubble); +} diff --git a/Widgets/private/core/TooltipController.hpp b/Widgets/private/core/TooltipController.hpp new file mode 100644 index 00000000..8724df25 --- /dev/null +++ b/Widgets/private/core/TooltipController.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include "Environment.hpp" + +#include + +namespace tp::ui { + + class Ui; + class Widget; + class TooltipBubble; + + class TooltipController { + public: + explicit TooltipController(Ui& ui); + ~TooltipController(); + + void onHoverChanged(Widget* hovered); + void onPointerActivity(); + void onPress(); + bool showFor(Widget& widget); + void advance(halnf deltaMs); + void forget(const Widget& widget); + + [[nodiscard]] bool isWaiting() const { return mCandidate != nullptr && !isShown(); } + [[nodiscard]] bool isShown() const; + [[nodiscard]] Widget* bubble() const; + + private: + void show(); + void hide(); + + Ui& mUi; + std::unique_ptr mBubble; + Widget* mCandidate = nullptr; + halnf mDwellMs = 0; + bool mPinned = false; + }; +} diff --git a/Widgets/private/core/WidgetInternals.hpp b/Widgets/private/core/WidgetInternals.hpp new file mode 100644 index 00000000..a0ee7958 --- /dev/null +++ b/Widgets/private/core/WidgetInternals.hpp @@ -0,0 +1,179 @@ +#pragma once + +#include "Widget.hpp" +#include "Animation.hpp" +#include "Ui.hpp" + +namespace tp::ui { + + class WidgetInternals { + public: + static void attach(Widget& widget, Ui& ui) { widget.attachSubtree(ui); } + static void detach(Widget& widget) { widget.detachSubtree(); } + + static Extent measureContent(const Widget& widget, Axis axis, halnf crossLength) { + return widget.measureContent(axis, crossLength); + } + + static void deliver(Widget& widget, Event& event) { widget.onEvent(event); } + static void filter(Widget& widget, Event& event) { widget.filterDescendantEvent(event); } + static const Widget* raisedChild(const Widget& widget) { return widget.raisedChild(); } + static Widget* defaultKeyTarget(Widget& widget) { return widget.defaultKeyTarget(); } + static void paintRaisedBackdrop(Widget& widget, Painter& painter, const RectF& childRect) { + widget.paintRaisedBackdrop(painter, childRect); + } + static void tick(Widget& widget, halnf deltaMs) { widget.onTick(deltaMs); } + static void paint(Widget& widget, Painter& painter) { widget.paint(painter); } + static void paintOverlay(Widget& widget, Painter& painter) { widget.paintOverlay(painter); } + static bool hitTest(const Widget& widget, Vec2F localPoint) { return widget.hitTest(localPoint); } + static bool acceptsKeyboardFocus(const Widget& widget) { return widget.acceptsKeyboardFocus(); } + + static void setHovered(Widget& widget, bool hovered) { + if (widget.mHovered == hovered) return; + widget.mHovered = hovered; + widget.onHoverChanged(); + widget.requestPaint(); + } + + static void notifyOwnedPopupChanged(Widget& owner) { + owner.onOwnedPopupChanged(); + owner.requestPaint(); + } + + static void setPressed(Widget& widget, bool pressed) { + if (widget.mPressed == pressed) return; + widget.mPressed = pressed; + widget.onPressedChanged(); + widget.requestPaint(); + } + + static void setContainsActive(Widget& widget, bool containsActive) { + if (widget.mContainsActive == containsActive) return; + widget.mContainsActive = containsActive; + widget.onActiveWithinChanged(); + widget.requestPaint(); + } + + static void notifyKeyboardFocusChanged(Widget& widget) { + widget.onKeyboardFocusChanged(); + widget.requestPaint(); + } + + static void notifyThemeChanged(Widget& widget) { + widget.onThemeChanged(); + for (Widget* child : widget.mChildren) + notifyThemeChanged(*child); + } + + static void notifyInteractionModeChanged(Widget& widget) { + widget.onInteractionModeChanged(); + for (Widget* child : widget.mChildren) + notifyInteractionModeChanged(*child); + } + + static void notifyWindowHostChanged(Widget& widget) { + widget.onWindowHostChanged(); + for (Widget* child : widget.mChildren) + notifyWindowHostChanged(*child); + } + + static bool needsLayout(const Widget& widget) { + return widget.mNeedsLayout || !(widget.mArrangedSize == widget.mRect.size); + } + static void markLaidOut(Widget& widget) { + widget.mNeedsLayout = false; + widget.mArrangedSize = widget.mRect.size; + } + + static bool assignTarget(Widget& widget, const RectF& target) { + const RectF previousRect = widget.mRect; + const bool firstPlacement = !widget.mPlaced; + const bool targetChanged = !(widget.mTargetRect == target); + + widget.mTargetRect = target; + widget.mPlaced = true; + + if (firstPlacement && widget.mAppearing && widget.mGeometryAnimated && widget.mUi) { + widget.mAppearing = false; + widget.mRect = widget.mAppearFrom.size.x < 0 ? collapsedAt(target) : widget.mAppearFrom; + widget.mAppearFrom = makeRect(0, 0, -1, -1); + startGeometry(widget, target); + return true; + } + widget.mAppearing = false; + + const bool tracksViewport = widget.mUi && widget.mUi->isViewportResizing(); + if ( + firstPlacement || !widget.mGeometryAnimated || !widget.mUi || tracksViewport || followsMovingAncestor(widget) + ) { + if (widget.mGeometry) widget.mGeometry->jumpTo(target); + widget.mRect = target; + return !(previousRect == widget.mRect); + } + + if (targetChanged) startGeometry(widget, target); + return false; + } + + static RectF collapsedAt(const RectF& target) { return makeRect(target.center(), Vec2F(0.0f, 0.0f)); } + + static bool followsMovingAncestor(const Widget& widget) { + for (const Widget* current = &widget; current->mParent; current = current->mParent) { + const Widget* parent = current->mParent; + if (parent->isGeometryMoving() || parent->isTransitioning()) return true; + for (const Widget* sibling : parent->mChildren) { + if (sibling == current) break; + if (sibling->isTransitioning()) return true; + } + } + return false; + } + + static void startGeometry(Widget& widget, const RectF& target) { + if (!widget.mGeometry) { + widget.mGeometry = + std::make_unique(widget.mRect, Motion::fluid(), AnimatedRect::kGeometryTolerance); + } else if (!widget.mGeometry->isAnimating()) { + widget.mGeometry->jumpTo(widget.mRect); + } + widget.mGeometry->animateTo(target); + widget.mUi->startGeometryAnimation(widget); + } + + static void resetPlacement(Widget& widget) { + widget.mPlaced = false; + for (Widget* child : widget.mChildren) + resetPlacement(*child); + } + + static bool stepGeometry(Widget& widget, halnf deltaMs) { + if (!widget.mGeometry) return false; + + const bool moving = widget.mGeometry->advance(deltaMs); + const RectF current = moving ? widget.mGeometry->value() : widget.mTargetRect; + + if (!(current == widget.mRect)) { + widget.mRect = current; + markArrangeNeeded(widget); + } + return moving; + } + + static void markArrangeNeeded(Widget& widget) { + for (Widget* iterator = &widget; iterator; iterator = iterator->mParent) + iterator->mNeedsLayout = true; + if (widget.mUi) widget.mUi->scheduleLayout(); + } + + static bool cachedMeasure(const Widget& widget, Axis axis, halnf crossLength, Extent& extent) { + const auto& entry = widget.mMeasureCache[(int) axis]; + if (!entry.valid || entry.crossLength != crossLength) return false; + extent = entry.extent; + return true; + } + + static void storeMeasure(Widget& widget, Axis axis, halnf crossLength, const Extent& extent) { + widget.mMeasureCache[(int) axis] = { true, crossLength, extent }; + } + }; +} diff --git a/Widgets/private/layouts/StackLayout.cpp b/Widgets/private/layouts/StackLayout.cpp new file mode 100644 index 00000000..d75b0e0c --- /dev/null +++ b/Widgets/private/layouts/StackLayout.cpp @@ -0,0 +1,153 @@ +#include "StackLayout.hpp" + +#include + +#include "Widget.hpp" + +using namespace tp; +using namespace tp::ui; + +namespace { + halnf clampToExtent(halnf length, const Extent& extent) { + if (length > extent.maximum) length = extent.maximum; + if (length < extent.minimum) length = extent.minimum; + return length; + } + + Extent largestOf(const LayoutContext& context, Axis axis, halnf crossLength) { + Extent result = { 0, 0, kUnbounded, 0 }; + for (const Widget* child : context.children()) { + const Extent extent = context.measure(*child, axis, crossLength); + if (extent.minimum > result.minimum) result.minimum = extent.minimum; + if (extent.preferred > result.preferred) result.preferred = extent.preferred; + if (extent.grow > result.grow) result.grow = extent.grow; + } + return result; + } + + Vec2F sizeWithin(const LayoutContext& context, const Widget& child, Vec2F available) { + const Extent horizontal = context.measure(child, Axis::Horizontal, available.y); + const halnf width = horizontal.grow > 0 ? clampToExtent(available.x, horizontal) : horizontal.preferred; + + const Extent vertical = context.measure(child, Axis::Vertical, width); + const halnf height = vertical.grow > 0 ? clampToExtent(available.y, vertical) : vertical.preferred; + + return { width, height }; + } +} + +Extent StackLayout::measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const { + return largestOf(context, axis, crossLength); +} + +void StackLayout::arrangeChildren(LayoutContext& context, const RectF& contentArea) { + for (Widget* child : context.children()) + context.place(*child, contentArea); +} + +Extent FreeLayout::measureChildren(const LayoutContext& context, Axis axis, halnf) const { + Extent result = { 0, 0, kUnbounded, 0 }; + for (const Widget* child : context.children()) { + const halnf end = along(child->requestedPosition(), axis) + context.measure(*child, axis, kUnbounded).preferred; + if (end > result.preferred) result.preferred = end; + } + return result; +} + +void FreeLayout::arrangeChildren(LayoutContext& context, const RectF& contentArea) { + for (Widget* child : context.children()) { + const Vec2F position = contentArea.pos + child->requestedPosition(); + const Vec2F available = contentArea.size - child->requestedPosition(); + context.place(*child, { position, sizeWithin(context, *child, available) }); + } +} + +GridLayout::GridLayout(ualni columns, halnf gap) : + mColumns(columns > 0 ? columns : 1), + mGap(gap) {} + +void GridLayout::setColumns(ualni columns) { + mColumns = columns > 0 ? columns : 1; + invalidate(); +} + +void GridLayout::setGap(halnf gap) { + mGap = gap; + invalidate(); +} + +void GridLayout::setCellSize(std::optional cellSize) { + mCellSize = cellSize; + invalidate(); +} + +Extent GridLayout::measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const { + if (mCellSize) { + const ualni lines = axis == Axis::Horizontal ? std::min(mColumns, context.children().size()) : rowCount(context); + const halnf cell = axis == Axis::Horizontal ? mCellSize->x : mCellSize->y; + return Extent::rigid(lines == 0 ? 0 : cell * (halnf) lines + mGap * (halnf) (lines - 1)); + } + + if (axis == Axis::Horizontal) { + const Extent widest = largestOf(context, Axis::Horizontal, kUnbounded); + const halnf gaps = mGap * (halnf) (mColumns - 1); + return { widest.minimum * (halnf) mColumns + gaps, widest.preferred * (halnf) mColumns + gaps, kUnbounded, 0 }; + } + + const halnf cellWidth = crossLength < kUnbounded ? columnWidth(crossLength) : kUnbounded; + const ualni rows = rowCount(context); + + halnf height = rows > 1 ? mGap * (halnf) (rows - 1) : 0; + for (ualni row = 0; row < rows; row++) + height += rowHeight(context, row, cellWidth); + return { height, height, kUnbounded, 0 }; +} + +void GridLayout::arrangeChildren(LayoutContext& context, const RectF& contentArea) { + if (mCellSize) { + for (ualni index = 0; index < context.children().size(); index++) { + const halnf left = contentArea.pos.x + (mCellSize->x + mGap) * (halnf) (index % mColumns); + const halnf top = contentArea.pos.y + (mCellSize->y + mGap) * (halnf) (index / mColumns); + context.place(*context.children()[index], makeRect({ left, top }, *mCellSize)); + } + return; + } + + const halnf cellWidth = columnWidth(contentArea.size.x); + halnf top = contentArea.pos.y; + + for (ualni row = 0; row < rowCount(context); row++) { + const halnf height = rowHeight(context, row, cellWidth); + + for (ualni column = 0; column < mColumns; column++) { + const ualni index = row * mColumns + column; + if (index >= context.children().size()) break; + + const halnf left = contentArea.pos.x + (cellWidth + mGap) * (halnf) column; + context.place(*context.children()[index], { left, top, cellWidth, height }); + } + + top += height + mGap; + } +} + +ualni GridLayout::rowCount(const LayoutContext& context) const { + return (context.children().size() + mColumns - 1) / mColumns; +} + +halnf GridLayout::columnWidth(halnf availableWidth) const { + const halnf width = (availableWidth - mGap * (halnf) (mColumns - 1)) / (halnf) mColumns; + return width > 0 ? width : 0; +} + +halnf GridLayout::rowHeight(const LayoutContext& context, ualni row, halnf cellWidth) const { + halnf height = 0; + for (ualni column = 0; column < mColumns; column++) { + const ualni index = row * mColumns + column; + if (index >= context.children().size()) break; + + const halnf childHeight = context.measure(*context.children()[index], Axis::Vertical, cellWidth).preferred; + if (childHeight > height) height = childHeight; + } + return height; +} diff --git a/Widgets/private/nodes/NodeGraph.cpp b/Widgets/private/nodes/NodeGraph.cpp new file mode 100644 index 00000000..29d821ed --- /dev/null +++ b/Widgets/private/nodes/NodeGraph.cpp @@ -0,0 +1,230 @@ +#include "NodeGraph.hpp" + +#include +#include + +using namespace tp; +using namespace tp::ui; + +Vec2F NodeGraphFragment::origin() const { + if (nodes.empty()) return { 0, 0 }; + + Vec2F origin = nodes.front().position; + for (const Node& node : nodes) { + origin.x = std::min(origin.x, node.position.x); + origin.y = std::min(origin.y, node.position.y); + } + return origin; +} + +NodeTypeId NodeGraph::registerType(NodeType type) { + mTypes.push_back(std::move(type)); + return mTypes.size() - 1; +} + +NodeId NodeGraph::addNode(NodeTypeId type, Vec2F position, std::any payload) { + const NodeId id = mNextId++; + mNodes.push_back( + { id, type, position, std::move(payload), mTypes.at(type).width, mTypes.at(type).bodyHeight, false } + ); + changed(); + return id; +} + +bool NodeGraph::addNodeWithId(NodeId id, NodeTypeId type, Vec2F position, std::any payload) { + if (id == 0 || findNode(id)) return false; + + mNodes.push_back( + { id, type, position, std::move(payload), mTypes.at(type).width, mTypes.at(type).bodyHeight, false } + ); + mNextId = std::max(mNextId, id + 1); + changed(); + return true; +} + +bool NodeGraph::renameNode(NodeId from, NodeId to) { + Node* node = findNode(from); + if (!node || to == 0 || (from != to && findNode(to))) return false; + if (from == to) return true; + + node->id = to; + for (Link& link : mLinks) { + if (link.from.node == from) link.from.node = to; + if (link.to.node == from) link.to.node = to; + } + mNextId = std::max(mNextId, to + 1); + changed(); + return true; +} + +void NodeGraph::clear() { + mNodes.clear(); + mLinks.clear(); + changed(); +} + +void NodeGraph::removeNode(NodeId id) { removeNodes({ id }); } + +void NodeGraph::removeNodes(const std::vector& ids) { + auto isRemoved = [&](NodeId id) { return std::find(ids.begin(), ids.end(), id) != ids.end(); }; + + std::erase_if(mLinks, [&](const Link& link) { return isRemoved(link.from.node) || isRemoved(link.to.node); }); + std::erase_if(mNodes, [&](const Node& node) { return isRemoved(node.id); }); + if (mObserver.nodesRemoved && !ids.empty()) mObserver.nodesRemoved(ids); + changed(); +} + +void NodeGraph::removeLinksWhere(const std::function& matches) { + std::vector removed; + for (const Link& link : mLinks) { + if (matches(link)) removed.push_back(link); + } + std::erase_if(mLinks, matches); + for (const Link& link : removed) { + if (mObserver.linkRemoved) mObserver.linkRemoved(link); + } +} + +void NodeGraph::moveNode(NodeId id, Vec2F position) { + Node* node = findNode(id); + if (!node) return; + + node->position = position; + changed(); +} + +void NodeGraph::resizeNode(NodeId id, halnf width, halnf bodyHeight) { + Node* node = findNode(id); + if (!node) return; + + node->width = width; + node->bodyHeight = bodyHeight; + changed(); +} + +void NodeGraph::setCollapsed(NodeId id, bool collapsed) { + Node* node = findNode(id); + if (!node || node->collapsed == collapsed) return; + + node->collapsed = collapsed; + changed(); +} + +void NodeGraph::bringToFront(NodeId id) { + auto found = std::find_if(mNodes.begin(), mNodes.end(), [&](const Node& node) { return node.id == id; }); + if (found == mNodes.end()) return; + + std::rotate(found, found + 1, mNodes.end()); +} + +Node* NodeGraph::findNode(NodeId id) { + auto found = std::find_if(mNodes.begin(), mNodes.end(), [&](const Node& node) { return node.id == id; }); + return found == mNodes.end() ? nullptr : &*found; +} + +const Node* NodeGraph::findNode(NodeId id) const { return const_cast(this)->findNode(id); } + +const PortDeclaration* NodeGraph::port(const PortRef& ref) const { + const Node* node = findNode(ref.node); + if (!node) return nullptr; + + const std::vector& ports = typeOf(*node).ports(ref.direction); + return ref.index < ports.size() ? &ports[ref.index] : nullptr; +} + +Link NodeGraph::orientedLink(const PortRef& first, const PortRef& second) { + if (first.direction == PortDirection::Output) return { first, second }; + return { second, first }; +} + +bool NodeGraph::isStructurallyValid(const Link& link) const { + if (link.from.direction != PortDirection::Output || link.to.direction != PortDirection::Input) return false; + if (link.from.node == link.to.node) return false; + + const PortDeclaration* from = port(link.from); + const PortDeclaration* to = port(link.to); + return from && to && from->kind == to->kind; +} + +bool NodeGraph::canConnect(const PortRef& first, const PortRef& second) const { + const Link link = orientedLink(first, second); + + if (!isStructurallyValid(link) || hasLink(link)) return false; + return !mLinkValidator || mLinkValidator(*this, link); +} + +bool NodeGraph::connect(const PortRef& first, const PortRef& second) { + if (!canConnect(first, second)) return false; + + const Link link = orientedLink(first, second); + if (!port(link.to)->allowsMultipleLinks) disconnectPort(link.to); + if (!port(link.from)->allowsMultipleLinks) disconnectPort(link.from); + + mLinks.push_back(link); + if (mObserver.linkAdded) mObserver.linkAdded(link); + changed(); + return true; +} + +void NodeGraph::disconnect(const Link& link) { + removeLinksWhere([&](const Link& candidate) { return candidate == link; }); + changed(); +} + +void NodeGraph::disconnectPort(const PortRef& port) { + removeLinksWhere([&](const Link& link) { return link.from == port || link.to == port; }); + changed(); +} + +std::vector NodeGraph::linksAt(const PortRef& port) const { + std::vector found; + for (const Link& link : mLinks) { + if (link.from == port || link.to == port) found.push_back(link); + } + return found; +} + +bool NodeGraph::hasLink(const Link& link) const { + return std::find(mLinks.begin(), mLinks.end(), link) != mLinks.end(); +} + +NodeGraphFragment NodeGraph::extract(const std::vector& ids) const { + NodeGraphFragment fragment; + auto isExtracted = [&](NodeId id) { return std::find(ids.begin(), ids.end(), id) != ids.end(); }; + + for (const Node& node : mNodes) { + if (isExtracted(node.id)) fragment.nodes.push_back(node); + } + for (const Link& link : mLinks) { + if (isExtracted(link.from.node) && isExtracted(link.to.node)) fragment.links.push_back(link); + } + return fragment; +} + +std::vector NodeGraph::insert(const NodeGraphFragment& fragment, Vec2F offset) { + std::unordered_map remapped; + std::vector inserted; + + for (const Node& node : fragment.nodes) { + const NodeId id = mNextId++; + Node copy = node; + copy.id = id; + copy.position = node.position + offset; + mNodes.push_back(std::move(copy)); + remapped[node.id] = id; + inserted.push_back(id); + } + + for (Link link : fragment.links) { + link.from.node = remapped.at(link.from.node); + link.to.node = remapped.at(link.to.node); + mLinks.push_back(link); + } + + changed(); + return inserted; +} + +void NodeGraph::changed() { + if (mOnChanged) mOnChanged(); +} diff --git a/Widgets/private/widgets/Collapsible.cpp b/Widgets/private/widgets/Collapsible.cpp new file mode 100644 index 00000000..4170050f --- /dev/null +++ b/Widgets/private/widgets/Collapsible.cpp @@ -0,0 +1,249 @@ +#include "Collapsible.hpp" + +#include "BoxLayout.hpp" +#include "Layout.hpp" +#include "Painter.hpp" +#include "Theme.hpp" +#include "Ui.hpp" + +#include +#include + +using namespace tp; +using namespace tp::ui; + +namespace { + constexpr halnf kChevronWidth = 18; + constexpr halnf kChevronHalf = 3.5f; + constexpr halnf kPi = 3.14159265f; +} + +CollapsibleHeader::CollapsibleHeader(std::string title) : + mTitle(std::move(title)) { + setDebugName("collapsible header"); + setSizing(AxisSizing::fill(), AxisSizing::fit()); +} + +void CollapsibleHeader::setTitle(std::string title) { + if (title == mTitle) return; + mTitle = std::move(title); + requestLayout(); +} + +void CollapsibleHeader::setSummary(std::string summary) { + if (summary == mSummary) return; + mSummary = std::move(summary); + requestPaint(); +} + +void CollapsibleHeader::setExpanded(bool expanded, bool animated) { + const halnf target = expanded ? 1.0f : 0.0f; + if (animated) animate(mTurn, target); + else { + mTurn.jumpTo(target); + requestPaint(); + } +} + +class CollapsibleHeader::HeaderLayout : public Layout { +public: + explicit HeaderLayout(CollapsibleHeader& header) : + mHeader(header) {} + +protected: + [[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override { + Widget& action = *mHeader.mAction; + const Extent own = mHeader.measureContent(axis, crossLength); + const Extent child = context.measure(action, axis, crossLength); + if (axis == Axis::Vertical) return Extent::natural(std::max(own.preferred, child.preferred)); + return Extent::shrinkable(own.preferred + child.preferred); + } + + void arrangeChildren(LayoutContext& context, const RectF& area) override { + Widget& action = *mHeader.mAction; + const halnf width = context.measure(action, Axis::Horizontal, area.size.y).preferred; + const halnf height = std::min(area.size.y, context.measure(action, Axis::Vertical, width).preferred); + context.place( + action, makeRect(area.pos.x + area.size.x - width, area.pos.y + (area.size.y - height) / 2, width, height) + ); + } + +private: + CollapsibleHeader& mHeader; +}; + +void CollapsibleHeader::setAction(Widget* action) { + if (mAction) removeChild(*mAction); + mAction = action; + if (!action) { + setLayout(nullptr); + return; + } + addChild(*action); + setLayout(std::make_unique(*this)); +} + +halnf CollapsibleHeader::actionWidth() const { return mAction ? mAction->size().x : 0; } + +Extent CollapsibleHeader::measureContent(Axis axis, halnf) const { + const Metrics& metrics = theme().metrics; + if (axis == Axis::Vertical) return Extent::natural(metrics.controlHeight); + const halnf summary = mSummary.empty() ? 0 : textWidth(mSummary, metrics.smallFontSize) + 12; + return Extent::shrinkable( + kChevronWidth + textWidth(mTitle, metrics.fontSize) + summary + metrics.controlPadding.left + ); +} + +RGBA CollapsibleHeader::backgroundTarget() const { + const Palette& palette = theme().palette; + if (isPressed()) return palette.controlPressed; + if (isHovered()) return palette.controlHovered; + return { palette.controlHovered.r, palette.controlHovered.g, palette.controlHovered.b, 0 }; +} + +void CollapsibleHeader::paint(Painter& painter) { + const Theme& style = theme(); + const Metrics& metrics = style.metrics; + painter.fillRect(localRect(), background(), metrics.cornerRadius); + if (showsFocusRing()) + painter.strokeRect(insetRect(localRect(), -2), style.palette.focusRing, metrics.cornerRadius + 2, 2); + + const Vec2F center = { kChevronWidth / 2 + 2, size().y / 2 }; + const halnf angle = mTurn.value() * kPi / 2; + const halnf cosine = std::cos(angle); + const halnf sine = std::sin(angle); + auto turned = [&](Vec2F point) { + return center + Vec2F(point.x * cosine - point.y * sine, point.x * sine + point.y * cosine); + }; + painter.polyline( + { turned(Vec2F(-kChevronHalf / 2, -kChevronHalf)), + turned(Vec2F(kChevronHalf / 2, 0.0f)), + turned(Vec2F(-kChevronHalf / 2, kChevronHalf)) }, + style.palette.mutedText, + 1.5f + ); + + const RectF textBox = makeRect( + kChevronWidth + 4, 0, size().x - kChevronWidth - 4 - metrics.controlPadding.right - actionWidth(), size().y + ); + painter.text(mTitle, textBox, metrics.fontSize, TextAlign::Start, style.palette.text); + if (!mSummary.empty()) + painter.text(mSummary, textBox, metrics.smallFontSize, TextAlign::End, style.palette.mutedText); +} + +class Collapsible::SectionLayout : public Layout { +public: + explicit SectionLayout(Collapsible& section) : + mSection(section) {} + +protected: + [[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override { + const Extent header = context.measure(mSection.mHeader, axis, crossLength); + if (!mSection.mReveal.isVisible()) return header; + + const Extent body = context.measure(mSection.mBody, axis, crossLength); + if (axis == Axis::Horizontal) { + return { std::max(header.minimum, body.minimum), std::max(header.preferred, body.preferred), kUnbounded, 0 }; + } + return Extent::rigid(header.preferred + revealed(body.preferred)); + } + + void arrangeChildren(LayoutContext& context, const RectF& area) override { + const halnf headerHeight = context.measure(mSection.mHeader, Axis::Vertical, area.size.x).preferred; + context.place(mSection.mHeader, makeRect(area.pos.x, area.pos.y, area.size.x, headerHeight)); + if (!mSection.mReveal.isVisible()) return; + + const halnf bodyHeight = context.measure(mSection.mBody, Axis::Vertical, area.size.x).preferred; + const halnf shown = revealed(bodyHeight); + context.place(mSection.mReveal, makeRect(area.pos.x, area.pos.y + headerHeight, area.size.x, shown)); + } + +private: + [[nodiscard]] halnf revealed(halnf bodyHeight) const { + return std::round(bodyHeight * std::clamp(mSection.mOpen.value(), 0.0f, 1.0f)); + } + + Collapsible& mSection; +}; + +class CollapsibleRevealLayout : public Layout { +public: + explicit CollapsibleRevealLayout(Widget& body) : + mBody(body) {} + +protected: + [[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override { + return context.measure(mBody, axis, crossLength); + } + + void arrangeChildren(LayoutContext& context, const RectF& area) override { + const halnf height = context.measure(mBody, Axis::Vertical, area.size.x).preferred; + context.place(mBody, makeRect(area.pos.x, area.pos.y + area.size.y - height, area.size.x, height)); + } + +private: + Widget& mBody; +}; + +Collapsible::Collapsible(std::string title) : + mHeader(std::move(title)) { + setDebugName("collapsible"); + setSizing(AxisSizing::fill(), AxisSizing::fit()); + setClipsChildren(true); + setLayout(std::make_unique(*this)); + mHeader.setGeometryAnimated(false); + setGeometryAnimated(false); + + mBody.setDebugName("collapsible body"); + mBody.setSizing(AxisSizing::fill(), AxisSizing::fit()); + mBody.setGeometryAnimated(false); + mBody.emplaceLayout(6).setPadding(Insets{ kChevronWidth + 4, 4, 0, 6 }); + + mReveal.setDebugName("collapsible reveal"); + mReveal.setSizing(AxisSizing::fill(), AxisSizing::fit()); + mReveal.setGeometryAnimated(false); + mReveal.setClipsChildren(true); + mReveal.setLayout(std::make_unique(mBody)); + mReveal.addChild(mBody); + mReveal.setVisible(false); + + mHeader.setOnActivate([this]() { toggle(); }); + + addChild(mReveal); + addChild(mHeader); +} + +void Collapsible::setExpanded(bool expanded, bool animated) { + if (expanded == mExpanded) return; + mExpanded = expanded; + mHeader.setExpanded(expanded, animated); + + const halnf target = expanded ? 1.0f : 0.0f; + if (expanded) mReveal.setVisible(true); + if (animated && ui()) { + mOpen.animateTo(target); + startTicking(); + } else { + mOpen.jumpTo(target); + mReveal.setVisible(expanded); + } + requestLayout(); + if (mOnToggled) mOnToggled(expanded); +} + +void Collapsible::finishTransitions() { + if (!mOpen.isAnimating()) return; + mOpen.finish(); + if (!mExpanded) mReveal.setVisible(false); + requestLayout(); +} + +void Collapsible::onTick(halnf deltaMs) { + const bool animating = mOpen.advance(deltaMs); + if (!animating) { + stopTicking(); + if (!mExpanded) mReveal.setVisible(false); + } + requestLayout(); + requestPaint(); +} diff --git a/Widgets/private/widgets/ColorPicker.cpp b/Widgets/private/widgets/ColorPicker.cpp new file mode 100644 index 00000000..4632f5ca --- /dev/null +++ b/Widgets/private/widgets/ColorPicker.cpp @@ -0,0 +1,185 @@ +#include "ColorPicker.hpp" + +#include "BoxLayout.hpp" +#include "StackLayout.hpp" +#include "Theme.hpp" +#include "Ui.hpp" + +#include + +using namespace tp; +using namespace tp::ui; + +namespace { + constexpr halnf kChipInset = 5; + constexpr halnf kPopupPadding = 8; + constexpr halnf kCellGap = 6; + constexpr halnf kCellRadius = 5; + constexpr halnf kWheelSize = 180; + constexpr halnf kPlusArm = 6; +} + +ColorCell::ColorCell(Kind kind, RGBA color) : + mKind(kind), + mColor(color) { + setDebugName(kind == Kind::Add ? "add color cell" : "color cell"); + setTooltip(kind == Kind::Add ? "Add new color" : std::string()); + setGeometryAnimated(false); +} + +void ColorCell::setSelected(bool selected) { + if (mSelected == selected) return; + mSelected = selected; + requestPaint(); +} + +Extent ColorCell::measureContent(Axis, halnf) const { return Extent::rigid(kSize); } + +void ColorCell::paint(Painter& painter) { + const Palette& palette = theme().palette; + const RectF cell = localRect(); + + if (mKind == Kind::Add) { + painter.fillRect(cell, background(), kCellRadius); + painter.strokeRect(cell, palette.controlBorder, kCellRadius, 1); + const Vec2F center = cell.center(); + painter.line(center - Vec2F(kPlusArm, 0.0f), center + Vec2F(kPlusArm, 0.0f), palette.text, 1.5f); + painter.line(center - Vec2F(0.0f, kPlusArm), center + Vec2F(0.0f, kPlusArm), palette.text, 1.5f); + return; + } + + painter.fillRect(cell, mColor, kCellRadius); + if (mSelected) painter.strokeRect(insetRect(cell, 1), palette.text, kCellRadius, 2); + else if (isHovered()) painter.strokeRect(cell, palette.mutedText, kCellRadius, 1); +} + +ColorPickerButton::ColorPickerButton() { + setDebugName("color picker"); + + PopupOptions options; + options.placement = Placement::Below; + options.gap = Theme::standard().metrics.popupGap; + setPopupOptions(options); + setPopupContent(&mPopup); + + buildPalettePopup(); + buildNewColorPopup(); +} + +void ColorPickerButton::buildPalettePopup() { + mPopup.setBorder(true); + mPopup.emplaceLayout().setPadding(Insets::uniform(kPopupPadding)); + + mGrid.setClipsChildren(true); + mGrid.emplaceLayout(kColumns, kCellGap).setCellSize(Vec2F(ColorCell::kSize, ColorCell::kSize)); + mAddCell.setOnActivate([this] { openNewColorEditor(); }); + + mPopup.addChild(mGrid); + rebuildCells(); +} + +void ColorPickerButton::buildNewColorPopup() { + mNewColorPopup.setBorder(true); + mNewColorPopup.emplaceLayout(Theme::standard().metrics.spacing) + .setPadding(Insets::uniform(kPopupPadding)); + + mWheel.setSizing(AxisSizing::fixed(kWheelSize), AxisSizing::fixed(kWheelSize)); + mConfirmNewColor.setStyle(Button::Style::Accent); + mNewColorActions.setSizing(AxisSizing::fill(), AxisSizing::fit()); + mNewColorActions.emplaceLayout(Theme::standard().metrics.spacing).setMainAlign(MainAlign::End); + mNewColorActions.addChild(mCancelNewColor); + mNewColorActions.addChild(mConfirmNewColor); + + mCancelNewColor.setOnActivate([this] { closeNewColorEditor(); }); + mConfirmNewColor.setOnActivate([this] { addNewColor(); }); + + mNewColorPopup.addChild(mWheel); + mNewColorPopup.addChild(mNewColorActions); +} + +void ColorPickerButton::rebuildCells() { + mGrid.removeAllChildren(); + mSwatches.clear(); + + for (const RGBA& color : mPalette) { + auto cell = std::make_unique(ColorCell::Kind::Swatch, color); + cell->setOnActivate([this, color] { pick(color); }); + mGrid.addChild(*cell); + mSwatches.push_back(std::move(cell)); + } + mGrid.addChild(mAddCell); + refreshSelection(); +} + +void ColorPickerButton::setPalette(std::vector colors) { + mPalette = std::move(colors); + rebuildCells(); +} + +void ColorPickerButton::setFallbackColor(const RGBA& color) { + mFallbackColor = color; + requestPaint(); +} + +void ColorPickerButton::setColor(std::optional color) { + mColor = color; + refreshSelection(); + requestPaint(); +} + +void ColorPickerButton::openNewColorEditor() { + if (!ui()) return; + + mWheel.setColor(shownColor()); + + PopupRequest request; + request.content = &mNewColorPopup; + request.owner = this; + request.anchor = windowRect(); + request.options.placement = Placement::Below; + request.options.gap = theme().metrics.popupGap; + + closePopup(); + ui()->openPopup(request); +} + +void ColorPickerButton::closeNewColorEditor() { + if (isNewColorEditorOpen()) ui()->closePopup(mNewColorPopup); +} + +bool ColorPickerButton::isNewColorEditorOpen() const { return ui() && ui()->isPopupOpen(mNewColorPopup); } + +void ColorPickerButton::addNewColor() { + const RGBA color = mWheel.color(); + if (std::find(mPalette.begin(), mPalette.end(), color) == mPalette.end()) { + mPalette.push_back(color); + rebuildCells(); + if (mOnPaletteChanged) mOnPaletteChanged(mPalette); + } + + closeNewColorEditor(); + pick(color); +} + +void ColorPickerButton::pick(const RGBA& color) { + setColor(color); + closePopup(); + if (mOnPicked) mOnPicked(color); +} + +void ColorPickerButton::refreshSelection() { + for (const std::unique_ptr& cell : mSwatches) + cell->setSelected(mColor && *mColor == cell->color()); +} + +Extent ColorPickerButton::measureContent(Axis axis, halnf) const { + const halnf height = theme().metrics.controlHeight; + return Extent::rigid(axis == Axis::Horizontal ? height * 1.6f : height); +} + +void ColorPickerButton::paint(Painter& painter) { + const Theme& style = theme(); + painter.fillRect(localRect(), background(), style.metrics.cornerRadius); + painter.strokeRect(localRect(), style.palette.controlBorder, style.metrics.cornerRadius, style.metrics.borderWidth); + painter.fillRect(insetRect(localRect(), kChipInset), shownColor(), style.metrics.indicatorCornerRadius); +} diff --git a/Widgets/private/widgets/Control.cpp b/Widgets/private/widgets/Control.cpp new file mode 100644 index 00000000..0a7c162b --- /dev/null +++ b/Widgets/private/widgets/Control.cpp @@ -0,0 +1,109 @@ +#include "Control.hpp" + +#include "Event.hpp" +#include "Theme.hpp" + +#include + +using namespace tp; +using namespace tp::ui; + +Control::Control() : + mBackground(Theme::standard().palette.control) {} + +void Control::onAttached() { + mBackground.jumpTo(backgroundTarget()); + for (AnimatedFloat* value : mRunningFloats) + value->finish(); + mRunningFloats.clear(); + stopTicking(); +} + +void Control::activate() { + if (!isEffectivelyEnabled()) return; + + onActivated(); + if (mOnActivate) mOnActivate(); +} + +void Control::onEvent(Event& event) { + switch (event.type()) { + case Event::Type::PointerDown: + case Event::Type::PointerUp: + if (event.button() != PointerButton::Primary) return; + refreshVisualState(); + event.accept(); + break; + + case Event::Type::Click: + if (mOnClicked && isEffectivelyEnabled()) mOnClicked(event); + else activate(); + event.accept(); + break; + + case Event::Type::LongPress: + if (!mOnLongPress || !isEffectivelyEnabled()) return; + mOnLongPress(); + event.accept(); + break; + + case Event::Type::KeyDown: + if (event.keyCode() != InputID::ENTER && event.keyCode() != InputID::SPACE) return; + activate(); + event.accept(); + break; + + default: break; + } +} + +void Control::onTick(halnf deltaMs) { + bool animating = mBackground.advance(deltaMs); + + for (AnimatedFloat* value : mRunningFloats) + animating = value->advance(deltaMs) || animating; + mRunningFloats.erase( + std::remove_if( + mRunningFloats.begin(), mRunningFloats.end(), [](AnimatedFloat* value) { return !value->isAnimating(); } + ), + mRunningFloats.end() + ); + + if (!animating) stopTicking(); + requestPaint(); +} + +void Control::onHoverChanged() { refreshVisualState(); } + +void Control::onThemeChanged() { refreshVisualState(); } + +void Control::onPressedChanged() { refreshVisualState(); } + +void Control::onOwnedPopupChanged() { refreshVisualState(); } + +RGBA Control::backgroundTarget() const { + const Palette& palette = theme().palette; + + if (!isEffectivelyEnabled()) return Palette::mix(palette.control, palette.window, 0.5f); + if (isPressed()) return palette.controlPressed; + if (isHovered()) return palette.controlHovered; + return palette.control; +} + +void Control::refreshVisualState() { + mBackground.animateTo(backgroundTarget()); + runAnimations(); +} + +void Control::animate(AnimatedFloat& value, halnf target) { + value.animateTo(target); + if (std::find(mRunningFloats.begin(), mRunningFloats.end(), &value) == mRunningFloats.end()) { + mRunningFloats.push_back(&value); + } + runAnimations(); +} + +void Control::runAnimations() { + startTicking(); + requestPaint(); +} diff --git a/Widgets/private/widgets/Dialog.cpp b/Widgets/private/widgets/Dialog.cpp new file mode 100644 index 00000000..dacbafe3 --- /dev/null +++ b/Widgets/private/widgets/Dialog.cpp @@ -0,0 +1,141 @@ +#include "Dialog.hpp" + +#include "BoxLayout.hpp" +#include "Theme.hpp" +#include "Ui.hpp" + +using namespace tp; +using namespace tp::ui; + +namespace { + constexpr halnf kDialogWidth = 360; +} + +Dialog::Dialog(std::string title) : + Panel(Surface::Raised), + mTitle(std::move(title)) { + setDebugName("dialog"); + setBorder(true); + setSizing(AxisSizing::fixed(kDialogWidth), AxisSizing::fit()); + emplaceLayout(10).setPadding(Insets::uniform(16)); + + mTitle.setFontSize(Theme::standard().metrics.fontSize + 2); + mTitle.setWrap(true); + mTitle.setSizing(AxisSizing::fill(), AxisSizing::fit()); + mMessage.setWrap(true); + mMessage.setSizing(AxisSizing::fill(), AxisSizing::fit()); + mMessage.setTone(Label::Tone::Muted); + mMessage.setVisible(false); + + mBody.setSizing(AxisSizing::fill(), AxisSizing::fit()); + mBody.emplaceLayout(8); + + mButtonRow.setSizing(AxisSizing::fill(), AxisSizing::fit()); + mButtonRow.emplaceLayout(6); + mButtonSpacer.setSizing(AxisSizing::fill(), AxisSizing::fixed(1)); + mButtonRow.addChild(mButtonSpacer); + + addChild(mTitle); + addChild(mMessage); + addChild(mBody); + addChild(mButtonRow); +} + +Dialog::~Dialog() { close(); } + +void Dialog::setTitle(std::string title) { mTitle.setText(std::move(title)); } + +void Dialog::setMessage(std::string message) { + mMessage.setVisible(!message.empty()); + mMessage.setText(std::move(message)); +} + +Button& Dialog::addButton(std::string text, std::function action, bool isDefault) { + auto button = std::make_unique