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/LibraryViewer/private/GUI.cpp b/LibraryViewer/private/GUI.cpp index 9be95bdf..6ab7ac26 100644 --- a/LibraryViewer/private/GUI.cpp +++ b/LibraryViewer/private/GUI.cpp @@ -4,6 +4,7 @@ #include "Canvas.hpp" #include "StackLayout.hpp" #include "Event.hpp" +#include "Glyph.hpp" #include "Theme.hpp" #include @@ -13,9 +14,6 @@ using namespace tp; using namespace tp::ui; namespace { - constexpr halnf kTrackHeight = 70; - constexpr halnf kArtSize = 60; - constexpr halnf kPlayingMarkWidth = 3; constexpr halnf kSearchWidth = 280; constexpr halnf kMillisecondsPerSecond = 1000; constexpr halnf kInfoCoverMaxSize = 240; @@ -32,58 +30,6 @@ namespace { } } -TrackWidget::TrackWidget(const Track& track) : - mTrack(track) { - setDebugName("track: " + track.mName); - setSizing(AxisSizing::fill(), AxisSizing::fixed(kTrackHeight)); -} - -void TrackWidget::setSelected(bool selected) { - mSelected = selected; - refreshVisualState(); -} - -void TrackWidget::setPlaying(bool playing) { - if (playing == mPlaying) return; - mPlaying = playing; - requestPaint(); -} - -Extent TrackWidget::measureContent(Axis axis, halnf) const { - if (axis == Axis::Vertical) return Extent::rigid(kTrackHeight); - return Extent::shrinkable(textWidth(mTrack.mName, theme().metrics.fontSize) + kArtSize + 20, kArtSize + 20); -} - -void TrackWidget::paint(Canvas& canvas) { - const Theme& style = theme(); - const halnf margin = (kTrackHeight - kArtSize) / 2; - - canvas.rect(localRect(), background(), style.metrics.cornerRadius); - canvas.rect( - makeRect(margin, margin, kArtSize, kArtSize), style.palette.controlPressed, style.metrics.cornerRadius - ); - if (mPlaying) { - canvas.rect(makeRect(0, margin, kPlayingMarkWidth, kArtSize), style.palette.text, kPlayingMarkWidth / 2); - } - - const halnf textLeft = kArtSize + margin * 3; - const halnf textWidthValue = size().x - textLeft - margin; - canvas.text( - mTrack.mName, makeRect(textLeft, margin, textWidthValue, kArtSize / 2), TextStyle{ 15, style.palette.text, TextAlign::leftCenter() } - ); - canvas.text( - mTrack.mArtist, - makeRect(textLeft, margin + kArtSize / 2, textWidthValue, kArtSize / 2), - TextStyle{ 12, style.palette.mutedText, TextAlign::leftCenter() } - ); -} - -RGBA TrackWidget::backgroundTarget() const { - if (mSelected) return theme().palette.controlPressed; - if (isHovered()) return theme().palette.controlHovered; - return { 0, 0, 0, 0 }; -} - TrackInfoView::TrackInfoView() { setDebugName("track info"); setSizing(AxisSizing::fill(), AxisSizing::fill()); @@ -94,11 +40,22 @@ TrackInfoView::TrackInfoView() { state.setFontSize(theme().metrics.smallFontSize); title.setFontSize(kInfoTitleSize); artist.setTone(Label::Tone::Muted); - details.setTone(Label::Tone::Muted); - details.setFontSize(theme().metrics.smallFontSize); - for (Label* label : { &state, &title, &artist, &details }) + for (Label* label : { &state, &title, &artist }) label->setSizing(AxisSizing::fill(), AxisSizing::fit()); + // Every row reads the shown track, so refreshing the list after showing a track fills them all. + const auto field = [this](std::function read) { + return [this, read = std::move(read)]() { return mTrack ? read(*mTrack) : std::string(); }; + }; + details.addValue("Album", field([](const Track& track) { return track.mAlbum; })); + details.addValue("Genre", field([](const Track& track) { return track.mGenre; })); + details.addValue("Year", field([](const Track& track) { return track.mYear > 0 ? std::to_string(track.mYear) : std::string("-"); })); + details.addValue("Length", field([](const Track& track) { return formatDuration((halnf) track.mTotalTime / kMillisecondsPerSecond); })); + details.addValue("Plays", field([](const Track& track) { return std::to_string(track.mPlayCount); })); + details.addValue("Loved", field([](const Track& track) { return std::string(track.mLoved ? "yes" : "no"); })); + details.addValue("File", field([](const Track& track) { return std::string(track.mExists ? "on disk" : "missing"); })); + details.addValue("Added", field([](const Track& track) { return track.mDateAdded; })); + mColumn.setSizing(AxisSizing::fill(), AxisSizing::fit()); mColumn.emplaceLayout(6).setPadding(Insets::uniform(12)); mColumn.addChild(cover); @@ -115,27 +72,18 @@ TrackInfoView::TrackInfoView() { void TrackInfoView::showTrack(const Track* track, bool playing) { mTrack = track; + details.setVisible(track != nullptr); + details.refresh(); if (!track) { state.setText(""); title.setText("No track selected"); artist.setText("Select a track to see its details"); - details.setText(""); return; } state.setText(playing ? "NOW PLAYING" : "SELECTED"); title.setText(track->mName); artist.setText(track->mArtist); - - std::string text = "Album: " + track->mAlbum; - text += "\nGenre: " + track->mGenre; - if (track->mYear > 0) text += "\nYear: " + std::to_string(track->mYear); - text += "\nLength: " + formatDuration((halnf) track->mTotalTime / kMillisecondsPerSecond); - text += "\nPlays: " + std::to_string(track->mPlayCount); - text += "\nLoved: " + std::string(track->mLoved ? "yes" : "no"); - text += "\nFile: " + std::string(track->mExists ? "on disk" : "missing"); - text += "\nAdded: " + track->mDateAdded; - details.setText(text); } bool TrackFilter::accepts(const Track& track) const { @@ -185,14 +133,19 @@ LibraryWidget::LibraryWidget(Library& library, PlaybackDevice& device) : mFilterBar.addChild(files); mFilterBar.addChild(count); - mTrackList.emplaceLayout(4).setPadding(Insets::uniform(8)); - mScroll.setContent(&mTrackList); - mScroll.setSizing(AxisSizing::fill(), AxisSizing::fill()); + mTracks.setDensity(ListRow::Density::Comfortable); + mTracks.setEmptyText("No tracks match the filter"); + mTracks.setOnSelectionChanged([this]() { + if (mTracks.current()) select(*mTracks.current()); + }); + mTracks.setOnActivated([this](size_t index) { + if (index < mVisible.size()) play(*mVisible[index]); + }); mBrowser.setSizing(AxisSizing::fill(), AxisSizing::fill()); mBrowser.emplaceLayout(); mBrowser.addChild(mFilterBar); - mBrowser.addChild(mScroll); + mBrowser.addChild(mTracks); infoPanel.setContent(&info); infoPanel.setDockedLength(kInfoPanelWidth); @@ -235,39 +188,41 @@ void LibraryWidget::buildMenus(MenuBar& menuBar) { } void LibraryWidget::rebuildTracks() { - mTrackList.removeAllChildren(); - mTracks.clear(); - mVisible.clear(); mSelected = nullptr; - - for (Track& track : mLibrary.mTraks) { - auto widget = std::make_unique(track); - TrackWidget* raw = widget.get(); - widget->setOnClicked([this, raw](const Event& click) { - select(*raw); - if (click.clickCount() == 2) play(raw->track()); - }); - mTracks.push_back(std::move(widget)); - } - + mTracks.clearSelection(); applyFilter(); } void LibraryWidget::applyFilter() { - mTrackList.removeAllChildren(); mVisible.clear(); - for (auto& track : mTracks) { - if (!mFilter.accepts(track->track())) continue; - mTrackList.addChild(*track); - mVisible.push_back(track.get()); + for (const Track& track : mLibrary.mTraks) { + if (mFilter.accepts(track)) mVisible.push_back(&track); } - count.setText(std::to_string(mVisible.size()) + " of " + std::to_string(mTracks.size()) + " tracks"); + + ListView::Model model; + model.count = mVisible.size(); + model.fill = [this](size_t index, ListRow& row) { + const Track& track = *mVisible[index]; + row.setTitle(track.mName); + row.setSubtitle(track.mArtist); + // The playing track is marked with the play glyph; the others keep the room so titles line up. + row.setLeading(&track == mPlaying ? ListRow::Leading::glyphOf(Glyph::Play) : ListRow::Leading::space()); + row.setMuted(!track.mExists); + }; + mTracks.setModel(std::move(model)); + + // The selection follows the selected track through filtering. + mTracks.clearSelection(); + for (size_t index = 0; index < mVisible.size(); index++) { + if (mVisible[index] == mSelected) mTracks.select(index); + } + count.setText(std::to_string(mVisible.size()) + " of " + std::to_string(mLibrary.mTraks.size()) + " tracks"); } -void LibraryWidget::select(TrackWidget& widget) { - for (auto& track : mTracks) - track->setSelected(track.get() == &widget); - mSelected = &widget.track(); +void LibraryWidget::select(size_t index) { + if (index >= mVisible.size()) return; + mSelected = mVisible[index]; + if (mTracks.current() != index) mTracks.select(index); refreshInfo(); } @@ -292,14 +247,14 @@ void LibraryWidget::play(const Track& track) { }; player.setTrack(&mNowPlaying); player.setPlaying(true); - refreshPlayingMarks(); + mTracks.refresh(); refreshInfo(); } void LibraryWidget::togglePlaying() { if (!mPlaying) { if (mSelected) play(*mSelected); - else if (!mVisible.empty()) play(mVisible.front()->track()); + else if (!mVisible.empty()) play(*mVisible.front()); return; } @@ -316,22 +271,18 @@ void LibraryWidget::playAdjacent(int direction) { long index = -1; if (mPlaying) { for (size_t position = 0; position < mVisible.size(); position++) { - if (&mVisible[position]->track() == mPlaying) index = (long) position; + if (mVisible[position] == mPlaying) index = (long) position; } } const long size = (long) mVisible.size(); const long target = index < 0 ? 0 : ((index + direction) % size + size) % size; - select(*mVisible[(size_t) target]); - play(mVisible[(size_t) target]->track()); -} - -void LibraryWidget::refreshPlayingMarks() { - for (auto& track : mTracks) - track->setPlaying(&track->track() == mPlaying); + select((size_t) target); + play(*mVisible[(size_t) target]); } void LibraryWidget::onEvent(Event& event) { if (event.type() != Event::Type::KeyDown || !event.isKey(Key::Space)) return; + // Space plays or pauses anywhere but in the search field, where it is a space. if (search.hasKeyboardFocus()) return; togglePlaying(); diff --git a/LibraryViewer/public/GUI.hpp b/LibraryViewer/public/GUI.hpp index d81176aa..81ae4155 100644 --- a/LibraryViewer/public/GUI.hpp +++ b/LibraryViewer/public/GUI.hpp @@ -7,6 +7,8 @@ #include "Control.hpp" #include "Docking.hpp" #include "MediaControls.hpp" +#include "ListView.hpp" +#include "PropertyList.hpp" #include "Menus.hpp" #include "TextField.hpp" #include "Toggles.hpp" @@ -17,27 +19,6 @@ namespace tp { - class TrackWidget : public ui::Control { - public: - explicit TrackWidget(const Track& track); - - [[nodiscard]] const Track& track() const { return mTrack; } - - void setSelected(bool selected); - void setPlaying(bool playing); - [[nodiscard]] bool isPlaying() const { return mPlaying; } - - protected: - [[nodiscard]] ui::Extent measureContent(ui::Axis axis, halnf crossLength) const override; - void paint(Canvas& canvas) override; - [[nodiscard]] RGBA backgroundTarget() const override; - - private: - const Track& mTrack; - bool mSelected = false; - bool mPlaying = false; - }; - enum class ExistenceFilter { All, Existing, Missing }; struct TrackFilter { @@ -91,7 +72,8 @@ namespace tp { ui::Label state; ui::Label title; ui::Label artist; - ui::Label details; + // Album, genre, year, length, plays, loved, file and date added, one row each. + ui::PropertyList details; private: const Track* mTrack = nullptr; @@ -113,8 +95,10 @@ namespace tp { [[nodiscard]] const Track* selectedTrack() const { return mSelected; } [[nodiscard]] const Track* playingTrack() const { return mPlaying; } - [[nodiscard]] const std::vector& visibleTracks() const { return mVisible; } - [[nodiscard]] ui::ScrollView& scroll() { return mScroll; } + // The tracks the filter lets through, in list order. + [[nodiscard]] const std::vector& visibleTracks() const { return mVisible; } + // The track list: click selects, double click or Enter plays, arrows and typing a title move. + [[nodiscard]] ui::ListView& tracks() { return mTracks; } ui::TextField search; ui::Checkbox lovedOnly{ "Loved only" }; @@ -133,8 +117,7 @@ namespace tp { void onTick(halnf deltaMs) override; private: - void select(TrackWidget& widget); - void refreshPlayingMarks(); + void select(size_t index); void refreshInfo(); Library& mLibrary; @@ -142,10 +125,8 @@ namespace tp { ui::Panel mFilterBar{ ui::Panel::Surface::None }; ui::Widget mBrowser; - ui::ScrollView mScroll; - ui::Widget mTrackList; - std::vector> mTracks; - std::vector mVisible; + ui::ListView mTracks; + std::vector mVisible; ui::NowPlaying mNowPlaying; const Track* mSelected = nullptr; diff --git a/LibraryViewer/tests/LibraryViewTests.cpp b/LibraryViewer/tests/LibraryViewTests.cpp index 1254b42e..d53d3555 100644 --- a/LibraryViewer/tests/LibraryViewTests.cpp +++ b/LibraryViewer/tests/LibraryViewTests.cpp @@ -62,7 +62,13 @@ namespace { driver = std::make_unique(*view, Vec2F(1100.0f, 700.0f)); } - TrackWidget& row(size_t index) { return *view->visibleTracks()[index]; } + ListRow& row(size_t index) { return view->tracks().row(index); } + const Track& track(size_t index) { return *view->visibleTracks()[index]; } + // The playing track's row leads with the play glyph. + bool showsPlaying(size_t index) { + const ListRow::Leading& leading = row(index).leading(); + return leading.kind == ListRow::Leading::Kind::Glyph && leading.glyph == Glyph::Play; + } }; } @@ -74,12 +80,12 @@ SUITE(LibraryFilters) { scene.driver->click(scene.view->search); scene.driver->type("don"); CHECK_EQUAL(1u, scene.view->visibleTracks().size()); - CHECK_EQUAL("No Idea", scene.row(0).track().mName); + CHECK_EQUAL("No Idea", scene.track(0).mName); scene.driver->key(Key::A, KeyModifiers::controlOnly()); scene.driver->type("album 4"); CHECK_EQUAL(1u, scene.view->visibleTracks().size()); - CHECK_EQUAL("GATTI", scene.row(0).track().mName); + CHECK_EQUAL("GATTI", scene.track(0).mName); } TEST(LovedAndFileFiltersCombine) { @@ -90,7 +96,7 @@ SUITE(LibraryFilters) { scene.driver->click(scene.view->files); scene.driver->click(*scene.view->files.menu().items()[1]); CHECK_EQUAL(1u, scene.view->visibleTracks().size()); - CHECK_EQUAL("Jumpsuit", scene.row(0).track().mName); + CHECK_EQUAL("Jumpsuit", scene.track(0).mName); CHECK_EQUAL("1 of 4 tracks", scene.view->count.text()); } } @@ -99,7 +105,7 @@ SUITE(LibraryPlayback) { TEST(DoubleClickPlaysTrackInPlayerBar) { LibraryScene scene; scene.driver->click(scene.row(1)); - CHECK(scene.view->selectedTrack() == &scene.row(1).track()); + CHECK(scene.view->selectedTrack() == &scene.track(1)); CHECK(scene.view->playingTrack() == nullptr); const Vec2F center = scene.driver->centerOf(scene.row(1)); @@ -110,8 +116,8 @@ SUITE(LibraryPlayback) { CHECK_EQUAL("Mystery Lady", scene.view->player.title.text()); CHECK_CLOSE(200, scene.view->player.seek.duration(), 0.01f); CHECK(scene.view->player.isPlaying()); - CHECK(scene.row(1).isPlaying()); - CHECK(!scene.row(0).isPlaying()); + CHECK(scene.showsPlaying(1)); + CHECK(!scene.showsPlaying(0)); } TEST(PlayButtonStartsSelectionThenPauses) { @@ -204,7 +210,7 @@ SUITE(LibraryLayoutInFrame) { CHECK_CLOSE(900, player.pos.y + player.size.y, 0.5f); CHECK_CLOSE(1500, player.size.x, 0.5f); - const RectF list = view.scroll().windowRect(); + const RectF list = view.tracks().windowRect(); const RectF center = view.dock.centerRect(); CHECK_CLOSE(player.pos.y, list.pos.y + list.size.y, 0.5f); CHECK_CLOSE(view.dock.windowPosition().x + center.pos.x + center.size.x, list.pos.x + list.size.x, 0.5f); @@ -230,13 +236,39 @@ SUITE(LibraryLayoutInFrame) { CHECK_EQUAL(4u, view.visibleTracks().size()); const RectF player = view.player.windowRect(); - const RectF list = view.scroll().windowRect(); + const RectF list = view.tracks().windowRect(); CHECK_CLOSE(900, player.pos.y + player.size.y, 0.5f); CHECK_CLOSE(1500, player.size.x, 0.5f); CHECK_CLOSE(player.pos.y, list.pos.y + list.size.y, 0.5f); } } +SUITE(LibraryKeyboard) { + TEST(ArrowsSelectAndEnterPlays) { + LibraryScene scene; + scene.driver->click(scene.row(0)); + + scene.driver->key(Key::Down); + CHECK(scene.view->selectedTrack() == &scene.track(1)); + scene.driver->key(Key::Enter); + CHECK_EQUAL(2u, scene.device.playing); + CHECK(scene.showsPlaying(1)); + } + + TEST(SelectionSurvivesFiltering) { + LibraryScene scene; + scene.driver->click(scene.row(3)); + REQUIRE CHECK(scene.view->selectedTrack() != nullptr); + const Track* selected = scene.view->selectedTrack(); + + scene.driver->click(scene.view->search); + scene.driver->type("gat"); + REQUIRE CHECK_EQUAL(1u, scene.view->visibleTracks().size()); + CHECK(scene.view->selectedTrack() == selected); + CHECK(scene.view->tracks().current() == std::optional(0)); + } +} + SUITE(LibraryInfoPanel) { TEST(InfoPanelIsDockedOpenOnTheRight) { LibraryScene scene; @@ -252,13 +284,14 @@ SUITE(LibraryInfoPanel) { LibraryScene scene; scene.driver->click(scene.row(0)); TrackInfoView& info = scene.view->info; - CHECK(info.track() == &scene.row(0).track()); + CHECK(info.track() == &scene.track(0)); CHECK_EQUAL("SELECTED", info.state.text()); CHECK_EQUAL("Jumpsuit", info.title.text()); CHECK_EQUAL("twenty one pilots", info.artist.text()); - CHECK(info.details.text().find("Album: Album 1") != std::string::npos); - CHECK(info.details.text().find("Length: 3:20") != std::string::npos); - CHECK(info.details.text().find("Loved: yes") != std::string::npos); + const auto value = [&info](const std::string& name) { return static_cast(info.details.editor(name))->text(); }; + CHECK_EQUAL("Album 1", value("Album")); + CHECK_EQUAL("3:20", value("Length")); + CHECK_EQUAL("yes", value("Loved")); CHECK(scene.driver->paint().hasText("Jumpsuit")); } 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; }; } diff --git a/Widgets/examples/Gallery.cpp b/Widgets/examples/Gallery.cpp index 55aad527..b4eedb11 100644 --- a/Widgets/examples/Gallery.cpp +++ b/Widgets/examples/Gallery.cpp @@ -917,6 +917,164 @@ void NodesPage::refreshStatus() { ); } +FormsPage::FormsPage() { + setDebugName("forms page"); + ColumnLayout& layout = emplaceLayout(kPageSpacing); + layout.setPadding(Insets::uniform(16)); + + contacts = { + { "Ada Lovelace", "Analyst", 80, true, 0 }, + { "Grace Hopper", "Compiler lead", 90, true, 1 }, + { "Alan Turing", "Researcher", 70, false, 0 }, + { "Barbara Liskov", "Architect", 60, false, 1 }, + { "Edsger Dijkstra", "Reviewer", 40, false, 2 }, + { "Margaret Hamilton", "Flight software", 95, true, 2 }, + }; + + // Commands: the menu shows their labels, shortcut text and state, and the keyboard runs them. + commands.define(Command::Star, { "Star", [this] { + if (Contact* contact = current()) contact->starred = !contact->starred; + refreshList(); + }, [this] { return current() != nullptr; }, [this] { return current() && current()->starred; } }); + commands.define(Command::Remove, { "Remove", [this] { + if (!list.current()) return; + contacts.erase(contacts.begin() + (std::ptrdiff_t) *list.current()); + refreshList(); + }, [this] { return current() != nullptr; }, {} }); + commands.define(Command::ShowArchived, { "Show archived", [this] { showArchived = !showArchived; }, {}, [this] { return showArchived; } }); + commands.shortcuts().bind(ctrl(Key::S), Command::Star); + commands.shortcuts().bind(plain(Key::Delete), Command::Remove); + actions.add(MenuEntry::command(commands, Command::Star)); + actions.add(MenuEntry::command(commands, Command::Remove)); + actions.addSeparator(); + actions.add(MenuEntry::command(commands, Command::ShowArchived)); + actionsButton.setPopupContent(&actions); + + density.addSegment("Compact", Density::Compact); + density.addSegment("Regular", Density::Regular); + density.addSegment("Comfortable", Density::Comfortable); + density.setValue(Density::Regular); + density.setOnChanged([this](const Density& value) { + list.setDensity(value == Density::Compact ? ListRow::Density::Compact : value == Density::Regular ? ListRow::Density::Regular : ListRow::Density::Comfortable); + }); + + mToolbar.setSizing(AxisSizing::fill(), AxisSizing::fit()); + mToolbar.emplaceLayout(8).setCrossAlign(CrossAlign::Center); + mToolbar.addChild(actionsButton); + mToolbar.addChild(density); + filterHint.setTone(Label::Tone::Muted); + mToolbar.addChild(filterHint); + + list.setEmptyText("No contacts left. Restart the gallery to bring them back."); + list.setOnSelectionChanged([this] { details.refresh(); }); + mListPanel.setContent(&list); + mListPanel.setSizing(AxisSizing::fill(), AxisSizing::fill()); + + // The form edits whichever contact is current; each row is bound once. + const auto field = [this](auto read, auto fallback) { + return [this, read, fallback]() { return current() ? read(*current()) : fallback; }; + }; + details.addValue("Name", field([](const Contact& contact) { return contact.name; }, std::string("-"))); + details.addText("Role", field([](const Contact& contact) { return contact.role; }, std::string()), [this](const std::string& role) { + if (current()) current()->role = role; + }); + details.addSlider("Priority", 0, 100, field([](const Contact& contact) { return contact.priority; }, 0.0f), [this](halnf value) { + if (current()) current()->priority = value; + }); + details.addCheckbox("Starred", field([](const Contact& contact) { return contact.starred; }, false), [this](bool starred) { + if (current()) current()->starred = starred; + }); + details.addChoice("Team", { "Research", "Platform", "Flight" }, field([](const Contact& contact) { return contact.team; }, 0L), [this](long team) { + if (current()) current()->team = team; + }); + details.setOnEdited([this] { refreshList(); }); + mDetailsPanel.setContent(&details); + mDetailsPanel.setSizing(AxisSizing::fixed(320), AxisSizing::fill()); + + mColumns.setSizing(AxisSizing::fill(), AxisSizing::fill()); + mColumns.emplaceLayout(kPageSpacing); + mColumns.addChild(mListPanel); + mColumns.addChild(mDetailsPanel); + + status.setTone(Label::Tone::Muted); + addChild(mToolbar); + addChild(mColumns); + addChild(status); + refreshList(); +} + +FormsPage::Contact* FormsPage::current() { + return list.current() && *list.current() < contacts.size() ? &contacts[*list.current()] : nullptr; +} + +void FormsPage::refreshList() { + ListView::Model model; + model.count = contacts.size(); + model.fill = [this](size_t index, ListRow& row) { + const Contact& contact = contacts[index]; + row.setTitle(contact.name); + row.setSubtitle(contact.role); + row.setLeading(contact.starred ? ListRow::Leading::glyphOf(Glyph::Check) : ListRow::Leading::space()); + row.setTrailing(ListRow::Trailing::textOf(std::to_string((int) contact.priority))); + }; + list.setModel(std::move(model)); + details.refresh(); + status.setText(std::to_string(contacts.size()) + " contacts, " + std::to_string(list.selection().selected().size()) + " selected"); +} + +PanelsPage::PanelsPage() { + setDebugName("panels page"); + ColumnLayout& layout = emplaceLayout(kPageSpacing); + layout.setPadding(Insets::uniform(16)); + + // A titled panel with header actions; the body owns the notes it adds. + addNote.setTooltip("Add a note"); + clearNotes.setTooltip("Clear the notes"); + notes.addHeaderAction(addNote); + notes.addHeaderAction(clearNotes); + notes.setSizing(AxisSizing::fill(), AxisSizing::fill()); + notes.setBodyGap(4); + noteCount.setTone(Label::Tone::Muted); + notes.footer().addChild(noteCount); + const auto countNotes = [this] { + noteCount.setText(std::to_string(notes.body().ownedChildCount()) + " notes"); + }; + addNote.setOnActivate([this, countNotes] { + notes.body().emplaceChild