From 6e4732d9ec01ac88033b0c6d1e68483813f9eeaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D1=83=D1=80=D1=83=D0=BF=D0=BE=D0=B2=20=D0=98=D0=BB?= =?UTF-8?q?=D1=8C=D1=8F=20=D0=92=D0=B8=D0=BA=D1=82=D0=BE=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Sun, 1 Nov 2026 11:07:42 +0300 Subject: [PATCH 01/10] 3DEditor, Sketch3D: port onto the refactored Widgets Both were ImGui or half-ported on master and did not build there. 3DEditor gets an EditorWidget (orbit/pan/zoom and click-select through router drags on a Viewport) with a docked controls panel; Sketch3D gets a SketchGUI with a router-drag viewport (Shift move, Alt rotate, Control zoom, pressure draws) and a dock area with color wheel, sliders and menus. --- 3DEditor/CMakeLists.txt | 2 +- 3DEditor/applications/Entry.cpp | 22 +-- 3DEditor/private/EditorWidget.cpp | 154 +++++++++++++++++ 3DEditor/public/EditorWidget.hpp | 189 +++++---------------- Sketch3D/CMakeLists.txt | 2 +- Sketch3D/applications/Entry.cpp | 25 ++- Sketch3D/private/SketchGUI.cpp | 265 ++++++++++++++---------------- Sketch3D/public/SketchGUI.hpp | 113 +++++-------- 8 files changed, 381 insertions(+), 391 deletions(-) create mode 100644 3DEditor/private/EditorWidget.cpp diff --git a/3DEditor/CMakeLists.txt b/3DEditor/CMakeLists.txt index bd87d17e..011cd85d 100644 --- a/3DEditor/CMakeLists.txt +++ b/3DEditor/CMakeLists.txt @@ -11,7 +11,7 @@ file(GLOB HEADERS "./public/*.hpp" "./public/*/*.hpp") add_library(${PROJECT_NAME} STATIC ${SOURCES} ${HEADERS}) target_include_directories(${PROJECT_NAME} PUBLIC ./public/ ${BINDINGS_INCLUDE} ./ext/) -target_link_libraries(${PROJECT_NAME} PUBLIC Widgets RasterRender RayTracer) +target_link_libraries(${PROJECT_NAME} PUBLIC WidgetsApp RasterRender RayTracer) target_link_libraries(${PROJECT_NAME} PRIVATE ${BINDINGS_LIBS}) ### -------------------------- Applications -------------------------- ### diff --git a/3DEditor/applications/Entry.cpp b/3DEditor/applications/Entry.cpp index ae402ecc..7028b72d 100644 --- a/3DEditor/applications/Entry.cpp +++ b/3DEditor/applications/Entry.cpp @@ -1,31 +1,27 @@ - #include "EditorWidget.hpp" #include "WidgetApplication.hpp" -#include "RootWidget.hpp" using namespace tp; -class EditorGUI : public WidgetApplication { +class EditorApplication : public ui::WidgetApplication { public: - EditorGUI() : - mEditor(application().gpu()) { - auto canvas = &this->canvas(); - mGui = new EditorWidget(canvas, &mEditor); - + EditorApplication() : + mEditor(gpu()), + mGui(canvas(), mEditor) { mEditor.loadDefaults(); - + mGui.buildMenus(menuBar()); setRoot(mGui); } - ~EditorGUI() override { delete mGui; } + ~EditorApplication() override { clearRoot(); } private: Editor mEditor; - EditorWidget* mGui; + EditorWidget mGui; }; int main() { - EditorGUI gui; - gui.run(); + EditorApplication application; + application.run(); } diff --git a/3DEditor/private/EditorWidget.cpp b/3DEditor/private/EditorWidget.cpp new file mode 100644 index 00000000..62aab4c0 --- /dev/null +++ b/3DEditor/private/EditorWidget.cpp @@ -0,0 +1,154 @@ +#include "EditorWidget.hpp" + +#include "BoxLayout.hpp" +#include "Canvas.hpp" +#include "Event.hpp" +#include "Theme.hpp" + +using namespace tp; +using namespace tp::ui; + +namespace { + constexpr halnf kOrbitSpeed = 3; + constexpr halnf kControlsWidth = 240; +} + +EditorViewport::EditorViewport(Canvas& canvas, Editor& editor) : + mEditor(editor), + mCanvas(canvas), + mRasterImage(canvas.createImageFromRenderTarget(editor.rasterTargetId())) { + setDebugName("3d viewport"); + setSizing(AxisSizing::fill(), AxisSizing::fill()); + startTicking(); +} + +EditorViewport::~EditorViewport() { + mCanvas.deleteImage(mRasterImage); + mCanvas.deleteImage(mPathImage); +} + +bool EditorViewport::acceptsDrag(PointerButton button) const { return button == PointerButton::Left; } + +void EditorViewport::onEvent(Event& event) { + if (event.type() == Event::Type::Click && mNavigation == Navigation::Select) { + mEditor.selectObject(toNormalized(event.position()) * -1); + event.accept(); + return; + } + + const Dragged* drag = event.drag(); + if (!drag || drag->button != PointerButton::Left) return; + if (drag->phase == DragPhase::Moved || drag->phase == DragPhase::Started) navigate(event.position(), drag->delta); + event.accept(); +} + +void EditorViewport::navigate(Vec2F localPoint, Vec2F delta) { + const Vec2F extent = size(); + if (extent.x <= 0 || extent.y <= 0) return; + + switch (mNavigation) { + case Navigation::Orbit: mEditor.navigationOrbit(delta / extent * kOrbitSpeed); break; + case Navigation::Pan: mEditor.navigationPan(toNormalized(localPoint - delta), toNormalized(localPoint)); break; + case Navigation::Zoom: mEditor.navigationZoom(1 + delta.y / extent.y); break; + case Navigation::Select: mEditor.selectObject(toNormalized(localPoint) * -1); break; + } + requestPaint(); +} + +Vec2F EditorViewport::toNormalized(Vec2F localPoint) const { return ((localPoint / size()) - 0.5f) * 2; } + +void EditorViewport::onTick(halnf) { + // The scene animates and the renderer follows the viewport size, so the view repaints every frame. + mEditor.setViewportSize(size()); + requestPaint(); + startTicking(); +} + +void EditorViewport::paint(Canvas& canvas) { + if (mEditor.renderType() == Editor::RenderType::PATH_TRACER) { + syncPathImage(canvas); + canvas.drawImage(localRect(), mPathImage, kImageAngle); + return; + } + mEditor.renderRaster(); + // Note: resizing recreates the target images, so the Canvas image is pointed at them every frame. + canvas.updateImageFromRenderTarget(mRasterImage, mEditor.rasterTargetId()); + canvas.drawImage(localRect(), mRasterImage, kImageAngle); +} + +void EditorViewport::syncPathImage(Canvas& canvas) { + const auto size = mEditor.pathImageSize(); + const auto* pixels = (const unsigned char*) mEditor.pathImage().data(); + if (mPathImage.isValid() && size == mPathImageSize) { + canvas.updateImageRGBA(mPathImage, pixels); + return; + } + canvas.deleteImage(mPathImage); + mPathImage = size.x && size.y ? canvas.createImageFromMemoryRGBA((int) size.x, (int) size.y, pixels) : ImageHandle(); + mPathImageSize = size; +} + +EditorWidget::EditorWidget(Canvas& canvas, Editor& editor) : + mEditor(editor), + mViewport(canvas, editor) { + setDebugName("3d editor"); + setSizing(AxisSizing::fill(), AxisSizing::fill()); + emplaceLayout(); + + mPathTrace.setOnActivate([this]() { + mEditor.renderPathFrame(); + mEditor.setRenderType(Editor::RenderType::PATH_TRACER); + }); + mRaster.setOnActivate([this]() { mEditor.setRenderType(Editor::RenderType::RASTER); }); + mDenoise.setOnActivate([this]() { mEditor.denoisePathRenderBuffers(); }); + mReset.setOnActivate([this]() { mEditor.navigationReset(); }); + + mControls.emplaceLayout(6).setPadding(Insets::uniform(10)); + for (Button* button : { &mPathTrace, &mRaster, &mDenoise, &mReset }) { + button->setSizing(AxisSizing::fill(), AxisSizing::fit()); + mControls.addChild(*button); + } + + mControlsPanel.setContent(&mControls); + mControlsPanel.setDockedLength(kControlsWidth); + mDock.setSizing(AxisSizing::fill(), AxisSizing::fill()); + mDock.setCenter(&mViewport); + mDock.addPanel(mControlsPanel); + mDock.openPanel(mControlsPanel); + addChild(mDock); + + startTicking(); +} + +void EditorWidget::buildMenus(MenuBar& menuBar) { + mRenderMenu.addItem("Path Tracer", [this]() { mPathTrace.activate(); }); + mRenderMenu.addItem("Raster", [this]() { mRaster.activate(); }); + mRenderMenu.addItem("Denoise", [this]() { mDenoise.activate(); }); + + using Navigation = EditorViewport::Navigation; + const auto navigationToggle = [this](const char* text, Navigation navigation) { + mNavigationMenu.addToggle( + text, + [this, navigation]() { return mViewport.navigation() == navigation; }, + [this, navigation]() { mViewport.setNavigation(navigation); } + ); + }; + navigationToggle("Select", Navigation::Select); + navigationToggle("Orbit", Navigation::Orbit); + navigationToggle("Pan", Navigation::Pan); + navigationToggle("Zoom", Navigation::Zoom); + mNavigationMenu.addItem("Reset", [this]() { mEditor.navigationReset(); }); + + menuBar.addMenu("Render", mRenderMenu); + menuBar.addMenu("Navigation", mNavigationMenu); +} + +void EditorWidget::onTick(halnf) { + // Note: the demo spins the selected object a little every frame. + if (Object* object = mEditor.getActiveObject()) { + auto rotator = object->mTopology.Basis.rotatorDir({ 1, 1, 1 }, 0.1); + object->mTopology.Basis = rotator * object->mTopology.Basis; + } + mEditor.getScene()->updateCache(); + startTicking(); +} diff --git a/3DEditor/public/EditorWidget.hpp b/3DEditor/public/EditorWidget.hpp index 6a225605..a6b7d6d6 100644 --- a/3DEditor/public/EditorWidget.hpp +++ b/3DEditor/public/EditorWidget.hpp @@ -1,175 +1,70 @@ -#include "Widget.hpp" -#include "DockWidget.hpp" +#pragma once +#include "Docking.hpp" #include "Editor.hpp" -#include "FloatingWidget.hpp" +#include "Menus.hpp" +#include "Button.hpp" +#include "Widget.hpp" namespace tp { - class ViewportWidget : public Widget { + // Shows the editor's raster or path-traced image and turns drags on it into camera navigation or picking. + class EditorViewport : public ui::Widget { public: - ViewportWidget(Canvas& canvas, Editor& editor) : - mEditor(editor), - mCanvas(canvas), - mRasterImage(canvas.createImageFromRenderTarget(editor.rasterTargetId())) {} + enum class Navigation { Select, Orbit, Pan, Zoom }; - ~ViewportWidget() override { - mCanvas.deleteImage(mRasterImage); - mCanvas.deleteImage(mPathImage); - } + EditorViewport(Canvas& canvas, Editor& editor); + ~EditorViewport() override; - void process(const InputView& events) override { mEditor.setViewportSize(getAreaT().size); } + void setNavigation(Navigation navigation) { mNavigation = navigation; } + [[nodiscard]] Navigation navigation() const { return mNavigation; } - void draw(Canvas& canvas) override { - if (mEditor.renderType() == Editor::RenderType::PATH_TRACER) { - syncPathImage(canvas); - canvas.drawImage(getArea().relative(), mPathImage, kImageAngle); - return; - } - mEditor.renderRaster(); - // Note: resizing recreates the target images, so the Canvas image is pointed at them every frame. - canvas.updateImageFromRenderTarget(mRasterImage, mEditor.rasterTargetId()); - canvas.drawImage(getArea().relative(), mRasterImage, kImageAngle); - } + protected: + void onEvent(ui::Event& event) override; + [[nodiscard]] bool acceptsDrag(PointerButton button) const override; + void paint(Canvas& canvas) override; + void onTick(halnf deltaMs) override; private: // Note: both images store row 0 at the camera's bottom edge (y = -1); a half turn shows them upright. static constexpr halnf kImageAngle = (halnf) PI; - void syncPathImage(Canvas& canvas) { - const auto size = mEditor.pathImageSize(); - const auto* pixels = (const unsigned char*) mEditor.pathImage().data(); - if (mPathImage.isValid() && size == mPathImageSize) { - canvas.updateImageRGBA(mPathImage, pixels); - return; - } - canvas.deleteImage(mPathImage); - mPathImage = size.x && size.y ? canvas.createImageFromMemoryRGBA((int) size.x, (int) size.y, pixels) : ImageHandle(); - mPathImageSize = size; - } + void navigate(Vec2F localPoint, Vec2F delta); + [[nodiscard]] Vec2F toNormalized(Vec2F localPoint) const; + void syncPathImage(Canvas& canvas); Editor& mEditor; Canvas& mCanvas; ImageHandle mRasterImage; ImageHandle mPathImage; Vec2 mPathImageSize = { 0, 0 }; + Navigation mNavigation = Navigation::Orbit; }; - class EditorWidget : public DockWidget { + // The 3D editor window content: the viewport in the middle and a docked panel with render and navigation controls. + class EditorWidget : public ui::Widget { public: - EditorWidget(Canvas* canvas, Editor* editor) : - mViewport(*canvas, *editor) { - mEditor = editor; + EditorWidget(Canvas& canvas, Editor& editor); - mPanel.setText("Controls"); - mPanel.addToMenu(&mNavigationMenu); - mPanel.addToMenu(&mRenderMenu); + void buildMenus(ui::MenuBar& menuBar); - dockWidget(&mPanel, DockLayout::RIGHT); - setCenterWidget(&mViewport); + [[nodiscard]] EditorViewport& viewport() { return mViewport; } + [[nodiscard]] ui::DockPanel& controlsPanel() { return mControlsPanel; } - // Render - { - mRenderPathTracer.setText("Render with Path Tracer"); - mRenderRaster.setText("Render with Raster"); - mRenderDeNoise.setText("Denoise (IntelOpenImage)"); + protected: + void onTick(halnf deltaMs) override; - mRenderMenu.addToMenu(&mRenderPathTracer); - mRenderMenu.addToMenu(&mRenderRaster); - mRenderMenu.addToMenu(&mRenderDeNoise); - - mRenderMenu.setText("Render"); - } - - // Navigation - { - mNavigationPan.setText("Pan"); - mNavigationOrbit.setText("Orbit"); - mNavigationZoom.setText("Zoom"); - mNavigationReset.setText("Reset"); - mNavigationSelect.setText("Select"); - - mNavigationMenu.addToMenu(&mNavigationPan); - mNavigationMenu.addToMenu(&mNavigationOrbit); - mNavigationMenu.addToMenu(&mNavigationZoom); - mNavigationMenu.addToMenu(&mNavigationReset); - mNavigationMenu.addToMenu(&mNavigationSelect); - - mNavigationMenu.setText("Navigation"); - } - - mRenderPathTracer.setAction([this]() { - mEditor->renderPathFrame(); - mEditor->setRenderType(Editor::RenderType::PATH_TRACER); - }); - - mRenderRaster.setAction( [this]() { mEditor->setRenderType(Editor::RenderType::RASTER); }); - mRenderDeNoise.setAction( [this]() { mEditor->denoisePathRenderBuffers(); }); - mNavigationOrbit.setAction( [this]() { mNavigationType = ORBIT; }); - mNavigationPan.setAction( [this]() { mNavigationType = PAN; }); - mNavigationZoom.setAction( [this](){ mNavigationType = ZOOM; }); - mNavigationSelect.setAction( [this](){ mNavigationType = SELECT; }); - mNavigationReset.setAction( [this]() { mEditor->navigationReset(); }); - } - - void process(const InputView& events) override { - DockWidget::process(events); - - if (auto obj = mEditor->getActiveObject()) { // dummy rotate active object - auto rotator = obj->mTopology.Basis.rotatorDir({1, 1, 1}, 0.1); - obj->mTopology.Basis = rotator * obj->mTopology.Basis; - } - - mEditor->getScene()->updateCache(); - - const auto& activeArea = mViewport.getArea(); - - auto pointer = events.getPointer(); - auto pointerPrev = events.getPointerPrev(); - auto pointerRelative = (((pointer - activeArea.pos) / activeArea.size) - 0.5f) * 2; - - if (activeArea.isInside(pointer) && events.isDown(PointerButton::Left)) { - switch (mNavigationType) { - case ORBIT: mEditor->navigationOrbit(events.getPointerDelta() / activeArea.size * 3); break; - - case PAN: - { - auto prevPointerRelative = (((pointerPrev - activeArea.pos) / activeArea.size) - 0.5f) * 2; - mEditor->navigationPan(prevPointerRelative, pointerRelative); - } - break; - - case ZOOM: mEditor->navigationZoom(1 + (events.getPointerDelta().y / activeArea.size.y)); break; - case SELECT: mEditor->selectObject(pointerRelative * -1); break; - } - } - } - - void draw(Canvas& canvas) override { canvas.rect(getArea().relative(), mBaseColor); } - - public: - Editor* mEditor = nullptr; - - ViewportWidget mViewport; - - FloatingMenu mPanel; - - // Controls - FloatingMenu mRenderMenu; - ButtonWidget mRenderPathTracer; - ButtonWidget mRenderRaster; - ButtonWidget mRenderDeNoise; - - // Navigation - enum NavigationType { SELECT, ORBIT, PAN, ZOOM } mNavigationType = ORBIT; - - FloatingMenu mNavigationMenu; - ButtonWidget mNavigationPan; - ButtonWidget mNavigationOrbit; - ButtonWidget mNavigationZoom; - ButtonWidget mNavigationReset; - ButtonWidget mNavigationSelect; - - RGBA mBaseColor; + private: + Editor& mEditor; + EditorViewport mViewport; + ui::DockArea mDock; + ui::DockPanel mControlsPanel{ "Controls", ui::DockSide::Right }; + ui::Widget mControls; + ui::Button mPathTrace{ "Render with Path Tracer" }; + ui::Button mRaster{ "Render with Raster" }; + ui::Button mDenoise{ "Denoise (IntelOpenImage)" }; + ui::Button mReset{ "Reset camera" }; + ui::Menu mRenderMenu; + ui::Menu mNavigationMenu; }; -} \ No newline at end of file +} diff --git a/Sketch3D/CMakeLists.txt b/Sketch3D/CMakeLists.txt index c2da2f12..1c18e6da 100644 --- a/Sketch3D/CMakeLists.txt +++ b/Sketch3D/CMakeLists.txt @@ -31,7 +31,7 @@ target_link_libraries(${PROJECT_NAME} PUBLIC Math Containers RasterRender) # The widgets of the sketch application. add_library(${PROJECT_NAME}Gui STATIC ./private/SketchGUI.cpp ./public/SketchGUI.hpp) -target_link_libraries(${PROJECT_NAME}Gui PUBLIC ${PROJECT_NAME} Widgets) +target_link_libraries(${PROJECT_NAME}Gui PUBLIC ${PROJECT_NAME} WidgetsApp) ### -------------------------- Applications -------------------------- ### add_executable(Sketch3DApp ./applications/Entry.cpp) diff --git a/Sketch3D/applications/Entry.cpp b/Sketch3D/applications/Entry.cpp index a3edc1f3..45590dc0 100644 --- a/Sketch3D/applications/Entry.cpp +++ b/Sketch3D/applications/Entry.cpp @@ -1,28 +1,25 @@ - -#include "WidgetApplication.hpp" - -#include "Sketch3D.hpp" #include "SketchGUI.hpp" +#include "WidgetApplication.hpp" using namespace tp; -class Sketch3DApplication : public WidgetApplication { +class Sketch3DApplication : public ui::WidgetApplication { public: Sketch3DApplication() { - setRoot(&mGui); - - mGui.createRenderWidget(application().gpu(), &canvas(), { 2560, 1440 }); + mGui.createRenderWidget(gpu(), canvas(), { 2560, 1440 }); mGui.setProject(&mSketch); + mGui.buildMenus(menuBar()); + setRoot(mGui); } + ~Sketch3DApplication() override { clearRoot(); } + private: - SketchGUI mGui; Project mSketch; + SketchGUI mGui; }; -void runApp() { - Sketch3DApplication app; - app.run(); +int main() { + Sketch3DApplication application; + application.run(); } - -int main() { runApp(); } diff --git a/Sketch3D/private/SketchGUI.cpp b/Sketch3D/private/SketchGUI.cpp index ad2e31c7..d68229e3 100644 --- a/Sketch3D/private/SketchGUI.cpp +++ b/Sketch3D/private/SketchGUI.cpp @@ -1,181 +1,154 @@ #include "SketchGUI.hpp" -#include "SimpleLayouts.hpp" + +#include "BoxLayout.hpp" +#include "Canvas.hpp" +#include "Event.hpp" +#include "Label.hpp" using namespace tp; +using namespace tp::ui; -SketchRenderWidget::SketchRenderWidget(GpuDevice& gpu, Canvas& canvas, Vec2F renderResolution) : - mRenderer(gpu, { (uint4) renderResolution.x, (uint4) renderResolution.y }) { - mImage = canvas.createImageFromRenderTarget(mRenderer.target().registryId()); - mCanvas = &canvas; - - setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); +namespace { + constexpr halnf kControlsWidth = 280; + constexpr halnf kBrushSizeScale = 0.2f; + constexpr halnf kMinimumBrushSize = 0.001f; } -SketchRenderWidget::~SketchRenderWidget() { mCanvas->deleteImage(mImage); } - -void SketchRenderWidget::draw(Canvas& canvas) { - auto area = getArea().relative(); - if (mProject) { - mRenderer.renderToTexture(mProject, area.size); - // Note: resizing recreates the target images, so the Canvas image is pointed at them every frame. - canvas.updateImageFromRenderTarget(mImage, mRenderer.target().registryId()); - canvas.drawImage(area, mImage, 0, 1, 12); - } +SketchViewport::SketchViewport(GpuDevice& gpu, Canvas& canvas, Vec2F renderResolution) : + mRenderer(gpu, { (uint4) renderResolution.x, (uint4) renderResolution.y }), + mCanvas(canvas), + mImage(canvas.createImageFromRenderTarget(mRenderer.target().registryId())) { + setDebugName("sketch viewport"); + setSizing(AxisSizing::fill(), AxisSizing::fill()); + startTicking(); } -void SketchRenderWidget::setProject(Project* project) { mProject = project; } +SketchViewport::~SketchViewport() { mCanvas.deleteImage(mImage); } -void SketchViewportWidget::setProject(Project* project) { +void SketchViewport::setProject(Project* project) { mProject = project; - mProject->mBackgroundColor = { 0.13f, 0.13f, 0.13f, 0.8f }; + if (mProject) mProject->mBackgroundColor = { 0.13f, 0.13f, 0.13f, 0.8f }; } -void SketchViewportWidget::setColor(const RGBA& color) { - ((PencilBrush*) mProject->mBrushes.get("pencil"))->mCol = color; -} +bool SketchViewport::acceptsDrag(PointerButton button) const { return button == PointerButton::Left; } -void SketchViewportWidget::setRenderWidget(SketchRenderWidget* widget) { addChild(widget); } +Vec2F SketchViewport::toNormalized(Vec2F localPoint) const { return ((localPoint / size()) - 0.5f) * 2.0f; } -void SketchViewportWidget::process(const InputView& events) { +halnf SketchViewport::ratio() const { return size().x > 0 ? size().y / size().x : 1; } + +void SketchViewport::onEvent(Event& event) { if (!mProject) return; - auto area = getArea().relative(); - Vec2F pointer = events.getPointer(); - auto crs = events.getPointer(); + if (event.type() == Event::Type::PointerMove && !event.drag()) { + // Note: hovering feeds pressure-free samples so the brush shows where the next stroke starts. + mProject->sample(0, ratio(), toNormalized(event.position())); + requestPaint(); + return; + } + if (event.drag()) { + dragged(event); + event.accept(); + } +} - if (!area.isInside(events.getPointer())) return; +void SketchViewport::dragged(const Event& event) { + const Dragged& drag = *event.drag(); + if (drag.button != PointerButton::Left) return; - crs.x /= area.z; - crs.y /= area.w; - crs = (crs - 0.5) * 2; + const Vec2F point = toNormalized(event.position()); + const Vec2F previous = toNormalized(event.position() - drag.delta); + mProject->mCamera.setRatio(ratio()); - if (events.isPressed(PointerButton::Left)) { - mAction = true; - lockFocus(); - - } else if (events.isReleased(PointerButton::Left)) { - mAction = false; - freeFocus(); + if (drag.phase == DragPhase::Finished || drag.phase == DragPhase::Cancelled) { + mProject->sample(0, ratio(), point); + requestPaint(); + return; } - mProject->mCamera.setRatio(area.w / area.z); - - if (mAction) { - Vec2F relativePos = ((pointer / area.size) - 0.5f) * 2.f; - Vec2F relativePosPrev = ((mActionPosAbsolutePrev / area.size) - 0.5f) * 2.f; - Vec2F relativeDelta = relativePos - relativePosPrev; - - if (events.isDown(Key::LeftShift)) { - mProject->mCamera.move(relativePos, relativePosPrev); - } else if (events.isDown(Key::LeftAlt)) { - mProject->mCamera.rotate(-relativeDelta.x * halnf(PI), -relativeDelta.y * halnf(PI)); - } else if (events.isDown(Key::LeftControl)) { - halnf factor = pointer.y / mActionPosAbsolutePrev.y; - mProject->mCamera.zoom(factor); - } else { - mProject->sample(events.getPointerPressure(), area.w / area.z, crs); - } - + const KeyModifiers& modifiers = event.modifiers(); + if (modifiers.shift) { + mProject->mCamera.move(point, previous); + } else if (modifiers.alt) { + const Vec2F delta = point - previous; + mProject->mCamera.rotate(-delta.x * halnf(PI), -delta.y * halnf(PI)); + } else if (modifiers.control) { + const halnf before = event.position().y - drag.delta.y; + if (before != 0) mProject->mCamera.zoom(event.position().y / before); } else { - mProject->sample(0, area.w / area.z, crs); + mProject->sample(event.sample().pressure, ratio(), point); + } + requestPaint(); +} + +void SketchViewport::onTick(halnf) { + // The stroke renderer follows the viewport size, so the view repaints every frame. + requestPaint(); + startTicking(); +} + +void SketchViewport::paint(Canvas& canvas) { + if (!mProject) return; + mRenderer.renderToTexture(mProject, size()); + // Note: resizing recreates the target images, so the Canvas image is pointed at them every frame. + canvas.updateImageFromRenderTarget(mImage, mRenderer.target().registryId()); + canvas.drawImage(localRect(), mImage, 0, 1, kImageRounding); +} + +SketchGUI::SketchGUI() { + setDebugName("sketch 3d"); + setSizing(AxisSizing::fill(), AxisSizing::fill()); + emplaceLayout(); + + mColor.setOnChanged([this](const RGBA&) { applyBrush(); }); + mBrushSize.setOnChanged([this](halnf) { applyBrush(); }); + mPencil.setOnActivate([this]() { + if (mProject) mProject->setPencil(); + }); + mEraser.setOnActivate([this]() { + if (mProject) mProject->setEraser(); + }); + + mControls.emplaceLayout(8).setPadding(Insets::uniform(10)); + mControls.addChild(mColor); + mControls.addChild(mBrushSize); + for (Button* button : { &mPencil, &mEraser }) { + button->setSizing(AxisSizing::fill(), AxisSizing::fit()); + mControls.addChild(*button); } - mActionPosAbsolutePrev = pointer; + mControlsPanel.setContent(&mControls); + mControlsPanel.setDockedLength(kControlsWidth); + mDock.setSizing(AxisSizing::fill(), AxisSizing::fill()); + mDock.addPanel(mControlsPanel); + addChild(mDock); } -SketchViewportWidget::SketchViewportWidget() { - setLayout(new ToolBarLayout(this)); -} - -void SketchViewportWidget::setToolbarWidget(Widget* widget) { - addChild(widget); - widget->bringToFront(); -} - -ToolbarWidget::ToolbarWidget() { - setDirection(false); - - addToToolbar(&mFileHover); - addToToolbar(&mPencilHover); - addToToolbar(&mViewHover); - - mFileHover.setText("File"); - mViewHover.setText("View"); - mPencilHover.setText("Brush"); -} - -void ToolbarWidget::addToToolbar(Widget* button) { - button->getLayout()->setMinSize({60, 30}); - getContainer()->addChild(button); -} - -SketchGUI::SketchGUI() : - DockWidget() { - - setCenterWidget(&mViewport); - dockWidget(&mPanel, DockLayout::RIGHT); - toggleWidgetVisibility(DockLayout::RIGHT); - - mPanel.addToMenu(&mControls); - mPanel.setText("Controls"); - mPanel.setArea({ 110, 110, 300, 500 }); - - mControls.setText("Tools"); - mControls.addToMenu(&mSelectPencil); - mControls.addToMenu(&mSelectEraser); - mControls.setArea({ 0, 0, 300, 400 }); - - // brush - { - auto popup = mToolbar.mPencilHover.getPopup(); - popup->addChild(&mColorPicker); - popup->addChild(&mBrushSizeSlider); - - mColorPicker.setArea({10, 10, 300, 300}); - } - - // view - { - auto popup = mToolbar.mViewHover.getPopup(); - popup->addChild(&mToggleSidePanel); - - mToggleSidePanel.setText("Toggle Panel"); - mToggleSidePanel.setAction([this](){ - toggleWidgetVisibility(getSide(&mPanel)); - }); - } - - mSelectPencil.setAction([this](){ mProject->setPencil(); }); - mSelectPencil.setText("Pencil"); - - mSelectEraser.setAction([this](){ mProject->setEraser(); }); - mSelectEraser.setText("Eraser TODO"); -} - -void SketchGUI::createRenderWidget(GpuDevice& gpu, Canvas* canvas, const Vec2F& renderResolution) { - mRenderWidget = new SketchRenderWidget(gpu, *canvas, renderResolution); - - mViewport.setRenderWidget(mRenderWidget); - mViewport.setToolbarWidget(&mToolbar); +void SketchGUI::createRenderWidget(GpuDevice& gpu, Canvas& canvas, const Vec2F& renderResolution) { + mViewport = std::make_unique(gpu, canvas, renderResolution); + mViewport->setProject(mProject); + mDock.setCenter(mViewport.get()); } void SketchGUI::setProject(Project* project) { mProject = project; - mViewport.setProject(project); - if (mRenderWidget) mRenderWidget->setProject(project); + if (mViewport) mViewport->setProject(project); + applyBrush(); } -SketchGUI::~SketchGUI() { delete mRenderWidget; } - -void SketchGUI::process(const InputView& events) { - DockWidget::process(events); +void SketchGUI::buildMenus(MenuBar& menuBar) { + mBrushMenu.addItem("Pencil", [this]() { mPencil.activate(); }); + mBrushMenu.addItem("Eraser", [this]() { mEraser.activate(); }); + mViewMenu.addToggle( + "Controls panel", [this]() { return mControlsPanel.isOpen(); }, [this]() { mDock.togglePanel(mControlsPanel); } + ); + menuBar.addMenu("Brush", mBrushMenu); + menuBar.addMenu("View", mViewMenu); +} +void SketchGUI::applyBrush() { if (!mProject) return; - - auto pencil = dynamic_cast(mProject->mBrushes.get("pencil")); - - pencil->mCol = mColorPicker.color(); - pencil->mSize = mBrushSizeSlider.val() * 0.2f + 0.001f; + auto* pencil = dynamic_cast(mProject->mBrushes.get("pencil")); + if (!pencil) return; + pencil->mCol = mColor.color(); + pencil->mSize = mBrushSize.value() * kBrushSizeScale + kMinimumBrushSize; } - -void SketchGUI::draw(Canvas& canvas) { canvas.rect(getArea().relative(), mBackgroundColor, mRounding); } diff --git a/Sketch3D/public/SketchGUI.hpp b/Sketch3D/public/SketchGUI.hpp index 5b8e521c..701f0fa6 100644 --- a/Sketch3D/public/SketchGUI.hpp +++ b/Sketch3D/public/SketchGUI.hpp @@ -1,95 +1,70 @@ #pragma once +#include "Button.hpp" +#include "Docking.hpp" +#include "Menus.hpp" #include "Sketch3D.hpp" +#include "Slider.hpp" +#include "Views.hpp" #include "Widget.hpp" -#include "DockWidget.hpp" -#include "FloatingWidget.hpp" -#include "ColorPickerWidget.hpp" + +#include namespace tp { - class SketchRenderWidget : public Widget { + // Renders the project's strokes and turns pointer drags into strokes or camera moves: + // Shift moves the camera, Alt rotates it, Control zooms, plain drags draw with the active brush. + class SketchViewport : public ui::Widget { public: - SketchRenderWidget(GpuDevice& gpu, Canvas& canvas, Vec2F renderResolution); - ~SketchRenderWidget() override; + SketchViewport(GpuDevice& gpu, Canvas& canvas, Vec2F renderResolution); + ~SketchViewport() override; - void draw(Canvas& canvas) override; void setProject(Project* project); + protected: + void onEvent(ui::Event& event) override; + [[nodiscard]] bool acceptsDrag(PointerButton button) const override; + void paint(Canvas& canvas) override; + void onTick(halnf deltaMs) override; + private: + static constexpr halnf kImageRounding = 12; + + [[nodiscard]] Vec2F toNormalized(Vec2F localPoint) const; + [[nodiscard]] halnf ratio() const; + void dragged(const ui::Event& event); + Renderer mRenderer; - Project* mProject = nullptr; + Canvas& mCanvas; ImageHandle mImage; - Canvas* mCanvas = nullptr; - }; - - class SketchViewportWidget : public Widget { - public: - SketchViewportWidget(); - - void setProject(Project* project); - void setColor(const RGBA& color); - - void setRenderWidget(SketchRenderWidget* widget); - void setToolbarWidget(Widget* widget); - - void process(const InputView& events) override; - [[nodiscard]] bool processesEvents() const override { return true; } - - private: Project* mProject = nullptr; - - public: - Vec2F mActionPosAbsolutePrev = { 0, 0 }; - bool mAction = false; }; - - class ToolbarWidget : public ScrollableWidget { - public: - ToolbarWidget(); - - private: - void addToToolbar(Widget* button); - - public: - HoverPopupTriggerWidget mFileHover; - HoverPopupTriggerWidget mPencilHover; - HoverPopupTriggerWidget mViewHover; - }; - - class SketchGUI : public DockWidget { + // The sketch window content: the viewport and a docked controls panel with the brush and tool choice. + class SketchGUI : public ui::Widget { public: SketchGUI(); - void createRenderWidget(GpuDevice& gpu, Canvas* canvas, const Vec2F& renderResolution); - void setProject(Project* project); - ~SketchGUI() override; - void process(const InputView& events) override; - void draw(Canvas& canvas) override; + void createRenderWidget(GpuDevice& gpu, Canvas& canvas, const Vec2F& renderResolution); + void setProject(Project* project); + void buildMenus(ui::MenuBar& menuBar); + + [[nodiscard]] ui::DockPanel& controlsPanel() { return mControlsPanel; } private: + void applyBrush(); + Project* mProject = nullptr; + std::unique_ptr mViewport; - SketchViewportWidget mViewport; - ToolbarWidget mToolbar; - - SketchRenderWidget* mRenderWidget = nullptr; - - FloatingMenu mPanel; - - RGBPickerWidget mColorPicker; - - ButtonWidget mToggleSidePanel; - - ButtonWidget mSelectPencil; - ButtonWidget mSelectEraser; - - SliderWidget mBrushSizeSlider; - - FloatingMenu mControls; - - RGBA mBackgroundColor; - halnf mRounding = 0; + ui::DockArea mDock; + ui::DockPanel mControlsPanel{ "Controls", ui::DockSide::Right }; + ui::Widget mControls; + ui::ColorWheel mColor; + ui::Slider mBrushSize{ 0, 1, 0.05f }; + ui::Button mPencil{ "Pencil" }; + ui::Button mEraser{ "Eraser" }; + ui::Menu mViewMenu; + ui::Menu mBrushMenu; }; } -- 2.49.1 From 56c264f14285e53dd6796302a4cdfee2a41c804e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D1=83=D1=80=D1=83=D0=BF=D0=BE=D0=B2=20=D0=98=D0=BB?= =?UTF-8?q?=D1=8C=D1=8F=20=D0=92=D0=B8=D0=BA=D1=82=D0=BE=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Sun, 1 Nov 2026 11:07:42 +0300 Subject: [PATCH 02/10] Widgets: shared visuals, layouts and choices; TabView and MarkdownEditor use them - core glyph set (plus Volume/Muted/ResizeCorner and a turn) with paintGlyph, InteractionVisual (hover/press/reveal) and the elevation shadow, adopted by buttons, toggles, panels and window controls; - GridLayout per-column widths; MarkdownView tables on it and its blocks owned; - RowLayout overflow; MenuBar fits its titles through it and moves its window through a router drag; - SegmentedControl and RadioGroup on SelectionModel; the tab strip and the MarkdownEditor modes are segmented controls. --- .../src/components/chrome/private/Menus.cpp | 127 ++++--------- .../chrome/private/WindowControls.cpp | 84 +++------ .../src/components/chrome/public/Menus.hpp | 9 +- .../chrome/public/WindowControls.hpp | 9 +- .../chrome/tests/WindowChromeTests.cpp | 45 +++++ .../components/controls/private/Button.cpp | 91 ++++----- .../controls/private/Containers.cpp | 5 + .../controls/private/SegmentedControl.cpp | 100 ++++++++++ .../components/controls/private/Toggles.cpp | 51 +++-- .../src/components/controls/public/Button.hpp | 29 ++- .../components/controls/public/Containers.hpp | 6 + .../controls/public/SegmentedControl.hpp | 103 +++++++++++ .../components/controls/public/Toggles.hpp | 9 +- .../controls/tests/SegmentedControlTests.cpp | 154 ++++++++++++++++ .../src/components/docking/public/Docking.hpp | 2 +- .../components/files/public/FileBrowser.hpp | 2 +- .../files/tests/FileBrowserTests.cpp | 2 +- .../components/kanban/public/KanbanBoard.hpp | 2 +- .../markdown/private/MarkdownEditor.cpp | 28 +-- .../markdown/private/MarkdownView.cpp | 174 ++++-------------- .../markdown/public/MarkdownEditor.hpp | 5 +- .../markdown/public/MarkdownView.hpp | 10 - .../markdown/tests/MarkdownViewTests.cpp | 15 ++ Widgets/src/components/tests/LayoutTests.cpp | 107 +++++++++++ Widgets/src/components/tests/WidgetTests.cpp | 87 ++++++++- .../src/components/views/private/Views.cpp | 43 ++--- Widgets/src/components/views/public/Views.hpp | 12 +- Widgets/src/core/private/Elevation.cpp | 32 ++++ Widgets/src/core/private/Glyph.cpp | 157 ++++++++++++++++ .../src/core/private/InteractionVisual.cpp | 65 +++++++ Widgets/src/core/private/Layout.cpp | 9 + Widgets/src/core/private/Theme.cpp | 1 + Widgets/src/core/public/Elevation.hpp | 19 ++ Widgets/src/core/public/Glyph.hpp | 52 ++++++ Widgets/src/core/public/InteractionVisual.hpp | 57 ++++++ Widgets/src/core/public/Layout.hpp | 1 + Widgets/src/core/public/Theme.hpp | 5 +- Widgets/src/layouts/private/BoxLayout.cpp | 92 +++++++++ Widgets/src/layouts/private/StackLayout.cpp | 119 +++++++++--- Widgets/src/layouts/public/BoxLayout.hpp | 29 +++ Widgets/src/layouts/public/StackLayout.hpp | 18 +- Widgets/tests/GlyphTests.cpp | 110 +++++++++++ Widgets/tests/InteractionVisualTests.cpp | 145 +++++++++++++++ 43 files changed, 1732 insertions(+), 490 deletions(-) create mode 100644 Widgets/src/components/controls/private/SegmentedControl.cpp create mode 100644 Widgets/src/components/controls/public/SegmentedControl.hpp create mode 100644 Widgets/src/components/controls/tests/SegmentedControlTests.cpp create mode 100644 Widgets/src/core/private/Elevation.cpp create mode 100644 Widgets/src/core/private/Glyph.cpp create mode 100644 Widgets/src/core/private/InteractionVisual.cpp create mode 100644 Widgets/src/core/public/Elevation.hpp create mode 100644 Widgets/src/core/public/Glyph.hpp create mode 100644 Widgets/src/core/public/InteractionVisual.hpp create mode 100644 Widgets/tests/GlyphTests.cpp create mode 100644 Widgets/tests/InteractionVisualTests.cpp diff --git a/Widgets/src/components/chrome/private/Menus.cpp b/Widgets/src/components/chrome/private/Menus.cpp index bab89b4a..02ffa1b1 100644 --- a/Widgets/src/components/chrome/private/Menus.cpp +++ b/Widgets/src/components/chrome/private/Menus.cpp @@ -14,7 +14,6 @@ using namespace tp::ui; namespace { constexpr halnf kSubmenuArrowWidth = 16; - constexpr halnf kWindowDragThreshold = 4; PopupOptions menuPopupOptions(Placement placement, halnf gap) { PopupOptions options; @@ -348,6 +347,8 @@ namespace { constexpr halnf kCenterMargin = 3; } +// A row whose titles give way to the overflow button when the bar is too narrow (RowLayout overflow), plus an optional +// center widget kept in the middle of the bar, over the drag space. class MenuBar::MenuBarLayout : public RowLayout { public: explicit MenuBarLayout(MenuBar& bar) : @@ -363,25 +364,26 @@ protected: if (axis == Axis::Vertical) { center.minimum += kCenterMargin * 2; center.preferred += kCenterMargin * 2; - } - if (axis == Axis::Vertical) return { std::max(row.minimum, center.minimum), std::max(row.preferred, center.preferred), row.maximum, row.grow }; + } return { row.minimum + center.minimum, row.preferred + center.preferred, row.maximum, row.grow }; } void arrangeChildren(LayoutContext& context, const RectF& contentArea) override { - mBar.showTitles(fittingTitles(context, contentArea)); if (!mBar.mCenter) { RowLayout::arrangeChildren(context, contentArea); return; } + // Note: the center's minimum width is held back from the row, so titles overflow before they cover it. + const halnf reserved = context.measure(*mBar.mCenter, Axis::Horizontal, contentArea.size.y).minimum + gap(); LayoutContext row = context.without(*mBar.mCenter); - RowLayout::arrangeChildren(row, contentArea); + RowLayout::arrangeChildren(row, makeRect(contentArea.pos, { contentArea.size.x - reserved, contentArea.size.y })); placeCenter(context, contentArea); } +private: void placeCenter(LayoutContext& context, const RectF& area) const { Widget& center = *mBar.mCenter; const RectF space = mBar.mDragSpace.rect(); @@ -393,35 +395,6 @@ protected: context.place(center, makeRect(left, area.pos.y + (area.size.y - height) / 2, width, height)); } -private: - [[nodiscard]] halnf widthOf(const LayoutContext& context, const Widget& widget, halnf height) const { - return context.measure(widget, Axis::Horizontal, height).preferred; - } - - [[nodiscard]] size_t fittingTitles(const LayoutContext& context, const RectF& area) const { - const halnf height = area.size.y; - halnf controls = mBar.mWindowControls.isVisible() ? widthOf(context, mBar.mWindowControls, height) : 0; - if (mBar.mTrailing && mBar.mTrailing->isVisible()) controls += widthOf(context, *mBar.mTrailing, height) + gap(); - halnf total = controls + centerWidth(context, height); - for (const auto& title : mBar.mTitles) - total += widthOf(context, *title, height) + gap(); - if (total <= area.size.x) return mBar.mTitles.size(); - - halnf used = controls + centerWidth(context, height) + widthOf(context, mBar.mOverflow, height) + gap(); - size_t count = 0; - for (const auto& title : mBar.mTitles) { - used += widthOf(context, *title, height) + gap(); - if (used > area.size.x) break; - count++; - } - return count; - } - - [[nodiscard]] halnf centerWidth(const LayoutContext& context, halnf height) const { - if (!mBar.mCenter) return 0; - return context.measure(*mBar.mCenter, Axis::Horizontal, height).minimum + gap(); - } - MenuBar& mBar; }; @@ -429,7 +402,9 @@ MenuBar::MenuBar() : Panel(Surface::None) { setDebugName("menu bar"); setSizing(AxisSizing::fill(), AxisSizing::fit()); - emplaceLayout(*this).setPadding(Insets::symmetric(4, 2)); + MenuBarLayout& layout = emplaceLayout(*this); + layout.setPadding(Insets::symmetric(4, 2)); + layout.setOnOverflowChanged([this](size_t shown) { titlesOverflowed(shown); }); mDragSpace.setDebugName("menu bar drag space"); mDragSpace.setSizing(AxisSizing::fill(), AxisSizing::fill()); @@ -445,76 +420,49 @@ MenuBar::MenuBar() : addChild(mWindowControls); } -void MenuBar::showTitles(size_t count) { - if (count == mShownTitles && mOverflow.isVisible() == (count < mTitles.size())) return; - mShownTitles = count; - for (size_t index = 0; index < mTitles.size(); index++) - mTitles[index]->setVisible(index < count); - mOverflow.setVisible(count < mTitles.size()); +void MenuBar::titlesOverflowed(size_t shown) { + mShownTitles = shown; mOverflowMenu.clear(); - for (size_t index = count; index < mTitles.size(); index++) + for (size_t index = shown; index < mTitles.size(); index++) mOverflowMenu.addSubmenu(mTitles[index]->text(), *mMenus[index]); } +void MenuBar::refreshOverflowRun() { + // Titles are the first children; the overflow button follows them and is not part of the run. + static_cast(layout())->setOverflow(0, mTitles.size(), &mOverflow); +} + +bool MenuBar::acceptsDrag(PointerButton button) const { + WindowHost* host = ui() ? ui()->windowHost() : nullptr; + return button == PointerButton::Left && host && host->canMoveWindow(); +} + void MenuBar::onEvent(Event& event) { WindowHost* host = ui() ? ui()->windowHost() : nullptr; if (!host || !host->canMoveWindow()) return; - if (mMovePending) { - handlePendingMove(event, *host); - return; - } - - if (event.type() == Event::Type::PointerDown && isDragSurface(event.target())) handleDragSurfacePress(event, *host); -} - -void MenuBar::handleDragSurfacePress(Event& event, WindowHost& host) { - if (event.button() == PointerButton::Right) { - host.showWindowMenu(event.windowPosition()); + if (const Dragged* drag = event.drag()) { + // The router recognised a drag that started on the bar itself; moving the window takes it from here. + if (drag->phase == DragPhase::Started && isDragSurface(event.target())) { + if (ui()) ui()->cancelDrag(); + host->beginWindowMove(); + } event.accept(); return; } - if (event.button() != PointerButton::Left) return; - event.accept(); - - if (event.clickCount() == 2) { - host.toggleMaximizeWindow(); + if (event.type() != Event::Type::PointerDown || !isDragSurface(event.target())) return; + if (event.button() == PointerButton::Right) { + host->showWindowMenu(event.windowPosition()); + event.accept(); return; } - - mMovePending = true; - mMovePressPoint = event.windowPosition(); - ui()->capturePointer(*this); -} - -void MenuBar::handlePendingMove(Event& event, WindowHost& host) { - switch (event.type()) { - case Event::Type::PointerMove: - { - const Vec2F offset = event.windowPosition() - mMovePressPoint; - if (offset.x * offset.x + offset.y * offset.y < kWindowDragThreshold * kWindowDragThreshold) return; - - cancelPendingMove(); - host.beginWindowMove(); - event.accept(); - return; - } - - case Event::Type::PointerUp: - cancelPendingMove(); - event.accept(); - return; - - default: return; + if (event.button() == PointerButton::Left && event.clickCount() == 2) { + host->toggleMaximizeWindow(); + event.accept(); } } -void MenuBar::cancelPendingMove() { - mMovePending = false; - if (ui()) ui()->releasePointer(*this); -} - MenuIconButton::MenuIconButton() { setDebugName("menu icon button"); setStyle(Style::Flat); @@ -566,7 +514,7 @@ void MenuBar::removeMenu(Menu& menu) { mMenus.erase(found); mShownTitles = mTitles.size(); mOverflowMenu.clear(); - requestLayout(); + refreshOverflowRun(); } void MenuBar::setTrailingWidget(Widget* widget) { @@ -594,6 +542,7 @@ PopupButton& MenuBar::insertMenu(size_t index, std::string title, Menu& menu) { mMenus.insert(mMenus.begin() + (std::ptrdiff_t) index, &menu); insertChild(index, button); mShownTitles = mTitles.size(); + refreshOverflowRun(); return button; } diff --git a/Widgets/src/components/chrome/private/WindowControls.cpp b/Widgets/src/components/chrome/private/WindowControls.cpp index 81642655..e81905f1 100644 --- a/Widgets/src/components/chrome/private/WindowControls.cpp +++ b/Widgets/src/components/chrome/private/WindowControls.cpp @@ -1,5 +1,4 @@ #include "WindowControls.hpp" -#include "Canvas.hpp" #include "ComponentSizes.hpp" #include "BoxLayout.hpp" @@ -11,13 +10,8 @@ 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"; @@ -26,80 +20,44 @@ namespace { } return "window button"; } + + Glyph glyphOf(WindowButton::Kind kind) { + switch (kind) { + case WindowButton::Kind::Minimize: return Glyph::Minus; + case WindowButton::Kind::Maximize: return Glyph::Maximize; + case WindowButton::Kind::Close: return Glyph::Close; + } + return Glyph::Close; + } } WindowButton::WindowButton(Kind kind) : + GlyphButton(glyphOf(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 ? sizes::windowButtonWidth : sizes::iconButtonSize); } -RGBA WindowButton::backgroundTarget() const { +GlyphButton::GlyphColors WindowButton::interactionColors() const { const Palette& palette = theme().palette; + if (mKind != Kind::Close) return GlyphButton::interactionColors(); - 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); + return { InteractionColors::highlight(palette.windowCloseHovered, palette.windowClosePressed), + palette.mutedText, + palette.onWindowClose }; } -void WindowButton::paint(Canvas& 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.rect(localRect(), background(), style.metrics.cornerRadius); - paintGlyph(painter, localRect().center(), glyph); +bool WindowButton::isWindowMaximized() const { + const WindowHost* host = ui() ? ui()->windowHost() : nullptr; + return host && host->isWindowMaximized(); } -void WindowButton::paintGlyph(Canvas& 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.outline(box, color, 2, kGlyphThickness); - return; - } - - const Vec2F shift = { kRestoreOffset, -kRestoreOffset }; - painter.outline(translated(insetRect(box, 1), shift), color, 2, kGlyphThickness); - painter.rect(insetRect(box, 1), background(), 2); - painter.outline(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; - } +Glyph WindowButton::displayedGlyph() const { + if (mKind == Kind::Maximize && isWindowMaximized()) return Glyph::Restore; + return GlyphButton::displayedGlyph(); } void WindowButton::onActivated() { diff --git a/Widgets/src/components/chrome/public/Menus.hpp b/Widgets/src/components/chrome/public/Menus.hpp index 53b2300d..0dd93aeb 100644 --- a/Widgets/src/components/chrome/public/Menus.hpp +++ b/Widgets/src/components/chrome/public/Menus.hpp @@ -162,22 +162,19 @@ namespace tp::ui { protected: void onEvent(Event& event) override; + [[nodiscard]] bool acceptsDrag(PointerButton button) const override; private: class MenuBarLayout; friend class MenuBarLayout; PopupButton& insertMenu(size_t index, std::string title, Menu& menu); - void showTitles(size_t count); + void titlesOverflowed(size_t shown); + void refreshOverflowRun(); void onTitleHovered(PopupButton& title); [[nodiscard]] bool isDragSurface(const Widget* target) const; - void handleDragSurfacePress(Event& event, WindowHost& host); - void handlePendingMove(Event& event, WindowHost& host); - void cancelPendingMove(); std::vector> mTitles; - bool mMovePending = false; - Vec2F mMovePressPoint = { 0, 0 }; Spacer mDragSpace; Widget* mCenter = nullptr; Widget* mTrailing = nullptr; diff --git a/Widgets/src/components/chrome/public/WindowControls.hpp b/Widgets/src/components/chrome/public/WindowControls.hpp index cf1ffab0..7d742169 100644 --- a/Widgets/src/components/chrome/public/WindowControls.hpp +++ b/Widgets/src/components/chrome/public/WindowControls.hpp @@ -5,7 +5,8 @@ namespace tp::ui { - class WindowButton : public Button { + // A title-bar button that minimizes, maximizes or closes the window of the Ui it is shown in. + class WindowButton : public GlyphButton { public: enum class Kind { Minimize, Maximize, Close }; @@ -15,12 +16,12 @@ namespace tp::ui { protected: [[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override; - void paint(Canvas& painter) override; - [[nodiscard]] RGBA backgroundTarget() const override; + [[nodiscard]] GlyphColors interactionColors() const override; + [[nodiscard]] Glyph displayedGlyph() const override; void onActivated() override; private: - void paintGlyph(Canvas& painter, Vec2F center, const RGBA& color) const; + [[nodiscard]] bool isWindowMaximized() const; Kind mKind; }; diff --git a/Widgets/src/components/chrome/tests/WindowChromeTests.cpp b/Widgets/src/components/chrome/tests/WindowChromeTests.cpp index 21b137ed..70810a2d 100644 --- a/Widgets/src/components/chrome/tests/WindowChromeTests.cpp +++ b/Widgets/src/components/chrome/tests/WindowChromeTests.cpp @@ -59,6 +59,51 @@ namespace { } SUITE(WindowControlButtons) { + TEST(ButtonsDrawTheCoreGlyphs) { + HostedChrome chrome; + WindowControls& controls = chrome.scene.bar.windowControls(); + + CHECK(controls.minimizeButton().glyph() == Glyph::Minus); + CHECK(controls.maximizeButton().glyph() == Glyph::Maximize); + CHECK(controls.closeButton().glyph() == Glyph::Close); + } + + TEST(MaximizeButtonShowsRestoreWhileTheWindowIsMaximized) { + HostedChrome chrome; + WindowButton& maximize = chrome.scene.bar.windowControls().maximizeButton(); + const auto outlines = [&chrome]() { + size_t count = 0; + for (const CanvasCommand& command : chrome.driver.paint().commands()) { + count += command.kind == CanvasCommand::Kind::Outline ? 1 : 0; + } + return count; + }; + const size_t windowed = outlines(); + + chrome.driver.click(maximize); + REQUIRE CHECK(chrome.scene.host.maximized); + + bool drewRestoreFrame = false; + for (const CanvasCommand& command : chrome.driver.paint().commands()) { + drewRestoreFrame |= command.kind == CanvasCommand::Kind::Polyline && command.points.size() == 5; + } + CHECK(drewRestoreFrame); + CHECK_EQUAL(windowed, outlines()); + } + + TEST(CloseButtonFadesToTheWindowCloseColorOnHover) { + HostedChrome chrome; + WindowButton& close = chrome.scene.bar.windowControls().closeButton(); + const RGBA hovered = chrome.driver.ui().theme().palette.windowCloseHovered; + + CHECK_CLOSE(0, close.visual().color().a, 0.01f); + chrome.driver.moveTo(close); + chrome.driver.settle(); + + CHECK_CLOSE(hovered.r, close.visual().color().r, 0.01f); + CHECK_CLOSE(1, close.visual().reveal(), 0.01f); + } + TEST(ControlsStayHiddenWithoutWindowHost) { ChromeScene scene; UiDriver driver(scene.root); diff --git a/Widgets/src/components/controls/private/Button.cpp b/Widgets/src/components/controls/private/Button.cpp index 83ff1ac0..d4a7ef0f 100644 --- a/Widgets/src/components/controls/private/Button.cpp +++ b/Widgets/src/components/controls/private/Button.cpp @@ -163,72 +163,51 @@ halnf Button::labelFontSize() const { } namespace { - constexpr halnf kChevronHalfHeight = 5; - constexpr halnf kChevronDepth = 3; - constexpr halnf kGlyphStroke = 1.5f; - - const char* glyphDebugName(GlyphButton::Glyph glyph) { - switch (glyph) { - case GlyphButton::Glyph::Options: return "options button"; - case GlyphButton::Glyph::Hide: return "hide button"; - case GlyphButton::Glyph::Previous: return "previous button"; - case GlyphButton::Glyph::Next: return "next button"; - case GlyphButton::Glyph::Up: return "up button"; - } - return "glyph button"; - } + constexpr halnf kGlyphSide = 10; } GlyphButton::GlyphButton(Glyph glyph) : mGlyph(glyph) { setStyle(Style::Flat); - setDebugName(glyphDebugName(glyph)); + setDebugName("glyph button"); } +void GlyphButton::setGlyph(Glyph glyph) { assign(mGlyph, glyph, Affects::Paint); } + Extent GlyphButton::measureContent(Axis, halnf) const { return Extent::rigid(sizes::iconButtonSize); } +GlyphButton::GlyphColors GlyphButton::interactionColors() const { + const Palette& palette = theme().palette; + return { InteractionColors::highlight(palette.controlHovered, palette.controlPressed), palette.mutedText, palette.text }; +} + +void GlyphButton::onAttached() { + Button::onAttached(); + mColors = interactionColors(); + mVisual.setColors(mColors.surface); + mVisual.finish(); +} + +void GlyphButton::refreshVisualState() { + mColors = interactionColors(); + mVisual.setColors(mColors.surface); + if (mVisual.refresh()) startTicking(); + requestPaint(); +} + +void GlyphButton::onTick(halnf deltaMs) { + if (!mVisual.advance(deltaMs)) stopTicking(); + requestPaint(); +} + +RGBA GlyphButton::glyphColor() const { + if (!isEffectivelyEnabled()) return theme().palette.disabledText; + return Palette::mix(mColors.glyphAtRest, mColors.glyphRevealed, mVisual.reveal()); +} + void GlyphButton::paint(Canvas& painter) { const Theme& style = theme(); - RGBA color = isHovered() && isEffectivelyEnabled() ? style.palette.text : style.palette.mutedText; - if (!isEffectivelyEnabled()) color = style.palette.disabledText; - const Vec2F center = localRect().center(); - painter.rect(localRect(), background(), style.metrics.cornerRadius); - - switch (mGlyph) { - case Glyph::Hide: painter.line(center - Vec2F(5.0f, 0.0f), center + Vec2F(5.0f, 0.0f), color, kGlyphStroke); return; - case Glyph::Previous: paintChevron(painter, -1, color); return; - case Glyph::Next: paintChevron(painter, 1, color); return; - case Glyph::Up: paintUpChevron(painter, color); return; - case Glyph::Options: - for (halnf offset : { -5.0f, 0.0f, 5.0f }) - painter.circle(center + Vec2F(0.0f, offset), 1.5f, color); - return; - } -} - -void GlyphButton::paintChevron(Canvas& painter, halnf direction, const RGBA& color) const { - const Vec2F center = localRect().center(); - const Vec2F tip = center + Vec2F(direction * kChevronDepth, 0.0f); - const Vec2F back = center - Vec2F(direction * kChevronDepth, 0.0f); - painter.polyline( - { back - Vec2F(0.0f, kChevronHalfHeight), tip, back + Vec2F(0.0f, kChevronHalfHeight) }, color, kGlyphStroke - ); -} - -void GlyphButton::paintUpChevron(Canvas& painter, const RGBA& color) const { - const Vec2F center = localRect().center(); - const Vec2F tip = center - Vec2F(0.0f, kChevronDepth); - const Vec2F back = center + Vec2F(0.0f, kChevronDepth); - painter.polyline( - { back - Vec2F(kChevronHalfHeight, 0.0f), tip, back + Vec2F(kChevronHalfHeight, 0.0f) }, color, kGlyphStroke - ); -} - -RGBA GlyphButton::backgroundTarget() const { - const Palette& palette = theme().palette; - const RGBA hovered = palette.controlHovered; - if (isPressed()) return palette.controlPressed; - if (isHovered()) return hovered; - return { hovered.r, hovered.g, hovered.b, 0 }; + painter.rect(localRect(), mVisual.color(), style.metrics.cornerRadius); + paintGlyph(painter, displayedGlyph(), glyphBox(localRect(), kGlyphSide), glyphColor(), style); } diff --git a/Widgets/src/components/controls/private/Containers.cpp b/Widgets/src/components/controls/private/Containers.cpp index 6694f3f2..4a7b7837 100644 --- a/Widgets/src/components/controls/private/Containers.cpp +++ b/Widgets/src/components/controls/private/Containers.cpp @@ -1,6 +1,7 @@ #include "Containers.hpp" #include "Canvas.hpp" #include "ComponentSizes.hpp" +#include "Elevation.hpp" #include "ScrollPhysics.hpp" #include "Event.hpp" @@ -45,10 +46,14 @@ void Panel::setBorder(bool border) { requestPaint(); } +void Panel::setElevated(bool elevated) { assign(mElevated, elevated, Affects::Paint); } + void Panel::paint(Canvas& painter) { const Theme& style = theme(); const halnf radius = mCornerRadius >= 0 ? mCornerRadius : style.metrics.cornerRadius; + if (isElevated()) paintElevationShadow(painter, localRect(), radius, style); + switch (mSurface) { case Surface::None: break; case Surface::Window: diff --git a/Widgets/src/components/controls/private/SegmentedControl.cpp b/Widgets/src/components/controls/private/SegmentedControl.cpp new file mode 100644 index 00000000..d78766ff --- /dev/null +++ b/Widgets/src/components/controls/private/SegmentedControl.cpp @@ -0,0 +1,100 @@ +#include "SegmentedControl.hpp" + +#include "BoxLayout.hpp" +#include "Event.hpp" +#include "Ui.hpp" + +#include + +using namespace tp; +using namespace tp::ui; + +namespace { + constexpr halnf kSegmentGap = 2; +} + +SegmentedControlBase::SegmentedControlBase(Mode mode) : + mSelection(mode) { + setDebugName("segmented control"); + setSizing(AxisSizing::fit(), AxisSizing::fit()); + emplaceLayout(kSegmentGap); + mSelection.setOnChanged([this]() { + refreshButtons(); + if (mUserChange && mOnIndexChanged) mOnIndexChanged(); + }); +} + +Button& SegmentedControlBase::addButton(std::string text) { + std::unique_ptr