Widgets: fix every component-review finding in one refactor #19
139 changed files with 7076 additions and 3337 deletions
|
|
@ -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 -------------------------- ###
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
154
3DEditor/private/EditorWidget.cpp
Normal file
154
3DEditor/private/EditorWidget.cpp
Normal file
|
|
@ -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<ColumnLayout>();
|
||||
|
||||
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<ColumnLayout>(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();
|
||||
}
|
||||
|
|
@ -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<uint4> 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include "Canvas.hpp"
|
||||
#include "StackLayout.hpp"
|
||||
#include "Event.hpp"
|
||||
#include "Glyph.hpp"
|
||||
#include "Theme.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
|
@ -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<std::string(const Track&)> 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<ColumnLayout>(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<ColumnLayout>(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<ColumnLayout>();
|
||||
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<TrackWidget>(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();
|
||||
|
|
|
|||
|
|
@ -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<TrackWidget*>& visibleTracks() const { return mVisible; }
|
||||
[[nodiscard]] ui::ScrollView& scroll() { return mScroll; }
|
||||
// The tracks the filter lets through, in list order.
|
||||
[[nodiscard]] const std::vector<const Track*>& 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<std::unique_ptr<TrackWidget>> mTracks;
|
||||
std::vector<TrackWidget*> mVisible;
|
||||
ui::ListView mTracks;
|
||||
std::vector<const Track*> mVisible;
|
||||
ui::NowPlaying mNowPlaying;
|
||||
|
||||
const Track* mSelected = nullptr;
|
||||
|
|
|
|||
|
|
@ -62,7 +62,13 @@ namespace {
|
|||
driver = std::make_unique<UiDriver>(*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<size_t>(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<Label*>(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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
|
|
|
|||
|
|
@ -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<ColumnLayout>();
|
||||
|
||||
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<ColumnLayout>(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<SketchViewport>(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<PencilBrush*>(mProject->mBrushes.get("pencil"));
|
||||
|
||||
pencil->mCol = mColorPicker.color();
|
||||
pencil->mSize = mBrushSizeSlider.val() * 0.2f + 0.001f;
|
||||
auto* pencil = dynamic_cast<PencilBrush*>(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); }
|
||||
|
|
|
|||
|
|
@ -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 <memory>
|
||||
|
||||
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<SketchViewport> 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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -917,6 +917,164 @@ void NodesPage::refreshStatus() {
|
|||
);
|
||||
}
|
||||
|
||||
FormsPage::FormsPage() {
|
||||
setDebugName("forms page");
|
||||
ColumnLayout& layout = emplaceLayout<ColumnLayout>(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<RowLayout>(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<RowLayout>(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<ColumnLayout>(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<Label>("Note " + std::to_string(notes.body().ownedChildCount() + 1));
|
||||
countNotes();
|
||||
});
|
||||
clearNotes.setOnActivate([this, countNotes] {
|
||||
notes.body().clearOwnedChildren();
|
||||
countNotes();
|
||||
});
|
||||
countNotes();
|
||||
|
||||
// A titled panel whose body scrolls.
|
||||
log.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
log.setScrollsBody(true);
|
||||
for (int entry = 1; entry <= 40; entry++)
|
||||
log.body().emplaceChild<Label>("Event " + std::to_string(entry) + ": something happened").setTone(Label::Tone::Muted);
|
||||
|
||||
mRow.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mRow.emplaceLayout<RowLayout>(kPageSpacing);
|
||||
mRow.addChild(notes);
|
||||
mRow.addChild(log);
|
||||
|
||||
// A dialog is a titled panel with modality: content in the body, choices in the footer.
|
||||
dialogName.setText("Background");
|
||||
dialog.body().addChild(dialogName);
|
||||
dialog.addButton("Rename", [this] { status.setText("Renamed to " + dialogName.text()); }, true);
|
||||
dialog.addCancelButton();
|
||||
openDialog.setOnActivate([this] {
|
||||
if (ui()) dialog.open(*ui(), &openDialog);
|
||||
});
|
||||
status.setTone(Label::Tone::Muted);
|
||||
|
||||
addChild(mRow);
|
||||
addChild(openDialog);
|
||||
addChild(status);
|
||||
}
|
||||
|
||||
Gallery::Gallery() :
|
||||
Panel(Surface::Window) {
|
||||
setDebugName("gallery");
|
||||
|
|
@ -941,6 +1099,8 @@ Gallery::Gallery() :
|
|||
tabs.addTab("Kanban", kanbanPage);
|
||||
tabs.addTab("Markdown", markdownPage);
|
||||
tabs.addTab("Carousel", carouselPage);
|
||||
tabs.addTab("Forms", formsPage);
|
||||
tabs.addTab("Panels", panelsPage);
|
||||
|
||||
statusBar.setTone(Label::Tone::Muted);
|
||||
statusBar.setText("ready");
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
#include "Docking.hpp"
|
||||
#include "FloatingPanel.hpp"
|
||||
#include "KanbanBoard.hpp"
|
||||
#include "ListView.hpp"
|
||||
#include "PropertyList.hpp"
|
||||
#include "SegmentedControl.hpp"
|
||||
#include "TitledPanel.hpp"
|
||||
#include "Label.hpp"
|
||||
#include "MarkdownEditor.hpp"
|
||||
#include "Menus.hpp"
|
||||
|
|
@ -340,6 +344,65 @@ namespace tp::ui::gallery {
|
|||
bool mOpenedFile = false;
|
||||
};
|
||||
|
||||
// The shared list, form and choice components: a ListView of contacts with keyboard navigation and an empty
|
||||
// state, a PropertyList editing the selected contact, a SegmentedControl choosing the list density, and a menu
|
||||
// whose entries are commands.
|
||||
class FormsPage : public Widget {
|
||||
public:
|
||||
enum class Density { Compact, Regular, Comfortable };
|
||||
enum class Command { Star, Remove, ShowArchived };
|
||||
|
||||
struct Contact {
|
||||
std::string name;
|
||||
std::string role;
|
||||
halnf priority = 50;
|
||||
bool starred = false;
|
||||
long team = 0;
|
||||
};
|
||||
|
||||
FormsPage();
|
||||
|
||||
[[nodiscard]] Contact* current();
|
||||
void refreshList();
|
||||
|
||||
std::vector<Contact> contacts;
|
||||
bool showArchived = false;
|
||||
Commands<Command> commands;
|
||||
Menu actions;
|
||||
PopupButton actionsButton{ "Actions" };
|
||||
SegmentedControl<Density> density;
|
||||
Label filterHint{ "Type in the list to jump to a name; Shift+arrows select a range." };
|
||||
ListView list{ ListView::Mode::Multiple };
|
||||
PropertyList details;
|
||||
Label status;
|
||||
|
||||
private:
|
||||
Widget mToolbar;
|
||||
Widget mColumns;
|
||||
TitledPanel mListPanel{ "Contacts", Panel::Surface::Raised };
|
||||
TitledPanel mDetailsPanel{ "Details", Panel::Surface::Raised };
|
||||
};
|
||||
|
||||
// TitledPanel and its relatives side by side: a plain titled panel with header actions and a footer, a
|
||||
// scrolling panel, and a dialog opened from a button.
|
||||
class PanelsPage : public Widget {
|
||||
public:
|
||||
PanelsPage();
|
||||
|
||||
TitledPanel notes{ "Notes" };
|
||||
GlyphButton addNote{ Glyph::Plus };
|
||||
GlyphButton clearNotes{ Glyph::Close };
|
||||
Label noteCount;
|
||||
TitledPanel log{ "Activity log" };
|
||||
Button openDialog{ "Open dialog" };
|
||||
Dialog dialog{ "Rename layer" };
|
||||
TextField dialogName;
|
||||
Label status;
|
||||
|
||||
private:
|
||||
Widget mRow;
|
||||
};
|
||||
|
||||
class Gallery : public Panel {
|
||||
public:
|
||||
Gallery();
|
||||
|
|
@ -369,6 +432,8 @@ namespace tp::ui::gallery {
|
|||
TreePage treePage;
|
||||
KanbanPage kanbanPage;
|
||||
MarkdownPage markdownPage;
|
||||
FormsPage formsPage;
|
||||
PanelsPage panelsPage;
|
||||
|
||||
enum class Action { New, Open, Quit, NextTab };
|
||||
ShortcutMap<Action> shortcuts;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ using namespace tp;
|
|||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kSwatchSize = 18;
|
||||
constexpr halnf kColorColumnWidth = 300;
|
||||
constexpr halnf kEditorColumnWidth = 300;
|
||||
|
||||
|
|
@ -102,69 +101,27 @@ std::string tp::ui::themeSource(const Theme& theme) {
|
|||
return source;
|
||||
}
|
||||
|
||||
ColorSwatch::ColorSwatch() { setDebugName("color swatch"); }
|
||||
|
||||
void ColorSwatch::setColor(const RGBA& color) {
|
||||
mColor = color;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
Extent ColorSwatch::measureContent(Axis, halnf) const { return Extent::rigid(kSwatchSize); }
|
||||
|
||||
void ColorSwatch::paint(Canvas& painter) {
|
||||
const RectF box = localRect();
|
||||
const halnf radius = theme().metrics.indicatorCornerRadius;
|
||||
const halnf half = box.size.x / 2;
|
||||
painter.rect(box, { 1, 1, 1, 1 }, radius);
|
||||
painter.rect(makeRect(0, 0, half, half), { 0.75f, 0.75f, 0.75f, 1 }, radius / 2);
|
||||
painter.rect(makeRect(half, half, half, half), { 0.75f, 0.75f, 0.75f, 1 }, radius / 2);
|
||||
painter.rect(box, mColor, radius);
|
||||
painter.outline(box, theme().palette.controlBorder, theme().metrics.indicatorCornerRadius);
|
||||
}
|
||||
|
||||
ThemeColorRow::ThemeColorRow(const ThemeColorField& field) :
|
||||
mField(field) {
|
||||
setDebugName(std::string("theme color: ") + field.name);
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
emplaceLayout<RowLayout>(6).setCrossAlign(CrossAlign::Center);
|
||||
|
||||
name.setText(field.name);
|
||||
name.setStyle(Button::Style::Flat);
|
||||
name.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
hex.setTone(Label::Tone::Muted);
|
||||
|
||||
addChild(swatch);
|
||||
addChild(name);
|
||||
addChild(hex);
|
||||
}
|
||||
|
||||
void ThemeColorRow::refresh(const Theme& theme) {
|
||||
const RGBA& color = theme.palette.*mField.member;
|
||||
swatch.setColor(color);
|
||||
hex.setText(colorHex(color));
|
||||
}
|
||||
|
||||
void ThemeColorRow::setSelected(bool selected) { name.setSelected(selected); }
|
||||
|
||||
ThemeEditorPage::ThemeEditorPage() {
|
||||
setDebugName("theme editor page");
|
||||
RowLayout& layout = emplaceLayout<RowLayout>(16);
|
||||
layout.setPadding(Insets::uniform(16));
|
||||
|
||||
colorList.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
colorList.emplaceLayout<ColumnLayout>(1);
|
||||
for (size_t index = 0; index < themeColorFields().size(); index++) {
|
||||
colorRows.push_back(std::make_unique<ThemeColorRow>(themeColorFields()[index]));
|
||||
colorRows.back()->name.setOnActivate([this, index]() { selectColor(index); });
|
||||
colorRows.back()->swatch.setOnActivate([this, index]() { selectColor(index); });
|
||||
colorList.addChild(*colorRows.back());
|
||||
}
|
||||
colorScroll.setContent(&colorList);
|
||||
colorScroll.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
ListView::Model model;
|
||||
model.count = themeColorFields().size();
|
||||
model.fill = [this](size_t index, ListRow& row) {
|
||||
const RGBA& color = editedTheme().palette.*themeColorFields()[index].member;
|
||||
row.setTitle(themeColorFields()[index].name);
|
||||
row.setLeading(ListRow::Leading::swatch(color));
|
||||
row.setTrailing(ListRow::Trailing::textOf(colorHex(color)));
|
||||
};
|
||||
colors.setModel(std::move(model));
|
||||
colors.setOnSelectionChanged([this]() {
|
||||
if (colors.current() && *colors.current() != mSelected) selectColor(*colors.current());
|
||||
});
|
||||
|
||||
mColorColumn.setSizing(AxisSizing::fixed(kColorColumnWidth), AxisSizing::fill());
|
||||
mColorColumn.emplaceLayout<ColumnLayout>(6);
|
||||
mColorColumn.addChild(colorScroll);
|
||||
mColorColumn.addChild(colors);
|
||||
|
||||
wheel.setSizing(AxisSizing::fill(), AxisSizing::fixed(200));
|
||||
wheel.setOnChanged([this](const RGBA& color) {
|
||||
|
|
@ -186,18 +143,21 @@ ThemeEditorPage::ThemeEditorPage() {
|
|||
mEditorColumn.addChild(alpha);
|
||||
mEditorColumn.addChild(mSeparator);
|
||||
|
||||
PropertyList::SliderOptions whole;
|
||||
whole.format = [](halnf value) { return std::to_string((int) std::lround(value)); };
|
||||
whole.step = 1;
|
||||
for (const ThemeMetricField& field : themeMetricFields()) {
|
||||
metricSliders.push_back(std::make_unique<LabeledSlider>(field.name, field.minimum, field.maximum, 0));
|
||||
LabeledSlider& slider = *metricSliders.back();
|
||||
slider.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
slider.setValueFormatter([](halnf value) { return std::to_string((int) std::lround(value)); });
|
||||
slider.slider().setStep(1);
|
||||
halnf Metrics::* member = field.member;
|
||||
slider.setOnChanged([this, member](halnf value) {
|
||||
edit([member, value](Theme& theme) { theme.metrics.*member = value; });
|
||||
});
|
||||
mEditorColumn.addChild(slider);
|
||||
metrics.addSlider(
|
||||
field.name,
|
||||
field.minimum,
|
||||
field.maximum,
|
||||
[this, member]() { return editedTheme().metrics.*member; },
|
||||
[this, member](halnf value) { edit([member, value](Theme& theme) { theme.metrics.*member = value; }); },
|
||||
whole
|
||||
);
|
||||
}
|
||||
mEditorColumn.addChild(metrics);
|
||||
|
||||
accentPreset.setTooltip("Accent preset");
|
||||
accentPreset.setOnSelected([this](long index) {
|
||||
|
|
@ -237,7 +197,7 @@ void ThemeEditorPage::onThemeChanged() {
|
|||
}
|
||||
|
||||
void ThemeEditorPage::selectColor(size_t index) {
|
||||
if (index >= colorRows.size()) return;
|
||||
if (index >= themeColorFields().size()) return;
|
||||
mSelected = index;
|
||||
refreshEditors();
|
||||
}
|
||||
|
|
@ -255,10 +215,8 @@ void ThemeEditorPage::refreshEditors() {
|
|||
const Theme theme = editedTheme();
|
||||
mRefreshing = true;
|
||||
|
||||
for (size_t index = 0; index < colorRows.size(); index++) {
|
||||
colorRows[index]->refresh(theme);
|
||||
colorRows[index]->setSelected(index == mSelected);
|
||||
}
|
||||
colors.refresh();
|
||||
if (colors.current() != mSelected) colors.select(mSelected);
|
||||
|
||||
const ThemeColorField& field = themeColorFields()[mSelected];
|
||||
const RGBA& color = theme.palette.*field.member;
|
||||
|
|
@ -266,8 +224,7 @@ void ThemeEditorPage::refreshEditors() {
|
|||
wheel.setColor(color);
|
||||
alpha.setValue(color.a);
|
||||
|
||||
for (size_t index = 0; index < metricSliders.size(); index++)
|
||||
metricSliders[index]->setValue(theme.metrics.*themeMetricFields()[index].member);
|
||||
metrics.refresh();
|
||||
|
||||
mRefreshing = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
#include "Button.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Label.hpp"
|
||||
#include "ListView.hpp"
|
||||
#include "Menus.hpp"
|
||||
#include "PropertyList.hpp"
|
||||
#include "Slider.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Views.hpp"
|
||||
|
|
@ -32,36 +34,6 @@ namespace tp::ui {
|
|||
std::string colorHex(const RGBA& color);
|
||||
std::string themeSource(const Theme& theme);
|
||||
|
||||
class ColorSwatch : public Control {
|
||||
public:
|
||||
ColorSwatch();
|
||||
|
||||
void setColor(const RGBA& color);
|
||||
[[nodiscard]] const RGBA& color() const { return mColor; }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Canvas& painter) override;
|
||||
|
||||
private:
|
||||
RGBA mColor;
|
||||
};
|
||||
|
||||
class ThemeColorRow : public Widget {
|
||||
public:
|
||||
explicit ThemeColorRow(const ThemeColorField& field);
|
||||
|
||||
void refresh(const Theme& theme);
|
||||
void setSelected(bool selected);
|
||||
|
||||
ColorSwatch swatch;
|
||||
Button name;
|
||||
Label hex;
|
||||
|
||||
private:
|
||||
const ThemeColorField& mField;
|
||||
};
|
||||
|
||||
class ThemeEditorPage : public Widget {
|
||||
public:
|
||||
ThemeEditorPage();
|
||||
|
|
@ -70,14 +42,14 @@ namespace tp::ui {
|
|||
void selectColor(size_t index);
|
||||
[[nodiscard]] Theme editedTheme() const;
|
||||
|
||||
ScrollView colorScroll;
|
||||
Widget colorList;
|
||||
std::vector<std::unique_ptr<ThemeColorRow>> colorRows;
|
||||
// The palette colors: a swatch, the name and the hex value per row; the current row is the edited color.
|
||||
ListView colors;
|
||||
|
||||
Label editingTitle;
|
||||
ColorWheel wheel;
|
||||
LabeledSlider alpha{ "Alpha", 0, 1, 1 };
|
||||
std::vector<std::unique_ptr<LabeledSlider>> metricSliders;
|
||||
// One slider per metric, bound to the live theme.
|
||||
PropertyList metrics;
|
||||
Dropdown accentPreset{ { "Blue", "Purple", "Green", "Orange", "Pink" } };
|
||||
Button reset{ "Reset to standard" };
|
||||
Button copySource{ "Copy as C++" };
|
||||
|
|
|
|||
|
|
@ -9,61 +9,54 @@ using namespace tp::ui;
|
|||
|
||||
namespace {
|
||||
constexpr halnf kDialogWidth = 360;
|
||||
constexpr halnf kDialogPadding = 16;
|
||||
constexpr halnf kDialogGap = 10;
|
||||
constexpr halnf kDialogBodyGap = 8;
|
||||
}
|
||||
|
||||
Dialog::Dialog(std::string title) :
|
||||
Panel(Surface::Raised),
|
||||
mTitle(std::move(title)) {
|
||||
TitledPanel(std::move(title), Surface::Raised) {
|
||||
setDebugName("dialog");
|
||||
setBorder(true);
|
||||
setSizing(AxisSizing::fixed(kDialogWidth), AxisSizing::fit());
|
||||
emplaceLayout<ColumnLayout>(10).setPadding(Insets::uniform(16));
|
||||
// A dialog spaces its parts more than a docked panel: the title, message, content and buttons each breathe.
|
||||
static_cast<ColumnLayout*>(layout())->setGap(kDialogGap);
|
||||
layout()->setPadding(Insets::uniform(kDialogPadding));
|
||||
header().layout()->setPadding(Insets());
|
||||
setBodyPadding(Insets());
|
||||
setBodyGap(kDialogBodyGap);
|
||||
body().setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
|
||||
mTitle.setFontSize(Theme::standard().metrics.fontSize + 2);
|
||||
mTitle.setWrap(true);
|
||||
mTitle.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
titleLabel().setFontSize(Theme::standard().metrics.fontSize + 2);
|
||||
titleLabel().setWrap(true);
|
||||
mMessage.setWrap(true);
|
||||
mMessage.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mMessage.setTone(Label::Tone::Muted);
|
||||
mMessage.setVisible(false);
|
||||
|
||||
mBody.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mBody.emplaceLayout<ColumnLayout>(8);
|
||||
|
||||
mButtonRow.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mButtonRow.emplaceLayout<RowLayout>(6);
|
||||
mButtonSpacer.setSizing(AxisSizing::fill(), AxisSizing::fixed(1));
|
||||
mButtonRow.addChild(mButtonSpacer);
|
||||
|
||||
addChild(mTitle);
|
||||
addChild(mMessage);
|
||||
addChild(mBody);
|
||||
addChild(mButtonRow);
|
||||
body().addChild(mMessage);
|
||||
footer();
|
||||
}
|
||||
|
||||
Dialog::~Dialog() { close(); }
|
||||
|
||||
void Dialog::setTitle(std::string title) { mTitle.setText(std::move(title)); }
|
||||
|
||||
void Dialog::setMessage(std::string message) {
|
||||
mMessage.setVisible(!message.empty());
|
||||
mMessage.setText(std::move(message));
|
||||
}
|
||||
|
||||
Button& Dialog::addButton(std::string text, std::function<void()> action, bool isDefault) {
|
||||
auto button = std::make_unique<Button>(std::move(text));
|
||||
button->setOnActivate([this, action]() {
|
||||
// The button row owns the buttons.
|
||||
Button& button = footer().emplaceChild<Button>(std::move(text));
|
||||
button.setOnActivate([this, action]() {
|
||||
close();
|
||||
if (action) action();
|
||||
});
|
||||
if (isDefault) {
|
||||
button->setStyle(Button::Style::Accent);
|
||||
mDefault = button.get();
|
||||
button.setStyle(Button::Style::Accent);
|
||||
mDefault = &button;
|
||||
}
|
||||
|
||||
mButtonRow.addChild(*button);
|
||||
mButtons.push_back(std::move(button));
|
||||
return *mButtons.back();
|
||||
mButtons.push_back(&button);
|
||||
return button;
|
||||
}
|
||||
|
||||
Button& Dialog::addCancelButton(std::string text) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "Menus.hpp"
|
||||
#include "Canvas.hpp"
|
||||
#include "Glyph.hpp"
|
||||
#include "ComponentSizes.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
|
@ -13,8 +14,9 @@ using namespace tp;
|
|||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kSubmenuArrowWidth = 16;
|
||||
constexpr halnf kWindowDragThreshold = 4;
|
||||
// Room the dropdown keeps at its right for its chevron, and the chevron's size.
|
||||
constexpr halnf kDropdownChevronRoom = 16;
|
||||
constexpr halnf kDropdownChevronSide = 8;
|
||||
|
||||
PopupOptions menuPopupOptions(Placement placement, halnf gap) {
|
||||
PopupOptions options;
|
||||
|
|
@ -134,73 +136,23 @@ MenuItem::MenuItem(Menu& menu, MenuEntry entry) :
|
|||
mMenu(menu),
|
||||
mEntry(std::move(entry)) {
|
||||
setDebugName("menu item: " + mEntry.text);
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
setDensity(Density::Compact);
|
||||
if (!mEntry.shortcut.empty()) setTrailing(Trailing::textOf(mEntry.shortcut));
|
||||
if (mEntry.kind == MenuEntry::Kind::Submenu) setTrailing(Trailing::chevron());
|
||||
setMuted(mEntry.muted);
|
||||
refresh();
|
||||
}
|
||||
|
||||
void MenuItem::refresh() {
|
||||
if (mEntry.isEnabled) setEnabled(mEntry.isEnabled());
|
||||
setTitle(mEntry.dynamicText ? mEntry.dynamicText() : mEntry.text);
|
||||
|
||||
std::string text = mEntry.dynamicText ? mEntry.dynamicText() : mEntry.text;
|
||||
if (text != mText) {
|
||||
mText = std::move(text);
|
||||
requestLayout();
|
||||
}
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
Extent MenuItem::measureContent(Axis axis, halnf) const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
|
||||
if (axis == Axis::Vertical) return Extent::rigid(sizes::menuItemHeight);
|
||||
|
||||
halnf width = metrics.controlPadding.total(Axis::Horizontal) + metrics.indicatorSize + metrics.spacing;
|
||||
width += textWidth(mText, metrics.fontSize);
|
||||
if (!mEntry.shortcut.empty()) width += metrics.spacing * 4 + textWidth(mEntry.shortcut, metrics.smallFontSize);
|
||||
if (mEntry.kind == MenuEntry::Kind::Submenu) width += kSubmenuArrowWidth;
|
||||
return Extent::natural(width);
|
||||
}
|
||||
|
||||
void MenuItem::paint(Canvas& painter) {
|
||||
const Theme& style = theme();
|
||||
const RectF content = style.metrics.controlPadding.shrink(localRect());
|
||||
RGBA textColor = isEffectivelyEnabled() ? style.palette.text : style.palette.disabledText;
|
||||
if (mEntry.muted && isEffectivelyEnabled()) textColor = style.palette.mutedText;
|
||||
|
||||
painter.rect(localRect(), background(), style.metrics.cornerRadius);
|
||||
|
||||
// Every item keeps room for a mark, so titles line up: a check when checked, else the icon, else empty.
|
||||
const bool checked = mEntry.isChecked && mEntry.isChecked();
|
||||
if (mEntry.icon.isValid() && !checked) {
|
||||
const halnf side = style.metrics.indicatorSize * 1.25f;
|
||||
const RectF slot = makeRect(content.pos.x - side * 0.1f, (size().y - side) / 2, side, side);
|
||||
painter.drawImage(slot, mEntry.icon);
|
||||
}
|
||||
if (checked) {
|
||||
const halnf unit = style.metrics.indicatorSize;
|
||||
const Vec2F origin = { content.pos.x, (size().y - unit) / 2 };
|
||||
painter.polyline(
|
||||
{ origin + Vec2F(unit * 0.2f, unit * 0.52f),
|
||||
origin + Vec2F(unit * 0.42f, unit * 0.74f),
|
||||
origin + Vec2F(unit * 0.82f, unit * 0.28f) },
|
||||
textColor,
|
||||
1.5f
|
||||
);
|
||||
}
|
||||
|
||||
const halnf textLeft = content.pos.x + style.metrics.indicatorSize + style.metrics.spacing;
|
||||
const RectF textBox = makeRect(textLeft, 0, size().x - textLeft, size().y);
|
||||
painter.text(mText, textBox, TextStyle{ style.metrics.fontSize, textColor, TextAlign::leftCenter() });
|
||||
|
||||
if (!mEntry.shortcut.empty()) {
|
||||
const halnf right = mEntry.kind == MenuEntry::Kind::Submenu ? kSubmenuArrowWidth : 0;
|
||||
const RectF shortcutBox = makeRect(content.pos.x, 0, content.size.x - right, size().y);
|
||||
painter.text(mEntry.shortcut, shortcutBox, TextStyle{ style.metrics.smallFontSize, style.palette.mutedText, TextAlign::rightCenter() });
|
||||
}
|
||||
|
||||
if (mEntry.kind == MenuEntry::Kind::Submenu) {
|
||||
const Vec2F tip = { size().x - 10, size().y / 2 };
|
||||
painter.polyline({ tip + Vec2F(-3.0f, -4.0f), tip, tip + Vec2F(-3.0f, 4.0f) }, style.palette.mutedText, 1.5f);
|
||||
}
|
||||
if (checked) setLeading(Leading::check());
|
||||
else if (mEntry.icon.isValid()) setLeading(Leading::imageOf(mEntry.icon));
|
||||
else setLeading(Leading::space());
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void MenuItem::onActivated() {
|
||||
|
|
@ -230,11 +182,11 @@ void MenuItem::onHoverChanged() {
|
|||
}
|
||||
|
||||
RGBA MenuItem::backgroundTarget() const {
|
||||
// A menu row highlights while hovered, keyboard-current or showing its open submenu; it is never "pressed".
|
||||
const Palette& palette = theme().palette;
|
||||
const bool submenuOpen = mEntry.kind == MenuEntry::Kind::Submenu && mEntry.submenu->isOpen();
|
||||
|
||||
if (isEffectivelyEnabled() && (isHovered() || submenuOpen)) return palette.menuHighlight;
|
||||
return { palette.menuHighlight.r, palette.menuHighlight.g, palette.menuHighlight.b, 0 };
|
||||
if (isEffectivelyEnabled() && (isHovered() || isHighlighted() || submenuOpen)) return palette.menuHighlight;
|
||||
return Palette::faded(palette.menuHighlight, 0);
|
||||
}
|
||||
|
||||
Placement MenuItem::submenuSide(const RectF& menuRect) const {
|
||||
|
|
@ -278,9 +230,9 @@ Menu::~Menu() {
|
|||
}
|
||||
|
||||
MenuItem& Menu::add(MenuEntry entry) {
|
||||
mItems.push_back(std::make_unique<MenuItem>(*this, std::move(entry)));
|
||||
mColumn.addChild(*mItems.back());
|
||||
return *mItems.back();
|
||||
MenuItem& item = mColumn.emplaceChild<MenuItem>(*this, std::move(entry));
|
||||
mItems.push_back(&item);
|
||||
return item;
|
||||
}
|
||||
|
||||
MenuItem& Menu::addItem(std::string text, std::function<void()> action, std::string shortcut) {
|
||||
|
|
@ -292,8 +244,7 @@ MenuItem& Menu::addToggle(std::string text, std::function<bool()> isChecked, std
|
|||
}
|
||||
|
||||
void Menu::addSeparator() {
|
||||
mSeparators.push_back(std::make_unique<Separator>());
|
||||
mColumn.addChild(*mSeparators.back());
|
||||
mColumn.emplaceChild<Separator>();
|
||||
}
|
||||
|
||||
MenuItem& Menu::addSubmenu(std::string text, Menu& submenu) { return add(MenuEntry::nested(std::move(text), submenu)); }
|
||||
|
|
@ -301,7 +252,7 @@ MenuItem& Menu::addSubmenu(std::string text, Menu& submenu) { return add(MenuEnt
|
|||
void Menu::clear() {
|
||||
mColumn.removeAllChildren();
|
||||
mItems.clear();
|
||||
mSeparators.clear();
|
||||
mCurrent.reset();
|
||||
}
|
||||
|
||||
std::vector<const MenuItem*> Menu::itemsWithSeparators() const {
|
||||
|
|
@ -334,6 +285,133 @@ void Menu::openAt(Widget& owner, const RectF& anchor, Placement placement) {
|
|||
owner.ui()->openPopup(request);
|
||||
}
|
||||
|
||||
void Menu::onAttached() {
|
||||
Panel::onAttached();
|
||||
// However the menu was opened (openAt, a popup button, a submenu item), as an open popup it shows the current
|
||||
// text, enabled and checked state of its entries and takes the keyboard.
|
||||
if (!ui() || !ui()->isPopupOpen(*this)) return;
|
||||
refresh();
|
||||
takeKeyboard();
|
||||
}
|
||||
|
||||
void Menu::takeKeyboard() {
|
||||
// An open menu has the keyboard, like a native menu; the previous focus comes back when it closes.
|
||||
Ui* host = ui();
|
||||
if (!host) return;
|
||||
Widget* focused = host->keyboardFocus();
|
||||
if (focused != this) mFocusBeforeOpen = focused;
|
||||
host->setKeyboardFocus(this);
|
||||
// Opened from the keyboard, the first item is current at once; opened by pointer, none is until a key moves.
|
||||
setCurrentItem(nullptr);
|
||||
if (!host->isKeyboardNavigating()) return;
|
||||
const std::optional<size_t> first = ListNavigator::step(navigationItems(), std::nullopt, Key::Down);
|
||||
if (first) setCurrentItem(mItems[*first]);
|
||||
}
|
||||
|
||||
void Menu::onDetached() {
|
||||
// However the menu closed (an item, Escape, a press outside), the keyboard goes back where it was when it opened,
|
||||
// unless something else took it meanwhile.
|
||||
Ui* host = ui();
|
||||
Widget* restore = mFocusBeforeOpen;
|
||||
mFocusBeforeOpen = nullptr;
|
||||
if (host && host->keyboardFocus() == this && restore && restore->ui() && !isSelfOrAncestorOf(*restore)) {
|
||||
host->setKeyboardFocus(restore);
|
||||
}
|
||||
Panel::onDetached();
|
||||
setCurrentItem(nullptr);
|
||||
}
|
||||
|
||||
Menu* Menu::parentMenu() const {
|
||||
Widget* owner = ui() ? ui()->popupOwnerOf(*this) : nullptr;
|
||||
for (Widget* current = owner; current; current = current->parent()) {
|
||||
if (auto* menu = dynamic_cast<Menu*>(current)) return menu;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
MenuItem* Menu::currentItem() const { return mCurrent && *mCurrent < mItems.size() ? mItems[*mCurrent] : nullptr; }
|
||||
|
||||
void Menu::setCurrentItem(MenuItem* item) {
|
||||
std::optional<size_t> next;
|
||||
for (size_t index = 0; index < mItems.size(); index++) {
|
||||
if (mItems[index] == item) next = index;
|
||||
}
|
||||
if (next == mCurrent) return;
|
||||
if (MenuItem* previous = currentItem()) previous->setHighlighted(false);
|
||||
mCurrent = next;
|
||||
if (MenuItem* current = currentItem()) {
|
||||
current->setHighlighted(true);
|
||||
const RectF rect = current->rect();
|
||||
mScroll.revealRange(rect.pos.y, rect.size.y);
|
||||
}
|
||||
}
|
||||
|
||||
ListNavigator::Items Menu::navigationItems() const {
|
||||
ListNavigator::Items items;
|
||||
items.count = mItems.size();
|
||||
items.isAvailable = [this](size_t index) { return mItems[index]->isEffectivelyEnabled(); };
|
||||
items.text = [this](size_t index) { return mItems[index]->title(); };
|
||||
return items;
|
||||
}
|
||||
|
||||
bool Menu::handleKey(Event& event) {
|
||||
if (event.type() == Event::Type::TextInput) {
|
||||
const halnf now = ui() ? ui()->clockMs() : 0;
|
||||
const std::optional<size_t> found = mNavigator.typeAhead(navigationItems(), mCurrent, event.text(), now);
|
||||
if (found) setCurrentItem(mItems[*found]);
|
||||
return found.has_value();
|
||||
}
|
||||
if (event.type() != Event::Type::KeyDown) return false;
|
||||
const std::optional<Key> key = event.key();
|
||||
if (!key) return false;
|
||||
|
||||
MenuItem* current = currentItem();
|
||||
switch (*key) {
|
||||
case Key::Enter:
|
||||
case Key::KeypadEnter:
|
||||
case Key::Space:
|
||||
if (current && current->isEffectivelyEnabled()) current->activate();
|
||||
return true;
|
||||
case Key::Right:
|
||||
if (current && current->entry().kind == MenuEntry::Kind::Submenu) {
|
||||
current->openSubmenu();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case Key::Left:
|
||||
// Left closes a submenu and gives the keyboard back to the menu it came from.
|
||||
if (Menu* parent = parentMenu()) {
|
||||
closeMenu();
|
||||
if (parent->ui()) parent->ui()->setKeyboardFocus(parent);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
default: break;
|
||||
}
|
||||
|
||||
// Menus wrap at the ends, like native menus.
|
||||
const std::optional<size_t> next = ListNavigator::step(navigationItems(), mCurrent, *key, 10, true);
|
||||
if (!next) return *key == Key::Up || *key == Key::Down;
|
||||
setCurrentItem(mItems[*next]);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Menu::onEvent(Event& event) {
|
||||
if (handleKey(event)) event.accept();
|
||||
}
|
||||
|
||||
void Menu::filterDescendantEvent(Event& event) {
|
||||
if ((event.type() == Event::Type::KeyDown || event.type() == Event::Type::TextInput) && handleKey(event)) {
|
||||
event.accept();
|
||||
}
|
||||
// The pointer and the keyboard share one current item: hovering a row makes it current.
|
||||
if (event.type() == Event::Type::PointerMove) {
|
||||
for (const auto& item : mItems) {
|
||||
if (item->isSelfOrAncestorOf(*event.target()) && item->isEffectivelyEnabled()) setCurrentItem(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Menu::closeMenu() {
|
||||
if (ui()) ui()->closePopup(*this);
|
||||
}
|
||||
|
|
@ -348,6 +426,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 +443,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 +474,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 +481,9 @@ MenuBar::MenuBar() :
|
|||
Panel(Surface::None) {
|
||||
setDebugName("menu bar");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
emplaceLayout<MenuBarLayout>(*this).setPadding(Insets::symmetric(4, 2));
|
||||
MenuBarLayout& layout = emplaceLayout<MenuBarLayout>(*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 +499,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<RowLayout*>(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);
|
||||
|
|
@ -523,15 +550,7 @@ MenuIconButton::MenuIconButton() {
|
|||
|
||||
void MenuIconButton::paintIcon(Canvas& painter, const RectF& box) {
|
||||
const RGBA color = isHovered() ? theme().palette.text : theme().palette.mutedText;
|
||||
const halnf thickness = 1.6f;
|
||||
const halnf width = box.size.x * 0.8f;
|
||||
const halnf left = box.pos.x + (box.size.x - width) / 2;
|
||||
const halnf spacing = box.size.y * 0.3f;
|
||||
const halnf middle = box.pos.y + box.size.y / 2;
|
||||
for (int row = -1; row <= 1; row++) {
|
||||
const halnf y = middle + halnf(row) * spacing;
|
||||
painter.line(Vec2F(left, y), Vec2F(left + width, y), color, thickness);
|
||||
}
|
||||
paintGlyph(painter, Glyph::Menu, glyphBox(box, std::min(box.size.x, box.size.y) * 0.8f), color, theme());
|
||||
}
|
||||
|
||||
bool MenuBar::isDragSurface(const Widget* target) const { return target == this || target == &mDragSpace; }
|
||||
|
|
@ -566,7 +585,7 @@ void MenuBar::removeMenu(Menu& menu) {
|
|||
mMenus.erase(found);
|
||||
mShownTitles = mTitles.size();
|
||||
mOverflowMenu.clear();
|
||||
requestLayout();
|
||||
refreshOverflowRun();
|
||||
}
|
||||
|
||||
void MenuBar::setTrailingWidget(Widget* widget) {
|
||||
|
|
@ -578,8 +597,9 @@ void MenuBar::setTrailingWidget(Widget* widget) {
|
|||
}
|
||||
|
||||
PopupButton& MenuBar::insertMenu(size_t index, std::string title, Menu& menu) {
|
||||
mTitles.insert(mTitles.begin() + (std::ptrdiff_t) index, std::make_unique<PopupButton>(std::move(title)));
|
||||
PopupButton& button = *mTitles[index];
|
||||
// The bar owns its titles; they come first among its children, in menu order.
|
||||
PopupButton& button = emplaceChildAt<PopupButton>(index, std::move(title));
|
||||
mTitles.insert(mTitles.begin() + (std::ptrdiff_t) index, &button);
|
||||
|
||||
button.setStyle(Button::Style::Flat);
|
||||
button.setPopupContent(&menu);
|
||||
|
|
@ -592,14 +612,14 @@ 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;
|
||||
}
|
||||
|
||||
void MenuBar::onTitleHovered(PopupButton& title) {
|
||||
for (const auto& other : mTitles) {
|
||||
if (other.get() == &title || !other->isPopupOpen()) continue;
|
||||
if (other == &title || !other->isPopupOpen()) continue;
|
||||
|
||||
other->closePopup();
|
||||
title.openPopupFromHover();
|
||||
|
|
@ -643,7 +663,7 @@ Extent Dropdown::measureContent(Axis axis, halnf crossLength) const {
|
|||
if (width > widest) widest = width;
|
||||
}
|
||||
|
||||
extent.preferred = widest + theme().metrics.controlPadding.total(Axis::Horizontal) + kSubmenuArrowWidth;
|
||||
extent.preferred = widest + theme().metrics.controlPadding.total(Axis::Horizontal) + kDropdownChevronRoom;
|
||||
return extent;
|
||||
}
|
||||
|
||||
|
|
@ -656,15 +676,11 @@ void Dropdown::paint(Canvas& painter) {
|
|||
painter.rect(localRect(), background(), radius);
|
||||
painter.outline(localRect(), border, radius);
|
||||
|
||||
const RectF textBox = makeRect(content.pos, { content.size.x - kSubmenuArrowWidth, content.size.y });
|
||||
const RectF textBox = makeRect(content.pos, Vec2F(content.size.x - kDropdownChevronRoom, content.size.y));
|
||||
painter.text(selectedText(), textBox, TextStyle{ style.metrics.fontSize, style.palette.text, TextAlign::leftCenter() });
|
||||
|
||||
const Vec2F chevron = { content.pos.x + content.size.x - kSubmenuArrowWidth / 2, size().y / 2 + 1.5f };
|
||||
painter.polyline(
|
||||
{ chevron + Vec2F(-4.0f, -2.5f), chevron + Vec2F(0.0f, 1.5f), chevron + Vec2F(4.0f, -2.5f) },
|
||||
style.palette.mutedText,
|
||||
1.5f
|
||||
);
|
||||
const RectF chevronRoom = makeRect(content.pos.x + content.size.x - kDropdownChevronRoom, 0, kDropdownChevronRoom, size().y);
|
||||
paintGlyph(painter, Glyph::ChevronDown, glyphBox(chevronRoom, kDropdownChevronSide), style.palette.mutedText, style);
|
||||
}
|
||||
|
||||
void Dropdown::onActivated() {
|
||||
|
|
|
|||
37
Widgets/src/components/chrome/private/PromptDialog.cpp
Normal file
37
Widgets/src/components/chrome/private/PromptDialog.cpp
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#include "PromptDialog.hpp"
|
||||
|
||||
#include "Ui.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
PromptDialog::PromptDialog(std::string title, std::string confirm) :
|
||||
Dialog(std::move(title)) {
|
||||
setDebugName("prompt dialog");
|
||||
mInput.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
// Enter in the field submits it, which confirms; the field also commits on focus loss, but that does not submit,
|
||||
// so a click on Cancel (which commits by focus) does not also confirm.
|
||||
mInput.setOnSubmitted([this](const std::string&) {
|
||||
if (isOpen()) accept();
|
||||
});
|
||||
body().addChild(mInput);
|
||||
|
||||
addButton(
|
||||
std::move(confirm),
|
||||
[this]() {
|
||||
if (mOnConfirm) mOnConfirm(mInput.text());
|
||||
},
|
||||
true
|
||||
);
|
||||
addCancelButton();
|
||||
}
|
||||
|
||||
void PromptDialog::show(
|
||||
Ui& ui, const std::string& suggestion, std::function<void(const std::string&)> onConfirm, Widget* owner
|
||||
) {
|
||||
mOnConfirm = std::move(onConfirm);
|
||||
mInput.setText(suggestion);
|
||||
open(ui, owner);
|
||||
ui.setKeyboardFocus(&mInput);
|
||||
mInput.selectAll();
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#include "Button.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Label.hpp"
|
||||
#include "TitledPanel.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
|
@ -11,21 +12,18 @@
|
|||
|
||||
namespace tp::ui {
|
||||
|
||||
class Dialog : public Panel {
|
||||
// A modal titled panel: a wrapping title, an optional message, a body for any content, and buttons in the footer.
|
||||
class Dialog : public TitledPanel {
|
||||
public:
|
||||
explicit Dialog(std::string title = {});
|
||||
~Dialog() override;
|
||||
|
||||
void setTitle(std::string title);
|
||||
void setMessage(std::string message);
|
||||
[[nodiscard]] const std::string& title() const { return mTitle.text(); }
|
||||
[[nodiscard]] const std::string& message() const { return mMessage.text(); }
|
||||
|
||||
[[nodiscard]] Widget& body() { return mBody; }
|
||||
|
||||
Button& addButton(std::string text, std::function<void()> action, bool isDefault = false);
|
||||
Button& addCancelButton(std::string text = "Cancel");
|
||||
[[nodiscard]] const std::vector<std::unique_ptr<Button>>& buttons() const { return mButtons; }
|
||||
[[nodiscard]] const std::vector<Button*>& buttons() const { return mButtons; }
|
||||
void setOnCancel(std::function<void()> onCancel) { mOnCancel = std::move(onCancel); }
|
||||
|
||||
void open(Ui& ui, Widget* owner = nullptr);
|
||||
|
|
@ -39,12 +37,9 @@ namespace tp::ui {
|
|||
void onEvent(Event& event) override;
|
||||
|
||||
private:
|
||||
Label mTitle;
|
||||
Label mMessage;
|
||||
Widget mBody;
|
||||
Widget mButtonRow;
|
||||
Spacer mButtonSpacer;
|
||||
std::vector<std::unique_ptr<Button>> mButtons;
|
||||
// The buttons in order; the button row owns them.
|
||||
std::vector<Button*> mButtons;
|
||||
Button* mDefault = nullptr;
|
||||
std::function<void()> mOnCancel;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
#pragma once
|
||||
|
||||
#include "Button.hpp"
|
||||
#include "Commands.hpp"
|
||||
#include "ListNavigator.hpp"
|
||||
#include "ListRow.hpp"
|
||||
#include "WindowControls.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Popup.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
|
|
@ -65,34 +69,47 @@ namespace tp::ui {
|
|||
Menu* submenu = nullptr;
|
||||
|
||||
static MenuEntry item(std::string text, std::function<void()> action, std::string shortcut = {});
|
||||
// An entry that shows and runs a command: its label, shortcut text, enabled and checked state come from the
|
||||
// registry each time the menu opens, so the menu never keeps its own copy. A checkable command becomes a toggle.
|
||||
template <typename Id>
|
||||
static MenuEntry command(Commands<Id>& commands, Id id) {
|
||||
MenuEntry entry;
|
||||
entry.kind = commands.isCheckable(id) ? Kind::Toggle : Kind::Action;
|
||||
entry.text = commands.label(id);
|
||||
entry.shortcut = commands.shortcutText(id);
|
||||
entry.action = [&commands, id]() { commands.run(id); };
|
||||
entry.isEnabled = [&commands, id]() { return commands.isEnabled(id); };
|
||||
if (commands.isCheckable(id)) entry.isChecked = [&commands, id]() { return commands.isChecked(id); };
|
||||
entry.dynamicText = [&commands, id]() { return commands.label(id); };
|
||||
return entry;
|
||||
}
|
||||
static MenuEntry toggle(std::string text, std::function<bool()> isChecked, std::function<void()> action);
|
||||
static MenuEntry separator();
|
||||
static MenuEntry nested(std::string text, Menu& menu);
|
||||
};
|
||||
|
||||
class MenuItem : public Control {
|
||||
// One entry of a menu shown as a compact list row: the check or icon leads, the shortcut or submenu chevron trails.
|
||||
class MenuItem : public ListRow {
|
||||
public:
|
||||
MenuItem(Menu& menu, MenuEntry entry);
|
||||
|
||||
[[nodiscard]] const MenuEntry& entry() const { return mEntry; }
|
||||
[[nodiscard]] const std::string& text() const { return mText; }
|
||||
[[nodiscard]] const std::string& text() const { return title(); }
|
||||
|
||||
// Reads the entry's dynamic text, enabled and checked state again.
|
||||
void refresh();
|
||||
void openSubmenu();
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Canvas& painter) override;
|
||||
void onActivated() override;
|
||||
void onHoverChanged() override;
|
||||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
|
||||
private:
|
||||
void openSubmenu();
|
||||
[[nodiscard]] Placement submenuSide(const RectF& menuRect) const;
|
||||
|
||||
Menu& mMenu;
|
||||
MenuEntry mEntry;
|
||||
std::string mText;
|
||||
};
|
||||
|
||||
class Menu : public Panel {
|
||||
|
|
@ -107,7 +124,7 @@ namespace tp::ui {
|
|||
MenuItem& addSubmenu(std::string text, Menu& submenu);
|
||||
void clear();
|
||||
|
||||
[[nodiscard]] const std::vector<std::unique_ptr<MenuItem>>& items() const { return mItems; }
|
||||
[[nodiscard]] const std::vector<MenuItem*>& items() const { return mItems; }
|
||||
[[nodiscard]] std::vector<const MenuItem*> itemsWithSeparators() const;
|
||||
void notifyItemActivated() const {
|
||||
if (mOnItemActivated) mOnItemActivated();
|
||||
|
|
@ -121,17 +138,36 @@ namespace tp::ui {
|
|||
void closeMenu();
|
||||
[[nodiscard]] bool isOpen() const;
|
||||
|
||||
// The keyboard-current item (Up/Down, Home/End, typing a title), or none.
|
||||
[[nodiscard]] MenuItem* currentItem() const;
|
||||
void setCurrentItem(MenuItem* item);
|
||||
|
||||
protected:
|
||||
[[nodiscard]] bool acceptsKeyboardFocus() const override { return true; }
|
||||
void onEvent(Event& event) override;
|
||||
void filterDescendantEvent(Event& event) override;
|
||||
void onAttached() override;
|
||||
void onDetached() override;
|
||||
|
||||
private:
|
||||
friend class MenuItem;
|
||||
|
||||
void closeChain();
|
||||
bool handleKey(Event& event);
|
||||
[[nodiscard]] ListNavigator::Items navigationItems() const;
|
||||
void takeKeyboard();
|
||||
[[nodiscard]] Menu* parentMenu() const;
|
||||
|
||||
ScrollView mScroll;
|
||||
Widget mColumn;
|
||||
std::vector<std::unique_ptr<MenuItem>> mItems;
|
||||
std::vector<std::unique_ptr<Separator>> mSeparators;
|
||||
// The items in order; the column owns them and the separators between them.
|
||||
std::vector<MenuItem*> mItems;
|
||||
std::function<void()> mOnItemActivated;
|
||||
std::function<void(Menu&)> mOnOpen;
|
||||
ListNavigator mNavigator;
|
||||
std::optional<size_t> mCurrent;
|
||||
// The widget that had the keyboard before the menu took it, given back when the menu closes.
|
||||
Widget* mFocusBeforeOpen = nullptr;
|
||||
};
|
||||
|
||||
class MenuIconButton : public PopupButton {
|
||||
|
|
@ -154,7 +190,7 @@ namespace tp::ui {
|
|||
void setTrailingWidget(Widget* widget);
|
||||
[[nodiscard]] Widget* trailingWidget() const { return mTrailing; }
|
||||
[[nodiscard]] Widget* centerWidget() const { return mCenter; }
|
||||
[[nodiscard]] const std::vector<std::unique_ptr<PopupButton>>& titles() const { return mTitles; }
|
||||
[[nodiscard]] const std::vector<PopupButton*>& titles() const { return mTitles; }
|
||||
[[nodiscard]] WindowControls& windowControls() { return mWindowControls; }
|
||||
[[nodiscard]] PopupButton& overflowButton() { return mOverflow; }
|
||||
[[nodiscard]] Menu& overflowMenu() { return mOverflowMenu; }
|
||||
|
|
@ -162,22 +198,20 @@ 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<std::unique_ptr<PopupButton>> mTitles;
|
||||
bool mMovePending = false;
|
||||
Vec2F mMovePressPoint = { 0, 0 };
|
||||
// The menu titles in order; the bar owns them.
|
||||
std::vector<PopupButton*> mTitles;
|
||||
Spacer mDragSpace;
|
||||
Widget* mCenter = nullptr;
|
||||
Widget* mTrailing = nullptr;
|
||||
|
|
|
|||
31
Widgets/src/components/chrome/public/PromptDialog.hpp
Normal file
31
Widgets/src/components/chrome/public/PromptDialog.hpp
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#pragma once
|
||||
|
||||
#include "Dialog.hpp"
|
||||
#include "TextField.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
// A dialog that asks for one line of text: rename a layer, save a sketch, name a skill. The field is focused and
|
||||
// selected when the dialog opens, Enter confirms, Escape cancels, and the confirm button calls the callback with the
|
||||
// text. Callers that validate the text (non-empty, no separators) keep their own check outside.
|
||||
// PromptDialog prompt{ "Rename layer", "Rename" };
|
||||
// prompt.input().setMaximumLength(24);
|
||||
// prompt.show(ui, "Layer 1", [&](const std::string& name) { rename(name); });
|
||||
class PromptDialog : public Dialog {
|
||||
public:
|
||||
explicit PromptDialog(std::string title = {}, std::string confirm = "OK");
|
||||
|
||||
[[nodiscard]] TextField& input() { return mInput; }
|
||||
|
||||
// Fills the field with the suggestion, focuses and selects it, then opens. confirm receives the text the user
|
||||
// confirmed (an empty string when they cancelled, since no callback runs then).
|
||||
void show(Ui& ui, const std::string& suggestion, std::function<void(const std::string&)> onConfirm, Widget* owner = nullptr);
|
||||
|
||||
private:
|
||||
TextField mInput;
|
||||
std::function<void(const std::string&)> mOnConfirm;
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ using namespace tp::ui::testing;
|
|||
namespace {
|
||||
MenuItem* findItem(Menu& menu, const std::string& text) {
|
||||
for (const auto& item : menu.items()) {
|
||||
if (item->entry().text == text) return item.get();
|
||||
if (item->entry().text == text) return item;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
|
@ -49,7 +49,6 @@ SUITE(PopupButtonWidget) {
|
|||
UiDriver driver(root, { 300, 200 });
|
||||
driver.recording().addImageFile("icon.png", { 16, 16 });
|
||||
entry.icon = driver.ui().canvas()->createImageFromFile("icon.png");
|
||||
std::printf("icon valid=%d\n", entry.icon.isValid());
|
||||
MenuItem& withIcon = menu.add(std::move(entry));
|
||||
menu.addItem("Plain", {});
|
||||
|
||||
|
|
@ -59,7 +58,6 @@ SUITE(PopupButtonWidget) {
|
|||
images++;
|
||||
CHECK(command.rect.pos.x < withIcon.windowRect().pos.x + withIcon.windowRect().size.x / 3);
|
||||
}
|
||||
fprintf(stderr, "images=%d total=%zu\n", images, driver.paint().commands().size());
|
||||
CHECK_EQUAL(1, images);
|
||||
}
|
||||
|
||||
|
|
@ -618,3 +616,168 @@ SUITE(MenuRefresh) {
|
|||
CHECK_EQUAL(5u, history.items().size());
|
||||
}
|
||||
}
|
||||
|
||||
SUITE(MenuKeyboard) {
|
||||
struct Scene {
|
||||
Widget root;
|
||||
PopupButton file{ "File" };
|
||||
Menu menu;
|
||||
Menu recent;
|
||||
std::vector<std::string> ran;
|
||||
|
||||
Scene() {
|
||||
root.emplaceLayout<ColumnLayout>().setCrossAlign(CrossAlign::Start);
|
||||
menu.addItem("New", [this]() { ran.push_back("New"); });
|
||||
menu.addItem("Open", [this]() { ran.push_back("Open"); });
|
||||
MenuItem& disabled = menu.addItem("Revert", [this]() { ran.push_back("Revert"); });
|
||||
disabled.setEnabled(false);
|
||||
menu.addSeparator();
|
||||
menu.addSubmenu("Recent", recent);
|
||||
recent.addItem("one.txt", [this]() { ran.push_back("one.txt"); });
|
||||
recent.addItem("two.txt", [this]() { ran.push_back("two.txt"); });
|
||||
file.setPopupContent(&menu);
|
||||
root.addChild(file);
|
||||
}
|
||||
};
|
||||
|
||||
TEST(ArrowsMoveThroughEnabledItemsAndEnterRunsOne) {
|
||||
Scene scene;
|
||||
UiDriver driver(scene.root, { 600, 400 });
|
||||
driver.click(scene.file);
|
||||
REQUIRE CHECK(scene.menu.isOpen());
|
||||
CHECK(driver.ui().keyboardFocus() == &scene.menu);
|
||||
|
||||
driver.key(Key::Down);
|
||||
CHECK(scene.menu.currentItem() == findItem(scene.menu, "New"));
|
||||
driver.key(Key::Down);
|
||||
driver.key(Key::Down);
|
||||
// "Revert" is disabled and the separator is not an item, so Down lands on "Recent".
|
||||
CHECK(scene.menu.currentItem() == findItem(scene.menu, "Recent"));
|
||||
driver.key(Key::Down);
|
||||
CHECK(scene.menu.currentItem() == findItem(scene.menu, "New"));
|
||||
|
||||
driver.key(Key::Down);
|
||||
driver.key(Key::Enter);
|
||||
CHECK((scene.ran == std::vector<std::string>{ "Open" }));
|
||||
CHECK(!scene.menu.isOpen());
|
||||
}
|
||||
|
||||
TEST(RightOpensASubmenuAndLeftComesBack) {
|
||||
Scene scene;
|
||||
UiDriver driver(scene.root, { 600, 400 });
|
||||
driver.click(scene.file);
|
||||
driver.key(Key::End);
|
||||
CHECK(scene.menu.currentItem() == findItem(scene.menu, "Recent"));
|
||||
|
||||
// Opened from the keyboard, the submenu starts on its first item.
|
||||
driver.key(Key::Right);
|
||||
REQUIRE CHECK(scene.recent.isOpen());
|
||||
CHECK(driver.ui().keyboardFocus() == &scene.recent);
|
||||
CHECK(scene.recent.currentItem() == findItem(scene.recent, "one.txt"));
|
||||
|
||||
driver.key(Key::Left);
|
||||
CHECK(!scene.recent.isOpen());
|
||||
CHECK(scene.menu.isOpen());
|
||||
CHECK(driver.ui().keyboardFocus() == &scene.menu);
|
||||
|
||||
driver.key(Key::Right);
|
||||
driver.key(Key::Down);
|
||||
driver.key(Key::Enter);
|
||||
CHECK((scene.ran == std::vector<std::string>{ "two.txt" }));
|
||||
CHECK(!scene.menu.isOpen());
|
||||
}
|
||||
|
||||
TEST(TypingATitleMakesItCurrent) {
|
||||
Scene scene;
|
||||
UiDriver driver(scene.root, { 600, 400 });
|
||||
driver.click(scene.file);
|
||||
|
||||
driver.type("o");
|
||||
CHECK(scene.menu.currentItem() == findItem(scene.menu, "Open"));
|
||||
}
|
||||
|
||||
TEST(HoverAndKeyboardShareTheCurrentItem) {
|
||||
Scene scene;
|
||||
UiDriver driver(scene.root, { 600, 400 });
|
||||
driver.click(scene.file);
|
||||
|
||||
driver.moveTo(findItem(scene.menu, "Open")->windowRect().center());
|
||||
CHECK(scene.menu.currentItem() == findItem(scene.menu, "Open"));
|
||||
driver.key(Key::Up);
|
||||
CHECK(scene.menu.currentItem() == findItem(scene.menu, "New"));
|
||||
}
|
||||
|
||||
TEST(ClosingGivesTheKeyboardBack) {
|
||||
Scene scene;
|
||||
TextField field;
|
||||
scene.root.addChild(field);
|
||||
UiDriver driver(scene.root, { 600, 400 });
|
||||
driver.click(field);
|
||||
REQUIRE CHECK(driver.ui().keyboardFocus() == &field);
|
||||
|
||||
scene.menu.openAt(scene.file, scene.file.windowRect(), Placement::Below);
|
||||
CHECK(driver.ui().keyboardFocus() == &scene.menu);
|
||||
driver.key(Key::Escape);
|
||||
CHECK(!scene.menu.isOpen());
|
||||
CHECK(driver.ui().keyboardFocus() == &field);
|
||||
}
|
||||
|
||||
TEST(DropdownOptionsArePickedWithTheKeyboard) {
|
||||
Widget root;
|
||||
root.emplaceLayout<ColumnLayout>().setCrossAlign(CrossAlign::Start);
|
||||
Dropdown dropdown{ { "Normal", "Multiply", "Screen" } };
|
||||
root.addChild(dropdown);
|
||||
UiDriver driver(root, { 600, 400 });
|
||||
|
||||
driver.click(dropdown);
|
||||
driver.key(Key::Down);
|
||||
driver.key(Key::Down);
|
||||
driver.key(Key::Down);
|
||||
driver.key(Key::Enter);
|
||||
|
||||
CHECK_EQUAL(2, dropdown.selectedIndex());
|
||||
CHECK(!dropdown.isPopupOpen());
|
||||
}
|
||||
}
|
||||
|
||||
SUITE(MenuCommands) {
|
||||
enum class Edit { Undo, Grid };
|
||||
|
||||
TEST(CommandEntriesReadTheRegistryEachTimeTheMenuOpens) {
|
||||
Widget root;
|
||||
root.emplaceLayout<ColumnLayout>().setCrossAlign(CrossAlign::Start);
|
||||
PopupButton title{ "Edit" };
|
||||
Menu menu;
|
||||
Commands<Edit> commands;
|
||||
int undone = 0;
|
||||
bool canUndo = false;
|
||||
bool grid = false;
|
||||
commands.define(Edit::Undo, { "Undo", [&undone]() { undone++; }, [&canUndo]() { return canUndo; }, {} });
|
||||
commands.define(Edit::Grid, { "Show Grid", [&grid]() { grid = !grid; }, {}, [&grid]() { return grid; } });
|
||||
commands.shortcuts().bind(ctrl(Key::Z), Edit::Undo);
|
||||
menu.add(MenuEntry::command(commands, Edit::Undo));
|
||||
menu.add(MenuEntry::command(commands, Edit::Grid));
|
||||
title.setPopupContent(&menu);
|
||||
root.addChild(title);
|
||||
UiDriver driver(root, { 600, 400 });
|
||||
|
||||
driver.click(title);
|
||||
MenuItem& undo = *findItem(menu, "Undo");
|
||||
CHECK(!undo.isEffectivelyEnabled());
|
||||
CHECK(driver.paint().hasText(commands.shortcutText(Edit::Undo)));
|
||||
driver.key(Key::Escape);
|
||||
|
||||
canUndo = true;
|
||||
driver.click(title);
|
||||
CHECK(undo.isEffectivelyEnabled());
|
||||
driver.click(undo);
|
||||
CHECK_EQUAL(1, undone);
|
||||
|
||||
driver.click(title);
|
||||
CHECK(findItem(menu, "Show Grid")->entry().kind == MenuEntry::Kind::Toggle);
|
||||
driver.click(*findItem(menu, "Show Grid"));
|
||||
CHECK(grid);
|
||||
driver.click(title);
|
||||
CHECK(findItem(menu, "Show Grid")->leading().kind == ListRow::Leading::Kind::Check);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
72
Widgets/src/components/chrome/tests/PromptDialogTests.cpp
Normal file
72
Widgets/src/components/chrome/tests/PromptDialogTests.cpp
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#include "PromptDialog.hpp"
|
||||
#include "Ui.hpp"
|
||||
#include "BoxLayout.hpp"
|
||||
#include "UiDriver.hpp"
|
||||
|
||||
#include "UnitTest++/UnitTest++.h"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
using namespace tp::ui::testing;
|
||||
|
||||
namespace {
|
||||
struct Scene {
|
||||
Widget root;
|
||||
Button open{ "Rename" };
|
||||
PromptDialog prompt{ "Rename layer", "Rename" };
|
||||
std::string confirmed;
|
||||
|
||||
Scene() {
|
||||
root.emplaceLayout<ColumnLayout>().setCrossAlign(CrossAlign::Start);
|
||||
open.setOnActivate([this] {
|
||||
if (open.ui()) prompt.show(*open.ui(), "Layer 1", [this](const std::string& name) { confirmed = name; });
|
||||
});
|
||||
root.addChild(open);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
SUITE(PromptDialog) {
|
||||
TEST(OpensFocusedAndSelectedWithTheSuggestion) {
|
||||
Scene scene;
|
||||
UiDriver driver(scene.root, { 400, 300 });
|
||||
driver.click(scene.open);
|
||||
|
||||
REQUIRE CHECK(scene.prompt.isOpen());
|
||||
CHECK(driver.ui().keyboardFocus() == &scene.prompt.input());
|
||||
CHECK_EQUAL("Layer 1", scene.prompt.input().text());
|
||||
|
||||
// The suggestion is selected, so typing replaces it whole rather than appending.
|
||||
driver.type("x");
|
||||
CHECK_EQUAL("x", scene.prompt.input().text());
|
||||
driver.key(Key::Escape);
|
||||
CHECK(!scene.prompt.isOpen());
|
||||
}
|
||||
|
||||
TEST(EnterConfirmsWithTheText) {
|
||||
Scene scene;
|
||||
UiDriver driver(scene.root, { 400, 300 });
|
||||
driver.click(scene.open);
|
||||
driver.type("Background");
|
||||
driver.key(Key::Enter);
|
||||
|
||||
CHECK_EQUAL("Background", scene.confirmed);
|
||||
CHECK(!scene.prompt.isOpen());
|
||||
}
|
||||
|
||||
TEST(ConfirmButtonRunsTheCallbackAndCancelDoesNot) {
|
||||
Scene scene;
|
||||
UiDriver driver(scene.root, { 400, 300 });
|
||||
driver.click(scene.open);
|
||||
driver.type("Ink");
|
||||
driver.click(*scene.prompt.buttons().front());
|
||||
CHECK_EQUAL("Ink", scene.confirmed);
|
||||
CHECK(!scene.prompt.isOpen());
|
||||
|
||||
driver.click(scene.open);
|
||||
driver.type("Tossed");
|
||||
driver.click(*scene.prompt.buttons().back());
|
||||
CHECK(!scene.prompt.isOpen());
|
||||
CHECK_EQUAL("Ink", scene.confirmed);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -37,24 +37,19 @@ Breadcrumb::Breadcrumb() {
|
|||
}
|
||||
|
||||
void Breadcrumb::setParts(const std::vector<std::string>& parts) {
|
||||
mRow.removeAllChildren();
|
||||
// The row owns its parts and separators, so clearing it destroys them.
|
||||
mRow.clearOwnedChildren();
|
||||
mParts.clear();
|
||||
mSeparators.clear();
|
||||
|
||||
for (size_t index = 0; index < parts.size(); index++) {
|
||||
if (index > 0) {
|
||||
mSeparators.push_back(std::make_unique<Label>(kSeparator));
|
||||
mSeparators.back()->setTone(Label::Tone::Muted);
|
||||
mRow.addChild(*mSeparators.back());
|
||||
}
|
||||
if (index > 0) mRow.emplaceChild<Label>(kSeparator).setTone(Label::Tone::Muted);
|
||||
|
||||
const bool current = index + 1 == parts.size();
|
||||
mParts.push_back(std::make_unique<BreadcrumbPart>(parts[index], current));
|
||||
BreadcrumbPart& part = *mParts.back();
|
||||
BreadcrumbPart& part = mRow.emplaceChild<BreadcrumbPart>(parts[index], current);
|
||||
mParts.push_back(&part);
|
||||
part.setOnActivate([this, index]() {
|
||||
if (mOnPartChosen) mOnPartChosen(index);
|
||||
});
|
||||
mRow.addChild(part);
|
||||
}
|
||||
requestLayout();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "Collapsible.hpp"
|
||||
#include "Canvas.hpp"
|
||||
#include "Glyph.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Layout.hpp"
|
||||
|
|
@ -22,17 +23,23 @@ CollapsibleHeader::CollapsibleHeader(std::string title) :
|
|||
mTitle(std::move(title)) {
|
||||
setDebugName("collapsible header");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
// The header paints its chevron, title and summary itself; a row keeps room for them and puts an action at the end.
|
||||
RowLayout& row = emplaceLayout<RowLayout>();
|
||||
row.setCrossAlign(CrossAlign::Center);
|
||||
addChild(mPainted);
|
||||
}
|
||||
|
||||
void CollapsibleHeader::setTitle(std::string title) {
|
||||
if (title == mTitle) return;
|
||||
mTitle = std::move(title);
|
||||
requestLayout();
|
||||
mPainted.requestLayout();
|
||||
}
|
||||
|
||||
void CollapsibleHeader::setSummary(std::string summary) {
|
||||
if (summary == mSummary) return;
|
||||
mSummary = std::move(summary);
|
||||
// The summary takes room in the header, so a longer one can push the title's room.
|
||||
mPainted.requestLayout();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
|
|
@ -45,47 +52,23 @@ void CollapsibleHeader::setExpanded(bool expanded, bool animated) {
|
|||
}
|
||||
}
|
||||
|
||||
class CollapsibleHeader::HeaderLayout : public Layout {
|
||||
public:
|
||||
explicit HeaderLayout(CollapsibleHeader& header) :
|
||||
mHeader(header) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override {
|
||||
Widget& action = *mHeader.mAction;
|
||||
const Extent own = mHeader.measureContent(axis, crossLength);
|
||||
const Extent child = context.measure(action, axis, crossLength);
|
||||
if (axis == Axis::Vertical) return Extent::natural(std::max(own.preferred, child.preferred));
|
||||
return Extent::shrinkable(own.preferred + child.preferred);
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& area) override {
|
||||
Widget& action = *mHeader.mAction;
|
||||
const halnf width = context.measure(action, Axis::Horizontal, area.size.y).preferred;
|
||||
const halnf height = std::min(area.size.y, context.measure(action, Axis::Vertical, width).preferred);
|
||||
context.place(
|
||||
action, makeRect(area.pos.x + area.size.x - width, area.pos.y + (area.size.y - height) / 2, width, height)
|
||||
);
|
||||
}
|
||||
|
||||
private:
|
||||
CollapsibleHeader& mHeader;
|
||||
};
|
||||
|
||||
void CollapsibleHeader::setAction(Widget* action) {
|
||||
if (mAction) removeChild(*mAction);
|
||||
mAction = action;
|
||||
if (!action) {
|
||||
setLayout(nullptr);
|
||||
return;
|
||||
}
|
||||
addChild(*action);
|
||||
setLayout(std::make_unique<HeaderLayout>(*this));
|
||||
if (action) addChild(*action);
|
||||
}
|
||||
|
||||
halnf CollapsibleHeader::actionWidth() const { return mAction ? mAction->size().x : 0; }
|
||||
|
||||
Extent CollapsibleHeader::measureContent(Axis axis, halnf) const {
|
||||
PaintedRoom::PaintedRoom(const CollapsibleHeader& header) :
|
||||
mHeader(header) {
|
||||
setDebugName("collapsible header text");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
}
|
||||
|
||||
Extent PaintedRoom::measureContent(Axis axis, halnf) const { return mHeader.paintedExtent(axis); }
|
||||
|
||||
Extent CollapsibleHeader::paintedExtent(Axis axis) const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
if (axis == Axis::Vertical) return Extent::natural(metrics.controlHeight);
|
||||
const halnf summary = mSummary.empty() ? 0 : textWidth(mSummary, metrics.smallFontSize) + 12;
|
||||
|
|
@ -108,20 +91,9 @@ void CollapsibleHeader::paint(Canvas& painter) {
|
|||
if (showsFocusRing())
|
||||
painter.outline(insetRect(localRect(), -2), style.palette.focusRing, metrics.cornerRadius + 2, 2);
|
||||
|
||||
const Vec2F center = { kChevronWidth / 2 + 2, size().y / 2 };
|
||||
const halnf angle = mTurn.value() * kPi / 2;
|
||||
const halnf cosine = std::cos(angle);
|
||||
const halnf sine = std::sin(angle);
|
||||
auto turned = [&](Vec2F point) {
|
||||
return center + Vec2F(point.x * cosine - point.y * sine, point.x * sine + point.y * cosine);
|
||||
};
|
||||
painter.polyline(
|
||||
{ turned(Vec2F(-kChevronHalf / 2, -kChevronHalf)),
|
||||
turned(Vec2F(kChevronHalf / 2, 0.0f)),
|
||||
turned(Vec2F(-kChevronHalf / 2, kChevronHalf)) },
|
||||
style.palette.mutedText,
|
||||
1.5f
|
||||
);
|
||||
// The chevron turns from pointing right (closed) to pointing down (open) as the section opens.
|
||||
const RectF chevron = glyphBox(makeRect(2, 0, kChevronWidth, size().y), kChevronHalf * 2);
|
||||
paintGlyph(painter, Glyph::ChevronRight, chevron, style.palette.mutedText, style, mTurn.value() * kPi / 2);
|
||||
|
||||
const RectF textBox = makeRect(
|
||||
kChevronWidth + 4, 0, size().x - kChevronWidth - 4 - metrics.controlPadding.right - actionWidth(), size().y
|
||||
|
|
@ -131,66 +103,36 @@ void CollapsibleHeader::paint(Canvas& painter) {
|
|||
painter.text(mSummary, textBox, TextStyle{ metrics.smallFontSize, style.palette.mutedText, TextAlign::rightCenter() });
|
||||
}
|
||||
|
||||
class Collapsible::SectionLayout : public Layout {
|
||||
// Shows the bottom of the body through a window whose height is the body's height times how far the section is open,
|
||||
// so the body slides out from under the header.
|
||||
class Collapsible::RevealLayout : public Layout {
|
||||
public:
|
||||
explicit SectionLayout(Collapsible& section) :
|
||||
explicit RevealLayout(Collapsible& section) :
|
||||
mSection(section) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override {
|
||||
const Extent header = context.measure(mSection.mHeader, axis, crossLength);
|
||||
if (!mSection.mReveal.isVisible()) return header;
|
||||
|
||||
const Extent body = context.measure(mSection.mBody, axis, crossLength);
|
||||
if (axis == Axis::Horizontal) {
|
||||
return { std::max(header.minimum, body.minimum), std::max(header.preferred, body.preferred), kUnbounded, 0 };
|
||||
}
|
||||
return Extent::rigid(header.preferred + revealed(body.preferred));
|
||||
if (axis == Axis::Horizontal) return body;
|
||||
return Extent::rigid(std::round(body.preferred * std::clamp(mSection.mOpen.value(), 0.0f, 1.0f)));
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& area) override {
|
||||
const halnf headerHeight = context.measure(mSection.mHeader, Axis::Vertical, area.size.x).preferred;
|
||||
context.place(mSection.mHeader, makeRect(area.pos.x, area.pos.y, area.size.x, headerHeight));
|
||||
if (!mSection.mReveal.isVisible()) return;
|
||||
|
||||
const halnf bodyHeight = context.measure(mSection.mBody, Axis::Vertical, area.size.x).preferred;
|
||||
const halnf shown = revealed(bodyHeight);
|
||||
context.place(mSection.mReveal, makeRect(area.pos.x, area.pos.y + headerHeight, area.size.x, shown));
|
||||
const halnf height = context.measure(mSection.mBody, Axis::Vertical, area.size.x).preferred;
|
||||
context.place(mSection.mBody, makeRect(area.pos.x, area.pos.y + area.size.y - height, area.size.x, height));
|
||||
}
|
||||
|
||||
private:
|
||||
[[nodiscard]] halnf revealed(halnf bodyHeight) const {
|
||||
return std::round(bodyHeight * std::clamp(mSection.mOpen.value(), 0.0f, 1.0f));
|
||||
}
|
||||
|
||||
Collapsible& mSection;
|
||||
};
|
||||
|
||||
class CollapsibleRevealLayout : public Layout {
|
||||
public:
|
||||
explicit CollapsibleRevealLayout(Widget& body) :
|
||||
mBody(body) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override {
|
||||
return context.measure(mBody, axis, crossLength);
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& area) override {
|
||||
const halnf height = context.measure(mBody, Axis::Vertical, area.size.x).preferred;
|
||||
context.place(mBody, makeRect(area.pos.x, area.pos.y + area.size.y - height, area.size.x, height));
|
||||
}
|
||||
|
||||
private:
|
||||
Widget& mBody;
|
||||
};
|
||||
|
||||
Collapsible::Collapsible(std::string title) :
|
||||
mHeader(std::move(title)) {
|
||||
setDebugName("collapsible");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
setClipsChildren(true);
|
||||
setLayout(std::make_unique<SectionLayout>(*this));
|
||||
// The header is placed first but is the last child, so it paints and takes hits over the body sliding out.
|
||||
emplaceLayout<ColumnLayout>(0).setReversed(true);
|
||||
mHeader.setGeometryAnimated(false);
|
||||
setGeometryAnimated(false);
|
||||
|
||||
|
|
@ -203,7 +145,7 @@ Collapsible::Collapsible(std::string title) :
|
|||
mReveal.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mReveal.setGeometryAnimated(false);
|
||||
mReveal.setClipsChildren(true);
|
||||
mReveal.setLayout(std::make_unique<CollapsibleRevealLayout>(mBody));
|
||||
mReveal.setLayout(std::make_unique<RevealLayout>(*this));
|
||||
mReveal.addChild(mBody);
|
||||
mReveal.setVisible(false);
|
||||
|
||||
|
|
@ -227,6 +169,7 @@ void Collapsible::setExpanded(bool expanded, bool animated) {
|
|||
mOpen.jumpTo(target);
|
||||
mReveal.setVisible(expanded);
|
||||
}
|
||||
mReveal.requestLayout();
|
||||
requestLayout();
|
||||
if (mOnToggled) mOnToggled(expanded);
|
||||
}
|
||||
|
|
@ -235,6 +178,7 @@ void Collapsible::finishTransitions() {
|
|||
if (!mOpen.isAnimating()) return;
|
||||
mOpen.finish();
|
||||
if (!mExpanded) mReveal.setVisible(false);
|
||||
mReveal.requestLayout();
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
|
|
@ -244,6 +188,7 @@ void Collapsible::onTick(halnf deltaMs) {
|
|||
stopTicking();
|
||||
if (!mExpanded) mReveal.setVisible(false);
|
||||
}
|
||||
mReveal.requestLayout();
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -100,53 +105,6 @@ Spacer::Spacer(halnf length) {
|
|||
setSizing(AxisSizing::fixed(length), AxisSizing::fixed(length));
|
||||
}
|
||||
|
||||
class ScrollView::ScrollLayout : public Layout {
|
||||
public:
|
||||
explicit ScrollLayout(ScrollView& view) :
|
||||
mView(view) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override {
|
||||
if (!mView.mContent || !mView.mContent->isVisible()) return {};
|
||||
|
||||
Extent extent = context.measure(*mView.mContent, axis, crossLength);
|
||||
if (axis == mView.mScrollAxis) extent.minimum = 0;
|
||||
return extent;
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& contentArea) override {
|
||||
const Axis scroll = mView.mScrollAxis;
|
||||
const Axis cross = crossAxis(scroll);
|
||||
|
||||
if (mView.mContent && mView.mContent->isVisible()) {
|
||||
const halnf crossLength = lengthAlong(contentArea, cross);
|
||||
const halnf contentLength = context.measure(*mView.mContent, scroll, crossLength).preferred;
|
||||
|
||||
mView.mContentLength = std::max(contentLength, lengthAlong(contentArea, scroll));
|
||||
mView.keepOffsetInRange();
|
||||
|
||||
RectF rect;
|
||||
setSpanAlong(
|
||||
rect,
|
||||
scroll,
|
||||
startAlong(contentArea, scroll) - mView.mOffset.value() + mView.stretchOffset(),
|
||||
mView.mContentLength
|
||||
);
|
||||
setSpanAlong(rect, cross, startAlong(contentArea, cross), crossLength);
|
||||
context.place(*mView.mContent, rect);
|
||||
}
|
||||
|
||||
const halnf thickness = sizes::scrollBar::thickness;
|
||||
RectF bar = contentArea;
|
||||
setSpanAlong(bar, cross, startAlong(contentArea, cross) + lengthAlong(contentArea, cross) - thickness, thickness);
|
||||
mView.mScrollBar.setVisible(mView.mShowsScrollBar && mView.isScrollable());
|
||||
if (mView.mScrollBar.isVisible()) context.place(mView.mScrollBar, bar);
|
||||
}
|
||||
|
||||
private:
|
||||
ScrollView& mView;
|
||||
};
|
||||
|
||||
ScrollBar::ScrollBar(ScrollView& view) :
|
||||
mView(view) {
|
||||
setDebugName("scroll bar");
|
||||
|
|
@ -154,7 +112,6 @@ ScrollBar::ScrollBar(ScrollView& view) :
|
|||
}
|
||||
|
||||
RectF ScrollBar::trackRect() const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
const Axis axis = mView.scrollAxis();
|
||||
const Axis cross = crossAxis(axis);
|
||||
const halnf thickness = sizes::scrollBar::trackThickness;
|
||||
|
|
@ -198,48 +155,51 @@ void ScrollBar::flash(halnf durationMs) {
|
|||
refreshReveal();
|
||||
}
|
||||
|
||||
void ScrollBar::grab(halnf pointerAlong, bool animate) {
|
||||
const Axis axis = mView.scrollAxis();
|
||||
const RectF thumb = thumbRect();
|
||||
const bool onThumb = pointerAlong >= startAlong(thumb, axis) && pointerAlong <= startAlong(thumb, axis) + lengthAlong(thumb, axis);
|
||||
// A press on the track centers the thumb under the pointer; a press on the thumb keeps where it was taken.
|
||||
mGrabOffset = onThumb ? pointerAlong - startAlong(thumb, axis) : lengthAlong(thumb, axis) / 2;
|
||||
if (onThumb) return;
|
||||
if (animate) mView.scrollTo(offsetForThumbAt(pointerAlong));
|
||||
else mView.jumpTo(offsetForThumbAt(pointerAlong));
|
||||
}
|
||||
|
||||
void ScrollBar::onEvent(Event& event) {
|
||||
const Axis axis = mView.scrollAxis();
|
||||
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
{
|
||||
if (event.button() != PointerButton::Left) return;
|
||||
const RectF thumb = thumbRect();
|
||||
const halnf pointer = along(event.position(), axis);
|
||||
const bool onThumb =
|
||||
pointer >= startAlong(thumb, axis) && pointer <= startAlong(thumb, axis) + lengthAlong(thumb, axis);
|
||||
|
||||
mGrabOffset = onThumb ? pointer - startAlong(thumb, axis) : lengthAlong(thumb, axis) / 2;
|
||||
mDragging = true;
|
||||
ui()->capturePointer(*this);
|
||||
if (!onThumb) mView.scrollTo(offsetForThumbAt(pointer));
|
||||
break;
|
||||
}
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (!mDragging) return;
|
||||
mView.jumpTo(offsetForThumbAt(along(event.position(), axis)));
|
||||
break;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (!mDragging || event.button() != PointerButton::Left) return;
|
||||
mDragging = false;
|
||||
ui()->releasePointer(*this);
|
||||
break;
|
||||
|
||||
default: return;
|
||||
if (event.type() == Event::Type::PointerDown && event.button() == PointerButton::Left) {
|
||||
grab(along(event.position(), axis), true);
|
||||
mDragging = true;
|
||||
refreshReveal();
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
event.accept();
|
||||
if (event.type() == Event::Type::PointerUp && event.button() == PointerButton::Left && mDragging) {
|
||||
mDragging = false;
|
||||
refreshReveal();
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
const Dragged* drag = event.drag();
|
||||
if (!drag || drag->button != PointerButton::Left) return;
|
||||
if (drag->phase == DragPhase::Started || drag->phase == DragPhase::Moved) {
|
||||
mDragging = true;
|
||||
mView.jumpTo(offsetForThumbAt(along(event.position(), axis)));
|
||||
} else {
|
||||
mDragging = false;
|
||||
}
|
||||
refreshReveal();
|
||||
event.accept();
|
||||
}
|
||||
|
||||
void ScrollBar::onHoverChanged() { refreshReveal(); }
|
||||
|
||||
void ScrollBar::refreshReveal() {
|
||||
mReveal.animateTo(isHovered() || mDragging || mFlashMs > 0 ? 1.0f : 0.0f);
|
||||
startTicking();
|
||||
mVisual.setRevealHeld(mDragging || mFlashMs > 0);
|
||||
if (mVisual.refresh() || mFlashMs > 0) startTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void ScrollBar::onTick(halnf deltaMs) {
|
||||
|
|
@ -248,12 +208,13 @@ void ScrollBar::onTick(halnf deltaMs) {
|
|||
if (mFlashMs <= 0) refreshReveal();
|
||||
}
|
||||
|
||||
if (!mReveal.advance(deltaMs) && mFlashMs <= 0) stopTicking();
|
||||
const bool revealing = mVisual.advance(deltaMs);
|
||||
if (!revealing && mFlashMs <= 0) stopTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void ScrollBar::paint(Canvas& painter) {
|
||||
const halnf reveal = clampLength(mReveal.value(), 0, 1);
|
||||
const halnf reveal = clampLength(mVisual.reveal(), 0, 1);
|
||||
if (reveal <= 0.01f) return;
|
||||
|
||||
const Axis cross = crossAxis(mView.scrollAxis());
|
||||
|
|
@ -274,209 +235,61 @@ void ScrollBar::paint(Canvas& painter) {
|
|||
painter.rect(thumb, { muted.r, muted.g, muted.b, active ? 0.65f : 0.45f }, lengthAlong(thumb, cross) / 2);
|
||||
}
|
||||
|
||||
namespace {
|
||||
ViewportPolicy scrollPolicy(Axis axis) {
|
||||
ViewportPolicy policy = axis == Axis::Horizontal ? ViewportPolicy::horizontalStrip() : ViewportPolicy::verticalList();
|
||||
policy.motion = ViewportPolicy::ScrollMotion::Animated;
|
||||
return policy;
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView::ScrollView(Axis scrollAxis) :
|
||||
mScrollAxis(scrollAxis),
|
||||
mOffset(0, Motion::quick()) {
|
||||
Viewport(scrollPolicy(scrollAxis)),
|
||||
mScrollAxis(scrollAxis) {
|
||||
setDebugName("scroll view");
|
||||
setClipsChildren(true);
|
||||
setLayout(std::make_unique<ScrollLayout>(*this));
|
||||
addChild(mScrollBar);
|
||||
}
|
||||
|
||||
void ScrollView::setContent(Widget* content) {
|
||||
if (mContent) removeChild(*mContent);
|
||||
|
||||
mContent = content;
|
||||
if (mContent) {
|
||||
insertChild(0, *mContent);
|
||||
mContent->setGeometryAnimated(false);
|
||||
}
|
||||
scrollTo(0);
|
||||
Vec2F ScrollView::alongAxis(halnf length) const {
|
||||
Vec2F vector = { 0, 0 };
|
||||
along(vector, mScrollAxis) = length;
|
||||
return vector;
|
||||
}
|
||||
|
||||
void ScrollView::scrollTo(halnf offset) {
|
||||
mOffset.animateTo(clampLength(offset, 0, maximumOffset()));
|
||||
if (isScrollable() && mShowsScrollBar) mScrollBar.flash(kShowAfterScrollMs);
|
||||
startTicking();
|
||||
halnf ScrollView::contentLength() const {
|
||||
return content() ? std::max(lengthAlong(content()->targetRect(), mScrollAxis), viewportLength()) : viewportLength();
|
||||
}
|
||||
|
||||
void ScrollView::jumpTo(halnf offset) {
|
||||
mOffset.jumpTo(clampLength(offset, 0, maximumOffset()));
|
||||
applyOffset();
|
||||
void ScrollView::setWheelStep(halnf step) {
|
||||
ViewportPolicy changed = policy();
|
||||
changed.wheelStep = step;
|
||||
setPolicy(changed);
|
||||
}
|
||||
|
||||
void ScrollView::scrollBy(halnf delta) { scrollTo(mOffset.target() + delta); }
|
||||
|
||||
halnf ScrollView::maximumOffset() const {
|
||||
const halnf overflow = mContentLength - viewportLength();
|
||||
return overflow > 0 ? overflow : 0;
|
||||
void ScrollView::revealRange(halnf start, halnf length, std::optional<halnf> margin) {
|
||||
scrollTo(revealOffsetFor(start, length, margin));
|
||||
}
|
||||
|
||||
Extent ScrollView::measureContent(Axis, halnf) const { return {}; }
|
||||
|
||||
void ScrollView::onEvent(Event& event) {
|
||||
if (event.type() == Event::Type::Wheel) {
|
||||
if (!isScrollable() || event.scrollDelta().y == 0) return;
|
||||
scrollBy(-event.scrollDelta().y * mWheelStep);
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
trackDrag(event);
|
||||
halnf ScrollView::revealOffsetFor(halnf start, halnf length, std::optional<halnf> margin) const {
|
||||
RectF range = makeRect(0, 0, 0, 0);
|
||||
setSpanAlong(range, mScrollAxis, start, length);
|
||||
return along(revealOffset(range, margin), mScrollAxis);
|
||||
}
|
||||
|
||||
void ScrollView::filterDescendantEvent(Event& event) { trackDrag(event); }
|
||||
|
||||
bool ScrollView::startsDrag(const Event& event) const {
|
||||
return event.type() == Event::Type::PointerDown && event.button() == PointerButton::Left && ui() &&
|
||||
ui()->isTouchMode() && isScrollable() && !mScrollBar.isDragging();
|
||||
void ScrollView::arrangeOverlays(LayoutContext& context, const RectF& area) {
|
||||
const Axis cross = crossAxis(mScrollAxis);
|
||||
const halnf thickness = sizes::scrollBar::thickness;
|
||||
RectF bar = area;
|
||||
setSpanAlong(bar, cross, startAlong(area, cross) + lengthAlong(area, cross) - thickness, thickness);
|
||||
mScrollBar.setVisible(mShowsScrollBar && isScrollable());
|
||||
if (mScrollBar.isVisible()) context.place(mScrollBar, bar);
|
||||
}
|
||||
|
||||
void ScrollView::trackDrag(Event& event) {
|
||||
if (startsDrag(event)) {
|
||||
const bool catching = mFling.isActive() || mStartStretch.isActive() || mEndStretch.isActive();
|
||||
mFling.stop();
|
||||
mFlingVelocity = 0;
|
||||
mStartStretch.pull(0);
|
||||
mEndStretch.pull(0);
|
||||
mDragPending = true;
|
||||
mDragging = catching;
|
||||
mDragStart = event.windowPosition();
|
||||
mDragStartOffset = mOffset.value();
|
||||
mLastDragAlong = 0;
|
||||
mDragSamples.clear();
|
||||
rememberDragSample(event.sample().timeMs);
|
||||
if (catching) {
|
||||
ui()->capturePointer(*this);
|
||||
event.accept();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!mDragPending) return;
|
||||
|
||||
if (event.type() == Event::Type::PointerMove) {
|
||||
const Widget* holder = ui()->pointerCapture();
|
||||
if (!mDragging && holder && holder != this) {
|
||||
mDragPending = false;
|
||||
return;
|
||||
}
|
||||
const halnf moved = along(event.windowPosition() - mDragStart, mScrollAxis);
|
||||
if (!mDragging && std::abs(moved) < kDragThreshold) return;
|
||||
if (!mDragging) {
|
||||
mDragging = true;
|
||||
ui()->capturePointer(*this);
|
||||
}
|
||||
dragBy(mLastDragAlong - moved);
|
||||
mLastDragAlong = moved;
|
||||
rememberDragSample(event.sample().timeMs);
|
||||
event.accept();
|
||||
} else if (event.type() == Event::Type::PointerUp && event.button() == PointerButton::Left) {
|
||||
mDragPending = false;
|
||||
if (!mDragging) return;
|
||||
mDragging = false;
|
||||
ui()->releasePointer(*this);
|
||||
event.accept();
|
||||
const halnf velocity = averageDragVelocity(event.sample().timeMs);
|
||||
const bool stretched = mStartStretch.isActive() || mEndStretch.isActive();
|
||||
mStartStretch.release();
|
||||
mEndStretch.release();
|
||||
if (stretched) {
|
||||
// The stretch absorbs the release, it does not fling.
|
||||
(velocity < 0 ? mStartStretch : mEndStretch).absorb(velocity);
|
||||
} else if (std::abs(velocity) >= kFlingMinimumSpeed) {
|
||||
mFling.start(mOffset.value(), velocity * 1000.0f, 0, maximumOffset());
|
||||
mFlingVelocity = velocity;
|
||||
}
|
||||
startTicking();
|
||||
}
|
||||
}
|
||||
|
||||
void ScrollView::rememberDragSample(uint4 timeMs) {
|
||||
if (!mDragSamples.empty() && mDragSamples.back().timeMs == timeMs) {
|
||||
mDragSamples.back().offset = mOffset.value();
|
||||
return;
|
||||
}
|
||||
mDragSamples.push_back({ timeMs, mOffset.value() });
|
||||
while (mDragSamples.size() > 2 && timeMs - mDragSamples[1].timeMs > kFlingRecentMs)
|
||||
mDragSamples.erase(mDragSamples.begin());
|
||||
}
|
||||
|
||||
void ScrollView::dragBy(halnf delta) {
|
||||
halnf remaining = delta;
|
||||
if (remaining < 0 && mEndStretch.distance() > 0) remaining -= mEndStretch.pull(remaining);
|
||||
if (remaining > 0 && mStartStretch.distance() > 0) remaining += mStartStretch.pull(-remaining);
|
||||
const halnf target = mOffset.value() + remaining;
|
||||
const halnf limit = maximumOffset();
|
||||
if (target < 0) {
|
||||
mOffset.jumpTo(0);
|
||||
mStartStretch.setLength(viewportLength());
|
||||
mStartStretch.pull(-target);
|
||||
} else if (target > limit) {
|
||||
mOffset.jumpTo(limit);
|
||||
mEndStretch.setLength(viewportLength());
|
||||
mEndStretch.pull(target - limit);
|
||||
} else {
|
||||
mOffset.jumpTo(target);
|
||||
}
|
||||
applyOffset();
|
||||
}
|
||||
|
||||
halnf ScrollView::stretchOffset() const { return mStartStretch.visualOffset() - mEndStretch.visualOffset(); }
|
||||
|
||||
halnf ScrollView::averageDragVelocity(uint4 nowMs) const { return averageVelocity(mDragSamples, nowMs); }
|
||||
|
||||
halnf ScrollView::averageVelocity(const std::vector<DragSample>& samples, uint4 nowMs) {
|
||||
if (samples.size() < 2) return 0;
|
||||
const DragSample& newest = samples.back();
|
||||
if (nowMs - newest.timeMs >= kFlingRecentMs) return 0;
|
||||
size_t first = samples.size() - 1;
|
||||
while (first > 0 && newest.timeMs - samples[first - 1].timeMs <= kFlingRecentMs)
|
||||
first--;
|
||||
if (first == samples.size() - 1) first--;
|
||||
const DragSample& oldest = samples[first];
|
||||
if (newest.timeMs <= oldest.timeMs) return 0;
|
||||
return (newest.offset - oldest.offset) / (halnf) (newest.timeMs - oldest.timeMs);
|
||||
}
|
||||
|
||||
void ScrollView::onTick(halnf deltaMs) {
|
||||
bool moving = false;
|
||||
if (mFling.isActive()) {
|
||||
moving = mFling.advance(deltaMs);
|
||||
mOffset.jumpTo(mFling.position());
|
||||
mFlingVelocity = mFling.velocity() / 1000.0f;
|
||||
if (!moving) {
|
||||
const halnf impact = mFling.velocity();
|
||||
mFlingVelocity = 0;
|
||||
if (mFling.hitEdge() && impact != 0) {
|
||||
StretchEffect& edge = impact < 0 ? mStartStretch : mEndStretch;
|
||||
edge.setLength(viewportLength());
|
||||
edge.absorb(impact);
|
||||
}
|
||||
}
|
||||
moving = true;
|
||||
} else if (mStartStretch.isActive() || mEndStretch.isActive()) {
|
||||
const bool start = mStartStretch.advance(deltaMs);
|
||||
const bool end = mEndStretch.advance(deltaMs);
|
||||
moving = start || end;
|
||||
} else {
|
||||
moving = mOffset.advance(deltaMs);
|
||||
}
|
||||
applyOffset();
|
||||
if (!moving) stopTicking();
|
||||
}
|
||||
|
||||
halnf ScrollView::viewportLength() const { return lengthAlong(localRect(), mScrollAxis); }
|
||||
|
||||
void ScrollView::keepOffsetInRange() {
|
||||
const halnf limit = maximumOffset();
|
||||
if (mOffset.target() > limit) mOffset.animateTo(limit);
|
||||
if (mOffset.value() > limit) mOffset.jumpTo(limit);
|
||||
}
|
||||
|
||||
void ScrollView::applyOffset() {
|
||||
const halnf current = mOffset.value();
|
||||
const bool moved = current != mReportedOffset;
|
||||
void ScrollView::offsetChanged() {
|
||||
const halnf current = currentOffset();
|
||||
if (current == mReportedOffset) return;
|
||||
mReportedOffset = current;
|
||||
if (moved && mOnScrolled) mOnScrolled(current);
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
// Note: the bar shows briefly whenever the content moves, by wheel, glide, fling or a program.
|
||||
if (isScrollable() && mShowsScrollBar && !mScrollBar.isDragging()) mScrollBar.flash(kShowAfterScrollMs);
|
||||
if (mOnScrolled) mOnScrolled(current);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,30 +31,30 @@ RectF ResizeHandle::lineRect() const {
|
|||
}
|
||||
|
||||
void ResizeHandle::onEvent(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
if (event.button() != PointerButton::Left) return;
|
||||
mDragging = true;
|
||||
mGrabOffset = along(event.position(), mDragAxis) - lengthAlong(localRect(), mDragAxis) / 2;
|
||||
ui()->capturePointer(*this);
|
||||
if (mOnStarted) mOnStarted();
|
||||
break;
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (!mDragging) return;
|
||||
if (mOnDragged) mOnDragged(along(event.windowPosition(), mDragAxis) - mGrabOffset);
|
||||
break;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (!mDragging || event.button() != PointerButton::Left) return;
|
||||
mDragging = false;
|
||||
ui()->releasePointer(*this);
|
||||
if (mOnFinished) mOnFinished();
|
||||
break;
|
||||
|
||||
default: return;
|
||||
if (event.type() == Event::Type::PointerDown && event.button() == PointerButton::Left) {
|
||||
// Where the handle was taken, so the resized edge does not jump to the pointer.
|
||||
mGrabOffset = along(event.position(), mDragAxis) - lengthAlong(localRect(), mDragAxis) / 2;
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
const Dragged* drag = event.drag();
|
||||
if (!drag || drag->button != PointerButton::Left) return;
|
||||
|
||||
switch (drag->phase) {
|
||||
case DragPhase::Started:
|
||||
mDragging = true;
|
||||
if (mOnStarted) mOnStarted();
|
||||
[[fallthrough]];
|
||||
case DragPhase::Moved:
|
||||
if (mOnDragged) mOnDragged(along(event.windowPosition(), mDragAxis) - mGrabOffset);
|
||||
break;
|
||||
case DragPhase::Finished:
|
||||
case DragPhase::Cancelled:
|
||||
mDragging = false;
|
||||
if (mOnFinished) mOnFinished();
|
||||
break;
|
||||
}
|
||||
event.accept();
|
||||
refreshReveal();
|
||||
}
|
||||
|
|
@ -62,20 +62,18 @@ void ResizeHandle::onEvent(Event& event) {
|
|||
void ResizeHandle::onHoverChanged() { refreshReveal(); }
|
||||
|
||||
void ResizeHandle::refreshReveal() {
|
||||
const halnf target = isHovered() || mDragging ? 1.0f : 0.0f;
|
||||
if (mReveal.target() == target && !mReveal.isAnimating()) return;
|
||||
|
||||
mReveal.animateTo(target);
|
||||
startTicking();
|
||||
mVisual.setRevealHeld(mDragging);
|
||||
if (mVisual.refresh()) startTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void ResizeHandle::onTick(halnf deltaMs) {
|
||||
if (!mReveal.advance(deltaMs)) stopTicking();
|
||||
if (!mVisual.advance(deltaMs)) stopTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void ResizeHandle::paint(Canvas& painter) {
|
||||
const halnf reveal = mReveal.value();
|
||||
const halnf reveal = mVisual.reveal();
|
||||
if (reveal <= 0.01f) return;
|
||||
|
||||
const Palette& palette = theme().palette;
|
||||
|
|
|
|||
100
Widgets/src/components/controls/private/SegmentedControl.cpp
Normal file
100
Widgets/src/components/controls/private/SegmentedControl.cpp
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#include "SegmentedControl.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Event.hpp"
|
||||
#include "Ui.hpp"
|
||||
|
||||
#include <numeric>
|
||||
|
||||
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<RowLayout>(kSegmentGap);
|
||||
mSelection.setOnChanged([this]() {
|
||||
refreshButtons();
|
||||
if (mUserChange && mOnIndexChanged) mOnIndexChanged();
|
||||
});
|
||||
}
|
||||
|
||||
Button& SegmentedControlBase::addButton(std::string text) {
|
||||
std::unique_ptr<Button> button = mFactory ? mFactory(std::move(text)) : std::make_unique<Button>(std::move(text));
|
||||
button->setStyle(mButtonStyle);
|
||||
Button* raw = button.get();
|
||||
button->setOnActivate([this, raw]() {
|
||||
// Note: the index is looked up on activation, so removing an earlier segment keeps this one correct.
|
||||
for (size_t index = 0; index < mButtons.size(); index++) {
|
||||
if (mButtons[index].get() == raw) clicked(index);
|
||||
}
|
||||
});
|
||||
addChild(*button);
|
||||
mButtons.push_back(std::move(button));
|
||||
refreshButtons();
|
||||
return *mButtons.back();
|
||||
}
|
||||
|
||||
void SegmentedControlBase::removeButton(size_t index) {
|
||||
if (index >= mButtons.size()) return;
|
||||
|
||||
const std::optional<size_t> chosen = mSelection.current();
|
||||
removeChild(*mButtons[index]);
|
||||
mButtons.erase(mButtons.begin() + (std::ptrdiff_t) index);
|
||||
// The choice follows its segment; removing the chosen segment leaves no choice until the owner picks one.
|
||||
if (!chosen || *chosen == index) mSelection.clear();
|
||||
else if (*chosen > index) mSelection.select(*chosen - 1);
|
||||
refreshButtons();
|
||||
}
|
||||
|
||||
void SegmentedControlBase::setButtonStyle(Button::Style style) {
|
||||
mButtonStyle = style;
|
||||
for (auto& button : mButtons)
|
||||
button->setStyle(style);
|
||||
}
|
||||
|
||||
void SegmentedControlBase::selectIndex(size_t index) {
|
||||
if (index < mButtons.size()) mSelection.select(index);
|
||||
}
|
||||
|
||||
std::vector<size_t> SegmentedControlBase::order() const {
|
||||
std::vector<size_t> indices(mButtons.size());
|
||||
std::iota(indices.begin(), indices.end(), 0);
|
||||
return indices;
|
||||
}
|
||||
|
||||
void SegmentedControlBase::clicked(size_t index) {
|
||||
const KeyModifiers modifiers = ui() ? ui()->modifiers() : KeyModifiers{};
|
||||
mUserChange = true;
|
||||
// Note: in single mode a click on the chosen segment keeps it chosen; there is always one choice.
|
||||
if (mSelection.mode() == Mode::Single) mSelection.select(index);
|
||||
else mSelection.click(index, modifiers, order());
|
||||
mUserChange = false;
|
||||
}
|
||||
|
||||
void SegmentedControlBase::onEvent(Event& event) {
|
||||
if (event.type() != Event::Type::KeyDown || mButtons.empty()) return;
|
||||
|
||||
const size_t current = mSelection.current().value_or(0);
|
||||
size_t next = current;
|
||||
if (event.isKey(Key::Left) || event.isKey(Key::Up)) next = current == 0 ? mButtons.size() - 1 : current - 1;
|
||||
else if (event.isKey(Key::Right) || event.isKey(Key::Down)) next = (current + 1) % mButtons.size();
|
||||
else if (event.isKey(Key::Home)) next = 0;
|
||||
else if (event.isKey(Key::End)) next = mButtons.size() - 1;
|
||||
else return;
|
||||
|
||||
mUserChange = true;
|
||||
mSelection.select(next);
|
||||
mUserChange = false;
|
||||
event.accept();
|
||||
}
|
||||
|
||||
void SegmentedControlBase::refreshButtons() {
|
||||
for (size_t index = 0; index < mButtons.size(); index++)
|
||||
mButtons[index]->setSelected(mSelection.isSelected(index));
|
||||
}
|
||||
|
|
@ -134,28 +134,36 @@ Extent Slider::measureContent(Axis axis, halnf) const {
|
|||
return Extent::natural(sizes::slider::handleSize + 4);
|
||||
}
|
||||
|
||||
void Slider::setDragging(bool dragging) {
|
||||
if (dragging == mDragging) return;
|
||||
mDragging = dragging;
|
||||
if (mOnDragging) mOnDragging(dragging);
|
||||
}
|
||||
|
||||
void Slider::onEvent(Event& event) {
|
||||
if (const Dragged* drag = event.drag()) {
|
||||
if (drag->button != PointerButton::Left) return;
|
||||
// The press already moved the value; the router's drag follows the pointer from there.
|
||||
const bool ongoing = drag->phase == DragPhase::Started || drag->phase == DragPhase::Moved;
|
||||
setDragging(ongoing);
|
||||
if (ongoing) changeValue(valueAt(event.position()));
|
||||
event.accept();
|
||||
refreshVisualState();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
if (event.button() != PointerButton::Left) return;
|
||||
mDragging = true;
|
||||
ui()->capturePointer(*this);
|
||||
if (mOnDragging) mOnDragging(true);
|
||||
changeValue(valueAt(event.position()));
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (!mDragging) return;
|
||||
setDragging(true);
|
||||
changeValue(valueAt(event.position()));
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (!mDragging || event.button() != PointerButton::Left) return;
|
||||
mDragging = false;
|
||||
ui()->releasePointer(*this);
|
||||
if (mOnDragging) mOnDragging(false);
|
||||
// A press that never became a drag ends here; a drag ends in its Finished phase, delivered right after.
|
||||
if (!mDragging || event.button() != PointerButton::Left || (ui() && ui()->isDragging())) return;
|
||||
setDragging(false);
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
#include "TextArea.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Canvas.hpp"
|
||||
|
||||
#include "Event.hpp"
|
||||
|
|
@ -17,30 +19,22 @@ namespace {
|
|||
constexpr halnf kBlinkPeriodMs = 1060;
|
||||
constexpr halnf kCaretWidth = 1.5f;
|
||||
constexpr halnf kHandleThickness = 8;
|
||||
constexpr halnf kMinimumWidth = 40;
|
||||
constexpr halnf kTextInset = 8;
|
||||
|
||||
bool isSpace(char character) { return character == ' '; }
|
||||
}
|
||||
|
||||
class TextArea::AreaLayout : public Layout {
|
||||
// The scroller over the resize handle; after placing them the area keeps the caret in view if its height changed.
|
||||
class TextArea::AreaLayout : public ColumnLayout {
|
||||
public:
|
||||
explicit AreaLayout(TextArea& area) :
|
||||
mArea(area) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext&, Axis axis, halnf) const override {
|
||||
if (axis == Axis::Vertical && mArea.mFillsHeight)
|
||||
return Extent::shrinkable(mArea.mMinimumHeight, mArea.mMinimumHeight);
|
||||
if (axis == Axis::Vertical) return Extent::rigid(mArea.mBoxHeight);
|
||||
return { 40, 200, kUnbounded, 0 };
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& contentArea) override {
|
||||
context.place(mArea.mScroll, contentArea);
|
||||
const halnf top = contentArea.pos.y + contentArea.size.y - kHandleThickness;
|
||||
const halnf handleThickness = mArea.mFillsHeight ? 0 : kHandleThickness;
|
||||
context.place(mArea.mHandle, makeRect(contentArea.pos.x, top, contentArea.size.x, handleThickness));
|
||||
mArea.viewportArranged(contentArea.size.y);
|
||||
ColumnLayout::arrangeChildren(context, contentArea);
|
||||
mArea.viewportArranged(mArea.mScroll.targetRect().size.y);
|
||||
}
|
||||
|
||||
private:
|
||||
|
|
@ -351,10 +345,7 @@ void TextAreaEditor::onEvent(Event& event) {
|
|||
mPreferredX = -1;
|
||||
mEditor.pointerDown(event, positionAt(event.position()));
|
||||
break;
|
||||
case Event::Type::PointerMove:
|
||||
mEditor.pointerMove(event, positionAt(event.position()));
|
||||
break;
|
||||
case Event::Type::PointerUp: mEditor.pointerUp(event); break;
|
||||
case Event::Type::Drag: mEditor.drag(event, positionAt(event.position())); break;
|
||||
case Event::Type::KeyDown: mEditor.key(event); break;
|
||||
case Event::Type::TextInput: mEditor.text(event); break;
|
||||
default: return;
|
||||
|
|
@ -415,6 +406,8 @@ TextArea::TextArea(std::string text) {
|
|||
mScroll.setContent(&mEditor);
|
||||
mScroll.setWheelStep(theme().metrics.lineHeight * 3);
|
||||
|
||||
mScroll.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mHandle.setSizing(AxisSizing::fill(), AxisSizing::fixed(kHandleThickness));
|
||||
mHandle.setCursor(CursorShape::ResizeVertical);
|
||||
mHandle.setOnDragStarted([this] {
|
||||
mDragStartHeight = mBoxHeight;
|
||||
|
|
@ -440,7 +433,11 @@ void TextArea::setBoxHeight(halnf height) {
|
|||
applyBoxHeight();
|
||||
}
|
||||
|
||||
void TextArea::applyBoxHeight() { requestLayout(); }
|
||||
void TextArea::applyBoxHeight() {
|
||||
// The area's own sizing is its height: the box height the user dragged to, or filling its parent.
|
||||
const AxisSizing height = mFillsHeight ? AxisSizing::fill().atLeast(mMinimumHeight) : AxisSizing::fixed(mBoxHeight);
|
||||
setSizing(AxisSizing::fill().atLeast(kMinimumWidth), height);
|
||||
}
|
||||
|
||||
void TextArea::setFontScale(halnf scale) {
|
||||
mEditor.setFontScale(scale);
|
||||
|
|
@ -452,8 +449,7 @@ void TextArea::setFillsHeight(bool fills) {
|
|||
|
||||
mFillsHeight = fills;
|
||||
mHandle.setVisible(!fills);
|
||||
setSizing(AxisSizing::fill(), fills ? AxisSizing::fill() : AxisSizing::fit());
|
||||
requestLayout();
|
||||
applyBoxHeight();
|
||||
}
|
||||
|
||||
void TextArea::viewportArranged(halnf height) {
|
||||
|
|
@ -468,22 +464,13 @@ void TextArea::onTick(halnf) {
|
|||
revealCaret();
|
||||
}
|
||||
|
||||
halnf TextArea::caretRevealOffset(halnf viewportHeight) const {
|
||||
const RectF caret = mEditor.caretRect();
|
||||
const halnf top = caret.pos.y - kTextInset / 2;
|
||||
const halnf bottom = caret.pos.y + caret.size.y + kTextInset / 2;
|
||||
const halnf offset = mScroll.offset();
|
||||
|
||||
if (top < offset) return top;
|
||||
if (bottom > offset + viewportHeight) return bottom - viewportHeight;
|
||||
return offset;
|
||||
}
|
||||
|
||||
void TextArea::revealCaret() {
|
||||
if (ui()) ui()->updateLayout();
|
||||
if (!mScroll.size().y) return;
|
||||
|
||||
mScroll.jumpTo(caretRevealOffset(mScroll.size().y));
|
||||
// The caret follows typing at once, with the text inset as its margin so the line it is on shows whole.
|
||||
const RectF caret = mEditor.caretRect();
|
||||
mScroll.jumpTo(mScroll.revealOffsetFor(caret.pos.y, caret.size.y, kTextInset / 2));
|
||||
}
|
||||
|
||||
void TextArea::setFillsWithWindowColor(bool window) {
|
||||
|
|
|
|||
|
|
@ -52,28 +52,21 @@ void TextEditor::pointerDown(Event& event, size_t position) {
|
|||
} else {
|
||||
setCaret(position, event.modifiers().shift);
|
||||
}
|
||||
// Only a single plain press selects by dragging; a focusing press selected everything and keeps it.
|
||||
mDragging = event.clickCount() == 1 && !focusPress;
|
||||
mFocusedByPress = false;
|
||||
if (mHost.ui()) {
|
||||
// The editor may be a grandchild of the pressed widget (a scroll view's content), so the
|
||||
// router's focus walk cannot reach it; focus the host explicitly.
|
||||
mHost.ui()->setKeyboardFocus(&mHost);
|
||||
mHost.ui()->capturePointer(mHost);
|
||||
}
|
||||
// The editor may be a grandchild of the pressed widget (a scroll view's content), so the router's focus walk
|
||||
// cannot reach it; focus the host explicitly.
|
||||
if (mHost.ui()) mHost.ui()->setKeyboardFocus(&mHost);
|
||||
caretMoved();
|
||||
event.accept();
|
||||
}
|
||||
|
||||
void TextEditor::pointerMove(Event& event, size_t position) {
|
||||
if (!mDragging) return;
|
||||
setCaret(position, true);
|
||||
event.accept();
|
||||
}
|
||||
|
||||
void TextEditor::pointerUp(Event& event) {
|
||||
if (event.button() != PointerButton::Left || !mDragging) return;
|
||||
mDragging = false;
|
||||
if (mHost.ui()) mHost.ui()->releasePointer(mHost);
|
||||
void TextEditor::drag(Event& event, size_t position) {
|
||||
const Dragged* dragged = event.drag();
|
||||
if (!dragged || dragged->button != PointerButton::Left || !mDragging) return;
|
||||
if (dragged->phase == DragPhase::Finished || dragged->phase == DragPhase::Cancelled) mDragging = false;
|
||||
else setCaret(position, true);
|
||||
event.accept();
|
||||
}
|
||||
|
||||
|
|
@ -121,8 +114,12 @@ void TextEditor::key(Event& event) {
|
|||
case Key::Delete: eraseForward(control); break;
|
||||
case Key::Enter:
|
||||
case Key::KeypadEnter:
|
||||
if (control || mSingleLine) commit();
|
||||
else insert("\n");
|
||||
if (control || mSingleLine) {
|
||||
commit();
|
||||
if (mOnSubmitted) mOnSubmitted(mBuffer.text());
|
||||
} else {
|
||||
insert("\n");
|
||||
}
|
||||
break;
|
||||
case Key::Escape:
|
||||
// Focusing elsewhere commits; the area only drops the focus, the field commits through it.
|
||||
|
|
@ -188,7 +185,10 @@ void TextEditor::focusChanged() {
|
|||
mFocused = focused;
|
||||
mFocusedByPress = focused && mHost.ui() && mHost.ui()->isButtonDown(PointerButton::Left);
|
||||
updateTextInputArea();
|
||||
caretMoved();
|
||||
// Focus only restarts the blink: the caret has not moved, so its owner must not scroll to it (a press that focuses
|
||||
// moves the caret right after, and that move is what reveals).
|
||||
mBlinkMs = 0;
|
||||
mHost.requestPaint();
|
||||
if (focused) {
|
||||
if (mOnEditingStarted) mOnEditingStarted();
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "TextField.hpp"
|
||||
#include "Canvas.hpp"
|
||||
#include "Glyph.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
|
|
@ -62,11 +63,7 @@ void TextField::onEvent(Event& event) {
|
|||
mEditor.pointerDown(event, positionAt(event.position().x));
|
||||
break;
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
mEditor.pointerMove(event, positionAt(event.position().x));
|
||||
break;
|
||||
|
||||
case Event::Type::PointerUp: mEditor.pointerUp(event); break;
|
||||
case Event::Type::Drag: mEditor.drag(event, positionAt(event.position().x)); break;
|
||||
|
||||
case Event::Type::KeyDown: mEditor.key(event); break;
|
||||
|
||||
|
|
@ -171,11 +168,8 @@ StepButton::StepButton(int direction) :
|
|||
}
|
||||
|
||||
void StepButton::paintIcon(Canvas& painter, const RectF& box) {
|
||||
const Vec2F center = box.pos + box.size / 2.0f;
|
||||
const halnf half = std::min(box.size.x, box.size.y) / 2;
|
||||
const RGBA color = isEffectivelyEnabled() ? theme().palette.text : theme().palette.mutedText;
|
||||
painter.line(center - Vec2F(half, 0.0f), center + Vec2F(half, 0.0f), color, kStepIconStroke);
|
||||
if (mDirection > 0) painter.line(center - Vec2F(0.0f, half), center + Vec2F(0.0f, half), color, kStepIconStroke);
|
||||
paintGlyph(painter, mDirection > 0 ? Glyph::Plus : Glyph::Minus, box, color, kStepIconStroke);
|
||||
}
|
||||
|
||||
NumberField::NumberField(halnf minimum, halnf maximum, halnf value, halnf step) :
|
||||
|
|
|
|||
130
Widgets/src/components/controls/private/TitledPanel.cpp
Normal file
130
Widgets/src/components/controls/private/TitledPanel.cpp
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
#include "TitledPanel.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kHeaderGap = 2;
|
||||
constexpr halnf kFooterGap = 6;
|
||||
const Insets kHeaderPadding = Insets::symmetric(8, 1);
|
||||
const Insets kBodyPadding = Insets::uniform(6);
|
||||
}
|
||||
|
||||
// Header, body and footer in a column; the corner widget, if any, sits over the bottom-right corner.
|
||||
class TitledPanel::TitledLayout : public ColumnLayout {
|
||||
public:
|
||||
explicit TitledLayout(TitledPanel& panel) :
|
||||
mPanel(panel) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override {
|
||||
return mPanel.mCorner ? ColumnLayout::measureChildren(context.without(*mPanel.mCorner), axis, crossLength)
|
||||
: ColumnLayout::measureChildren(context, axis, crossLength);
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& area) override {
|
||||
if (!mPanel.mCorner) {
|
||||
ColumnLayout::arrangeChildren(context, area);
|
||||
return;
|
||||
}
|
||||
LayoutContext column = context.without(*mPanel.mCorner);
|
||||
ColumnLayout::arrangeChildren(column, area);
|
||||
if (!mPanel.mCorner->isVisible()) return;
|
||||
const Vec2F size = { context.measure(*mPanel.mCorner, Axis::Horizontal, kUnbounded).preferred,
|
||||
context.measure(*mPanel.mCorner, Axis::Vertical, kUnbounded).preferred };
|
||||
context.place(*mPanel.mCorner, makeRect(area.pos + area.size - size, size));
|
||||
}
|
||||
|
||||
private:
|
||||
TitledPanel& mPanel;
|
||||
};
|
||||
|
||||
TitledPanel::TitledPanel(std::string title, Surface surface) :
|
||||
Panel(surface),
|
||||
mTitle(std::move(title)) {
|
||||
setDebugName("titled panel");
|
||||
setLayout(std::make_unique<TitledLayout>(*this));
|
||||
|
||||
mHeader.setDebugName("panel header");
|
||||
mHeader.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
RowLayout& headerRow = mHeader.emplaceLayout<RowLayout>(kHeaderGap);
|
||||
headerRow.setPadding(kHeaderPadding);
|
||||
headerRow.setCrossAlign(CrossAlign::Center);
|
||||
mTitle.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mHeader.addChild(mTitle);
|
||||
|
||||
mBody.setDebugName("panel body");
|
||||
mBody.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mBody.emplaceLayout<ColumnLayout>(0).setPadding(kBodyPadding);
|
||||
|
||||
mFooter.setDebugName("panel footer");
|
||||
mFooter.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mFooter.emplaceLayout<RowLayout>(kFooterGap);
|
||||
// The spacer pushes the footer's buttons to the right, where dialogs keep them.
|
||||
mFooterSpacer.setSizing(AxisSizing::fill(), AxisSizing::fixed(1));
|
||||
mFooter.addChild(mFooterSpacer);
|
||||
mFooter.setVisible(false);
|
||||
|
||||
addChild(mHeader);
|
||||
addChild(mBody);
|
||||
addChild(mFooter);
|
||||
}
|
||||
|
||||
Widget& TitledPanel::footer() {
|
||||
mFooter.setVisible(true);
|
||||
return mFooter;
|
||||
}
|
||||
|
||||
void TitledPanel::setLeadingHeader(Widget* widget) {
|
||||
if (mLeading) mHeader.removeChild(*mLeading);
|
||||
mLeading = widget;
|
||||
if (widget) mHeader.insertChild(0, *widget);
|
||||
}
|
||||
|
||||
void TitledPanel::addHeaderAction(Widget& action) { mHeader.addChild(action); }
|
||||
|
||||
void TitledPanel::removeHeaderAction(Widget& action) {
|
||||
if (action.parent() == &mHeader && &action != &mTitle) mHeader.removeChild(action);
|
||||
}
|
||||
|
||||
void TitledPanel::setContent(Widget* content) {
|
||||
if (content == mContent) return;
|
||||
if (mContent && mContent->parent() == &mBody) mBody.removeChild(*mContent);
|
||||
mContent = content;
|
||||
if (content) mBody.addChild(*content);
|
||||
}
|
||||
|
||||
void TitledPanel::setBodyPadding(Insets padding) { mBody.layout()->setPadding(padding); }
|
||||
|
||||
void TitledPanel::setScrollsBody(bool scrolls) {
|
||||
if (scrolls == scrollsBody()) return;
|
||||
// The body keeps its place between header and footer; scrolling puts a scroller there with the body inside.
|
||||
const AxisSizing width = mBody.sizing().horizontal;
|
||||
if (scrolls) {
|
||||
const size_t index = (size_t) (std::find(children().begin(), children().end(), &mBody) - children().begin());
|
||||
removeChild(mBody);
|
||||
mBody.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mBodyScroll.setSizing(width, AxisSizing::fill());
|
||||
mBodyScroll.setContent(&mBody);
|
||||
insertChild(index, mBodyScroll);
|
||||
return;
|
||||
}
|
||||
const size_t index = (size_t) (std::find(children().begin(), children().end(), &mBodyScroll) - children().begin());
|
||||
mBodyScroll.setContent(nullptr);
|
||||
removeChild(mBodyScroll);
|
||||
mBody.setSizing(mBodyScroll.sizing().horizontal, AxisSizing::fill());
|
||||
insertChild(index, mBody);
|
||||
}
|
||||
|
||||
void TitledPanel::setCornerWidget(Widget* widget) {
|
||||
if (mCorner) removeChild(*mCorner);
|
||||
mCorner = widget;
|
||||
if (widget) addChild(*widget);
|
||||
}
|
||||
|
||||
void TitledPanel::setBodyGap(halnf gap) { static_cast<ColumnLayout*>(mBody.layout())->setGap(gap); }
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
#include "Toggles.hpp"
|
||||
#include "Canvas.hpp"
|
||||
|
||||
#include "Glyph.hpp"
|
||||
#include "Theme.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
|
@ -9,9 +10,10 @@ using namespace tp;
|
|||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kCheckMarkFraction = 0.55f;
|
||||
|
||||
RGBA mix(const RGBA& from, const RGBA& to, halnf amount) { return Palette::mix(from, to, amount); }
|
||||
|
||||
RGBA withAlpha(const RGBA& color, halnf alpha) { return { color.r, color.g, color.b, color.a * alpha }; }
|
||||
}
|
||||
|
||||
ToggleControl::ToggleControl(std::string text) :
|
||||
|
|
@ -78,18 +80,12 @@ void Checkbox::paintIndicator(Canvas& painter, const RectF& box, halnf checkedAm
|
|||
|
||||
if (showsFocusRing()) painter.outline(insetRect(box, -3), palette.focusRing, radius + 3, 2);
|
||||
painter.rect(box, mix(uncheckedFill, checkedFill, checkedAmount), radius);
|
||||
painter.outline(box, withAlpha(uncheckedBorder, 1 - checkedAmount), radius);
|
||||
painter.outline(box, Palette::faded(uncheckedBorder, 1 - checkedAmount), radius);
|
||||
|
||||
if (checkedAmount <= 0.01f) return;
|
||||
|
||||
const Vec2F origin = box.pos;
|
||||
const halnf unit = box.size.x;
|
||||
const halnf grow = 0.6f + 0.4f * checkedAmount;
|
||||
const Vec2F center = box.center();
|
||||
const auto point = [&](halnf x, halnf y) { return center + (origin + Vec2F(unit * x, unit * y) - center) * grow; };
|
||||
painter.polyline(
|
||||
{ point(0.27f, 0.52f), point(0.44f, 0.68f), point(0.74f, 0.35f) }, withAlpha(palette.onToggle, checkedAmount), 2
|
||||
);
|
||||
const halnf markSide = box.size.x * kCheckMarkFraction * (0.6f + 0.4f * checkedAmount);
|
||||
paintGlyph(painter, Glyph::Check, glyphBox(box, markSide), Palette::faded(palette.onToggle, checkedAmount), theme());
|
||||
}
|
||||
|
||||
Switch::Switch(std::string text) :
|
||||
|
|
@ -134,7 +130,7 @@ void RadioButton::paintIndicator(Canvas& painter, const RectF& box, halnf checke
|
|||
|
||||
if (showsFocusRing()) painter.circleOutline(center, radius + 3, palette.focusRing, 2);
|
||||
painter.circle(center, radius, mix(uncheckedFill, checkedFill, checkedAmount));
|
||||
painter.circleOutline(center, radius, withAlpha(uncheckedBorder, 1 - checkedAmount));
|
||||
painter.circleOutline(center, radius, Palette::faded(uncheckedBorder, 1 - checkedAmount));
|
||||
|
||||
if (checkedAmount > 0.01f) painter.circle(center, radius * 0.4f * checkedAmount, palette.onToggle);
|
||||
}
|
||||
|
|
@ -144,6 +140,10 @@ void RadioButton::onActivated() {
|
|||
else setChecked(true);
|
||||
}
|
||||
|
||||
RadioGroup::RadioGroup() {
|
||||
mSelection.setOnChanged([this]() { refreshButtons(); });
|
||||
}
|
||||
|
||||
RadioGroup::~RadioGroup() {
|
||||
for (RadioButton* button : mButtons)
|
||||
button->mGroup = nullptr;
|
||||
|
|
@ -154,32 +154,44 @@ void RadioGroup::add(RadioButton& button) {
|
|||
|
||||
button.mGroup = this;
|
||||
mButtons.push_back(&button);
|
||||
// A button that was checked before joining becomes the group's choice.
|
||||
if (button.isChecked()) mSelection.select(mButtons.size() - 1);
|
||||
else refreshButtons();
|
||||
}
|
||||
|
||||
void RadioGroup::remove(RadioButton& button) {
|
||||
mButtons.erase(std::remove(mButtons.begin(), mButtons.end(), &button), mButtons.end());
|
||||
const auto found = std::find(mButtons.begin(), mButtons.end(), &button);
|
||||
if (found == mButtons.end()) return;
|
||||
|
||||
const size_t index = (size_t) (found - mButtons.begin());
|
||||
const std::optional<size_t> chosen = mSelection.current();
|
||||
mButtons.erase(found);
|
||||
button.mGroup = nullptr;
|
||||
// Note: indices after the removed button shift down, so the choice follows its button.
|
||||
if (!chosen || *chosen == index) mSelection.clear();
|
||||
else if (*chosen > index) mSelection.select(*chosen - 1);
|
||||
}
|
||||
|
||||
void RadioGroup::select(RadioButton& button) {
|
||||
if (button.mGroup != this) return;
|
||||
if (button.isChecked()) return;
|
||||
const auto found = std::find(mButtons.begin(), mButtons.end(), &button);
|
||||
const size_t index = (size_t) (found - mButtons.begin());
|
||||
if (mSelection.isSelected(index)) return;
|
||||
|
||||
for (RadioButton* other : mButtons)
|
||||
other->setChecked(other == &button);
|
||||
if (mOnChanged) mOnChanged((size_t) selectedIndex());
|
||||
mSelection.select(index);
|
||||
if (mOnChanged) mOnChanged(index);
|
||||
}
|
||||
|
||||
void RadioGroup::refreshButtons() {
|
||||
for (size_t index = 0; index < mButtons.size(); index++)
|
||||
mButtons[index]->setChecked(mSelection.isSelected(index));
|
||||
}
|
||||
|
||||
void RadioGroup::selectIndex(size_t index) {
|
||||
if (index < mButtons.size()) select(*mButtons[index]);
|
||||
}
|
||||
|
||||
long RadioGroup::selectedIndex() const {
|
||||
for (size_t index = 0; index < mButtons.size(); index++) {
|
||||
if (mButtons[index]->isChecked()) return (long) index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
long RadioGroup::selectedIndex() const { return mSelection.current() ? (long) *mSelection.current() : -1; }
|
||||
|
||||
RadioButton* RadioGroup::selected() const {
|
||||
const long index = selectedIndex();
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ namespace tp::ui {
|
|||
private:
|
||||
ScrollView mScroll{ Axis::Horizontal };
|
||||
Widget mRow;
|
||||
std::vector<std::unique_ptr<BreadcrumbPart>> mParts;
|
||||
std::vector<std::unique_ptr<Label>> mSeparators;
|
||||
// The parts in order; the row owns them and the separators between them.
|
||||
std::vector<BreadcrumbPart*> mParts;
|
||||
std::function<void(size_t)> mOnPartChosen;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
#pragma once
|
||||
|
||||
#include "Control.hpp"
|
||||
#include "CanvasTypes.hpp"
|
||||
#include "Control.hpp"
|
||||
#include "Glyph.hpp"
|
||||
#include "InteractionVisual.hpp"
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
|
@ -68,23 +70,38 @@ namespace tp::ui {
|
|||
std::optional<RGBA> mFill;
|
||||
};
|
||||
|
||||
// A small flat button showing one core glyph, for toolbar and header actions: GlyphButton close{ Glyph::Close }.
|
||||
// Its surface and glyph fade with an InteractionVisual; subclasses choose other colors through interactionColors().
|
||||
class GlyphButton : public Button {
|
||||
public:
|
||||
enum class Glyph { Options, Hide, Previous, Next, Up };
|
||||
|
||||
explicit GlyphButton(Glyph glyph);
|
||||
|
||||
void setGlyph(Glyph glyph);
|
||||
[[nodiscard]] Glyph glyph() const { return mGlyph; }
|
||||
[[nodiscard]] const InteractionVisual& visual() const { return mVisual; }
|
||||
|
||||
protected:
|
||||
struct GlyphColors {
|
||||
InteractionColors surface;
|
||||
RGBA glyphAtRest;
|
||||
RGBA glyphRevealed;
|
||||
};
|
||||
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Canvas& painter) override;
|
||||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void onAttached() override;
|
||||
void refreshVisualState() override;
|
||||
|
||||
[[nodiscard]] virtual GlyphColors interactionColors() const;
|
||||
// The glyph painted now; a subclass may follow outside state (the maximize button shows Restore when maximized).
|
||||
[[nodiscard]] virtual Glyph displayedGlyph() const { return mGlyph; }
|
||||
|
||||
private:
|
||||
void paintChevron(Canvas& painter, halnf direction, const RGBA& color) const;
|
||||
void paintUpChevron(Canvas& painter, const RGBA& color) const;
|
||||
[[nodiscard]] RGBA glyphColor() const;
|
||||
|
||||
Glyph mGlyph;
|
||||
GlyphColors mColors;
|
||||
InteractionVisual mVisual{ *this };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,21 @@
|
|||
|
||||
namespace tp::ui {
|
||||
|
||||
class CollapsibleHeader;
|
||||
|
||||
// The room in a header for what it paints itself (chevron, title, summary): measured from that text, never hit.
|
||||
class PaintedRoom : public Widget {
|
||||
public:
|
||||
explicit PaintedRoom(const CollapsibleHeader& header);
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
[[nodiscard]] bool hitTest(Vec2F) const override { return false; }
|
||||
|
||||
private:
|
||||
const CollapsibleHeader& mHeader;
|
||||
};
|
||||
|
||||
class CollapsibleHeader : public Control {
|
||||
public:
|
||||
explicit CollapsibleHeader(std::string title);
|
||||
|
|
@ -22,17 +37,18 @@ namespace tp::ui {
|
|||
[[nodiscard]] Widget* action() const { return mAction; }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Canvas& painter) override;
|
||||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
|
||||
private:
|
||||
class HeaderLayout;
|
||||
friend class HeaderLayout;
|
||||
friend class PaintedRoom;
|
||||
|
||||
[[nodiscard]] Extent paintedExtent(Axis axis) const;
|
||||
[[nodiscard]] halnf actionWidth() const;
|
||||
|
||||
std::string mTitle;
|
||||
// Holds the room the header paints its chevron, title and summary in, before the action.
|
||||
PaintedRoom mPainted{ *this };
|
||||
std::string mSummary;
|
||||
Widget* mAction = nullptr;
|
||||
AnimatedFloat mTurn{ 0 };
|
||||
|
|
@ -60,8 +76,8 @@ namespace tp::ui {
|
|||
void finishTransitions() override;
|
||||
|
||||
private:
|
||||
class SectionLayout;
|
||||
friend class SectionLayout;
|
||||
class RevealLayout;
|
||||
friend class RevealLayout;
|
||||
|
||||
CollapsibleHeader mHeader;
|
||||
Widget mReveal;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
#include "ScrollPhysics.hpp"
|
||||
#include "Animation.hpp"
|
||||
#include "InteractionVisual.hpp"
|
||||
#include "ScrollPhysics.hpp"
|
||||
#include "Viewport.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
// A container painting one theme surface behind its children. Raised surfaces sit above their neighbours and cast
|
||||
// the theme's elevation shadow unless setElevated(false) keeps them flat (rows inside a list, inline art).
|
||||
class Panel : public Widget {
|
||||
public:
|
||||
enum class Surface { None, Window, Base, Raised };
|
||||
|
|
@ -15,8 +21,11 @@ namespace tp::ui {
|
|||
explicit Panel(Surface surface = Surface::Base);
|
||||
|
||||
void setSurface(Surface surface);
|
||||
[[nodiscard]] Surface surface() const { return mSurface; }
|
||||
void setCornerRadius(halnf radius);
|
||||
void setBorder(bool border);
|
||||
void setElevated(bool elevated);
|
||||
[[nodiscard]] bool isElevated() const { return mElevated && mSurface == Surface::Raised; }
|
||||
[[nodiscard]] halnf effectiveCornerRadius() const;
|
||||
|
||||
protected:
|
||||
|
|
@ -26,6 +35,7 @@ namespace tp::ui {
|
|||
Surface mSurface;
|
||||
halnf mCornerRadius = -1;
|
||||
bool mBorder = false;
|
||||
bool mElevated = true;
|
||||
};
|
||||
|
||||
class Separator : public Widget {
|
||||
|
|
@ -52,11 +62,13 @@ namespace tp::ui {
|
|||
|
||||
class ScrollView;
|
||||
|
||||
// The bar beside a scroll view: a thin track that appears while hovered, dragged or briefly after scrolling. The
|
||||
// thumb follows router drags; clicking the track scrolls there and keeps the thumb under the pointer.
|
||||
class ScrollBar : public Widget {
|
||||
public:
|
||||
explicit ScrollBar(ScrollView& view);
|
||||
|
||||
[[nodiscard]] halnf revealAmount() const { return mReveal.value(); }
|
||||
[[nodiscard]] halnf revealAmount() const { return mVisual.reveal(); }
|
||||
[[nodiscard]] bool isDragging() const { return mDragging; }
|
||||
[[nodiscard]] RectF trackRect() const;
|
||||
[[nodiscard]] RectF thumbRect() const;
|
||||
|
|
@ -65,41 +77,46 @@ namespace tp::ui {
|
|||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
void onHoverChanged() override;
|
||||
[[nodiscard]] bool acceptsDrag(PointerButton button) const override { return button == PointerButton::Left; }
|
||||
void onHoverChanged() override { refreshReveal(); }
|
||||
void onTick(halnf deltaMs) override;
|
||||
void paint(Canvas& painter) override;
|
||||
|
||||
private:
|
||||
[[nodiscard]] halnf offsetForThumbAt(halnf pointerAlong) const;
|
||||
void grab(halnf pointerAlong, bool animate);
|
||||
void refreshReveal();
|
||||
|
||||
ScrollView& mView;
|
||||
InteractionVisual mVisual{ *this };
|
||||
bool mDragging = false;
|
||||
halnf mGrabOffset = 0;
|
||||
halnf mFlashMs = 0;
|
||||
AnimatedFloat mReveal{ 0, Motion::fluid() };
|
||||
};
|
||||
|
||||
class ScrollView : public Widget {
|
||||
// A Viewport that scrolls along one axis, with a scroll bar. Wheel notches glide; touchpad, touch drags, flings and
|
||||
// edge stretch come from the Viewport, the same in every scroller; Shift+wheel and horizontal scroll reach a
|
||||
// horizontal ScrollView too.
|
||||
class ScrollView : public Viewport {
|
||||
public:
|
||||
struct DragSample {
|
||||
uint4 timeMs;
|
||||
halnf offset;
|
||||
};
|
||||
|
||||
explicit ScrollView(Axis scrollAxis = Axis::Vertical);
|
||||
|
||||
void setContent(Widget* content);
|
||||
[[nodiscard]] Widget* content() const { return mContent; }
|
||||
void scrollTo(halnf offset) { Viewport::scrollTo(alongAxis(offset)); }
|
||||
void scrollBy(halnf delta) { Viewport::scrollBy(alongAxis(delta)); }
|
||||
void jumpTo(halnf offset) { Viewport::jumpTo(alongAxis(offset)); }
|
||||
// Where the view is heading (the end of a glide or fling).
|
||||
[[nodiscard]] halnf offset() const { return along(targetOffset(), mScrollAxis); }
|
||||
// Where the view is now.
|
||||
[[nodiscard]] halnf currentOffset() const { return along(Viewport::offset(), mScrollAxis); }
|
||||
[[nodiscard]] halnf maximumOffset() const { return along(Viewport::maximumOffset(), mScrollAxis); }
|
||||
[[nodiscard]] halnf contentLength() const;
|
||||
[[nodiscard]] bool isScrollable() const { return Viewport::isScrollable(mScrollAxis); }
|
||||
// Reveals a range along the scroll axis, in content coordinates, with the theme's reveal margin by default.
|
||||
void revealRange(halnf start, halnf length, std::optional<halnf> margin = std::nullopt);
|
||||
// The offset that reveals a range along the scroll axis, without moving.
|
||||
[[nodiscard]] halnf revealOffsetFor(halnf start, halnf length, std::optional<halnf> margin = std::nullopt) const;
|
||||
|
||||
void scrollTo(halnf offset);
|
||||
void scrollBy(halnf delta);
|
||||
[[nodiscard]] halnf offset() const { return mOffset.target(); }
|
||||
[[nodiscard]] halnf maximumOffset() const;
|
||||
[[nodiscard]] halnf contentLength() const { return mContentLength; }
|
||||
[[nodiscard]] bool isScrollable() const { return maximumOffset() > 0; }
|
||||
|
||||
void setWheelStep(halnf step) { mWheelStep = step; }
|
||||
void setWheelStep(halnf step);
|
||||
void setOnScrolled(std::function<void(halnf offset)> callback) { mOnScrolled = std::move(callback); }
|
||||
void setShowsScrollBar(bool shows) {
|
||||
mShowsScrollBar = shows;
|
||||
|
|
@ -107,60 +124,28 @@ namespace tp::ui {
|
|||
}
|
||||
|
||||
[[nodiscard]] Axis scrollAxis() const { return mScrollAxis; }
|
||||
[[nodiscard]] halnf viewportLength() const;
|
||||
[[nodiscard]] halnf currentOffset() const { return mOffset.value(); }
|
||||
[[nodiscard]] bool isBouncing() const { return mStartStretch.isActive() || mEndStretch.isActive(); }
|
||||
[[nodiscard]] halnf stretchOffset() const;
|
||||
[[nodiscard]] const StretchEffect& startStretch() const { return mStartStretch; }
|
||||
[[nodiscard]] const StretchEffect& endStretch() const { return mEndStretch; }
|
||||
[[nodiscard]] const FlingSpline& fling() const { return mFling; }
|
||||
void jumpTo(halnf offset);
|
||||
[[nodiscard]] halnf viewportLength() const { return lengthAlong(localRect(), mScrollAxis); }
|
||||
[[nodiscard]] bool isBouncing() const { return isStretched(); }
|
||||
[[nodiscard]] halnf stretchOffset() const { return along(stretch(), mScrollAxis); }
|
||||
[[nodiscard]] const StretchEffect& startStretch() const { return Viewport::startStretch(mScrollAxis); }
|
||||
[[nodiscard]] const StretchEffect& endStretch() const { return Viewport::endStretch(mScrollAxis); }
|
||||
[[nodiscard]] const FlingSpline& fling() const { return Viewport::fling(mScrollAxis); }
|
||||
[[nodiscard]] halnf flingVelocity() const { return Viewport::flingVelocity(mScrollAxis); }
|
||||
|
||||
[[nodiscard]] ScrollBar& scrollBar() { return mScrollBar; }
|
||||
[[nodiscard]] halnf scrollBarReveal() const { return mScrollBar.revealAmount(); }
|
||||
[[nodiscard]] halnf flingVelocity() const { return mFlingVelocity; }
|
||||
[[nodiscard]] static halnf averageVelocity(const std::vector<DragSample>& samples, uint4 nowMs);
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void onEvent(Event& event) override;
|
||||
void filterDescendantEvent(Event& event) override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void arrangeOverlays(LayoutContext& context, const RectF& area) override;
|
||||
void offsetChanged() override;
|
||||
|
||||
private:
|
||||
class ScrollLayout;
|
||||
friend class ScrollBar;
|
||||
|
||||
static constexpr halnf kDragThreshold = 8;
|
||||
static constexpr halnf kFlingMinimumSpeed = 0.1f;
|
||||
static constexpr halnf kFlingRecentMs = 100;
|
||||
|
||||
void keepOffsetInRange();
|
||||
void applyOffset();
|
||||
void trackDrag(Event& event);
|
||||
void dragBy(halnf delta);
|
||||
void rememberDragSample(uint4 timeMs);
|
||||
[[nodiscard]] halnf averageDragVelocity(uint4 nowMs) const;
|
||||
[[nodiscard]] bool startsDrag(const Event& event) const;
|
||||
[[nodiscard]] Vec2F alongAxis(halnf length) const;
|
||||
|
||||
Axis mScrollAxis;
|
||||
Widget* mContent = nullptr;
|
||||
ScrollBar mScrollBar{ *this };
|
||||
AnimatedFloat mOffset;
|
||||
halnf mContentLength = 0;
|
||||
halnf mWheelStep = 48;
|
||||
std::function<void(halnf)> mOnScrolled;
|
||||
halnf mReportedOffset = 0;
|
||||
bool mShowsScrollBar = true;
|
||||
bool mDragPending = false;
|
||||
bool mDragging = false;
|
||||
Vec2F mDragStart = { 0, 0 };
|
||||
halnf mDragStartOffset = 0;
|
||||
std::vector<DragSample> mDragSamples;
|
||||
halnf mFlingVelocity = 0;
|
||||
halnf mLastDragAlong = 0;
|
||||
FlingSpline mFling;
|
||||
StretchEffect mStartStretch;
|
||||
StretchEffect mEndStretch;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
#pragma once
|
||||
|
||||
#include "Animation.hpp"
|
||||
#include "InteractionVisual.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
#include <functional>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
// A thin bar that resizes something along one axis: it reveals its line while hovered or dragged, and reports the
|
||||
// window coordinate of its center while the router drags it (so a press that does not move is not a resize).
|
||||
class ResizeHandle : public Widget {
|
||||
public:
|
||||
explicit ResizeHandle(Axis dragAxis);
|
||||
|
|
@ -17,12 +19,13 @@ namespace tp::ui {
|
|||
|
||||
[[nodiscard]] Axis dragAxis() const { return mDragAxis; }
|
||||
[[nodiscard]] bool isDragging() const { return mDragging; }
|
||||
[[nodiscard]] halnf revealAmount() const { return mReveal.value(); }
|
||||
[[nodiscard]] halnf revealAmount() const { return mVisual.reveal(); }
|
||||
|
||||
[[nodiscard]] RectF lineRect() const;
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
[[nodiscard]] bool acceptsDrag(PointerButton button) const override { return button == PointerButton::Left; }
|
||||
void onHoverChanged() override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void paint(Canvas& painter) override;
|
||||
|
|
@ -33,7 +36,7 @@ namespace tp::ui {
|
|||
Axis mDragAxis;
|
||||
bool mDragging = false;
|
||||
halnf mGrabOffset = 0;
|
||||
AnimatedFloat mReveal{ 0, Motion::quick() };
|
||||
InteractionVisual mVisual{ *this };
|
||||
|
||||
std::function<void()> mOnStarted;
|
||||
std::function<void(halnf)> mOnDragged;
|
||||
|
|
|
|||
103
Widgets/src/components/controls/public/SegmentedControl.hpp
Normal file
103
Widgets/src/components/controls/public/SegmentedControl.hpp
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
#pragma once
|
||||
|
||||
#include "Button.hpp"
|
||||
#include "SelectionModel.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
// A row of buttons where one (or, in multiple mode, several) is selected: tool pickers, view modes, tab strips.
|
||||
// The choice is a SelectionModel over the segment indices; arrows move the choice, Control/Shift clicks extend it in
|
||||
// multiple mode. SegmentedControl<Mode> view; view.addSegment("Editor", Mode::Editor); view.setValue(Mode::Split);
|
||||
class SegmentedControlBase : public Widget {
|
||||
public:
|
||||
using Mode = SelectionModel<size_t>::Mode;
|
||||
|
||||
explicit SegmentedControlBase(Mode mode = Mode::Single);
|
||||
|
||||
[[nodiscard]] size_t segmentCount() const { return mButtons.size(); }
|
||||
[[nodiscard]] Button& segment(size_t index) { return *mButtons[index]; }
|
||||
[[nodiscard]] const Button& segment(size_t index) const { return *mButtons[index]; }
|
||||
[[nodiscard]] const SelectionModel<size_t>& selection() const { return mSelection; }
|
||||
|
||||
void selectIndex(size_t index);
|
||||
[[nodiscard]] std::optional<size_t> selectedIndex() const { return mSelection.current(); }
|
||||
[[nodiscard]] bool isIndexSelected(size_t index) const { return mSelection.isSelected(index); }
|
||||
|
||||
void setButtonStyle(Button::Style style);
|
||||
// Segments are plain buttons unless a factory makes them (e.g. a tab strip with its own selected look).
|
||||
void setButtonFactory(std::function<std::unique_ptr<Button>(std::string text)> factory) {
|
||||
mFactory = std::move(factory);
|
||||
}
|
||||
// Called with the changed selection after a user choice; not called by selectIndex.
|
||||
void setOnIndexChanged(std::function<void()> callback) { mOnIndexChanged = std::move(callback); }
|
||||
|
||||
protected:
|
||||
Button& addButton(std::string text);
|
||||
void removeButton(size_t index);
|
||||
void onEvent(Event& event) override;
|
||||
[[nodiscard]] bool acceptsKeyboardFocus() const override { return true; }
|
||||
|
||||
private:
|
||||
void clicked(size_t index);
|
||||
void refreshButtons();
|
||||
[[nodiscard]] std::vector<size_t> order() const;
|
||||
|
||||
SelectionModel<size_t> mSelection;
|
||||
std::vector<std::unique_ptr<Button>> mButtons;
|
||||
Button::Style mButtonStyle = Button::Style::Tab;
|
||||
std::function<std::unique_ptr<Button>(std::string)> mFactory;
|
||||
bool mUserChange = false;
|
||||
std::function<void()> mOnIndexChanged;
|
||||
};
|
||||
|
||||
template <typename Value>
|
||||
class SegmentedControl : public SegmentedControlBase {
|
||||
public:
|
||||
explicit SegmentedControl(Mode mode = Mode::Single) :
|
||||
SegmentedControlBase(mode) {
|
||||
setOnIndexChanged([this]() {
|
||||
if (mOnChanged && selectedIndex()) mOnChanged(mValues[*selectedIndex()]);
|
||||
});
|
||||
}
|
||||
|
||||
Button& addSegment(std::string text, Value value) {
|
||||
mValues.push_back(value);
|
||||
return addButton(std::move(text));
|
||||
}
|
||||
|
||||
void removeSegmentAt(size_t index) {
|
||||
if (index >= mValues.size()) return;
|
||||
mValues.erase(mValues.begin() + (std::ptrdiff_t) index);
|
||||
removeButton(index);
|
||||
}
|
||||
void setSegmentValue(size_t index, Value value) {
|
||||
if (index < mValues.size()) mValues[index] = std::move(value);
|
||||
}
|
||||
|
||||
void setValue(const Value& value) {
|
||||
for (size_t index = 0; index < mValues.size(); index++) {
|
||||
if (mValues[index] == value) selectIndex(index);
|
||||
}
|
||||
}
|
||||
[[nodiscard]] std::optional<Value> value() const {
|
||||
return selectedIndex() ? std::optional<Value>(mValues[*selectedIndex()]) : std::nullopt;
|
||||
}
|
||||
[[nodiscard]] std::vector<Value> values() const {
|
||||
std::vector<Value> chosen;
|
||||
for (size_t index : selection().selected())
|
||||
chosen.push_back(mValues[index]);
|
||||
return chosen;
|
||||
}
|
||||
|
||||
void setOnChanged(std::function<void(const Value&)> callback) { mOnChanged = std::move(callback); }
|
||||
|
||||
private:
|
||||
std::vector<Value> mValues;
|
||||
std::function<void(const Value&)> mOnChanged;
|
||||
};
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ namespace tp::ui {
|
|||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void onEvent(Event& event) override;
|
||||
[[nodiscard]] bool acceptsDrag(PointerButton button) const override { return button == PointerButton::Left; }
|
||||
void paint(Canvas& painter) override;
|
||||
void refreshVisualState() override;
|
||||
|
||||
|
|
@ -70,6 +71,8 @@ namespace tp::ui {
|
|||
halnf mStep = 0;
|
||||
Scale mScale = Scale::Linear;
|
||||
Axis mOrientation = Axis::Horizontal;
|
||||
void setDragging(bool dragging);
|
||||
|
||||
bool mDragging = false;
|
||||
AnimatedFloat mHandleReveal{ 0 };
|
||||
std::function<void(halnf)> mOnChanged;
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ namespace tp::ui {
|
|||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void onEvent(Event& event) override;
|
||||
[[nodiscard]] bool acceptsDrag(PointerButton button) const override { return button == PointerButton::Left; }
|
||||
void paint(Canvas& painter) override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void onKeyboardFocusChanged() override {
|
||||
|
|
@ -164,7 +165,6 @@ namespace tp::ui {
|
|||
void revealCaret();
|
||||
void applyBoxHeight();
|
||||
void viewportArranged(halnf height);
|
||||
[[nodiscard]] halnf caretRevealOffset(halnf viewportHeight) const;
|
||||
|
||||
ScrollView mScroll{ Axis::Vertical };
|
||||
TextAreaEditor mEditor;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ namespace tp::ui {
|
|||
// Internal: fires when the text changed, for presentation state (layout, highlights). Not the user callback.
|
||||
void setOnEditApplied(std::function<void()> callback) { mOnApplied = std::move(callback); }
|
||||
void setOnCommitted(std::function<void(const std::string&)> callback) { mOnCommitted = std::move(callback); }
|
||||
// Fired only when Enter submits a single-line editor, not when it commits by losing focus: a dialog's field
|
||||
// uses it to confirm, so a click on Cancel (which commits by focus) does not also submit.
|
||||
void setOnSubmitted(std::function<void(const std::string&)> callback) { mOnSubmitted = std::move(callback); }
|
||||
void setOnCaretMoved(std::function<void()> callback) { mOnCaretMoved = std::move(callback); }
|
||||
void setOnEditingStarted(std::function<void()> callback) { mOnEditingStarted = std::move(callback); }
|
||||
void setPasteHandler(std::function<bool()> handler) { mPasteHandler = std::move(handler); }
|
||||
|
|
@ -51,8 +54,9 @@ namespace tp::ui {
|
|||
void setLineSelector(std::function<void(size_t position)> selector) { mLineSelector = std::move(selector); }
|
||||
|
||||
void pointerDown(Event& event, size_t position);
|
||||
void pointerMove(Event& event, size_t position);
|
||||
void pointerUp(Event& event);
|
||||
// A router drag that started from a press on the text extends the selection to where the pointer is.
|
||||
void drag(Event& event, size_t position);
|
||||
[[nodiscard]] bool selectsByDragging() const { return mDragging; }
|
||||
void key(Event& event);
|
||||
void text(Event& event);
|
||||
// Returns whether the editor wants to keep ticking (blink while focused).
|
||||
|
|
@ -102,6 +106,7 @@ namespace tp::ui {
|
|||
std::function<void(const std::string&)> mOnEdited;
|
||||
std::function<void()> mOnApplied;
|
||||
std::function<void(const std::string&)> mOnCommitted;
|
||||
std::function<void(const std::string&)> mOnSubmitted;
|
||||
std::function<void()> mOnCaretMoved;
|
||||
std::function<void()> mOnEditingStarted;
|
||||
std::function<bool()> mPasteHandler;
|
||||
|
|
|
|||
|
|
@ -35,12 +35,16 @@ namespace tp::ui {
|
|||
void setOnCommitted(std::function<void(const std::string&)> onCommitted) {
|
||||
mEditor.setOnCommitted(std::move(onCommitted));
|
||||
}
|
||||
void setOnSubmitted(std::function<void(const std::string&)> onSubmitted) {
|
||||
mEditor.setOnSubmitted(std::move(onSubmitted));
|
||||
}
|
||||
|
||||
[[nodiscard]] bool isEditing() const { return mEditor.isEditing(); }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void onEvent(Event& event) override;
|
||||
[[nodiscard]] bool acceptsDrag(PointerButton button) const override { return button == PointerButton::Left; }
|
||||
void paint(Canvas& painter) override;
|
||||
void onTick(halnf deltaMs) override {
|
||||
Control::onTick(deltaMs);
|
||||
|
|
|
|||
68
Widgets/src/components/controls/public/TitledPanel.hpp
Normal file
68
Widgets/src/components/controls/public/TitledPanel.hpp
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
#pragma once
|
||||
|
||||
#include "Containers.hpp"
|
||||
#include "Label.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
// A panel with a header (a leading slot, the title, trailing actions), a body and an optional footer, stacked in a
|
||||
// column. Dialogs, dock and floating panels and stack pages are titled panels with behaviour added; they all take
|
||||
// content the same way:
|
||||
// panel.setContent(&view); // one widget fills the body
|
||||
// panel.body().addChild(row); // or several, stacked
|
||||
// panel.addHeaderAction(closeButton); // trailing header buttons, in order
|
||||
class TitledPanel : public Panel {
|
||||
public:
|
||||
explicit TitledPanel(std::string title = {}, Surface surface = Surface::Raised);
|
||||
|
||||
void setTitle(std::string title) { mTitle.setText(std::move(title)); }
|
||||
[[nodiscard]] const std::string& title() const { return mTitle.text(); }
|
||||
[[nodiscard]] Label& titleLabel() { return mTitle; }
|
||||
[[nodiscard]] const Label& titleLabel() const { return mTitle; }
|
||||
|
||||
[[nodiscard]] Widget& header() { return mHeader; }
|
||||
[[nodiscard]] const Widget& header() const { return mHeader; }
|
||||
[[nodiscard]] Widget& body() { return mBody; }
|
||||
// Buttons along the bottom (a dialog's choices); hidden until something is added.
|
||||
[[nodiscard]] Widget& footer();
|
||||
|
||||
// Shown before the title (a back button, an icon); null removes it.
|
||||
void setLeadingHeader(Widget* widget);
|
||||
[[nodiscard]] Widget* leadingHeader() const { return mLeading; }
|
||||
// Added after the title and the previous actions.
|
||||
void addHeaderAction(Widget& action);
|
||||
void removeHeaderAction(Widget& action);
|
||||
void setHeaderShown(bool shown) { mHeader.setVisible(shown); }
|
||||
|
||||
// Replaces the body's content with one widget (or none).
|
||||
void setContent(Widget* content);
|
||||
[[nodiscard]] Widget* content() const { return mContent; }
|
||||
|
||||
// The space around the body's content, and between stacked body children.
|
||||
void setBodyPadding(Insets padding);
|
||||
void setBodyGap(halnf gap);
|
||||
|
||||
// A widget drawn over the panel's bottom-right corner at its own size (a resize grip); null removes it.
|
||||
void setCornerWidget(Widget* widget);
|
||||
|
||||
// Whether the body scrolls when its content is taller than the panel (pages of settings, long forms).
|
||||
void setScrollsBody(bool scrolls);
|
||||
[[nodiscard]] bool scrollsBody() const { return mBodyScroll.content() == &mBody; }
|
||||
[[nodiscard]] ScrollView& bodyScroller() { return mBodyScroll; }
|
||||
|
||||
private:
|
||||
class TitledLayout;
|
||||
|
||||
Widget mHeader;
|
||||
Widget* mLeading = nullptr;
|
||||
Label mTitle;
|
||||
Widget mBody;
|
||||
ScrollView mBodyScroll;
|
||||
Widget mFooter;
|
||||
Spacer mFooterSpacer;
|
||||
Widget* mContent = nullptr;
|
||||
Widget* mCorner = nullptr;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
#include "Control.hpp"
|
||||
#include "SelectionModel.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
|
@ -74,9 +75,10 @@ namespace tp::ui {
|
|||
RadioGroup* mGroup = nullptr;
|
||||
};
|
||||
|
||||
// Radio buttons sharing one choice: the same single-selection model as SegmentedControl, shown as radio buttons.
|
||||
class RadioGroup {
|
||||
public:
|
||||
RadioGroup() = default;
|
||||
RadioGroup();
|
||||
~RadioGroup();
|
||||
|
||||
RadioGroup(const RadioGroup&) = delete;
|
||||
|
|
@ -92,8 +94,13 @@ namespace tp::ui {
|
|||
|
||||
void setOnChanged(std::function<void(size_t)> onChanged) { mOnChanged = std::move(onChanged); }
|
||||
|
||||
[[nodiscard]] const SelectionModel<size_t>& selection() const { return mSelection; }
|
||||
|
||||
private:
|
||||
void refreshButtons();
|
||||
|
||||
std::vector<RadioButton*> mButtons;
|
||||
SelectionModel<size_t> mSelection{ SelectionModel<size_t>::Mode::Single };
|
||||
std::function<void(size_t)> mOnChanged;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
154
Widgets/src/components/controls/tests/SegmentedControlTests.cpp
Normal file
154
Widgets/src/components/controls/tests/SegmentedControlTests.cpp
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
#include "BoxLayout.hpp"
|
||||
#include "SegmentedControl.hpp"
|
||||
#include "Toggles.hpp"
|
||||
#include "Ui.hpp"
|
||||
#include "UiDriver.hpp"
|
||||
|
||||
#include "UnitTest++/UnitTest++.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
using namespace tp::ui::testing;
|
||||
|
||||
namespace {
|
||||
enum class ViewMode { Editor, Split, Preview };
|
||||
|
||||
struct SegmentScene {
|
||||
Widget root;
|
||||
SegmentedControl<ViewMode> modes;
|
||||
std::vector<ViewMode> changes;
|
||||
|
||||
explicit SegmentScene(SegmentedControlBase::Mode mode = SegmentedControlBase::Mode::Single) :
|
||||
modes(mode) {
|
||||
root.emplaceLayout<ColumnLayout>().setCrossAlign(CrossAlign::Start);
|
||||
modes.addSegment("Editor", ViewMode::Editor);
|
||||
modes.addSegment("Split", ViewMode::Split);
|
||||
modes.addSegment("Preview", ViewMode::Preview);
|
||||
modes.setOnChanged([this](const ViewMode& value) { changes.push_back(value); });
|
||||
root.addChild(modes);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
SUITE(SegmentedControl) {
|
||||
TEST(ClickingASegmentChoosesItAndReportsTheValue) {
|
||||
SegmentScene scene;
|
||||
scene.modes.setValue(ViewMode::Editor);
|
||||
UiDriver driver(scene.root, { 400, 100 });
|
||||
|
||||
driver.click(scene.modes.segment(2));
|
||||
|
||||
CHECK(scene.modes.value() == ViewMode::Preview);
|
||||
CHECK(scene.modes.segment(2).isSelected());
|
||||
CHECK(!scene.modes.segment(0).isSelected());
|
||||
REQUIRE CHECK_EQUAL(1u, scene.changes.size());
|
||||
CHECK(scene.changes[0] == ViewMode::Preview);
|
||||
}
|
||||
|
||||
TEST(SettingTheValueIsNotReportedAsAUserChange) {
|
||||
SegmentScene scene;
|
||||
UiDriver driver(scene.root, { 400, 100 });
|
||||
|
||||
scene.modes.setValue(ViewMode::Split);
|
||||
|
||||
CHECK(scene.modes.segment(1).isSelected());
|
||||
CHECK(scene.changes.empty());
|
||||
}
|
||||
|
||||
TEST(ClickingTheChosenSegmentKeepsItChosen) {
|
||||
SegmentScene scene;
|
||||
scene.modes.setValue(ViewMode::Split);
|
||||
UiDriver driver(scene.root, { 400, 100 });
|
||||
|
||||
driver.click(scene.modes.segment(1));
|
||||
|
||||
CHECK(scene.modes.value() == ViewMode::Split);
|
||||
CHECK(scene.changes.empty());
|
||||
}
|
||||
|
||||
TEST(ArrowKeysMoveTheChoiceAndWrap) {
|
||||
SegmentScene scene;
|
||||
scene.modes.setValue(ViewMode::Editor);
|
||||
UiDriver driver(scene.root, { 400, 100 });
|
||||
driver.click(scene.modes.segment(0));
|
||||
|
||||
driver.key(Key::Right);
|
||||
CHECK(scene.modes.value() == ViewMode::Split);
|
||||
driver.key(Key::Left);
|
||||
driver.key(Key::Left);
|
||||
CHECK(scene.modes.value() == ViewMode::Preview);
|
||||
driver.key(Key::Home);
|
||||
CHECK(scene.modes.value() == ViewMode::Editor);
|
||||
CHECK_EQUAL(4u, scene.changes.size());
|
||||
}
|
||||
|
||||
TEST(MultipleModeTogglesWithControl) {
|
||||
SegmentScene scene(SegmentedControlBase::Mode::Multiple);
|
||||
UiDriver driver(scene.root, { 400, 100 });
|
||||
|
||||
driver.click(scene.modes.segment(0));
|
||||
driver.click(scene.modes.segment(2), KeyModifiers::controlOnly());
|
||||
|
||||
const std::vector<ViewMode> chosen = scene.modes.values();
|
||||
REQUIRE CHECK_EQUAL(2u, chosen.size());
|
||||
CHECK(scene.modes.segment(0).isSelected());
|
||||
CHECK(!scene.modes.segment(1).isSelected());
|
||||
CHECK(scene.modes.segment(2).isSelected());
|
||||
}
|
||||
}
|
||||
|
||||
SUITE(RadioGroupSelection) {
|
||||
TEST(RadioGroupIsASingleChoice) {
|
||||
Widget root;
|
||||
root.emplaceLayout<ColumnLayout>().setCrossAlign(CrossAlign::Start);
|
||||
RadioButton first("First");
|
||||
RadioButton second("Second");
|
||||
RadioGroup group;
|
||||
group.add(first);
|
||||
group.add(second);
|
||||
root.addChild(first);
|
||||
root.addChild(second);
|
||||
std::vector<size_t> changes;
|
||||
group.setOnChanged([&changes](size_t index) { changes.push_back(index); });
|
||||
UiDriver driver(root, { 300, 200 });
|
||||
|
||||
driver.click(second);
|
||||
CHECK(second.isChecked() && !first.isChecked());
|
||||
CHECK_EQUAL(1, group.selectedIndex());
|
||||
CHECK(group.selection().isSelected(1));
|
||||
|
||||
driver.click(first);
|
||||
CHECK(first.isChecked() && !second.isChecked());
|
||||
CHECK((changes == std::vector<size_t>{ 1, 0 }));
|
||||
}
|
||||
|
||||
TEST(RemovingAButtonKeepsTheChoiceOnItsButton) {
|
||||
RadioButton first("First");
|
||||
RadioButton second("Second");
|
||||
RadioButton third("Third");
|
||||
RadioGroup group;
|
||||
group.add(first);
|
||||
group.add(second);
|
||||
group.add(third);
|
||||
group.selectIndex(2);
|
||||
|
||||
group.remove(first);
|
||||
|
||||
CHECK_EQUAL(1, group.selectedIndex());
|
||||
CHECK(group.selected() == &third);
|
||||
}
|
||||
|
||||
TEST(PreCheckedButtonBecomesTheChoice) {
|
||||
RadioButton first("First");
|
||||
RadioButton second("Second");
|
||||
second.setChecked(true);
|
||||
RadioGroup group;
|
||||
group.add(first);
|
||||
group.add(second);
|
||||
|
||||
CHECK_EQUAL(1, group.selectedIndex());
|
||||
CHECK(!first.isChecked());
|
||||
}
|
||||
}
|
||||
94
Widgets/src/components/controls/tests/TitledPanelTests.cpp
Normal file
94
Widgets/src/components/controls/tests/TitledPanelTests.cpp
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
#include "BoxLayout.hpp"
|
||||
#include "Button.hpp"
|
||||
#include "Dialog.hpp"
|
||||
#include "Docking.hpp"
|
||||
#include "FloatingPanel.hpp"
|
||||
#include "Probes.hpp"
|
||||
#include "StackLayout.hpp"
|
||||
#include "TitledPanel.hpp"
|
||||
#include "Ui.hpp"
|
||||
#include "UiDriver.hpp"
|
||||
|
||||
#include "UnitTest++/UnitTest++.h"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
using namespace tp::ui::testing;
|
||||
|
||||
SUITE(TitledPanel) {
|
||||
TEST(HeaderSitsAboveTheBodyWithActionsAfterTheTitle) {
|
||||
Widget root;
|
||||
root.emplaceLayout<StackLayout>();
|
||||
TitledPanel panel("Layers");
|
||||
Button add("+");
|
||||
Button close("x");
|
||||
Box content({ 100, 300 });
|
||||
panel.addHeaderAction(add);
|
||||
panel.addHeaderAction(close);
|
||||
panel.setContent(&content);
|
||||
root.addChild(panel);
|
||||
UiDriver driver(root, { 400, 400 });
|
||||
|
||||
const RectF header = panel.header().windowRect();
|
||||
CHECK(content.windowRect().pos.y >= header.pos.y + header.size.y - 0.5f);
|
||||
CHECK(add.windowRect().pos.x > panel.titleLabel().windowRect().pos.x);
|
||||
CHECK(close.windowRect().pos.x > add.windowRect().pos.x);
|
||||
CHECK(driver.paint().hasText("Layers"));
|
||||
}
|
||||
|
||||
TEST(SetContentReplacesTheBodyContent) {
|
||||
TitledPanel panel("Info");
|
||||
Box first({ 10, 10 });
|
||||
Box second({ 10, 10 });
|
||||
panel.setContent(&first);
|
||||
panel.setContent(&second);
|
||||
|
||||
CHECK(first.parent() == nullptr);
|
||||
CHECK(second.parent() == &panel.body());
|
||||
CHECK(panel.content() == &second);
|
||||
panel.setContent(nullptr);
|
||||
CHECK(second.parent() == nullptr);
|
||||
}
|
||||
|
||||
TEST(TheFooterAppearsOnceSomethingIsAddedToIt) {
|
||||
Widget root;
|
||||
root.emplaceLayout<StackLayout>();
|
||||
TitledPanel panel("Save?");
|
||||
root.addChild(panel);
|
||||
UiDriver driver(root, { 400, 300 });
|
||||
|
||||
Button ok("OK");
|
||||
panel.footer().addChild(ok);
|
||||
driver.layout();
|
||||
CHECK(ok.isVisible());
|
||||
CHECK(ok.windowRect().pos.y > panel.header().windowRect().pos.y);
|
||||
}
|
||||
|
||||
TEST(AScrollingBodyScrollsItsContent) {
|
||||
Widget root;
|
||||
root.emplaceLayout<StackLayout>();
|
||||
TitledPanel panel("Long");
|
||||
Box tall({ 100, 2000 });
|
||||
panel.setContent(&tall);
|
||||
panel.setScrollsBody(true);
|
||||
root.addChild(panel);
|
||||
UiDriver driver(root, { 300, 300 });
|
||||
|
||||
CHECK(panel.bodyScroller().isScrollable());
|
||||
driver.moveTo(panel.bodyScroller().windowRect().center());
|
||||
driver.wheel({ 0, -2 });
|
||||
driver.settle();
|
||||
CHECK(panel.bodyScroller().offset() > 0);
|
||||
}
|
||||
|
||||
TEST(DialogsDockPanelsAndFloatingPanelsAreTitledPanels) {
|
||||
Dialog dialog("Delete?");
|
||||
DockPanel dock("Layers");
|
||||
FloatingPanel floating("Brush");
|
||||
const TitledPanel* panels[] = { &dialog, &dock, &floating };
|
||||
for (const TitledPanel* panel : panels)
|
||||
CHECK(!panel->title().empty());
|
||||
CHECK(dock.optionsButton().parent() == &dock.header());
|
||||
CHECK(dialog.message().empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ namespace {
|
|||
|
||||
MenuItem* itemNamed(Menu& menu, const std::string& text) {
|
||||
for (const auto& item : menu.items()) {
|
||||
if (item->entry().text == text) return item.get();
|
||||
if (item->entry().text == text) return item;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "Docking.hpp"
|
||||
#include "Canvas.hpp"
|
||||
#include "Glyph.hpp"
|
||||
#include "ComponentSizes.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
|
|
@ -16,9 +17,10 @@ using namespace tp;
|
|||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kDragThreshold = 4;
|
||||
constexpr halnf kEdgeGrip = 6;
|
||||
constexpr halnf kCornerGrip = 16;
|
||||
constexpr halnf kCornerGlyph = 8;
|
||||
constexpr halnf kCornerInset = 5;
|
||||
constexpr halnf kMinimumDockedLength = 80;
|
||||
constexpr halnf kMaximumDockedFraction = 0.85f;
|
||||
constexpr halnf kDropZoneFraction = 0.3f;
|
||||
|
|
@ -43,7 +45,6 @@ namespace {
|
|||
return value;
|
||||
}
|
||||
|
||||
RGBA withAlpha(const RGBA& color, halnf alpha) { return { color.r, color.g, color.b, alpha }; }
|
||||
}
|
||||
|
||||
StripeButton::StripeButton(std::string title, ImageHandle icon) :
|
||||
|
|
@ -122,57 +123,14 @@ RGBA OptionsButton::backgroundTarget() const {
|
|||
const Palette& palette = theme().palette;
|
||||
if (isPopupOpen() || isPressed()) return palette.controlPressed;
|
||||
if (isHovered()) return palette.controlHovered;
|
||||
return withAlpha(palette.controlHovered, 0);
|
||||
return Palette::faded(palette.controlHovered, 0);
|
||||
}
|
||||
|
||||
class DockPanel::PanelLayout : public Layout {
|
||||
public:
|
||||
explicit PanelLayout(DockPanel& panel) :
|
||||
mPanel(panel) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override {
|
||||
Extent header = context.measure(mPanel.mHeader, axis, crossLength);
|
||||
Extent body = context.measure(mPanel.mBody, axis, crossLength);
|
||||
if (axis == Axis::Horizontal)
|
||||
return { std::max(header.minimum, body.minimum), std::max(header.preferred, body.preferred) };
|
||||
return { header.minimum + body.minimum, header.preferred + body.preferred };
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& contentArea) override {
|
||||
const halnf headerHeight = context.measure(mPanel.mHeader, Axis::Vertical, contentArea.size.x).preferred;
|
||||
context.place(mPanel.mHeader, makeRect(contentArea.pos, Vec2F(contentArea.size.x, headerHeight)));
|
||||
context.place(
|
||||
mPanel.mBody,
|
||||
makeRect(
|
||||
contentArea.pos.x, contentArea.pos.y + headerHeight, contentArea.size.x, contentArea.size.y - headerHeight
|
||||
)
|
||||
);
|
||||
|
||||
if (!mPanel.mCornerGrip.isVisible()) return;
|
||||
const Vec2F corner = contentArea.pos + contentArea.size;
|
||||
context.place(
|
||||
mPanel.mCornerGrip, makeRect(corner - Vec2F(kCornerGrip, kCornerGrip), Vec2F(kCornerGrip, kCornerGrip))
|
||||
);
|
||||
}
|
||||
|
||||
private:
|
||||
DockPanel& mPanel;
|
||||
};
|
||||
|
||||
DockPanel::DockPanel(std::string title, DockSide side) :
|
||||
Panel(Surface::Base),
|
||||
mSide(side),
|
||||
mTitle(std::move(title)) {
|
||||
setDebugName("dock panel: " + mTitle.text());
|
||||
TitledPanel(std::move(title), Surface::Base),
|
||||
mSide(side) {
|
||||
setDebugName("dock panel: " + this->title());
|
||||
setVisible(false);
|
||||
setLayout(std::make_unique<PanelLayout>(*this));
|
||||
|
||||
BoxLayout& headerLayout = mHeader.emplaceLayout<RowLayout>(2);
|
||||
headerLayout.setPadding(Insets::symmetric(8, 1));
|
||||
headerLayout.setCrossAlign(CrossAlign::Center);
|
||||
mHeader.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mTitle.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
|
||||
mHideButton.setTooltip("Hide");
|
||||
mHideButton.setOnActivate([this]() {
|
||||
|
|
@ -180,16 +138,9 @@ DockPanel::DockPanel(std::string title, DockSide side) :
|
|||
});
|
||||
buildOptionsMenu();
|
||||
|
||||
mHeader.addChild(mTitle);
|
||||
mHeader.addChild(mOptionsButton);
|
||||
mHeader.addChild(mHideButton);
|
||||
|
||||
mBody.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mBody.emplaceLayout<StackLayout>().setPadding(Insets::uniform(6));
|
||||
|
||||
addChild(mHeader);
|
||||
addChild(mBody);
|
||||
addChild(mCornerGrip);
|
||||
addHeaderAction(mOptionsButton);
|
||||
addHeaderAction(mHideButton);
|
||||
setCornerWidget(&mCornerGrip);
|
||||
refreshChrome();
|
||||
}
|
||||
|
||||
|
|
@ -197,13 +148,6 @@ DockPanel::~DockPanel() {
|
|||
if (mArea) mArea->removePanel(*this);
|
||||
}
|
||||
|
||||
void DockPanel::setContent(Widget* content) {
|
||||
if (mContent) mBody.removeChild(*mContent);
|
||||
|
||||
mContent = content;
|
||||
if (mContent) mBody.addChild(*mContent);
|
||||
}
|
||||
|
||||
void DockPanel::setDockedLength(halnf length) {
|
||||
mDockedLength = length > kMinimumDockedLength ? length : kMinimumDockedLength;
|
||||
if (parent()) parent()->requestLayout();
|
||||
|
|
@ -218,30 +162,37 @@ void DockPanel::setFloatingRect(const RectF& rect) {
|
|||
|
||||
RectF DockPanel::resizeGripRect() const { return mCornerGrip.rect(); }
|
||||
|
||||
bool DockPanel::acceptsDrag(PointerButton button) const {
|
||||
return button == PointerButton::Left && mArea && mHeaderPressed;
|
||||
}
|
||||
|
||||
void DockPanel::onEvent(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
{
|
||||
if (event.button() != PointerButton::Left || !mArea) return;
|
||||
|
||||
if (!contains(headerRect(), event.position())) return;
|
||||
beginInteraction(Interaction::PendingDrag, event);
|
||||
break;
|
||||
}
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (mInteraction == Interaction::None) return;
|
||||
updateInteraction(event);
|
||||
break;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (mInteraction == Interaction::None) return;
|
||||
endInteraction(event);
|
||||
break;
|
||||
|
||||
default: return;
|
||||
if (event.type() == Event::Type::PointerDown && event.button() == PointerButton::Left) {
|
||||
// Only a press on the header can drag the panel; where it was taken keeps the panel under the pointer.
|
||||
mHeaderPressed = mArea && contains(headerRect(), event.position());
|
||||
if (mHeaderPressed) mGrabOffset = event.position();
|
||||
return;
|
||||
}
|
||||
|
||||
const Dragged* drag = event.drag();
|
||||
if (!drag || drag->button != PointerButton::Left || !mArea) return;
|
||||
|
||||
switch (drag->phase) {
|
||||
case DragPhase::Started:
|
||||
mDragging = true;
|
||||
refreshChrome();
|
||||
mArea->panelDragStarted(*this);
|
||||
mArea->panelDragged(*this, event.windowPosition());
|
||||
break;
|
||||
case DragPhase::Moved: mArea->panelDragged(*this, event.windowPosition()); break;
|
||||
case DragPhase::Finished:
|
||||
case DragPhase::Cancelled:
|
||||
mDragging = false;
|
||||
mHeaderPressed = false;
|
||||
refreshChrome();
|
||||
mArea->panelDragEnded(*this, event.windowPosition());
|
||||
break;
|
||||
}
|
||||
event.accept();
|
||||
requestPaint();
|
||||
}
|
||||
|
|
@ -261,31 +212,34 @@ DockPanel::CornerGrip::CornerGrip(DockPanel& panel) :
|
|||
setSizing(AxisSizing::fixed(kCornerGrip), AxisSizing::fixed(kCornerGrip));
|
||||
}
|
||||
|
||||
bool DockPanel::CornerGrip::acceptsDrag(PointerButton button) const {
|
||||
return button == PointerButton::Left && mPanel.mArea;
|
||||
}
|
||||
|
||||
void DockPanel::CornerGrip::onEvent(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
if (event.button() != PointerButton::Left || !mPanel.mArea) return;
|
||||
mDragging = true;
|
||||
mGrabOffset = event.position() - size();
|
||||
ui()->capturePointer(*this);
|
||||
mPanel.mArea->beginDirectResize(mPanel);
|
||||
break;
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (!mDragging) return;
|
||||
mPanel.mArea->floatResized(mPanel, mPanel.mArea->toLocal(event.windowPosition() - mGrabOffset));
|
||||
break;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (!mDragging || event.button() != PointerButton::Left) return;
|
||||
mDragging = false;
|
||||
ui()->releasePointer(*this);
|
||||
mPanel.mArea->endDirectResize(mPanel);
|
||||
break;
|
||||
|
||||
default: return;
|
||||
if (event.type() == Event::Type::PointerDown && event.button() == PointerButton::Left) {
|
||||
mGrabOffset = event.position() - size();
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
const Dragged* drag = event.drag();
|
||||
if (!drag || drag->button != PointerButton::Left || !mPanel.mArea) return;
|
||||
|
||||
switch (drag->phase) {
|
||||
case DragPhase::Started:
|
||||
mDragging = true;
|
||||
mPanel.mArea->beginDirectResize(mPanel);
|
||||
[[fallthrough]];
|
||||
case DragPhase::Moved:
|
||||
mPanel.mArea->floatResized(mPanel, mPanel.mArea->toLocal(event.windowPosition() - mGrabOffset));
|
||||
break;
|
||||
case DragPhase::Finished:
|
||||
case DragPhase::Cancelled:
|
||||
mDragging = false;
|
||||
mPanel.mArea->endDirectResize(mPanel);
|
||||
break;
|
||||
}
|
||||
event.accept();
|
||||
refreshReveal();
|
||||
}
|
||||
|
|
@ -293,25 +247,25 @@ void DockPanel::CornerGrip::onEvent(Event& event) {
|
|||
void DockPanel::CornerGrip::onHoverChanged() { refreshReveal(); }
|
||||
|
||||
void DockPanel::CornerGrip::refreshReveal() {
|
||||
mReveal.animateTo(isHovered() || mDragging ? 1.0f : 0.0f);
|
||||
startTicking();
|
||||
mVisual.setRevealHeld(mDragging);
|
||||
if (mVisual.refresh()) startTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void DockPanel::CornerGrip::onTick(halnf deltaMs) {
|
||||
if (!mReveal.advance(deltaMs)) stopTicking();
|
||||
if (!mVisual.advance(deltaMs)) stopTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void DockPanel::CornerGrip::paint(Canvas& painter) {
|
||||
const halnf reveal = mReveal.value();
|
||||
const halnf reveal = mVisual.reveal();
|
||||
if (reveal <= 0.01f) return;
|
||||
|
||||
const Palette& palette = theme().palette;
|
||||
const RGBA base = mDragging ? palette.text : palette.mutedText;
|
||||
const Vec2F corner = size() - Vec2F(5.0f, 5.0f);
|
||||
painter.polyline(
|
||||
{ corner - Vec2F(8.0f, 0.0f), corner, corner - Vec2F(0.0f, 8.0f) }, withAlpha(base, base.a * reveal), 1.5f
|
||||
);
|
||||
// The grip is the bottom-right corner of a box: a chevron pointing down-right, inset from the panel corner.
|
||||
const RectF box = makeRect(size() - Vec2F(kCornerGlyph + kCornerInset, kCornerGlyph + kCornerInset), Vec2F(kCornerGlyph, kCornerGlyph));
|
||||
paintGlyph(painter, Glyph::ResizeCorner, box, Palette::faded(base, reveal), theme());
|
||||
}
|
||||
|
||||
void DockPanel::buildOptionsMenu() {
|
||||
|
|
@ -373,37 +327,6 @@ void DockPanel::refreshChrome() {
|
|||
setCornerRadius(detached ? theme().metrics.popupCornerRadius : theme().metrics.surfaceCornerRadius);
|
||||
}
|
||||
|
||||
void DockPanel::beginInteraction(Interaction interaction, const Event& event) {
|
||||
mInteraction = interaction;
|
||||
mPressPosition = event.windowPosition();
|
||||
mGrabOffset = event.position();
|
||||
ui()->capturePointer(*this);
|
||||
}
|
||||
|
||||
void DockPanel::updateInteraction(const Event& event) {
|
||||
if (mInteraction == Interaction::PendingDrag) {
|
||||
if ((event.windowPosition() - mPressPosition).length() < kDragThreshold) return;
|
||||
|
||||
mInteraction = Interaction::Dragging;
|
||||
mDragging = true;
|
||||
refreshChrome();
|
||||
mArea->panelDragStarted(*this);
|
||||
}
|
||||
|
||||
mArea->panelDragged(*this, event.windowPosition());
|
||||
}
|
||||
|
||||
void DockPanel::endInteraction(const Event& event) {
|
||||
const Interaction finished = mInteraction;
|
||||
|
||||
mInteraction = Interaction::None;
|
||||
mDragging = false;
|
||||
refreshChrome();
|
||||
ui()->releasePointer(*this);
|
||||
|
||||
if (finished == Interaction::Dragging) mArea->panelDragEnded(*this, event.windowPosition());
|
||||
}
|
||||
|
||||
class DockArea::DockLayout : public Layout {
|
||||
public:
|
||||
explicit DockLayout(DockArea& area) :
|
||||
|
|
@ -609,16 +532,16 @@ void DockArea::paintOverlay(Canvas& painter) {
|
|||
for (DockSide side : { DockSide::Left, DockSide::Right, DockSide::Bottom }) {
|
||||
if (indexOf(side) == mDropSide) continue;
|
||||
const RectF target = dropTarget(side);
|
||||
painter.rect(target, withAlpha(palette.text, 0.08f), radius);
|
||||
painter.outline(target, withAlpha(palette.mutedText, 0.6f), radius);
|
||||
painter.rect(target, Palette::faded(palette.text, 0.08f), radius);
|
||||
painter.outline(target, Palette::faded(palette.mutedText, 0.6f), radius);
|
||||
}
|
||||
|
||||
const halnf opacity = mDropPreviewOpacity.value();
|
||||
if (opacity <= 0.01f) return;
|
||||
|
||||
const RectF preview = mDropPreview.value();
|
||||
painter.rect(preview, withAlpha(palette.text, 0.10f * opacity), radius);
|
||||
painter.outline(preview, withAlpha(palette.mutedText, 0.9f * opacity), radius, 1.5f);
|
||||
painter.rect(preview, Palette::faded(palette.text, 0.10f * opacity), radius);
|
||||
painter.outline(preview, Palette::faded(palette.mutedText, 0.9f * opacity), radius, 1.5f);
|
||||
}
|
||||
|
||||
void DockArea::onTick(halnf deltaMs) {
|
||||
|
|
|
|||
|
|
@ -15,86 +15,58 @@ namespace {
|
|||
}
|
||||
|
||||
FloatingPanel::FloatingPanel(std::string title) :
|
||||
Panel(Surface::Raised),
|
||||
mTitle(std::move(title)) {
|
||||
TitledPanel(std::move(title), Surface::Raised) {
|
||||
setDebugName("floating panel");
|
||||
setBorder(true);
|
||||
|
||||
emplaceLayout<ColumnLayout>(0);
|
||||
|
||||
mTitleBar.setSizing(AxisSizing::fill(), AxisSizing::fixed(Theme::standard().metrics.controlHeight));
|
||||
mTitleBar.emplaceLayout<RowLayout>().setPadding(Insets::symmetric(8, 0));
|
||||
mTitle.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mTitleBar.addChild(mTitle);
|
||||
|
||||
mBody.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mBody.emplaceLayout<StackLayout>().setPadding(Insets::uniform(6));
|
||||
|
||||
addChild(mTitleBar);
|
||||
addChild(mBody);
|
||||
// The header is the handle the panel moves by, so it keeps a full control height.
|
||||
header().setSizing(AxisSizing::fill(), AxisSizing::fixed(Theme::standard().metrics.controlHeight));
|
||||
applySize();
|
||||
}
|
||||
|
||||
void FloatingPanel::setContent(Widget* content) {
|
||||
if (mContent) mBody.removeChild(*mContent);
|
||||
|
||||
mContent = content;
|
||||
if (mContent) mBody.addChild(*mContent);
|
||||
}
|
||||
|
||||
void FloatingPanel::setPanelSize(Vec2F size) {
|
||||
mPanelSize = { size.x > mMinimumSize.x ? size.x : mMinimumSize.x, size.y > mMinimumSize.y ? size.y : mMinimumSize.y };
|
||||
applySize();
|
||||
}
|
||||
|
||||
RectF FloatingPanel::titleBarRect() const { return mTitleBar.rect(); }
|
||||
RectF FloatingPanel::titleBarRect() const { return header().rect(); }
|
||||
|
||||
RectF FloatingPanel::resizeGripRect() const {
|
||||
return makeRect(size().x - kGripSize, size().y - kGripSize, kGripSize, kGripSize);
|
||||
}
|
||||
|
||||
void FloatingPanel::onEvent(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
{
|
||||
if (event.button() != PointerButton::Left) return;
|
||||
if (event.type() == Event::Type::PointerDown && event.button() == PointerButton::Left) {
|
||||
// A press raises the panel and remembers which part was taken; moving starts once the router sees a drag.
|
||||
const Vec2F local = event.position();
|
||||
if (contains(resizeGripRect(), local)) mPressedPart = Interaction::Resizing;
|
||||
else if (contains(titleBarRect(), local)) mPressedPart = Interaction::Moving;
|
||||
else return;
|
||||
|
||||
const Vec2F local = event.position();
|
||||
if (contains(resizeGripRect(), local)) mInteraction = Interaction::Resizing;
|
||||
else if (contains(titleBarRect(), local)) mInteraction = Interaction::Moving;
|
||||
else return;
|
||||
|
||||
mGrabOffset = mInteraction == Interaction::Moving ? local : local - size();
|
||||
bringToFront();
|
||||
ui()->capturePointer(*this);
|
||||
event.accept();
|
||||
break;
|
||||
}
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
{
|
||||
if (mInteraction == Interaction::None || !parent()) return;
|
||||
|
||||
const Vec2F inParent = event.windowPosition() - parent()->windowPosition();
|
||||
if (mInteraction == Interaction::Moving) requestPosition(inParent - mGrabOffset);
|
||||
else setPanelSize(event.position() - mGrabOffset);
|
||||
event.accept();
|
||||
break;
|
||||
}
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (mInteraction == Interaction::None) return;
|
||||
mInteraction = Interaction::None;
|
||||
ui()->releasePointer(*this);
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
default: return;
|
||||
mGrabOffset = mPressedPart == Interaction::Moving ? local : local - size();
|
||||
bringToFront();
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
const Dragged* drag = event.drag();
|
||||
if (!drag || drag->button != PointerButton::Left) return;
|
||||
|
||||
if (drag->phase == DragPhase::Started) mInteraction = mPressedPart;
|
||||
if (drag->phase == DragPhase::Finished || drag->phase == DragPhase::Cancelled) {
|
||||
mInteraction = Interaction::None;
|
||||
} else if (mInteraction != Interaction::None && parent()) {
|
||||
const Vec2F inParent = event.windowPosition() - parent()->windowPosition();
|
||||
if (mInteraction == Interaction::Moving) requestPosition(inParent - mGrabOffset);
|
||||
else setPanelSize(event.position() - mGrabOffset);
|
||||
}
|
||||
event.accept();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
bool FloatingPanel::acceptsDrag(PointerButton button) const {
|
||||
return button == PointerButton::Left && mPressedPart != Interaction::None;
|
||||
}
|
||||
|
||||
void FloatingPanel::paintOverlay(Canvas& painter) {
|
||||
const Theme& style = theme();
|
||||
const RectF grip = resizeGripRect();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
#pragma once
|
||||
|
||||
#include "InteractionVisual.hpp"
|
||||
|
||||
#include "Animation.hpp"
|
||||
#include "Button.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Label.hpp"
|
||||
#include "Menus.hpp"
|
||||
#include "TitledPanel.hpp"
|
||||
#include "ResizeHandle.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
|
@ -43,20 +46,18 @@ namespace tp::ui {
|
|||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
};
|
||||
|
||||
class DockPanel : public Panel {
|
||||
// A titled panel a DockArea docks at a side, overlays (unpinned) or floats; its header has the options menu and a
|
||||
// hide button and drags the panel, and a floating panel resizes from its corner grip.
|
||||
class DockPanel : public TitledPanel {
|
||||
public:
|
||||
enum class ViewMode { Docked, Undocked, Float };
|
||||
|
||||
explicit DockPanel(std::string title, DockSide side = DockSide::Left);
|
||||
~DockPanel() override;
|
||||
|
||||
void setContent(Widget* content);
|
||||
[[nodiscard]] Widget* content() const { return mContent; }
|
||||
|
||||
void setIcon(ImageHandle icon) { mIcon = icon; }
|
||||
[[nodiscard]] ImageHandle icon() const { return mIcon; }
|
||||
|
||||
[[nodiscard]] const std::string& title() const { return mTitle.text(); }
|
||||
[[nodiscard]] DockSide side() const { return mSide; }
|
||||
[[nodiscard]] ViewMode viewMode() const { return mViewMode; }
|
||||
[[nodiscard]] bool isOpen() const { return mOpen; }
|
||||
|
|
@ -71,25 +72,25 @@ namespace tp::ui {
|
|||
[[nodiscard]] PopupButton& optionsButton() { return mOptionsButton; }
|
||||
[[nodiscard]] Menu& optionsMenu() { return mOptionsMenu; }
|
||||
[[nodiscard]] Button& hideButton() { return mHideButton; }
|
||||
[[nodiscard]] RectF headerRect() const { return mHeader.rect(); }
|
||||
[[nodiscard]] RectF headerRect() const { return header().rect(); }
|
||||
[[nodiscard]] RectF resizeGripRect() const;
|
||||
[[nodiscard]] Widget& cornerGrip() { return mCornerGrip; }
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
[[nodiscard]] bool acceptsDrag(PointerButton button) const override;
|
||||
void paint(Canvas& painter) override;
|
||||
void paintOverlay(Canvas& painter) override;
|
||||
void onActiveWithinChanged() override;
|
||||
|
||||
private:
|
||||
class PanelLayout;
|
||||
|
||||
class CornerGrip : public Widget {
|
||||
public:
|
||||
explicit CornerGrip(DockPanel& panel);
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
[[nodiscard]] bool acceptsDrag(PointerButton button) const override;
|
||||
void onHoverChanged() override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void paint(Canvas& painter) override;
|
||||
|
|
@ -100,18 +101,13 @@ namespace tp::ui {
|
|||
DockPanel& mPanel;
|
||||
bool mDragging = false;
|
||||
Vec2F mGrabOffset = { 0, 0 };
|
||||
AnimatedFloat mReveal{ 0, Motion::quick() };
|
||||
InteractionVisual mVisual{ *this };
|
||||
};
|
||||
|
||||
friend class DockArea;
|
||||
|
||||
enum class Interaction { None, PendingDrag, Dragging };
|
||||
|
||||
void buildOptionsMenu();
|
||||
void refreshChrome();
|
||||
void beginInteraction(Interaction interaction, const Event& event);
|
||||
void updateInteraction(const Event& event);
|
||||
void endInteraction(const Event& event);
|
||||
|
||||
DockArea* mArea = nullptr;
|
||||
DockSide mSide;
|
||||
|
|
@ -123,19 +119,15 @@ namespace tp::ui {
|
|||
halnf mDockedLength = 260;
|
||||
RectF mFloatingRect = makeRect(120, 120, 280, 320);
|
||||
|
||||
Interaction mInteraction = Interaction::None;
|
||||
// Whether the last press landed on the header, which is the only part that drags the panel.
|
||||
bool mHeaderPressed = false;
|
||||
Vec2F mGrabOffset = { 0, 0 };
|
||||
Vec2F mPressPosition = { 0, 0 };
|
||||
|
||||
Widget mHeader;
|
||||
Label mTitle;
|
||||
OptionsButton mOptionsButton;
|
||||
GlyphButton mHideButton{ GlyphButton::Glyph::Hide };
|
||||
GlyphButton mHideButton{ Glyph::Minus };
|
||||
Menu mOptionsMenu;
|
||||
Menu mViewModeMenu;
|
||||
Menu mMoveToMenu;
|
||||
Widget mBody;
|
||||
Widget* mContent = nullptr;
|
||||
CornerGrip mCornerGrip{ *this };
|
||||
std::function<void(bool)> mOnOpenChanged;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,22 +1,16 @@
|
|||
#pragma once
|
||||
|
||||
#include "Containers.hpp"
|
||||
#include "Label.hpp"
|
||||
#include "TitledPanel.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class FloatingPanel : public Panel {
|
||||
// A titled panel the user moves by its header and resizes by its corner, inside its parent.
|
||||
class FloatingPanel : public TitledPanel {
|
||||
public:
|
||||
explicit FloatingPanel(std::string title = {});
|
||||
|
||||
void setTitle(std::string title) { mTitle.setText(std::move(title)); }
|
||||
[[nodiscard]] const std::string& title() const { return mTitle.text(); }
|
||||
|
||||
void setContent(Widget* content);
|
||||
[[nodiscard]] Widget* content() const { return mContent; }
|
||||
|
||||
void setPanelSize(Vec2F size);
|
||||
[[nodiscard]] Vec2F panelSize() const { return mPanelSize; }
|
||||
void setMinimumPanelSize(Vec2F size) { mMinimumSize = size; }
|
||||
|
|
@ -29,6 +23,7 @@ namespace tp::ui {
|
|||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
[[nodiscard]] bool acceptsDrag(PointerButton button) const override;
|
||||
void paintOverlay(Canvas& painter) override;
|
||||
|
||||
private:
|
||||
|
|
@ -36,15 +31,12 @@ namespace tp::ui {
|
|||
|
||||
void applySize();
|
||||
|
||||
Label mTitle;
|
||||
Widget mTitleBar;
|
||||
Widget mBody;
|
||||
Widget* mContent = nullptr;
|
||||
|
||||
Vec2F mPanelSize = { 240, 200 };
|
||||
Vec2F mMinimumSize = { 120, 80 };
|
||||
|
||||
Interaction mInteraction = Interaction::None;
|
||||
Vec2F mGrabOffset = { 0, 0 };
|
||||
// The part the last press landed on; a drag that starts from it moves or resizes the panel.
|
||||
Interaction mPressedPart = Interaction::None;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
124
Widgets/src/components/files/private/DocumentFolderPanel.cpp
Normal file
124
Widgets/src/components/files/private/DocumentFolderPanel.cpp
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
#include "DocumentFolderPanel.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kPadding = 10;
|
||||
constexpr halnf kGap = 8;
|
||||
constexpr const char* kMarkdownExtension = ".md";
|
||||
|
||||
bool isMarkdownFile(const std::string& path) {
|
||||
const std::string extension = kMarkdownExtension;
|
||||
return path.size() >= extension.size() && path.compare(path.size() - extension.size(), extension.size(), extension) == 0;
|
||||
}
|
||||
|
||||
void mutedHint(Label& label) {
|
||||
label.setTone(Label::Tone::Muted);
|
||||
label.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
}
|
||||
}
|
||||
|
||||
DocumentFolderPanel::DocumentFolderPanel(std::string rootDirectory, std::string libraryTitle, FileSystem& files) :
|
||||
browser(files),
|
||||
mFiles(files),
|
||||
mRoot(std::move(rootDirectory)),
|
||||
mTitle(std::move(libraryTitle)) {
|
||||
setDebugName("document folder panel");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
emplaceLayout<ColumnLayout>();
|
||||
|
||||
mDocumentTitle = [](const std::string& path) { return fileName(parentPath(path)) + "/" + fileName(path); };
|
||||
|
||||
mPages.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mPages.setPagePadding(Insets::uniform(kPadding));
|
||||
mPages.setPageHeadersShown(false);
|
||||
mPages.setOnPageShown([this](StackPage&) {
|
||||
if (mPages.depth() == 1) document.setMarkdown(std::string());
|
||||
});
|
||||
|
||||
browser.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
browser.setExtensions({ kMarkdownExtension });
|
||||
browser.setRootDirectory(mRoot);
|
||||
browser.onFileOpened = [this](const std::string& path) { openDocument(path); };
|
||||
|
||||
mutedHint(hint);
|
||||
hint.setVisible(false);
|
||||
mLibraryPage.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mLibraryPage.emplaceLayout<ColumnLayout>(kGap);
|
||||
mLibraryPage.addChild(hint);
|
||||
mLibraryPage.addChild(browser);
|
||||
|
||||
document.setMode(MarkdownEditor::Mode::Split);
|
||||
document.setOnChanged([this](const std::string& text) { saveDocument(text); });
|
||||
mutedHint(documentStatus);
|
||||
documentStatus.setVisible(false);
|
||||
mDocumentPage.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mDocumentPage.emplaceLayout<ColumnLayout>(kGap);
|
||||
mDocumentPage.addChild(documentStatus);
|
||||
mDocumentPage.addChild(document);
|
||||
|
||||
addChild(mPages);
|
||||
|
||||
// The library title is what the root page is called; the constructor needs it, so the root page is built lazily.
|
||||
showLibrary();
|
||||
refresh();
|
||||
}
|
||||
|
||||
void DocumentFolderPanel::setLibraryHint(std::string text) {
|
||||
hint.setVisible(!text.empty());
|
||||
hint.setText(std::move(text));
|
||||
}
|
||||
|
||||
void DocumentFolderPanel::setDocumentMode(MarkdownEditor::Mode mode) {
|
||||
mMode = mode;
|
||||
document.setMode(mode);
|
||||
}
|
||||
|
||||
void DocumentFolderPanel::setPageHeadersShown(bool shown) { mPages.setPageHeadersShown(shown); }
|
||||
|
||||
void DocumentFolderPanel::setDocumentTitle(std::function<std::string(const std::string& path)> title) {
|
||||
mDocumentTitle = std::move(title);
|
||||
}
|
||||
|
||||
void DocumentFolderPanel::showLibrary() {
|
||||
mPages.clear();
|
||||
mDocument.clear();
|
||||
document.setMarkdown(std::string());
|
||||
StackPage& root = mPages.push(mTitle, mLibraryPage);
|
||||
root.setScrollsContent(false);
|
||||
root.header().setVisible(false);
|
||||
}
|
||||
|
||||
void DocumentFolderPanel::refresh() { browser.setDirectory(browser.directory().empty() ? mRoot : browser.directory()); }
|
||||
|
||||
bool DocumentFolderPanel::openDocument(const std::string& path) {
|
||||
if (!isMarkdownFile(path)) return false;
|
||||
|
||||
const std::optional<std::string> text = mFiles.readText(path);
|
||||
documentStatus.setVisible(!text);
|
||||
documentStatus.setText(text ? std::string() : "Can't read " + path);
|
||||
document.setBaseDirectory(parentPath(path));
|
||||
document.setMarkdown(text.value_or(std::string()));
|
||||
document.setMode(mMode);
|
||||
mDocument = path;
|
||||
|
||||
StackPage& page = mPages.push(mDocumentTitle(path), mDocumentPage);
|
||||
page.setScrollsContent(false);
|
||||
page.header().setVisible(true);
|
||||
|
||||
if (onDocumentOpened) onDocumentOpened(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
void DocumentFolderPanel::saveDocument(const std::string& text) {
|
||||
if (mDocument.empty()) return;
|
||||
if (mFiles.writeText(mDocument, text)) {
|
||||
documentStatus.setVisible(false);
|
||||
return;
|
||||
}
|
||||
documentStatus.setText("Can't save " + mDocument);
|
||||
documentStatus.setVisible(true);
|
||||
}
|
||||
|
|
@ -19,8 +19,6 @@ using namespace tp::ui;
|
|||
namespace {
|
||||
constexpr halnf kBrowserHeight = 300;
|
||||
constexpr halnf kFileDialogWidth = 520;
|
||||
constexpr halnf kRowHeight = 26;
|
||||
constexpr halnf kKindWidth = 18;
|
||||
|
||||
class LocalFileSystem : public FileSystem {
|
||||
public:
|
||||
|
|
@ -149,63 +147,6 @@ std::string tp::ui::describeSize(uint64_t bytes) {
|
|||
return text;
|
||||
}
|
||||
|
||||
FileRow::FileRow(const FileEntry& entry, std::function<void(FileRow&)> onSelect, std::function<void(FileRow&)> onOpen) :
|
||||
Button(entry.name),
|
||||
mEntry(entry),
|
||||
mOnSelect(std::move(onSelect)),
|
||||
mOnOpen(std::move(onOpen)) {
|
||||
setDebugName("file row: " + entry.name);
|
||||
setStyle(Style::Flat);
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fixed(kRowHeight));
|
||||
}
|
||||
|
||||
void FileRow::onEvent(Event& event) {
|
||||
if (event.type() == Event::Type::Click && event.button() == PointerButton::Left) {
|
||||
event.accept();
|
||||
if (mOnSelect) mOnSelect(*this);
|
||||
if (event.clickCount() >= 2 && mOnOpen) mOnOpen(*this);
|
||||
return;
|
||||
}
|
||||
if (event.type() == Event::Type::KeyDown && event.isKey(Key::Enter)) {
|
||||
event.accept();
|
||||
if (mOnOpen) mOnOpen(*this);
|
||||
return;
|
||||
}
|
||||
Button::onEvent(event);
|
||||
}
|
||||
|
||||
void FileRow::paint(Canvas& painter) {
|
||||
const Theme& style = theme();
|
||||
const RectF area = localRect();
|
||||
painter.rect(area, background(), style.metrics.cornerRadius);
|
||||
if (showsFocusRing()) painter.outline(area, style.palette.focusRing, style.metrics.cornerRadius, 2);
|
||||
|
||||
const halnf fontSize = style.metrics.fontSize;
|
||||
const RGBA muted = style.palette.mutedText;
|
||||
const halnf pad = style.metrics.controlPadding.left;
|
||||
|
||||
const RectF kind = makeRect(pad, 0, kKindWidth, area.size.y);
|
||||
if (mEntry.isDirectory) {
|
||||
const halnf top = area.size.y * 0.5f - 5;
|
||||
painter.rect(makeRect(kind.pos.x, top - 2, 7, 3), muted, 1);
|
||||
painter.outline(makeRect(kind.pos.x, top, 14, 10), muted, 2, 1.25f);
|
||||
} else {
|
||||
const halnf top = area.size.y * 0.5f - 7;
|
||||
painter.outline(makeRect(kind.pos.x + 2, top, 10, 14), muted, 1.5f, 1.25f);
|
||||
}
|
||||
|
||||
const std::string size = mEntry.isDirectory ? std::string() : describeSize(mEntry.size);
|
||||
const halnf sizeWidth = size.empty() ? 0 : textWidth(size, fontSize) + pad;
|
||||
const halnf nameLeft = kind.pos.x + kKindWidth + pad * 0.5f;
|
||||
painter.text(
|
||||
mEntry.name,
|
||||
makeRect(nameLeft, 0, area.size.x - nameLeft - sizeWidth - pad, area.size.y),
|
||||
TextStyle{ fontSize, isSelected() ? style.palette.text : (mEntry.isHidden() ? muted : style.palette.text), TextAlign::leftCenter() }
|
||||
);
|
||||
if (!size.empty())
|
||||
painter.text(size, makeRect(area.size.x - sizeWidth - pad, 0, sizeWidth, area.size.y), TextStyle{ fontSize, muted, TextAlign::rightCenter() });
|
||||
}
|
||||
|
||||
FileBrowser::FileBrowser(FileSystem& fileSystem) :
|
||||
mFileSystem(fileSystem) {
|
||||
setDebugName("file browser");
|
||||
|
|
@ -223,14 +164,16 @@ FileBrowser::FileBrowser(FileSystem& fileSystem) :
|
|||
mHeader.addChild(up);
|
||||
mHeader.addChild(path);
|
||||
|
||||
mList.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mList.emplaceLayout<ColumnLayout>(1);
|
||||
mEmpty.setTone(Label::Tone::Muted);
|
||||
scroll.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
scroll.setContent(&mList);
|
||||
list.setEmptyText("This folder is empty");
|
||||
list.setOnSelectionChanged([this]() {
|
||||
if (list.current() && *list.current() < mListed.size()) select(mListed[*list.current()].name);
|
||||
});
|
||||
list.setOnActivated([this](size_t index) {
|
||||
if (index < mListed.size()) open(mListed[index]);
|
||||
});
|
||||
|
||||
addChild(mHeader);
|
||||
addChild(scroll);
|
||||
addChild(list);
|
||||
}
|
||||
|
||||
bool FileBrowser::setDirectory(const std::string& directory) {
|
||||
|
|
@ -249,7 +192,7 @@ bool FileBrowser::setDirectory(const std::string& directory) {
|
|||
refreshPath();
|
||||
up.setEnabled(!isAtRoot());
|
||||
rebuildRows();
|
||||
if (changed) scroll.jumpTo(0);
|
||||
if (changed) list.scroller().jumpTo(0);
|
||||
if (changed && onDirectoryChanged) onDirectoryChanged(mDirectory);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -338,36 +281,49 @@ bool FileBrowser::isListed(const FileEntry& entry) const {
|
|||
}
|
||||
|
||||
void FileBrowser::rebuildRows() {
|
||||
mList.removeAllChildren();
|
||||
mRows.clear();
|
||||
mListed.clear();
|
||||
for (const FileEntry& entry : mEntries) {
|
||||
if (!isListed(entry)) continue;
|
||||
auto row = std::make_unique<FileRow>(
|
||||
entry, [this](FileRow& chosen) { select(chosen.entry().name); }, [this](FileRow& chosen) { open(chosen.entry()); }
|
||||
);
|
||||
row->setSelected(entry.name == mSelected);
|
||||
mList.addChild(*row);
|
||||
mRows.push_back(std::move(row));
|
||||
if (isListed(entry)) mListed.push_back(entry);
|
||||
}
|
||||
if (mRows.empty()) mList.addChild(mEmpty);
|
||||
|
||||
ListView::Model model;
|
||||
model.count = mListed.size();
|
||||
model.fill = [this](size_t index, ListRow& row) {
|
||||
const FileEntry& entry = mListed[index];
|
||||
row.setTitle(entry.name);
|
||||
row.setLeading(ListRow::Leading::glyphOf(entry.isDirectory ? Glyph::Folder : Glyph::File));
|
||||
row.setTrailing(entry.isDirectory ? ListRow::Trailing::none() : ListRow::Trailing::textOf(describeSize(entry.size)));
|
||||
row.setMuted(entry.isHidden());
|
||||
row.setDebugName("file row: " + entry.name);
|
||||
};
|
||||
list.setModel(std::move(model));
|
||||
const std::optional<size_t> selectedIndex = listedIndex(mSelected);
|
||||
if (selectedIndex) list.select(*selectedIndex);
|
||||
else list.clearSelection();
|
||||
}
|
||||
|
||||
FileRow* FileBrowser::row(const std::string& name) const {
|
||||
for (const auto& row : mRows) {
|
||||
if (row->entry().name == name) return row.get();
|
||||
std::optional<size_t> FileBrowser::listedIndex(const std::string& name) const {
|
||||
for (size_t index = 0; index < mListed.size(); index++) {
|
||||
if (mListed[index].name == name) return index;
|
||||
}
|
||||
return nullptr;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
ListRow* FileBrowser::row(const std::string& name) {
|
||||
const std::optional<size_t> index = listedIndex(name);
|
||||
return index ? &list.row(*index) : nullptr;
|
||||
}
|
||||
|
||||
const FileEntry* FileBrowser::selected() const {
|
||||
const FileRow* found = row(mSelected);
|
||||
return found ? &found->entry() : nullptr;
|
||||
const std::optional<size_t> index = listedIndex(mSelected);
|
||||
return index ? &mListed[*index] : nullptr;
|
||||
}
|
||||
|
||||
void FileBrowser::select(const std::string& name) {
|
||||
if (name == mSelected && list.current() == listedIndex(name)) return;
|
||||
mSelected = name;
|
||||
for (const auto& row : mRows)
|
||||
row->setSelected(row->entry().name == name);
|
||||
const std::optional<size_t> index = listedIndex(name);
|
||||
if (index && list.current() != index) list.select(*index);
|
||||
const FileEntry* entry = selected();
|
||||
if (entry && onSelected) onSelected(*entry);
|
||||
}
|
||||
|
|
@ -439,7 +395,8 @@ void FileDialog::filterDescendantEvent(Event& event) {
|
|||
void FileDialog::onEvent(Event& event) {
|
||||
if (event.type() != Event::Type::KeyDown) return;
|
||||
if (!event.isKey(Key::Enter) && !event.isKey(Key::KeypadEnter)) return;
|
||||
if (ui() && dynamic_cast<FileRow*>(ui()->keyboardFocus())) return;
|
||||
// Enter in the file list opens the current entry; elsewhere in the dialog it confirms.
|
||||
if (ui() && ui()->keyboardFocus() && browser.list.isSelfOrAncestorOf(*ui()->keyboardFocus())) return;
|
||||
event.accept();
|
||||
confirm();
|
||||
}
|
||||
|
|
|
|||
62
Widgets/src/components/files/public/DocumentFolderPanel.hpp
Normal file
62
Widgets/src/components/files/public/DocumentFolderPanel.hpp
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
#pragma once
|
||||
|
||||
#include "FileBrowser.hpp"
|
||||
#include "Label.hpp"
|
||||
#include "MarkdownEditor.hpp"
|
||||
#include "PageStack.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
// A folder of markdown documents: a file browser over the folder and an editor for the file chosen. Opening a
|
||||
// markdown file switches to the editor page, loads it and writes edits back to disk as they happen. The shared
|
||||
// half of a skills library and a task workspace; each adds only its own behaviour (creating a skill, picking a
|
||||
// task's workspace).
|
||||
class DocumentFolderPanel : public Widget {
|
||||
public:
|
||||
explicit DocumentFolderPanel(std::string rootDirectory, std::string libraryTitle, FileSystem& files = FileSystem::local());
|
||||
|
||||
void refresh();
|
||||
// Shows the folder again and drops the open document.
|
||||
void showLibrary();
|
||||
// Loads a markdown file and opens its editor page; returns whether it opened.
|
||||
bool openDocument(const std::string& path);
|
||||
|
||||
// An optional hint shown above the browser (a workspace panel shows one when no workspace is set).
|
||||
void setLibraryHint(std::string hint);
|
||||
void setDocumentMode(MarkdownEditor::Mode mode);
|
||||
void setPageHeadersShown(bool shown);
|
||||
// How the editor page is titled for a path; defaults to "folder/name".
|
||||
void setDocumentTitle(std::function<std::string(const std::string& path)> title);
|
||||
|
||||
[[nodiscard]] const std::string& openedDocument() const { return mDocument; }
|
||||
[[nodiscard]] const std::string& rootDirectory() const { return mRoot; }
|
||||
[[nodiscard]] PageStack& pages() { return mPages; }
|
||||
|
||||
// Called after a document opened (in addition to switching pages).
|
||||
std::function<void(const std::string& path)> onDocumentOpened;
|
||||
|
||||
FileBrowser browser;
|
||||
Label hint;
|
||||
MarkdownEditor document;
|
||||
Label documentStatus;
|
||||
|
||||
protected:
|
||||
// Writes the text back to the open document; sets the status on failure. Overridable, rarely needed.
|
||||
virtual void saveDocument(const std::string& text);
|
||||
|
||||
FileSystem& mFiles;
|
||||
std::string mRoot;
|
||||
std::string mTitle;
|
||||
std::string mDocument;
|
||||
MarkdownEditor::Mode mMode = MarkdownEditor::Mode::Split;
|
||||
|
||||
private:
|
||||
std::function<std::string(const std::string&)> mDocumentTitle;
|
||||
PageStack mPages;
|
||||
Widget mLibraryPage;
|
||||
Widget mDocumentPage;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
#include "Breadcrumb.hpp"
|
||||
#include "ListView.hpp"
|
||||
#include "Button.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Dialog.hpp"
|
||||
|
|
@ -47,22 +48,6 @@ namespace tp::ui {
|
|||
[[nodiscard]] std::string fileName(const std::string& path);
|
||||
[[nodiscard]] std::string describeSize(uint64_t bytes);
|
||||
|
||||
class FileRow : public Button {
|
||||
public:
|
||||
FileRow(const FileEntry& entry, std::function<void(FileRow&)> onSelect, std::function<void(FileRow&)> onOpen);
|
||||
|
||||
[[nodiscard]] const FileEntry& entry() const { return mEntry; }
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
void paint(Canvas& painter) override;
|
||||
|
||||
private:
|
||||
FileEntry mEntry;
|
||||
std::function<void(FileRow&)> mOnSelect;
|
||||
std::function<void(FileRow&)> mOnOpen;
|
||||
};
|
||||
|
||||
class FileBrowser : public Widget {
|
||||
public:
|
||||
explicit FileBrowser(FileSystem& fileSystem = FileSystem::local());
|
||||
|
|
@ -81,8 +66,10 @@ namespace tp::ui {
|
|||
[[nodiscard]] bool showsHidden() const { return mShowsHidden; }
|
||||
|
||||
[[nodiscard]] const std::vector<FileEntry>& entries() const { return mEntries; }
|
||||
[[nodiscard]] const std::vector<std::unique_ptr<FileRow>>& rows() const { return mRows; }
|
||||
[[nodiscard]] FileRow* row(const std::string& name) const;
|
||||
// The entries shown, in list order (hidden and filtered entries left out).
|
||||
[[nodiscard]] const std::vector<FileEntry>& listed() const { return mListed; }
|
||||
// The row showing an entry, or null when it is not listed.
|
||||
[[nodiscard]] ListRow* row(const std::string& name);
|
||||
[[nodiscard]] const FileEntry* selected() const;
|
||||
void select(const std::string& name);
|
||||
|
||||
|
|
@ -91,9 +78,10 @@ namespace tp::ui {
|
|||
|
||||
[[nodiscard]] std::vector<std::string> pathDirectories() const;
|
||||
|
||||
GlyphButton up{ GlyphButton::Glyph::Up };
|
||||
GlyphButton up{ Glyph::ChevronUp };
|
||||
Breadcrumb path;
|
||||
ScrollView scroll;
|
||||
// The entries: click selects, double click or Enter opens, arrows and typing move.
|
||||
ListView list;
|
||||
|
||||
std::function<void(const FileEntry&)> onSelected;
|
||||
std::function<void(const std::string& path)> onFileOpened;
|
||||
|
|
@ -107,6 +95,7 @@ namespace tp::ui {
|
|||
[[nodiscard]] bool isAtRoot() const;
|
||||
[[nodiscard]] bool isInsideRoot(const std::string& directory) const;
|
||||
void rebuildRows();
|
||||
[[nodiscard]] std::optional<size_t> listedIndex(const std::string& name) const;
|
||||
void refreshPath();
|
||||
void open(const FileEntry& entry);
|
||||
|
||||
|
|
@ -118,11 +107,9 @@ namespace tp::ui {
|
|||
bool mShowsFiles = true;
|
||||
bool mShowsHidden = false;
|
||||
std::vector<FileEntry> mEntries;
|
||||
std::vector<std::unique_ptr<FileRow>> mRows;
|
||||
std::vector<FileEntry> mListed;
|
||||
std::string mSelected;
|
||||
Widget mHeader;
|
||||
Widget mList;
|
||||
Label mEmpty{ "This folder is empty" };
|
||||
};
|
||||
|
||||
class FileDialog : public Dialog {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
#include "DocumentFolderPanel.hpp"
|
||||
#include "MemoryFileSystem.hpp"
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Ui.hpp"
|
||||
#include "UiDriver.hpp"
|
||||
|
||||
#include "UnitTest++/UnitTest++.h"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
using namespace tp::ui::testing;
|
||||
|
||||
namespace {
|
||||
MemoryFileSystem sampleFiles() {
|
||||
MemoryFileSystem files("/home/ilya/skills");
|
||||
files.addTextFile("/home/ilya/skills/self-host.md", "# Self-host\nDocs.");
|
||||
files.addTextFile("/home/ilya/skills/report.md", "# Report\nMore.");
|
||||
files.addTextFile("/home/ilya/skills/README.md", "Index.");
|
||||
return files;
|
||||
}
|
||||
}
|
||||
|
||||
SUITE(DocumentFolderPanel) {
|
||||
TEST(StartsOnTheLibraryAndOpensADocumentOnDoubleClick) {
|
||||
MemoryFileSystem files = sampleFiles();
|
||||
Widget root;
|
||||
DocumentFolderPanel panel("/home/ilya/skills", "Skills", files);
|
||||
std::string opened;
|
||||
panel.onDocumentOpened = [&](const std::string& path) { opened = path; };
|
||||
root.emplaceLayout<ColumnLayout>();
|
||||
root.addChild(panel);
|
||||
UiDriver driver(root, { 800, 600 });
|
||||
|
||||
CHECK_EQUAL(1u, panel.pages().depth());
|
||||
CHECK_EQUAL("Skills", panel.pages().currentPage().title());
|
||||
CHECK_EQUAL("Index.", files.readText("/home/ilya/skills/README.md").value_or(""));
|
||||
|
||||
driver.click(*panel.browser.row("self-host.md"));
|
||||
driver.click(*panel.browser.row("self-host.md"));
|
||||
CHECK_EQUAL("/home/ilya/skills/self-host.md", opened);
|
||||
CHECK_EQUAL("/home/ilya/skills/self-host.md", panel.openedDocument());
|
||||
CHECK_EQUAL(2u, panel.pages().depth());
|
||||
CHECK(driver.paint().hasText("# Self-host"));
|
||||
}
|
||||
|
||||
TEST(EditsAreWrittenBackToDisk) {
|
||||
MemoryFileSystem files = sampleFiles();
|
||||
Widget root;
|
||||
DocumentFolderPanel panel("/home/ilya/skills", "Skills", files);
|
||||
root.emplaceLayout<ColumnLayout>();
|
||||
root.addChild(panel);
|
||||
UiDriver driver(root, { 800, 600 });
|
||||
|
||||
driver.click(*panel.browser.row("report.md"));
|
||||
driver.click(*panel.browser.row("report.md"));
|
||||
REQUIRE CHECK_EQUAL(2u, panel.pages().depth());
|
||||
|
||||
// Editing the source writes the whole document back to disk through the panel.
|
||||
driver.click(panel.document.sourceArea().windowPosition() + Vec2F(20.0f, 20.0f));
|
||||
driver.key(Key::A, KeyModifiers::controlOnly());
|
||||
driver.type("# Changed");
|
||||
driver.settle();
|
||||
CHECK_EQUAL("# Changed", files.readText("/home/ilya/skills/report.md").value_or(""));
|
||||
|
||||
panel.showLibrary();
|
||||
CHECK_EQUAL(1u, panel.pages().depth());
|
||||
CHECK(panel.openedDocument().empty());
|
||||
}
|
||||
|
||||
TEST(UnreadableFilesReportItAndKeepTheEditorEmpty) {
|
||||
MemoryFileSystem files = sampleFiles();
|
||||
Widget root;
|
||||
DocumentFolderPanel panel("/home/ilya/skills", "Skills", files);
|
||||
root.emplaceLayout<ColumnLayout>();
|
||||
root.addChild(panel);
|
||||
UiDriver driver(root, { 800, 600 });
|
||||
|
||||
panel.openDocument("/home/ilya/skills/missing.md");
|
||||
CHECK(panel.documentStatus.isVisible());
|
||||
CHECK_EQUAL("Can't read /home/ilya/skills/missing.md", panel.documentStatus.text());
|
||||
}
|
||||
}
|
||||
|
|
@ -29,8 +29,8 @@ namespace {
|
|||
|
||||
std::vector<std::string> rowNames(const FileBrowser& browser) {
|
||||
std::vector<std::string> names;
|
||||
for (const auto& row : browser.rows())
|
||||
names.push_back(row->entry().name);
|
||||
for (const FileEntry& entry : browser.listed())
|
||||
names.push_back(entry.name);
|
||||
return names;
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +116,7 @@ SUITE(FileBrowser) {
|
|||
CHECK(scene.browser.path.part(2).fontFace() == FontFace::Bold);
|
||||
CHECK(scene.browser.path.part(1).fontFace() == FontFace::Regular);
|
||||
CHECK(scene.browser.up.text().empty());
|
||||
CHECK(scene.browser.up.glyph() == GlyphButton::Glyph::Up);
|
||||
CHECK(scene.browser.up.glyph() == Glyph::ChevronUp);
|
||||
|
||||
driver.click(scene.browser.path.part(1));
|
||||
CHECK_EQUAL("/home/ilya", scene.browser.directory());
|
||||
|
|
@ -145,7 +145,7 @@ SUITE(FileBrowser) {
|
|||
driver.click(*scene.browser.row("notes.txt"));
|
||||
REQUIRE CHECK(scene.browser.selected() != nullptr);
|
||||
CHECK_EQUAL("notes.txt", scene.browser.selected()->name);
|
||||
CHECK(scene.browser.row("notes.txt")->isSelected());
|
||||
CHECK(scene.browser.row("notes.txt")->isHighlighted());
|
||||
|
||||
driver.advance(1000);
|
||||
doubleClick(driver, *scene.browser.row("Pictures"));
|
||||
|
|
@ -154,6 +154,29 @@ SUITE(FileBrowser) {
|
|||
CHECK(scene.browser.selected() == nullptr);
|
||||
}
|
||||
|
||||
TEST(ArrowsAndEnterBrowseWithoutAPointer) {
|
||||
BrowserScene scene;
|
||||
UiDriver driver(scene.root, { 600, 400 });
|
||||
std::string opened;
|
||||
scene.browser.onFileOpened = [&](const std::string& path) { opened = path; };
|
||||
const std::vector<std::string> names = rowNames(scene.browser);
|
||||
driver.click(*scene.browser.row(names[0]));
|
||||
|
||||
// Directories come first; the keyboard walks the same order the rows show.
|
||||
driver.key(Key::Down);
|
||||
REQUIRE CHECK(scene.browser.selected() != nullptr);
|
||||
CHECK_EQUAL(names[1], scene.browser.selected()->name);
|
||||
|
||||
driver.type("s");
|
||||
REQUIRE CHECK(scene.browser.selected() != nullptr);
|
||||
CHECK(scene.browser.selected()->name[0] == 's');
|
||||
driver.advance(1000);
|
||||
driver.type("sk");
|
||||
CHECK_EQUAL("sketch.png", scene.browser.selected()->name);
|
||||
driver.key(Key::Enter);
|
||||
CHECK_EQUAL("/home/ilya/sketch.png", opened);
|
||||
}
|
||||
|
||||
TEST(DoubleClickOnAFileReportsItsPath) {
|
||||
BrowserScene scene;
|
||||
UiDriver driver(scene.root, { 600, 400 });
|
||||
|
|
@ -190,7 +213,7 @@ SUITE(FileBrowser) {
|
|||
UiDriver driver(scene.root, { 600, 400 });
|
||||
scene.browser.setDirectory("/home/ilya/Pictures");
|
||||
driver.settle();
|
||||
CHECK(scene.browser.rows().empty());
|
||||
CHECK(scene.browser.listed().empty());
|
||||
CHECK(driver.paint().hasText("This folder is empty"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
#include "KanbanBoard.hpp"
|
||||
#include "ReorderSession.hpp"
|
||||
#include "Elevation.hpp"
|
||||
#include "Canvas.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
|
|
@ -24,30 +26,13 @@ namespace {
|
|||
constexpr halnf kColumnPadding = 8;
|
||||
constexpr halnf kBoardPadding = 10;
|
||||
constexpr halnf kHeaderAccentHeight = 3;
|
||||
constexpr halnf kLiftedShadowOffset = 4;
|
||||
constexpr halnf kAutoScrollEdge = 40;
|
||||
constexpr halnf kAutoScrollSpeed = 0.8f;
|
||||
|
||||
RGBA withAlpha(RGBA color, halnf alpha) {
|
||||
color.a *= alpha;
|
||||
return color;
|
||||
}
|
||||
|
||||
bool movedPast(Vec2F from, Vec2F to, halnf threshold) {
|
||||
return std::fabs(to.x - from.x) >= threshold || std::fabs(to.y - from.y) >= threshold;
|
||||
}
|
||||
|
||||
halnf edgeSpeed(halnf pointer, halnf start, halnf end) {
|
||||
if (end - start < kAutoScrollEdge * 3) return 0;
|
||||
if (pointer < start + kAutoScrollEdge) return -(start + kAutoScrollEdge - pointer) / kAutoScrollEdge;
|
||||
if (pointer > end - kAutoScrollEdge) return (pointer - (end - kAutoScrollEdge)) / kAutoScrollEdge;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
KanbanCard::KanbanCard(KanbanItemId id) :
|
||||
mId(id) {
|
||||
setDebugName("kanban card #" + std::to_string(id));
|
||||
onThemeChanged();
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
emplaceLayout<ColumnLayout>(kCardLineGap)
|
||||
.setPadding({ kCardPadding + kCardAccentInset, kCardPadding, kCardPadding + kCardBadgeRadius * 2, kCardPadding });
|
||||
|
|
@ -108,13 +93,28 @@ void KanbanCard::setSelected(bool selected) {
|
|||
requestPaint();
|
||||
}
|
||||
|
||||
void KanbanCard::onThemeChanged() {
|
||||
const Palette& palette = theme().palette;
|
||||
mVisual.setColors({ palette.raisedSurface, palette.controlHovered, palette.controlPressed, palette.raisedSurface });
|
||||
mVisual.finish();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void KanbanCard::refreshVisual() {
|
||||
if (mVisual.refresh()) startTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void KanbanCard::onTick(halnf deltaMs) {
|
||||
if (!mVisual.advance(deltaMs)) stopTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void KanbanCard::paint(Canvas& painter) {
|
||||
const Theme& style = theme();
|
||||
const RectF rect = localRect();
|
||||
|
||||
painter.rect(
|
||||
rect, isHovered() ? style.palette.controlHovered : style.palette.raisedSurface, style.metrics.cornerRadius
|
||||
);
|
||||
painter.rect(rect, mVisual.color(), style.metrics.cornerRadius);
|
||||
if (mAccent) {
|
||||
const RectF strip =
|
||||
makeRect(kCardAccentInset, kCardAccentInset, kCardAccentWidth, rect.size.y - kCardAccentInset * 2);
|
||||
|
|
@ -131,75 +131,61 @@ void KanbanCard::paintOverlay(Canvas& painter) {
|
|||
painter.outline(localRect(), style.palette.accent, style.metrics.cornerRadius, 2);
|
||||
}
|
||||
|
||||
class KanbanCardStack::CardLayout : public Layout {
|
||||
// The cards in a column, in child order with the placeholder spacer among them; after placing them it starts the
|
||||
// landing glide of cards that came from elsewhere.
|
||||
class KanbanCardStack::LandingLayout : public ColumnLayout {
|
||||
public:
|
||||
explicit CardLayout(KanbanCardStack& stack) :
|
||||
explicit LandingLayout(KanbanCardStack& stack) :
|
||||
ColumnLayout(kCardGap),
|
||||
mStack(stack) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override {
|
||||
if (axis == Axis::Horizontal) return Extent::shrinkable(0);
|
||||
|
||||
halnf total = 0;
|
||||
size_t slots = 0;
|
||||
for (const KanbanCard* card : mStack.mCards) {
|
||||
if (!isArranged(context, *card)) continue;
|
||||
total += context.measure(*card, axis, crossLength).preferred;
|
||||
slots++;
|
||||
}
|
||||
if (mStack.mPlaceholderIndex) {
|
||||
total += mStack.mPlaceholderHeight;
|
||||
slots++;
|
||||
}
|
||||
if (slots > 1) total += kCardGap * halnf(slots - 1);
|
||||
return Extent::rigid(total);
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& area) override {
|
||||
halnf y = area.pos.y;
|
||||
size_t slot = 0;
|
||||
mStack.mPlaceholderRect = makeRect(0, 0, 0, 0);
|
||||
|
||||
const auto placePlaceholderAt = [&](size_t index) {
|
||||
if (mStack.mPlaceholderIndex != index) return;
|
||||
mStack.mPlaceholderRect = makeRect(area.pos.x, y, area.size.x, mStack.mPlaceholderHeight);
|
||||
y += mStack.mPlaceholderHeight + kCardGap;
|
||||
};
|
||||
|
||||
for (KanbanCard* card : mStack.mCards) {
|
||||
if (!isArranged(context, *card)) continue;
|
||||
placePlaceholderAt(slot);
|
||||
const halnf height = context.measure(*card, Axis::Vertical, area.size.x).preferred;
|
||||
context.place(*card, makeRect(area.pos.x, y, area.size.x, height));
|
||||
land(*card);
|
||||
y += height + kCardGap;
|
||||
slot++;
|
||||
}
|
||||
placePlaceholderAt(slot);
|
||||
ColumnLayout::arrangeChildren(context, area);
|
||||
mStack.onArranged();
|
||||
}
|
||||
|
||||
private:
|
||||
[[nodiscard]] static bool isArranged(const LayoutContext& context, const KanbanCard& card) {
|
||||
const auto& children = context.children();
|
||||
return std::find(children.begin(), children.end(), &card) != children.end();
|
||||
}
|
||||
|
||||
void land(KanbanCard& card) {
|
||||
const auto landing = mStack.mLandings.find(&card);
|
||||
if (landing == mStack.mLandings.end()) return;
|
||||
|
||||
const RectF from = makeRect(landing->second.pos - mStack.windowPosition(), landing->second.size);
|
||||
mStack.mLandings.erase(landing);
|
||||
card.animateGeometryFrom(from);
|
||||
}
|
||||
|
||||
KanbanCardStack& mStack;
|
||||
};
|
||||
|
||||
KanbanCardStack::KanbanCardStack() {
|
||||
setDebugName("kanban card stack");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
setLayout(std::make_unique<CardLayout>(*this));
|
||||
setLayout(std::make_unique<LandingLayout>(*this));
|
||||
mPlaceholder.setDebugName("kanban placeholder");
|
||||
mPlaceholder.setVisible(false);
|
||||
addChild(mPlaceholder);
|
||||
}
|
||||
|
||||
void KanbanCardStack::arrangeChildren() {
|
||||
// Children in display order: the cards still in the stack, with the placeholder at its slot among them. A card
|
||||
// that is already a child is only moved when its position changes, so it keeps its placement and can glide.
|
||||
std::vector<Widget*> order;
|
||||
for (KanbanCard* card : mCards) {
|
||||
// A lifted card belongs to the board until it is dropped; it is not pulled back here.
|
||||
if (!card->parent() || card->parent() == this) order.push_back(card);
|
||||
}
|
||||
if (mPlaceholderIndex) order.insert(order.begin() + (std::ptrdiff_t) std::min(*mPlaceholderIndex, order.size()), &mPlaceholder);
|
||||
else order.push_back(&mPlaceholder);
|
||||
|
||||
for (size_t index = 0; index < order.size(); index++) {
|
||||
const bool inPlace = index < children().size() && children()[index] == order[index];
|
||||
if (!inPlace) insertChild(index, *order[index]);
|
||||
}
|
||||
}
|
||||
|
||||
void KanbanCardStack::onArranged() {
|
||||
// Cards that arrived from somewhere else (a drop, a model change) glide from where they were.
|
||||
for (auto landing = mLandings.begin(); landing != mLandings.end();) {
|
||||
KanbanCard& card = *landing->first;
|
||||
if (card.parent() != this) {
|
||||
++landing;
|
||||
continue;
|
||||
}
|
||||
card.animateGeometryFrom(makeRect(landing->second.pos - windowPosition(), landing->second.size));
|
||||
landing = mLandings.erase(landing);
|
||||
}
|
||||
}
|
||||
|
||||
void KanbanCardStack::setCards(
|
||||
|
|
@ -209,11 +195,11 @@ void KanbanCardStack::setCards(
|
|||
if (std::find(cards.begin(), cards.end(), card) == cards.end() && card->parent() == this) removeChild(*card);
|
||||
}
|
||||
for (KanbanCard* card : cards) {
|
||||
if (card->parent() != this) addChild(*card);
|
||||
const auto landing = landings.find(card);
|
||||
if (landing != landings.end()) mLandings[card] = landing->second;
|
||||
}
|
||||
mCards = cards;
|
||||
arrangeChildren();
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
|
|
@ -221,6 +207,7 @@ void KanbanCardStack::removeCard(KanbanCard& card) {
|
|||
mCards.erase(std::remove(mCards.begin(), mCards.end(), &card), mCards.end());
|
||||
mLandings.erase(&card);
|
||||
if (card.parent() == this) removeChild(card);
|
||||
arrangeChildren();
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
|
|
@ -228,6 +215,9 @@ void KanbanCardStack::setPlaceholder(std::optional<size_t> index, halnf height)
|
|||
if (mPlaceholderIndex == index && mPlaceholderHeight == height) return;
|
||||
mPlaceholderIndex = index;
|
||||
mPlaceholderHeight = height;
|
||||
mPlaceholder.setSizing(AxisSizing::fill(), AxisSizing::fixed(height));
|
||||
mPlaceholder.setVisible(index.has_value());
|
||||
arrangeChildren();
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
}
|
||||
|
|
@ -238,22 +228,27 @@ std::optional<size_t> KanbanCardStack::indexOf(const KanbanCard& card) const {
|
|||
return size_t(position - mCards.begin());
|
||||
}
|
||||
|
||||
size_t KanbanCardStack::insertionIndexAt(halnf windowY) const {
|
||||
size_t index = 0;
|
||||
ReorderLane KanbanCardStack::lane() const {
|
||||
// The cards still in the stack (a lifted card has left it), in window coordinates.
|
||||
ReorderLane lane;
|
||||
lane.axis = Axis::Vertical;
|
||||
lane.area = windowRect();
|
||||
for (const KanbanCard* card : mCards) {
|
||||
if (card->parent() != this) continue;
|
||||
const RectF rect = card->windowRect();
|
||||
if (windowY < rect.pos.y + rect.size.y / 2) return index;
|
||||
index++;
|
||||
if (card->parent() == this) lane.items.push_back(card->windowRect());
|
||||
}
|
||||
return index;
|
||||
return lane;
|
||||
}
|
||||
|
||||
size_t KanbanCardStack::insertionIndexAt(halnf windowY) const {
|
||||
return ReorderSession::slotIn(lane(), Vec2F(windowRect().center().x, windowY));
|
||||
}
|
||||
|
||||
void KanbanCardStack::paint(Canvas& painter) {
|
||||
if (!mPlaceholderIndex) return;
|
||||
const Theme& style = theme();
|
||||
painter.rect(mPlaceholderRect, withAlpha(style.palette.accent, 0.12f), style.metrics.cornerRadius);
|
||||
painter.outline(mPlaceholderRect, style.palette.focusRing, style.metrics.cornerRadius, 1.5f);
|
||||
const RectF slot = mPlaceholder.rect();
|
||||
painter.rect(slot, Palette::faded(style.palette.accent, 0.12f), style.metrics.cornerRadius);
|
||||
painter.outline(slot, style.palette.focusRing, style.metrics.cornerRadius, 1.5f);
|
||||
}
|
||||
|
||||
KanbanColumnView::KanbanColumnView(const KanbanColumn& column) :
|
||||
|
|
@ -317,58 +312,6 @@ void KanbanColumnView::paint(Canvas& painter) {
|
|||
if (mDropState == DropState::Refused) painter.outline(rect, style.palette.windowCloseHovered, radius, 2);
|
||||
}
|
||||
|
||||
class KanbanBoard::StripLayout : public Layout {
|
||||
public:
|
||||
explicit StripLayout(KanbanBoard& board) :
|
||||
mBoard(board) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf) const override {
|
||||
const size_t count = context.children().size() + (mBoard.columnGapSlot() ? 1 : 0);
|
||||
if (axis == Axis::Vertical) return Extent::shrinkable(0);
|
||||
return Extent::shrinkable(stripLength(count, mBoard.mMinimumColumnWidth), 0);
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& area) override {
|
||||
const std::optional<size_t> gap = mBoard.columnGapSlot();
|
||||
const size_t count = context.children().size() + (gap ? 1 : 0);
|
||||
if (count == 0) return;
|
||||
|
||||
const halnf gaps = kColumnGap * halnf(count - 1);
|
||||
const halnf share = (area.size.x - gaps) / halnf(count);
|
||||
const halnf width = std::clamp(share, mBoard.mMinimumColumnWidth, mBoard.mMaximumColumnWidth);
|
||||
halnf x = area.pos.x;
|
||||
for (size_t slot = 0; slot < count; slot++) {
|
||||
const bool isGap = gap == slot;
|
||||
const size_t child = gap && slot > *gap ? slot - 1 : slot;
|
||||
const RectF rect = makeRect(x, area.pos.y, width, area.size.y);
|
||||
x += width + kColumnGap;
|
||||
if (isGap) {
|
||||
mBoard.mColumnGapRect = rect;
|
||||
continue;
|
||||
}
|
||||
Widget& column = *context.children()[child];
|
||||
context.place(column, rect);
|
||||
land(column);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
void land(Widget& column) {
|
||||
const std::optional<Landing> landing = mBoard.mColumnLanding;
|
||||
if (!landing || landing->widget != &column) return;
|
||||
mBoard.mColumnLanding.reset();
|
||||
column.animateGeometryFrom(translated(landing->from, mBoard.mColumnStrip.windowPosition() * -1.0f));
|
||||
}
|
||||
|
||||
[[nodiscard]] static halnf stripLength(size_t count, halnf width) {
|
||||
if (count == 0) return 0;
|
||||
return width * halnf(count) + kColumnGap * halnf(count - 1);
|
||||
}
|
||||
|
||||
KanbanBoard& mBoard;
|
||||
};
|
||||
|
||||
class KanbanBoard::BoardLayout : public Layout {
|
||||
public:
|
||||
explicit BoardLayout(KanbanBoard& board) :
|
||||
|
|
@ -404,9 +347,12 @@ KanbanBoard::KanbanBoard(const KanbanModel& model) :
|
|||
setClipsChildren(true);
|
||||
setLayout(std::make_unique<BoardLayout>(*this));
|
||||
|
||||
// The columns share the strip's width equally within the width range; when they do not fit they keep the minimum
|
||||
// and the strip scrolls. While a column is lifted, a spacer of its width holds its slot.
|
||||
mColumnStrip.setDebugName("kanban columns");
|
||||
mColumnStrip.setLayout(std::make_unique<StripLayout>(*this));
|
||||
mColumnStrip.layout()->setPadding(Insets::uniform(kBoardPadding));
|
||||
mColumnStrip.emplaceLayout<RowLayout>(kColumnGap).setPadding(Insets::uniform(kBoardPadding));
|
||||
mColumnGap.setDebugName("kanban column gap");
|
||||
mColumnGap.setVisible(false);
|
||||
mColumnScroller.setWheelStep(kColumnWheelStep);
|
||||
mColumnScroller.setContent(&mColumnStrip);
|
||||
addChild(mColumnScroller);
|
||||
|
|
@ -439,7 +385,15 @@ void KanbanBoard::setCardHeight(std::optional<halnf> height) {
|
|||
void KanbanBoard::setColumnWidthRange(halnf minimum, halnf maximum) {
|
||||
mMinimumColumnWidth = minimum;
|
||||
mMaximumColumnWidth = std::max(minimum, maximum);
|
||||
mColumnStrip.requestLayout();
|
||||
for (auto& view : mColumnViews)
|
||||
applyColumnWidth(*view);
|
||||
applyColumnWidth(mColumnGap);
|
||||
}
|
||||
|
||||
void KanbanBoard::applyColumnWidth(Widget& column) const {
|
||||
// The minimum is also where the share starts, so a strip that is too narrow keeps every column at it and scrolls;
|
||||
// wider strips share the room up to the maximum.
|
||||
column.setSizing(AxisSizing::fill().atLeast(mMinimumColumnWidth).atMost(mMaximumColumnWidth), AxisSizing::fill());
|
||||
}
|
||||
|
||||
void KanbanBoard::rebuildColumns() {
|
||||
|
|
@ -450,8 +404,11 @@ void KanbanBoard::rebuildColumns() {
|
|||
|
||||
for (const KanbanColumn& column : mColumns) {
|
||||
mColumnViews.push_back(std::make_unique<KanbanColumnView>(column));
|
||||
applyColumnWidth(*mColumnViews.back());
|
||||
mColumnStrip.addChild(*mColumnViews.back());
|
||||
}
|
||||
applyColumnWidth(mColumnGap);
|
||||
mColumnStrip.addChild(mColumnGap);
|
||||
refreshOptionsMenus();
|
||||
}
|
||||
|
||||
|
|
@ -633,20 +590,7 @@ std::optional<KanbanDropTarget> KanbanBoard::dropTarget() const {
|
|||
return mDrag->target;
|
||||
}
|
||||
|
||||
void KanbanBoard::onEvent(Event& event) {
|
||||
if (event.type() == Event::Type::Wheel) {
|
||||
scrollColumnsSideways(event);
|
||||
return;
|
||||
}
|
||||
handlePointer(event);
|
||||
}
|
||||
|
||||
void KanbanBoard::scrollColumnsSideways(Event& event) {
|
||||
const halnf amount = event.scrollDelta().x;
|
||||
if (amount == 0 || !mColumnScroller.isScrollable() || isFullViewShown()) return;
|
||||
mColumnScroller.scrollBy(-amount * kColumnWheelStep);
|
||||
event.accept();
|
||||
}
|
||||
void KanbanBoard::onEvent(Event& event) { handlePointer(event); }
|
||||
|
||||
void KanbanBoard::filterDescendantEvent(Event& event) {
|
||||
if (event.target() && mFrame.isSelfOrAncestorOf(*event.target())) return;
|
||||
|
|
@ -774,7 +718,18 @@ std::optional<KanbanItemId> KanbanBoard::firstCard() const {
|
|||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool KanbanBoard::acceptsDrag(PointerButton button) const {
|
||||
// With a mouse a pressed card or header drags at once; on touch only after a long press claims the drag, so a
|
||||
// swipe scrolls the column instead.
|
||||
return button == PointerButton::Left && mPress && !(ui() && ui()->isTouchMode());
|
||||
}
|
||||
|
||||
void KanbanBoard::handlePointer(Event& event) {
|
||||
if (const Dragged* drag = event.drag()) {
|
||||
handleDrag(event, *drag);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
{
|
||||
|
|
@ -790,36 +745,12 @@ void KanbanBoard::handlePointer(Event& event) {
|
|||
case Event::Type::LongPress:
|
||||
if (!mPress || currentLift() || !ui() || !ui()->isTouchMode()) return;
|
||||
mPress->held = true;
|
||||
ui()->capturePointer(*this);
|
||||
event.accept();
|
||||
return;
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (currentLift()) {
|
||||
updateDrag(event.windowPosition());
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
if (!mPress || !movedPast(mPress->windowPoint, event.windowPosition(), kDragThreshold)) return;
|
||||
if (ui() && ui()->isTouchMode() && !mPress->held) {
|
||||
mPress.reset();
|
||||
return;
|
||||
}
|
||||
if (mPress->card) beginDrag();
|
||||
else beginColumnDrag();
|
||||
updateDrag(event.windowPosition());
|
||||
ui()->claimDrag(*this);
|
||||
event.accept();
|
||||
return;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (event.button() != PointerButton::Left) return;
|
||||
if (mDrag || mColumnDrag) {
|
||||
if (mDrag) finishDrag(true);
|
||||
else finishColumnDrag(true);
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
if (mPress && mPress->held && ui()) ui()->releasePointer(*this);
|
||||
if (event.button() != PointerButton::Left || currentLift()) return;
|
||||
mPress.reset();
|
||||
return;
|
||||
|
||||
|
|
@ -829,6 +760,33 @@ void KanbanBoard::handlePointer(Event& event) {
|
|||
}
|
||||
}
|
||||
|
||||
void KanbanBoard::handleDrag(Event& event, const Dragged& drag) {
|
||||
if (drag.button != PointerButton::Left) return;
|
||||
switch (drag.phase) {
|
||||
case DragPhase::Started:
|
||||
if (!mPress || currentLift()) return;
|
||||
if (mPress->card) beginDrag();
|
||||
else beginColumnDrag();
|
||||
if (!currentLift()) return;
|
||||
updateDrag(event.windowPosition());
|
||||
break;
|
||||
case DragPhase::Moved:
|
||||
if (!currentLift()) return;
|
||||
updateDrag(event.windowPosition());
|
||||
break;
|
||||
case DragPhase::Finished:
|
||||
if (mDrag) finishDrag(true);
|
||||
else if (mColumnDrag) finishColumnDrag(true);
|
||||
else return;
|
||||
break;
|
||||
case DragPhase::Cancelled:
|
||||
if (!currentLift()) return;
|
||||
cancelDrag();
|
||||
break;
|
||||
}
|
||||
event.accept();
|
||||
}
|
||||
|
||||
std::optional<size_t> KanbanBoard::columnHeaderAround(const Widget* target) const {
|
||||
if (!target) return std::nullopt;
|
||||
for (size_t column = 0; column < mColumnViews.size(); column++) {
|
||||
|
|
@ -853,7 +811,6 @@ KanbanBoard::Lift KanbanBoard::lift(Widget& widget, Vec2F windowPoint) {
|
|||
addChild(widget);
|
||||
if (ui()) {
|
||||
lifted.focusBeforeLift = ui()->keyboardFocus();
|
||||
ui()->capturePointer(*this);
|
||||
ui()->setKeyboardFocus(this);
|
||||
}
|
||||
setCursor(CursorShape::Grabbing);
|
||||
|
|
@ -867,10 +824,7 @@ void KanbanBoard::drop(const Lift& lifted) {
|
|||
setCursor(CursorShape::Default);
|
||||
removeChild(*lifted.widget);
|
||||
lifted.widget->setGeometryAnimated(true);
|
||||
if (ui()) {
|
||||
ui()->releasePointer(*this);
|
||||
if (ui()->keyboardFocus() == this) ui()->setKeyboardFocus(lifted.focusBeforeLift);
|
||||
}
|
||||
if (ui() && ui()->keyboardFocus() == this) ui()->setKeyboardFocus(lifted.focusBeforeLift);
|
||||
}
|
||||
|
||||
const KanbanBoard::Lift* KanbanBoard::currentLift() const {
|
||||
|
|
@ -906,7 +860,7 @@ void KanbanBoard::beginColumnDrag() {
|
|||
drag.destination = column;
|
||||
drag.lift = lift(*mColumnViews[column], mPress->windowPoint);
|
||||
mColumnDrag = drag;
|
||||
mColumnStrip.requestLayout();
|
||||
placeColumnGap(columnGapSlot());
|
||||
}
|
||||
|
||||
void KanbanBoard::updateDrag(Vec2F windowPoint) {
|
||||
|
|
@ -918,8 +872,14 @@ void KanbanBoard::updateDrag(Vec2F windowPoint) {
|
|||
if (mColumnDrag) {
|
||||
mColumnDrag->lift.pointer = windowPoint;
|
||||
mColumnDrag->lift.position.x = windowPoint.x - mColumnDrag->lift.grabOffset.x - windowPosition().x;
|
||||
const size_t before = mColumnDrag->destination;
|
||||
mColumnDrag->destination = columnDestinationAt(mColumnDrag->lift.position.x + windowPosition().x);
|
||||
mColumnStrip.requestLayout();
|
||||
// The rule compares the lifted edge with where the other columns stand around the gap; when the gap moves they
|
||||
// move, so lay out now and the next pointer event in the same frame does not read stale positions.
|
||||
if (mColumnDrag->destination != before) {
|
||||
placeColumnGap(columnGapSlot());
|
||||
if (ui()) ui()->updateLayout();
|
||||
}
|
||||
}
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
|
|
@ -948,6 +908,7 @@ void KanbanBoard::finishColumnDrag(bool commit) {
|
|||
mColumnDrag.reset();
|
||||
mSuppressNextClick = commit;
|
||||
drop(drag.lift);
|
||||
placeColumnGap(std::nullopt);
|
||||
mColumnStrip.insertChild(drag.column, *drag.lift.widget);
|
||||
mColumnLanding = Landing{ drag.lift.widget, landing };
|
||||
|
||||
|
|
@ -956,8 +917,11 @@ void KanbanBoard::finishColumnDrag(bool commit) {
|
|||
|
||||
void KanbanBoard::cancelDrag() {
|
||||
mPress.reset();
|
||||
const bool lifted = currentLift() != nullptr;
|
||||
if (mDrag) finishDrag(false);
|
||||
if (mColumnDrag) finishColumnDrag(false);
|
||||
// Escape ends the router's drag too, so the release after it is not taken for a drop.
|
||||
if (lifted && ui() && ui()->isDragging()) ui()->cancelDrag();
|
||||
}
|
||||
|
||||
std::optional<size_t> KanbanBoard::columnDropIndex() const {
|
||||
|
|
@ -980,6 +944,25 @@ size_t KanbanBoard::columnDestinationAt(halnf liftedLeft) const {
|
|||
return destination;
|
||||
}
|
||||
|
||||
void KanbanBoard::placeColumnGap(std::optional<size_t> slot) {
|
||||
// The gap is a spacer among the visible columns at the slot the lifted column would land in.
|
||||
mColumnStrip.removeChild(mColumnGap);
|
||||
mColumnGap.setVisible(slot.has_value());
|
||||
size_t index = mColumnStrip.children().size();
|
||||
if (slot) {
|
||||
size_t visible = 0;
|
||||
for (size_t child = 0; child < mColumnStrip.children().size(); child++) {
|
||||
if (!mColumnStrip.children()[child]->isVisible()) continue;
|
||||
if (visible == *slot) {
|
||||
index = child;
|
||||
break;
|
||||
}
|
||||
visible++;
|
||||
}
|
||||
}
|
||||
mColumnStrip.insertChild(index, mColumnGap);
|
||||
}
|
||||
|
||||
std::optional<size_t> KanbanBoard::columnGapSlot() const {
|
||||
if (!mColumnDrag) return std::nullopt;
|
||||
size_t visibleBefore = 0;
|
||||
|
|
@ -1005,25 +988,15 @@ void KanbanBoard::onTick(halnf deltaMs) {
|
|||
}
|
||||
|
||||
void KanbanBoard::autoScroll(halnf deltaMs) {
|
||||
// The viewports scroll themselves near their edges, the same as under a lifted list item: the column strip sideways,
|
||||
// the card column under the pointer up and down.
|
||||
const Vec2F pointer = currentLift()->pointer;
|
||||
const RectF board = windowRect();
|
||||
bool scrolled = false;
|
||||
|
||||
const halnf horizontal = edgeSpeed(pointer.x, board.pos.x, board.pos.x + board.size.x);
|
||||
if (horizontal != 0 && mColumnScroller.isScrollable()) {
|
||||
mColumnScroller.jumpTo(mColumnScroller.offset() + horizontal * kAutoScrollSpeed * deltaMs);
|
||||
scrolled = true;
|
||||
}
|
||||
bool scrolled = mColumnScroller.autoScrollAt(mColumnScroller.toLocal(pointer), deltaMs);
|
||||
|
||||
const std::optional<size_t> column = mDrag ? columnAt(pointer.x) : std::nullopt;
|
||||
if (column) {
|
||||
ScrollView& scroller = mColumnViews[*column]->scroller();
|
||||
const RectF area = scroller.windowRect();
|
||||
const halnf vertical = edgeSpeed(pointer.y, area.pos.y, area.pos.y + area.size.y);
|
||||
if (vertical != 0 && scroller.isScrollable()) {
|
||||
scroller.jumpTo(scroller.offset() + vertical * kAutoScrollSpeed * deltaMs);
|
||||
scrolled = true;
|
||||
}
|
||||
scrolled = scroller.autoScrollAt(scroller.toLocal(pointer), deltaMs) || scrolled;
|
||||
}
|
||||
|
||||
if (scrolled) updateDrag(pointer);
|
||||
|
|
@ -1111,12 +1084,9 @@ void KanbanBoard::paintRaisedBackdrop(Canvas& painter, const RectF& childRect) {
|
|||
return;
|
||||
}
|
||||
if (mColumnDrag) {
|
||||
const RectF slot = translated(mColumnGapRect, mColumnStrip.windowPosition() - windowPosition());
|
||||
const RectF slot = translated(mColumnGap.rect(), mColumnStrip.windowPosition() - windowPosition());
|
||||
painter.outline(slot, style.palette.focusRing, style.metrics.surfaceCornerRadius);
|
||||
}
|
||||
painter.rect(
|
||||
makeRect(childRect.pos.x + 2, childRect.pos.y + kLiftedShadowOffset, childRect.size.x, childRect.size.y),
|
||||
style.palette.shadow,
|
||||
style.metrics.cornerRadius
|
||||
);
|
||||
// A lifted card or column casts the theme elevation shadow, like a lifted list item.
|
||||
paintElevationShadow(painter, childRect, style.metrics.cornerRadius, style);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@
|
|||
|
||||
#include "Button.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Event.hpp"
|
||||
#include "FullViewFrame.hpp"
|
||||
#include "InputCodes.hpp"
|
||||
#include "InteractionVisual.hpp"
|
||||
#include "Label.hpp"
|
||||
#include "Menus.hpp"
|
||||
#include "ReorderSession.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
|
|
@ -77,15 +80,20 @@ namespace tp::ui {
|
|||
protected:
|
||||
void paint(Canvas& painter) override;
|
||||
void paintOverlay(Canvas& painter) override;
|
||||
void onHoverChanged() override { requestPaint(); }
|
||||
void onHoverChanged() override { refreshVisual(); }
|
||||
void onPressedChanged() override { refreshVisual(); }
|
||||
void onThemeChanged() override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
|
||||
private:
|
||||
friend class KanbanBoard;
|
||||
|
||||
void setSelected(bool selected);
|
||||
void refreshVisual();
|
||||
[[nodiscard]] size_t detailLinesFor(halnf height) const;
|
||||
|
||||
KanbanItemId mId;
|
||||
InteractionVisual mVisual{ *this };
|
||||
Label mTitle;
|
||||
Label mDetail;
|
||||
std::optional<RGBA> mAccent;
|
||||
|
|
@ -106,11 +114,19 @@ namespace tp::ui {
|
|||
|
||||
private:
|
||||
friend class KanbanBoard;
|
||||
class CardLayout;
|
||||
|
||||
class LandingLayout;
|
||||
|
||||
// Puts the cards and the placeholder in display order as children.
|
||||
void arrangeChildren();
|
||||
// Starts the landing glide of cards that came from elsewhere, once they are placed.
|
||||
void onArranged();
|
||||
|
||||
void setCards(const std::vector<KanbanCard*>& cards, const std::unordered_map<KanbanCard*, RectF>& landings);
|
||||
void removeCard(KanbanCard& card);
|
||||
void setPlaceholder(std::optional<size_t> index, halnf height);
|
||||
// The cards in the stack as a reorder lane, in window coordinates.
|
||||
[[nodiscard]] ReorderLane lane() const;
|
||||
[[nodiscard]] size_t insertionIndexAt(halnf windowY) const;
|
||||
[[nodiscard]] std::optional<size_t> indexOf(const KanbanCard& card) const;
|
||||
|
||||
|
|
@ -118,7 +134,8 @@ namespace tp::ui {
|
|||
std::unordered_map<KanbanCard*, RectF> mLandings;
|
||||
std::optional<size_t> mPlaceholderIndex;
|
||||
halnf mPlaceholderHeight = 0;
|
||||
RectF mPlaceholderRect = makeRect(0, 0, 0, 0);
|
||||
// Holds the open slot among the cards while a card is dragged over the stack.
|
||||
Spacer mPlaceholder;
|
||||
};
|
||||
|
||||
class KanbanColumnView : public Widget {
|
||||
|
|
@ -149,7 +166,7 @@ namespace tp::ui {
|
|||
Widget mHeader;
|
||||
Label mTitle;
|
||||
Label mCount;
|
||||
GlyphButton mOptions{ GlyphButton::Glyph::Options };
|
||||
GlyphButton mOptions{ Glyph::More };
|
||||
Menu mOptionsMenu;
|
||||
ScrollView mScroller{ Axis::Vertical };
|
||||
KanbanCardStack mStack;
|
||||
|
|
@ -166,7 +183,6 @@ namespace tp::ui {
|
|||
using CardFactory = std::function<std::unique_ptr<KanbanCard>(KanbanItemId)>;
|
||||
using CardUpdater = std::function<void(KanbanCard&)>;
|
||||
|
||||
static constexpr halnf kDragThreshold = 5;
|
||||
static constexpr halnf kColumnWheelStep = 60;
|
||||
|
||||
explicit KanbanBoard(const KanbanModel& model);
|
||||
|
|
@ -227,6 +243,7 @@ namespace tp::ui {
|
|||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
[[nodiscard]] bool acceptsDrag(PointerButton button) const override;
|
||||
void filterDescendantEvent(Event& event) override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
[[nodiscard]] Extent measureContent(Axis, halnf) const override { return {}; }
|
||||
|
|
@ -236,9 +253,7 @@ namespace tp::ui {
|
|||
|
||||
private:
|
||||
class BoardLayout;
|
||||
class StripLayout;
|
||||
friend class BoardLayout;
|
||||
friend class StripLayout;
|
||||
|
||||
struct Press {
|
||||
KanbanCard* card = nullptr;
|
||||
|
|
@ -289,8 +304,8 @@ namespace tp::ui {
|
|||
KanbanCard& cardFor(KanbanItemId id);
|
||||
void updateCard(KanbanCard& card);
|
||||
|
||||
void scrollColumnsSideways(Event& event);
|
||||
void handlePointer(Event& event);
|
||||
void handleDrag(Event& event, const Dragged& drag);
|
||||
void handleClick(Event& event);
|
||||
void handleKey(Event& event);
|
||||
[[nodiscard]] std::optional<KanbanItemId> neighbourOfSelection(Key key) const;
|
||||
|
|
@ -307,6 +322,8 @@ namespace tp::ui {
|
|||
void finishColumnDrag(bool commit);
|
||||
[[nodiscard]] size_t columnDestinationAt(halnf liftedLeft) const;
|
||||
[[nodiscard]] std::optional<size_t> columnGapSlot() const;
|
||||
void placeColumnGap(std::optional<size_t> slot);
|
||||
void applyColumnWidth(Widget& column) const;
|
||||
void autoScroll(halnf deltaMs);
|
||||
void refreshOptionsMenus();
|
||||
void showDropTarget(const std::optional<KanbanDropTarget>& target);
|
||||
|
|
@ -340,7 +357,8 @@ namespace tp::ui {
|
|||
std::optional<Drag> mDrag;
|
||||
std::optional<ColumnDrag> mColumnDrag;
|
||||
std::optional<Landing> mColumnLanding;
|
||||
RectF mColumnGapRect = makeRect(0, 0, 0, 0);
|
||||
// Holds the lifted column's slot in the strip while it is dragged.
|
||||
Spacer mColumnGap;
|
||||
bool mSuppressNextClick = false;
|
||||
|
||||
FullViewFrame mFrame;
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ namespace {
|
|||
|
||||
std::vector<std::string> menuTitles(size_t column) {
|
||||
std::vector<std::string> titles;
|
||||
for (const std::unique_ptr<MenuItem>& item : board.columnView(column).optionsMenu().items())
|
||||
for (const MenuItem* item : board.columnView(column).optionsMenu().items())
|
||||
titles.push_back(item->text());
|
||||
return titles;
|
||||
}
|
||||
|
|
@ -487,7 +487,15 @@ SUITE(KanbanBoard) {
|
|||
driver.moveTo(scene.centerOfColumn(1));
|
||||
driver.wheel({-3, 0});
|
||||
driver.settle();
|
||||
CHECK(scene.board.columnScroller().offset() > 0);
|
||||
const halnf afterSwipe = scene.board.columnScroller().offset();
|
||||
CHECK(afterSwipe > 0);
|
||||
|
||||
// Shift+wheel over a column that cannot scroll its cards also moves the strip sideways (it did not before).
|
||||
driver.moveTo(scene.centerOfColumn(2));
|
||||
driver.withModifiers(KeyModifiers::shiftOnly());
|
||||
driver.wheel({ 0, -1 });
|
||||
driver.settle();
|
||||
CHECK(scene.board.columnScroller().offset() > afterSwipe);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -608,12 +616,14 @@ SUITE(KanbanColumnMove) {
|
|||
driver.settle();
|
||||
|
||||
REQUIRE CHECK_EQUAL(2u, *scene.board.columnDropIndex());
|
||||
// The slot is the width of the lifted column, right after the last column (pixel snapping may round it).
|
||||
const RectF slot = scene.board.columnView(2).windowRect();
|
||||
const RectF expected = makeRect(slot.pos.x + slot.size.x + 10, slot.pos.y, slot.size.x, slot.size.y);
|
||||
const halnf liftedWidth = scene.board.columnView(0).windowRect().size.x;
|
||||
const RectF expected = makeRect(slot.pos.x + slot.size.x + 10, slot.pos.y, liftedWidth, slot.size.y);
|
||||
bool outlined = false;
|
||||
for (const CanvasCommand& command : driver.paint().commands()) {
|
||||
const bool sameRect = std::fabs(command.rect.pos.x - expected.pos.x) < 0.5f &&
|
||||
std::fabs(command.rect.size.x - expected.size.x) < 0.5f;
|
||||
const bool sameRect = std::fabs(command.rect.pos.x - expected.pos.x) < 1.0f &&
|
||||
std::fabs(command.rect.size.x - expected.size.x) < 1.0f;
|
||||
outlined |= command.kind == CanvasCommand::Kind::Outline && sameRect;
|
||||
}
|
||||
CHECK(outlined);
|
||||
|
|
|
|||
|
|
@ -170,16 +170,12 @@ MarkdownEditor::MarkdownEditor() {
|
|||
|
||||
mToolbar.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mToolbar.emplaceLayout<RowLayout>(4).setCrossAlign(CrossAlign::Center);
|
||||
for (Button* button : { &mEditorButton, &mSplitButton, &mPreviewButton }) {
|
||||
button->setStyle(Button::Style::Tab);
|
||||
mToolbar.addChild(*button);
|
||||
}
|
||||
mEditorButton.setTooltip("Show only the source");
|
||||
mSplitButton.setTooltip("Source and preview side by side");
|
||||
mPreviewButton.setTooltip("Show only the rendered document");
|
||||
mEditorButton.setOnActivate([this] { setMode(Mode::Editor); });
|
||||
mSplitButton.setOnActivate([this] { setMode(Mode::Split); });
|
||||
mPreviewButton.setOnActivate([this] { setMode(Mode::Preview); });
|
||||
mModes.addSegment("Editor", Mode::Editor).setTooltip("Show only the source");
|
||||
mModes.addSegment("Split", Mode::Split).setTooltip("Source and preview side by side");
|
||||
mModes.addSegment("Preview", Mode::Preview).setTooltip("Show only the rendered document");
|
||||
mModes.setValue(mMode);
|
||||
mModes.setOnChanged([this](const Mode& mode) { setMode(mode); });
|
||||
mToolbar.addChild(mModes);
|
||||
mToolbarSpacer.setSizing(AxisSizing::fill(), AxisSizing::fixed(1));
|
||||
mToolbar.addChild(mToolbarSpacer);
|
||||
mStatus.setTone(Label::Tone::Muted);
|
||||
|
|
@ -230,13 +226,7 @@ MarkdownEditor::MarkdownEditor() {
|
|||
refreshStatus();
|
||||
}
|
||||
|
||||
Button& MarkdownEditor::modeButton(Mode mode) {
|
||||
switch (mode) {
|
||||
case Mode::Editor: return mEditorButton;
|
||||
case Mode::Preview: return mPreviewButton;
|
||||
default: return mSplitButton;
|
||||
}
|
||||
}
|
||||
Button& MarkdownEditor::modeButton(Mode mode) { return mModes.segment((size_t) mode); }
|
||||
|
||||
void MarkdownEditor::setMarkdown(std::string source) {
|
||||
mSourceArea.setText(source);
|
||||
|
|
@ -252,9 +242,7 @@ void MarkdownEditor::setMode(Mode mode) {
|
|||
}
|
||||
|
||||
void MarkdownEditor::applyMode() {
|
||||
mEditorButton.setSelected(mMode == Mode::Editor);
|
||||
mSplitButton.setSelected(mMode == Mode::Split);
|
||||
mPreviewButton.setSelected(mMode == Mode::Preview);
|
||||
mModes.setValue(mMode);
|
||||
|
||||
mSourcePane.setVisible(mMode != Mode::Preview);
|
||||
mPreviewPane.setVisible(mMode != Mode::Editor);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Event.hpp"
|
||||
#include "StackLayout.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
|
||||
|
|
@ -61,109 +62,6 @@ namespace {
|
|||
return titled;
|
||||
}
|
||||
|
||||
class TableLayout : public Layout {
|
||||
public:
|
||||
explicit TableLayout(size_t columns) :
|
||||
mColumns(std::max<size_t>(columns, 1)) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override {
|
||||
if (axis == Axis::Horizontal) {
|
||||
halnf preferred = 0;
|
||||
halnf minimum = 0;
|
||||
for (size_t column = 0; column < mColumns; column++) {
|
||||
const Extent widest = widestIn(context, column);
|
||||
preferred += widest.preferred;
|
||||
minimum += widest.minimum;
|
||||
}
|
||||
const halnf gaps = kTableGap * (halnf) (mColumns - 1);
|
||||
return Extent::shrinkable(preferred + gaps, minimum + gaps);
|
||||
}
|
||||
|
||||
const std::vector<halnf> widths = columnWidths(context, crossLength);
|
||||
halnf height = 0;
|
||||
for (size_t row = 0; row < rowCount(context); row++)
|
||||
height += rowHeight(context, row, widths) + (row > 0 ? kTableGap : 0);
|
||||
return Extent::natural(height);
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& contentArea) override {
|
||||
const std::vector<halnf> widths = columnWidths(context, contentArea.size.x);
|
||||
halnf y = contentArea.pos.y;
|
||||
for (size_t row = 0; row < rowCount(context); row++) {
|
||||
const halnf height = rowHeight(context, row, widths);
|
||||
halnf x = contentArea.pos.x;
|
||||
for (size_t column = 0; column < mColumns; column++) {
|
||||
Widget* cell = cellAt(context, row, column);
|
||||
if (cell) context.place(*cell, makeRect(x, y, widths[column], height));
|
||||
x += widths[column] + kTableGap;
|
||||
}
|
||||
y += height + kTableGap;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
[[nodiscard]] size_t rowCount(const LayoutContext& context) const {
|
||||
return (context.children().size() + mColumns - 1) / mColumns;
|
||||
}
|
||||
|
||||
[[nodiscard]] Widget* cellAt(const LayoutContext& context, size_t row, size_t column) const {
|
||||
const size_t index = row * mColumns + column;
|
||||
return index < context.children().size() ? context.children()[index] : nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] Extent widestIn(const LayoutContext& context, size_t column) const {
|
||||
Extent widest = { 0, 0, kUnbounded, 0 };
|
||||
for (size_t row = 0; row < rowCount(context); row++) {
|
||||
const Widget* cell = cellAt(context, row, column);
|
||||
if (!cell) continue;
|
||||
const Extent extent = context.measure(*cell, Axis::Horizontal, kUnbounded);
|
||||
widest.preferred = std::max(widest.preferred, extent.preferred);
|
||||
widest.minimum = std::max(widest.minimum, extent.minimum);
|
||||
}
|
||||
return widest;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<halnf> columnWidths(const LayoutContext& context, halnf available) const {
|
||||
std::vector<halnf> preferred(mColumns);
|
||||
std::vector<halnf> minimum(mColumns);
|
||||
halnf preferredTotal = 0;
|
||||
halnf minimumTotal = 0;
|
||||
for (size_t column = 0; column < mColumns; column++) {
|
||||
const Extent extent = widestIn(context, column);
|
||||
preferred[column] = extent.preferred;
|
||||
minimum[column] = extent.minimum;
|
||||
preferredTotal += extent.preferred;
|
||||
minimumTotal += extent.minimum;
|
||||
}
|
||||
|
||||
const halnf usable = available - kTableGap * (halnf) (mColumns - 1);
|
||||
if (available >= kUnbounded || usable >= preferredTotal) {
|
||||
const halnf extra = available >= kUnbounded ? 0 : (usable - preferredTotal) / (halnf) mColumns;
|
||||
for (halnf& width : preferred)
|
||||
width += extra;
|
||||
return preferred;
|
||||
}
|
||||
|
||||
const halnf slack = preferredTotal - minimumTotal;
|
||||
const halnf share = slack > 0 ? std::max(0.0f, usable - minimumTotal) / slack : 0;
|
||||
std::vector<halnf> widths(mColumns);
|
||||
for (size_t column = 0; column < mColumns; column++)
|
||||
widths[column] = minimum[column] + (preferred[column] - minimum[column]) * share;
|
||||
return widths;
|
||||
}
|
||||
|
||||
[[nodiscard]] halnf rowHeight(const LayoutContext& context, size_t row, const std::vector<halnf>& widths) const {
|
||||
halnf height = 0;
|
||||
for (size_t column = 0; column < mColumns; column++) {
|
||||
const Widget* cell = cellAt(context, row, column);
|
||||
if (cell) height = std::max(height, context.measure(*cell, Axis::Vertical, widths[column]).preferred);
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
size_t mColumns;
|
||||
};
|
||||
}
|
||||
|
||||
MarkdownBlockWidget::MarkdownBlockWidget(const Block& block, Surface surface) :
|
||||
|
|
@ -357,8 +255,6 @@ void MarkdownView::setStyle(const MarkdownStyle& style) {
|
|||
|
||||
void MarkdownView::rebuild() {
|
||||
removeAllChildren();
|
||||
mRetired = std::move(mOwned);
|
||||
mOwned.clear();
|
||||
mTopBlocks.clear();
|
||||
|
||||
for (const Block& block : mDocument.blocks()) {
|
||||
|
|
@ -372,7 +268,6 @@ void MarkdownView::rebuild() {
|
|||
|
||||
void MarkdownView::onTick(halnf) {
|
||||
stopTicking();
|
||||
mRetired.clear();
|
||||
|
||||
if (!mPendingTaskToggle) return;
|
||||
const size_t offset = *mPendingTaskToggle;
|
||||
|
|
@ -391,8 +286,7 @@ void MarkdownView::requestTaskToggle(size_t taskMarkOffset) {
|
|||
Widget& MarkdownView::build(const Block& block, Widget& parent, size_t depth) {
|
||||
const auto surface =
|
||||
block.kind == BlockKind::Table ? MarkdownBlockWidget::Surface::None : MarkdownBlockWidget::Surface::Automatic;
|
||||
auto& owner = own<MarkdownBlockWidget>(block, surface);
|
||||
parent.addChild(owner);
|
||||
auto& owner = parent.emplaceChild<MarkdownBlockWidget>(block, surface);
|
||||
const Theme& style = theme();
|
||||
|
||||
switch (block.kind) {
|
||||
|
|
@ -428,12 +322,11 @@ Widget& MarkdownView::build(const Block& block, Widget& parent, size_t depth) {
|
|||
case BlockKind::Admonition:
|
||||
{
|
||||
owner.emplaceLayout<ColumnLayout>(6).setPadding({ mStyle.quoteIndent, 8, 10, 8 });
|
||||
auto& title = own<RichText>();
|
||||
auto& title = owner.emplaceChild<RichText>();
|
||||
title.setFontSize(scaledFontSize(1));
|
||||
title.setSpans(
|
||||
{ { titleCase(block.admonitionType), FontFace::Bold, admonitionColor(style.palette, block.admonitionType) } }
|
||||
);
|
||||
owner.addChild(title);
|
||||
buildChildren(block, owner, depth);
|
||||
break;
|
||||
}
|
||||
|
|
@ -447,8 +340,7 @@ Widget& MarkdownView::build(const Block& block, Widget& parent, size_t depth) {
|
|||
case BlockKind::Table:
|
||||
{
|
||||
owner.emplaceLayout<RowLayout>();
|
||||
auto& grid = own<MarkdownBlockWidget>(block, MarkdownBlockWidget::Surface::TableGrid);
|
||||
owner.addChild(grid);
|
||||
auto& grid = owner.emplaceChild<MarkdownBlockWidget>(block, MarkdownBlockWidget::Surface::TableGrid);
|
||||
buildTable(block, grid);
|
||||
break;
|
||||
}
|
||||
|
|
@ -483,40 +375,36 @@ void MarkdownView::buildList(const Block& list, Widget& owner, size_t depth) {
|
|||
void MarkdownView::buildListItem(
|
||||
const Block& item, const std::optional<unsigned>& number, Widget& owner, size_t depth
|
||||
) {
|
||||
auto& row = own<MarkdownBlockWidget>(item);
|
||||
auto& row = owner.emplaceChild<MarkdownBlockWidget>(item);
|
||||
row.emplaceLayout<RowLayout>(kMarkerGap).setCrossAlign(CrossAlign::Start);
|
||||
owner.addChild(row);
|
||||
|
||||
if (item.taskChecked) {
|
||||
auto& box = own<Checkbox>();
|
||||
auto& box = row.emplaceChild<Checkbox>();
|
||||
box.setChecked(*item.taskChecked);
|
||||
box.setTooltip("Toggle task");
|
||||
const size_t offset = item.taskMarkOffset;
|
||||
box.setOnChanged([this, offset](bool) { requestTaskToggle(offset); });
|
||||
row.addChild(box);
|
||||
} else {
|
||||
const halnf markerWidth = mStyle.listIndent * mFontScale - kMarkerGap;
|
||||
if (number) {
|
||||
auto& numeral = own<RichText>();
|
||||
auto& numeral = row.emplaceChild<RichText>();
|
||||
numeral.setFontSize(scaledFontSize(1));
|
||||
numeral.setSizing(AxisSizing::fixed(markerWidth), AxisSizing::fit());
|
||||
numeral.setAlignment(TextAlign::rightCenter());
|
||||
numeral.setBaseColor(theme().palette.mutedText);
|
||||
numeral.setSpans({ { std::to_string(*number) + "." } });
|
||||
row.addChild(numeral);
|
||||
} else {
|
||||
const ListMarker::Shape shapes[] = { ListMarker::Shape::Disc,
|
||||
ListMarker::Shape::Circle,
|
||||
ListMarker::Shape::Square };
|
||||
const halnf lineHeight = std::round(theme().metrics.lineHeight * mFontScale * 1.15f);
|
||||
row.addChild(own<ListMarker>(shapes[depth % 3], markerWidth, lineHeight));
|
||||
row.emplaceChild<ListMarker>(shapes[depth % 3], markerWidth, lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
auto& body = own<Widget>();
|
||||
auto& body = row.emplaceChild<Widget>();
|
||||
body.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
body.emplaceLayout<ColumnLayout>(kListGap);
|
||||
row.addChild(body);
|
||||
|
||||
bool hasContent = false;
|
||||
if (!item.runs.empty()) {
|
||||
|
|
@ -539,24 +427,25 @@ void MarkdownView::buildListItem(
|
|||
|
||||
void MarkdownView::buildTable(const Block& table, Widget& owner) {
|
||||
const size_t columns = table.children.empty() ? 1 : table.children.front().children.size();
|
||||
owner.setLayout(std::make_unique<TableLayout>(columns));
|
||||
owner.layout()->setPadding(Insets::uniform(1));
|
||||
// Every column is as wide as its widest cell; when the table is too narrow, the columns shrink towards their cells'
|
||||
// minimum widths in proportion to their slack.
|
||||
GridLayout& grid = owner.emplaceLayout<GridLayout>(std::max<size_t>(columns, 1), kTableGap);
|
||||
grid.setColumnWidths(std::vector<AxisSizing>(std::max<size_t>(columns, 1), AxisSizing::fit()));
|
||||
grid.setPadding(Insets::uniform(1));
|
||||
owner.setSizing(AxisSizing::fit(), AxisSizing::fit());
|
||||
|
||||
for (const Block& row : table.children) {
|
||||
for (size_t column = 0; column < columns; column++) {
|
||||
if (column >= row.children.size()) {
|
||||
auto& filler = own<MarkdownBlockWidget>(row);
|
||||
owner.addChild(filler);
|
||||
owner.emplaceChild<MarkdownBlockWidget>(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
const Block& cell = row.children[column];
|
||||
auto& cellWidget = own<MarkdownBlockWidget>(cell);
|
||||
auto& cellWidget = owner.emplaceChild<MarkdownBlockWidget>(cell);
|
||||
cellWidget.emplaceLayout<ColumnLayout>().setPadding(
|
||||
Insets::symmetric(mStyle.cellPadding + 2, mStyle.cellPadding / 2 + 1)
|
||||
);
|
||||
owner.addChild(cellWidget);
|
||||
RichText& text = addText(cellWidget, cell, 1, row.headerRow ? FontFace::Bold : FontFace::Regular);
|
||||
text.setAlignment(textAlignFor(cell.align));
|
||||
}
|
||||
|
|
@ -564,7 +453,7 @@ void MarkdownView::buildTable(const Block& table, Widget& owner) {
|
|||
}
|
||||
|
||||
RichText& MarkdownView::addText(Widget& owner, const Block& block, halnf fontScale, FontFace baseFace) {
|
||||
auto& text = own<RichText>();
|
||||
auto& text = owner.emplaceChild<RichText>();
|
||||
text.setFontSize(scaledFontSize(fontScale));
|
||||
text.setLineSpacing(fontScale > 1.2f ? 1.05f : 1.15f);
|
||||
text.setSpans(spansOf(block, baseFace));
|
||||
|
|
@ -574,12 +463,11 @@ RichText& MarkdownView::addText(Widget& owner, const Block& block, halnf fontSca
|
|||
text.setOnSourceActivated([this](size_t offset) {
|
||||
if (mOnSource) mOnSource(offset);
|
||||
});
|
||||
owner.addChild(text);
|
||||
return text;
|
||||
}
|
||||
|
||||
RichText& MarkdownView::addCode(Widget& owner, const Block& block) {
|
||||
auto& text = own<RichText>();
|
||||
auto& text = owner.emplaceChild<RichText>();
|
||||
text.setFontSize(scaledFontSize(1));
|
||||
std::vector<TextSpan> spans;
|
||||
size_t start = 0;
|
||||
|
|
@ -598,7 +486,6 @@ RichText& MarkdownView::addCode(Widget& owner, const Block& block) {
|
|||
text.setOnSourceActivated([this](size_t offset) {
|
||||
if (mOnSource) mOnSource(offset);
|
||||
});
|
||||
owner.addChild(text);
|
||||
return text;
|
||||
}
|
||||
|
||||
|
|
@ -627,11 +514,10 @@ void MarkdownView::addImages(Widget& owner, const Block& block) {
|
|||
for (const Block& child : block.children) {
|
||||
if (child.kind != BlockKind::Image) continue;
|
||||
|
||||
auto& image = own<MarkdownImage>(resolveImagePath(child.imageSource), child.imageAlt, child.source.begin);
|
||||
auto& image = owner.emplaceChild<MarkdownImage>(resolveImagePath(child.imageSource), child.imageAlt, child.source.begin);
|
||||
image.setOnActivated([this](size_t offset) {
|
||||
if (mOnSource) mOnSource(offset);
|
||||
});
|
||||
owner.addChild(image);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -654,27 +540,31 @@ std::vector<TextSpan> MarkdownView::spansOf(const Block& block, FontFace baseFac
|
|||
return spans;
|
||||
}
|
||||
|
||||
namespace {
|
||||
template <typename Type>
|
||||
void collect(const Widget& widget, std::vector<Type*>& found) {
|
||||
for (Widget* child : widget.children()) {
|
||||
if (auto* match = dynamic_cast<Type*>(child)) found.push_back(match);
|
||||
collect(*child, found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<RichText*> MarkdownView::textWidgets() const {
|
||||
std::vector<RichText*> texts;
|
||||
for (const auto& widget : mOwned) {
|
||||
if (auto* text = dynamic_cast<RichText*>(widget.get())) texts.push_back(text);
|
||||
}
|
||||
collect(*this, texts);
|
||||
return texts;
|
||||
}
|
||||
|
||||
std::vector<MarkdownImage*> MarkdownView::images() const {
|
||||
std::vector<MarkdownImage*> found;
|
||||
for (const auto& widget : mOwned) {
|
||||
if (auto* image = dynamic_cast<MarkdownImage*>(widget.get())) found.push_back(image);
|
||||
}
|
||||
collect(*this, found);
|
||||
return found;
|
||||
}
|
||||
|
||||
std::vector<Checkbox*> MarkdownView::taskBoxes() const {
|
||||
std::vector<Checkbox*> boxes;
|
||||
for (const auto& widget : mOwned) {
|
||||
if (auto* box = dynamic_cast<Checkbox*>(widget.get())) boxes.push_back(box);
|
||||
}
|
||||
collect(*this, boxes);
|
||||
return boxes;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
#include "Button.hpp"
|
||||
#include "SegmentedControl.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Label.hpp"
|
||||
#include "MarkdownView.hpp"
|
||||
|
|
@ -91,9 +92,7 @@ namespace tp::ui {
|
|||
|
||||
Mode mMode = Mode::Split;
|
||||
Panel mToolbar{ Panel::Surface::None };
|
||||
Button mEditorButton{ "Editor" };
|
||||
Button mSplitButton{ "Split" };
|
||||
Button mPreviewButton{ "Preview" };
|
||||
SegmentedControl<Mode> mModes;
|
||||
Spacer mToolbarSpacer;
|
||||
Label mStatus;
|
||||
|
||||
|
|
|
|||
|
|
@ -134,20 +134,10 @@ namespace tp::ui {
|
|||
[[nodiscard]] std::vector<TextSpan> spansOf(const markdown::Block& block, FontFace baseFace) const;
|
||||
void requestTaskToggle(size_t taskMarkOffset);
|
||||
|
||||
template <typename Type, typename... Arguments>
|
||||
Type& own(Arguments&&... arguments) {
|
||||
auto widget = std::make_unique<Type>(std::forward<Arguments>(arguments)...);
|
||||
Type& reference = *widget;
|
||||
mOwned.push_back(std::move(widget));
|
||||
return reference;
|
||||
}
|
||||
|
||||
markdown::MarkdownDocument mDocument;
|
||||
MarkdownStyle mStyle;
|
||||
std::string mBaseDirectory;
|
||||
halnf mFontScale = 1;
|
||||
std::vector<std::unique_ptr<Widget>> mOwned;
|
||||
std::vector<std::unique_ptr<Widget>> mRetired;
|
||||
std::vector<MarkdownBlockWidget*> mTopBlocks;
|
||||
std::optional<size_t> mPendingTaskToggle;
|
||||
|
||||
|
|
|
|||
|
|
@ -194,6 +194,21 @@ SUITE(MarkdownViewWidget) {
|
|||
CHECK(painter.findText("1")->rect.pos.x > painter.findText("22")->rect.pos.x);
|
||||
}
|
||||
|
||||
TEST(RebuildingReplacesTheOwnedBlocks) {
|
||||
ViewScene scene("first\n\nsecond");
|
||||
UiDriver driver(scene.root);
|
||||
CHECK_EQUAL(2u, scene.view.topBlocks().size());
|
||||
CHECK_EQUAL(2u, scene.view.ownedChildCount());
|
||||
|
||||
scene.view.setMarkdown("only one");
|
||||
driver.settle();
|
||||
|
||||
// The previous blocks are destroyed with their owner, so no stale text is found or painted.
|
||||
CHECK_EQUAL(1u, scene.view.ownedChildCount());
|
||||
CHECK_EQUAL(1u, scene.view.textWidgets().size());
|
||||
CHECK(!driver.paint().hasText("first"));
|
||||
}
|
||||
|
||||
TEST(LinksReportTheirTargetWhenClicked) {
|
||||
ViewScene scene("go to [site](https://example.com) now");
|
||||
std::string activated;
|
||||
|
|
|
|||
|
|
@ -23,13 +23,6 @@ namespace {
|
|||
constexpr halnf kSeekKeyStepSeconds = 5;
|
||||
constexpr halnf kVolumeSliderWidth = 96;
|
||||
|
||||
std::vector<Vec2F> triangle(Vec2F center, halnf size, bool pointsRight) {
|
||||
const halnf half = size / 2;
|
||||
const halnf direction = pointsRight ? 1.0f : -1.0f;
|
||||
return { center + Vec2F(-half * 0.8f * direction, -half),
|
||||
center + Vec2F(-half * 0.8f * direction, half),
|
||||
center + Vec2F(half * direction, 0.0f) };
|
||||
}
|
||||
}
|
||||
|
||||
std::string tp::ui::formatDuration(halnf seconds) {
|
||||
|
|
@ -78,68 +71,9 @@ void TransportButton::paint(Canvas& painter) {
|
|||
|
||||
RGBA glyphColor = mPrimary ? style.palette.onToggle : style.palette.text;
|
||||
if (!isEffectivelyEnabled()) glyphColor = style.palette.disabledText;
|
||||
paintGlyph(painter, box.center(), box.size.x * 0.36f, glyphColor);
|
||||
tp::ui::paintGlyph(painter, mGlyph, glyphBox(box, box.size.x * 0.4f), glyphColor, style);
|
||||
}
|
||||
|
||||
void TransportButton::paintGlyph(Canvas& painter, Vec2F center, halnf size, const RGBA& color) const {
|
||||
const halnf half = size / 2;
|
||||
|
||||
switch (mGlyph) {
|
||||
case Glyph::Play: painter.polygon(triangle(center + Vec2F(size * 0.08f, 0.0f), size, true), color); return;
|
||||
|
||||
case Glyph::Pause:
|
||||
{
|
||||
const halnf bar = size * 0.32f;
|
||||
painter.rect(makeRect(center.x - half * 0.8f, center.y - half, bar, size), color, bar / 3);
|
||||
painter.rect(makeRect(center.x + half * 0.8f - bar, center.y - half, bar, size), color, bar / 3);
|
||||
return;
|
||||
}
|
||||
|
||||
case Glyph::Previous:
|
||||
case Glyph::Next:
|
||||
{
|
||||
const bool next = mGlyph == Glyph::Next;
|
||||
const halnf bar = size * 0.16f;
|
||||
const halnf barX = next ? center.x + half - bar : center.x - half;
|
||||
painter.polygon(triangle(center + Vec2F(next ? -bar : bar, 0.0f), size * 0.85f, next), color);
|
||||
painter.rect(makeRect(barX, center.y - half, bar, size), color, bar / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
case Glyph::Volume:
|
||||
case Glyph::Muted:
|
||||
{
|
||||
const halnf body = size * 0.3f;
|
||||
const halnf left = center.x - half;
|
||||
painter.polygon(
|
||||
{ { left, center.y - body / 2 },
|
||||
{ left + body, center.y - body / 2 },
|
||||
{ left + body * 2, center.y - half },
|
||||
{ left + body * 2, center.y + half },
|
||||
{ left + body, center.y + body / 2 },
|
||||
{ left, center.y + body / 2 } },
|
||||
color
|
||||
);
|
||||
const halnf waveX = left + body * 2.4f;
|
||||
if (mGlyph == Glyph::Muted) {
|
||||
painter.line({ waveX, center.y - body }, { waveX + body * 1.6f, center.y + body }, color, 1.5f);
|
||||
painter.line({ waveX, center.y + body }, { waveX + body * 1.6f, center.y - body }, color, 1.5f);
|
||||
return;
|
||||
}
|
||||
painter.polyline(
|
||||
{ { waveX, center.y - body }, { waveX + body * 0.7f, center.y }, { waveX, center.y + body } }, color, 1.5f
|
||||
);
|
||||
painter.polyline(
|
||||
{ { waveX + body * 0.8f, center.y - half * 0.9f },
|
||||
{ waveX + body * 1.7f, center.y },
|
||||
{ waveX + body * 0.8f, center.y + half * 0.9f } },
|
||||
color,
|
||||
1.5f
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SeekBar::SeekBar() {
|
||||
setDebugName("seek bar");
|
||||
|
|
@ -190,32 +124,63 @@ void SeekBar::seekBy(halnf seconds) {
|
|||
if (mOnSeek) mOnSeek(mPosition);
|
||||
}
|
||||
|
||||
void SeekBar::onHoverChanged() {
|
||||
Control::onHoverChanged();
|
||||
refreshReveal();
|
||||
}
|
||||
|
||||
void SeekBar::refreshReveal() {
|
||||
mVisual.setRevealHeld(mScrubbing);
|
||||
if (mVisual.refresh()) startTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void SeekBar::onTick(halnf deltaMs) {
|
||||
Control::onTick(deltaMs);
|
||||
if (!mVisual.advance(deltaMs) && !isTransitioning()) stopTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void SeekBar::finishScrub() {
|
||||
mScrubbing = false;
|
||||
refreshReveal();
|
||||
setPosition(mScrubPosition);
|
||||
if (mOnSeek) mOnSeek(mPosition);
|
||||
}
|
||||
|
||||
void SeekBar::onEvent(Event& event) {
|
||||
if (!isEffectivelyEnabled()) return;
|
||||
|
||||
if (const Dragged* drag = event.drag()) {
|
||||
if (drag->button != PointerButton::Left) return;
|
||||
if (drag->phase == DragPhase::Cancelled) {
|
||||
// Escape puts the playhead back where it was; nothing is sought.
|
||||
mScrubbing = false;
|
||||
} else if (drag->phase == DragPhase::Finished) {
|
||||
finishScrub();
|
||||
} else {
|
||||
mScrubbing = true;
|
||||
mScrubPosition = positionAt(event.position().x);
|
||||
}
|
||||
refreshReveal();
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
if (event.button() != PointerButton::Left) return;
|
||||
mScrubbing = true;
|
||||
mScrubPosition = positionAt(event.position().x);
|
||||
if (ui()) ui()->capturePointer(*this);
|
||||
requestPaint();
|
||||
event.accept();
|
||||
return;
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (!mScrubbing) return;
|
||||
mScrubPosition = positionAt(event.position().x);
|
||||
requestPaint();
|
||||
event.accept();
|
||||
return;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (!mScrubbing || event.button() != PointerButton::Left) return;
|
||||
mScrubbing = false;
|
||||
if (ui()) ui()->releasePointer(*this);
|
||||
setPosition(mScrubPosition);
|
||||
if (mOnSeek) mOnSeek(mPosition);
|
||||
// A click without a drag seeks to where it was pressed; a drag finishes in its Finished phase, which the
|
||||
// router delivers right after this release.
|
||||
if (!mScrubbing || event.button() != PointerButton::Left || (ui() && ui()->isDragging())) return;
|
||||
finishScrub();
|
||||
event.accept();
|
||||
return;
|
||||
|
||||
|
|
@ -253,8 +218,10 @@ void SeekBar::paint(Canvas& painter) {
|
|||
painter.rect(makeRect(track.pos, { track.size.x * shown, track.size.y }), style.palette.sliderFill, radius);
|
||||
}
|
||||
|
||||
if (!known || !(isHovered() || mScrubbing || showsFocusRing())) return;
|
||||
const halnf handleRadius = sizes::slider::handleSize / 2;
|
||||
// The handle grows in while hovered or scrubbed, and stays for keyboard focus.
|
||||
const halnf reveal = showsFocusRing() ? 1.0f : mVisual.reveal();
|
||||
if (!known || reveal <= 0.01f) return;
|
||||
const halnf handleRadius = sizes::slider::handleSize / 2 * reveal;
|
||||
const Vec2F handle = { track.pos.x + track.size.x * shown, size().y / 2 };
|
||||
if (showsFocusRing()) painter.circleOutline(handle, handleRadius + 3, style.palette.focusRing, 2);
|
||||
painter.circle(handle, handleRadius, style.palette.text);
|
||||
|
|
@ -270,7 +237,7 @@ VolumeControl::VolumeControl() {
|
|||
mSlider.setSizing(AxisSizing::fixed(kVolumeSliderWidth), AxisSizing::fit());
|
||||
mSlider.setOnChanged([this](halnf) {
|
||||
mMuted = false;
|
||||
mMute.setGlyph(TransportButton::Glyph::Volume);
|
||||
mMute.setGlyph(Glyph::Volume);
|
||||
notify();
|
||||
});
|
||||
|
||||
|
|
@ -282,7 +249,7 @@ void VolumeControl::setVolume(halnf volume) { mSlider.setValue(std::clamp(volume
|
|||
|
||||
void VolumeControl::setMuted(bool muted) {
|
||||
mMuted = muted;
|
||||
mMute.setGlyph(muted ? TransportButton::Glyph::Muted : TransportButton::Glyph::Volume);
|
||||
mMute.setGlyph(muted ? Glyph::Muted : Glyph::Volume);
|
||||
mMute.setTooltip(muted ? "Unmute" : "Mute");
|
||||
notify();
|
||||
}
|
||||
|
|
@ -373,6 +340,6 @@ void PlayerBar::setTrack(const NowPlaying* track) {
|
|||
|
||||
void PlayerBar::setPlaying(bool playing) {
|
||||
mPlaying = playing;
|
||||
playPause.setGlyph(playing ? TransportButton::Glyph::Pause : TransportButton::Glyph::Play);
|
||||
playPause.setGlyph(playing ? Glyph::Pause : Glyph::Play);
|
||||
playPause.setTooltip(playing ? "Pause" : "Play");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
#pragma once
|
||||
|
||||
#include "Glyph.hpp"
|
||||
#include "InteractionVisual.hpp"
|
||||
|
||||
#include "Button.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Control.hpp"
|
||||
|
|
@ -13,8 +16,7 @@ namespace tp::ui {
|
|||
|
||||
class TransportButton : public Button {
|
||||
public:
|
||||
enum class Glyph { Play, Pause, Previous, Next, Volume, Muted };
|
||||
|
||||
// The transport symbols are core glyphs: Play, Pause, SkipPrevious, SkipNext, Volume, Muted.
|
||||
explicit TransportButton(Glyph glyph, bool primary = false);
|
||||
|
||||
void setGlyph(Glyph glyph);
|
||||
|
|
@ -27,7 +29,6 @@ namespace tp::ui {
|
|||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
|
||||
private:
|
||||
void paintGlyph(Canvas& painter, Vec2F center, halnf size, const RGBA& color) const;
|
||||
|
||||
Glyph mGlyph;
|
||||
bool mPrimary;
|
||||
|
|
@ -57,6 +58,9 @@ namespace tp::ui {
|
|||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void onEvent(Event& event) override;
|
||||
[[nodiscard]] bool acceptsDrag(PointerButton button) const override { return button == PointerButton::Left; }
|
||||
void onHoverChanged() override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void paint(Canvas& painter) override;
|
||||
|
||||
private:
|
||||
|
|
@ -67,8 +71,12 @@ namespace tp::ui {
|
|||
halnf mDuration = 0;
|
||||
halnf mPosition = 0;
|
||||
halnf mBuffered = 0;
|
||||
void refreshReveal();
|
||||
void finishScrub();
|
||||
|
||||
halnf mScrubPosition = 0;
|
||||
bool mScrubbing = false;
|
||||
InteractionVisual mVisual{ *this };
|
||||
std::function<void(halnf)> mOnSeek;
|
||||
};
|
||||
|
||||
|
|
@ -89,7 +97,7 @@ namespace tp::ui {
|
|||
private:
|
||||
void notify();
|
||||
|
||||
TransportButton mMute{ TransportButton::Glyph::Volume };
|
||||
TransportButton mMute{ Glyph::Volume };
|
||||
Slider mSlider{ 0, 1, 1 };
|
||||
bool mMuted = false;
|
||||
std::function<void(halnf)> mOnChanged;
|
||||
|
|
@ -126,9 +134,9 @@ namespace tp::ui {
|
|||
Panel art{ Surface::Raised };
|
||||
Label title;
|
||||
Label artist;
|
||||
TransportButton previous{ TransportButton::Glyph::Previous };
|
||||
TransportButton playPause{ TransportButton::Glyph::Play, true };
|
||||
TransportButton next{ TransportButton::Glyph::Next };
|
||||
TransportButton previous{ Glyph::SkipPrevious };
|
||||
TransportButton playPause{ Glyph::Play, true };
|
||||
TransportButton next{ Glyph::SkipNext };
|
||||
SeekBar seek;
|
||||
VolumeControl volume;
|
||||
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ SUITE(TransportButtons) {
|
|||
TEST(GlyphsPaintAsShapesAndPrimaryIsLight) {
|
||||
Widget root;
|
||||
root.emplaceLayout<RowLayout>();
|
||||
TransportButton play(TransportButton::Glyph::Play, true);
|
||||
TransportButton next(TransportButton::Glyph::Next);
|
||||
TransportButton play(Glyph::Play, true);
|
||||
TransportButton next(Glyph::SkipNext);
|
||||
root.addChild(play);
|
||||
root.addChild(next);
|
||||
UiDriver driver(root);
|
||||
|
|
@ -147,7 +147,7 @@ SUITE(VolumeControlBehaviour) {
|
|||
driver.click(volume.muteButton());
|
||||
CHECK(volume.isMuted());
|
||||
CHECK_CLOSE(0, reported, 0.001f);
|
||||
CHECK(volume.muteButton().glyph() == TransportButton::Glyph::Muted);
|
||||
CHECK(volume.muteButton().glyph() == Glyph::Muted);
|
||||
|
||||
driver.click(volume.muteButton());
|
||||
CHECK(!volume.isMuted());
|
||||
|
|
@ -195,7 +195,7 @@ SUITE(PlayerBarBehaviour) {
|
|||
CHECK_EQUAL(1, previous);
|
||||
|
||||
scene.bar.setPlaying(true);
|
||||
CHECK(scene.bar.playPause.glyph() == TransportButton::Glyph::Pause);
|
||||
CHECK(scene.bar.playPause.glyph() == Glyph::Pause);
|
||||
CHECK_EQUAL("Pause", scene.bar.playPause.tooltip());
|
||||
}
|
||||
|
||||
|
|
|
|||
81
Widgets/src/components/nodes/private/GraphGeometry.cpp
Normal file
81
Widgets/src/components/nodes/private/GraphGeometry.cpp
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#include "GraphGeometry.hpp"
|
||||
|
||||
#include "Canvas.hpp"
|
||||
#include "Theme.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kMarqueeFill = 0.12f;
|
||||
constexpr halnf kMarqueeOutline = 0.8f;
|
||||
constexpr halnf kMarqueeOutlineWidth = 1;
|
||||
|
||||
halnf distanceToSegment(Vec2F point, Vec2F start, Vec2F end) {
|
||||
const Vec2F along = end - start;
|
||||
const halnf lengthSquared = along.x * along.x + along.y * along.y;
|
||||
halnf t = 0;
|
||||
if (lengthSquared > 0) {
|
||||
const Vec2F offset = point - start;
|
||||
t = std::clamp((offset.x * along.x + offset.y * along.y) / lengthSquared, 0.0f, 1.0f);
|
||||
}
|
||||
const Vec2F closest = start + along * t;
|
||||
const Vec2F difference = point - closest;
|
||||
return std::sqrt(difference.x * difference.x + difference.y * difference.y);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Vec2F> graph::linkPoints(Vec2F from, Vec2F to, const LinkShape& shape) {
|
||||
const halnf bend = std::max(std::abs(to.x - from.x) * 0.5f, shape.minimumBend);
|
||||
const Vec2F controlFrom = from + Vec2F(bend, 0.0f);
|
||||
const Vec2F controlTo = to - Vec2F(bend, 0.0f);
|
||||
const int segments = std::max(shape.segments, 1);
|
||||
|
||||
std::vector<Vec2F> points;
|
||||
points.reserve((size_t) segments + 1);
|
||||
for (int step = 0; step <= segments; step++) {
|
||||
const halnf t = halnf(step) / halnf(segments);
|
||||
const halnf u = 1 - t;
|
||||
points.push_back(from * (u * u * u) + controlFrom * (3 * u * u * t) + controlTo * (3 * u * t * t) + to * (t * t * t));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
void graph::paintLink(Canvas& canvas, Vec2F from, Vec2F to, const LinkShape& shape, const RGBA& color, halnf thickness) {
|
||||
canvas.polyline(linkPoints(from, to, shape), color, thickness);
|
||||
}
|
||||
|
||||
halnf graph::distanceToLink(Vec2F point, const std::vector<Vec2F>& link) {
|
||||
halnf nearest = 1.0e9f;
|
||||
for (size_t index = 1; index < link.size(); index++)
|
||||
nearest = std::min(nearest, distanceToSegment(point, link[index - 1], link[index]));
|
||||
return nearest;
|
||||
}
|
||||
|
||||
std::optional<size_t> graph::nearestWithin(const std::vector<Vec2F>& positions, Vec2F point, halnf reach) {
|
||||
std::optional<size_t> nearest;
|
||||
halnf best = reach;
|
||||
for (size_t index = 0; index < positions.size(); index++) {
|
||||
const Vec2F difference = positions[index] - point;
|
||||
const halnf distance = std::sqrt(difference.x * difference.x + difference.y * difference.y);
|
||||
if (distance > best) continue;
|
||||
best = distance;
|
||||
nearest = index;
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
RectF graph::marquee(Vec2F press, Vec2F pointer) {
|
||||
const Vec2F low = { std::min(press.x, pointer.x), std::min(press.y, pointer.y) };
|
||||
const Vec2F high = { std::max(press.x, pointer.x), std::max(press.y, pointer.y) };
|
||||
return makeRect(low, high - low);
|
||||
}
|
||||
|
||||
void graph::paintMarquee(Canvas& canvas, const RectF& box, const Theme& theme) {
|
||||
const halnf radius = theme.metrics.indicatorCornerRadius;
|
||||
canvas.rect(box, Palette::faded(theme.palette.accent, kMarqueeFill), radius);
|
||||
canvas.outline(box, Palette::faded(theme.palette.accent, kMarqueeOutline), radius, kMarqueeOutlineWidth);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
#include "NodeEditor.hpp"
|
||||
#include "GraphGeometry.hpp"
|
||||
#include "Canvas.hpp"
|
||||
|
||||
#include "Event.hpp"
|
||||
|
|
@ -16,6 +17,7 @@ using namespace tp::ui;
|
|||
namespace {
|
||||
constexpr int kLinkSegments = 24;
|
||||
constexpr int kSimplifiedLinkSegments = 8;
|
||||
constexpr halnf kLinkBend = 40;
|
||||
constexpr halnf kCullMargin = 16;
|
||||
constexpr halnf kMinimumShortTitleSize = 7;
|
||||
constexpr halnf kShortTitleFontSize = 22;
|
||||
|
|
@ -27,20 +29,7 @@ namespace {
|
|||
return std::sqrt(difference.x * difference.x + difference.y * difference.y);
|
||||
}
|
||||
|
||||
halnf distanceToSegment(Vec2F point, Vec2F start, Vec2F end) {
|
||||
const Vec2F segment = end - start;
|
||||
const halnf lengthSquared = segment.x * segment.x + segment.y * segment.y;
|
||||
if (lengthSquared <= 0) return distance(point, start);
|
||||
const Vec2F toPoint = point - start;
|
||||
const halnf t = std::clamp((toPoint.x * segment.x + toPoint.y * segment.y) / lengthSquared, 0.0f, 1.0f);
|
||||
return distance(point, start + segment * t);
|
||||
}
|
||||
|
||||
RectF normalized(Vec2F first, Vec2F second) {
|
||||
const Vec2F low = { std::min(first.x, second.x), std::min(first.y, second.y) };
|
||||
const Vec2F high = { std::max(first.x, second.x), std::max(first.y, second.y) };
|
||||
return makeRect(low, high - low);
|
||||
}
|
||||
|
||||
RectF boundingRect(const RectF& first, const RectF& second) {
|
||||
const Vec2F low = { std::min(first.pos.x, second.pos.x), std::min(first.pos.y, second.pos.y) };
|
||||
|
|
@ -57,26 +46,57 @@ NodeEditor::NodeEditor(NodeGraph& graph) :
|
|||
setDebugName("node editor");
|
||||
setClipsChildren(true);
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
bindDefaultShortcuts();
|
||||
defineCommands();
|
||||
}
|
||||
|
||||
void NodeEditor::bindDefaultShortcuts() {
|
||||
mShortcuts.bind(ctrl(Key::C), Command::Copy);
|
||||
mShortcuts.bind(ctrl(Key::X), Command::Cut);
|
||||
mShortcuts.bind(ctrl(Key::V), Command::Paste);
|
||||
mShortcuts.bind(ctrl(Key::D), Command::Duplicate);
|
||||
mShortcuts.bind(ctrl(Key::A), Command::SelectAll);
|
||||
mShortcuts.bind(plain(Key::Delete), Command::Delete);
|
||||
mShortcuts.bind(plain(Key::Backspace), Command::Delete);
|
||||
mShortcuts.bind(plain(Key::Home), Command::FrameAll);
|
||||
void NodeEditor::defineCommands() {
|
||||
const auto hasSelection = [this]() { return !mSelection.empty(); };
|
||||
const auto hasNodes = [this]() { return !mGraph.nodes().empty(); };
|
||||
const auto done = [this]() { requestPaint(); };
|
||||
|
||||
mCommands.define(Command::Copy, { "Copy", [this, done]() { copySelection(); done(); }, hasSelection, {} });
|
||||
mCommands.define(Command::Cut, { "Cut", [this, done]() {
|
||||
copySelection();
|
||||
deleteSelection();
|
||||
done();
|
||||
}, hasSelection, {} });
|
||||
mCommands.define(Command::Paste, { "Paste", [this, done]() {
|
||||
pasteClipboard(mStyle.pasteOffset * halnf(++mPasteCount));
|
||||
done();
|
||||
}, [this]() { return hasClipboard(); }, {} });
|
||||
mCommands.define(Command::Duplicate, { "Duplicate", [this, done]() {
|
||||
copySelection();
|
||||
pasteClipboard(mStyle.pasteOffset);
|
||||
done();
|
||||
}, hasSelection, {} });
|
||||
mCommands.define(Command::Delete, { "Delete", [this, done]() { deleteSelection(); done(); }, [this]() {
|
||||
return !mSelection.empty() || mSelectedLink.has_value();
|
||||
}, {} });
|
||||
mCommands.define(Command::SelectAll, { "Select All", [this, done]() {
|
||||
std::vector<NodeId> all;
|
||||
for (const Node& node : mGraph.nodes())
|
||||
if (isShown(node.id)) all.push_back(node.id);
|
||||
select(all);
|
||||
done();
|
||||
}, hasNodes, {} });
|
||||
mCommands.define(Command::FrameAll, { "Frame All", [this, done]() { frameAll(); done(); }, hasNodes, {} });
|
||||
|
||||
ShortcutMap<Command>& shortcuts = mCommands.shortcuts();
|
||||
shortcuts.bind(ctrl(Key::C), Command::Copy);
|
||||
shortcuts.bind(ctrl(Key::X), Command::Cut);
|
||||
shortcuts.bind(ctrl(Key::V), Command::Paste);
|
||||
shortcuts.bind(ctrl(Key::D), Command::Duplicate);
|
||||
shortcuts.bind(ctrl(Key::A), Command::SelectAll);
|
||||
shortcuts.bind(plain(Key::Delete), Command::Delete);
|
||||
shortcuts.bind(plain(Key::Backspace), Command::Delete);
|
||||
shortcuts.bind(plain(Key::Home), Command::FrameAll);
|
||||
}
|
||||
|
||||
bool NodeEditor::isSelected(NodeId id) const {
|
||||
return std::find(mSelection.begin(), mSelection.end(), id) != mSelection.end();
|
||||
}
|
||||
bool NodeEditor::isSelected(NodeId id) const { return mSelection.isSelected(id); }
|
||||
|
||||
void NodeEditor::select(const std::vector<NodeId>& ids) {
|
||||
mSelection = ids;
|
||||
if (ids.empty()) mSelection.clear();
|
||||
else mSelection.assign(ids, ids.back());
|
||||
mSelectedLink.reset();
|
||||
selectionChanged();
|
||||
}
|
||||
|
|
@ -94,36 +114,10 @@ void NodeEditor::selectionChanged() {
|
|||
if (mOnSelectionChanged) mOnSelectionChanged();
|
||||
}
|
||||
|
||||
void NodeEditor::execute(Command command) {
|
||||
switch (command) {
|
||||
case Command::Copy: copySelection(); break;
|
||||
case Command::Cut:
|
||||
copySelection();
|
||||
deleteSelection();
|
||||
break;
|
||||
case Command::Paste: pasteClipboard(mStyle.pasteOffset * halnf(++mPasteCount)); break;
|
||||
case Command::Duplicate:
|
||||
copySelection();
|
||||
pasteClipboard(mStyle.pasteOffset);
|
||||
break;
|
||||
case Command::Delete: deleteSelection(); break;
|
||||
case Command::SelectAll:
|
||||
{
|
||||
std::vector<NodeId> all;
|
||||
for (const Node& node : mGraph.nodes())
|
||||
if (isShown(node.id)) all.push_back(node.id);
|
||||
select(all);
|
||||
break;
|
||||
}
|
||||
case Command::FrameAll: frameAll(); break;
|
||||
}
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void NodeEditor::copySelection() {
|
||||
if (mSelection.empty()) return;
|
||||
|
||||
mClipboard = mGraph.extract(mSelection);
|
||||
mClipboard = mGraph.extract(mSelection.selected());
|
||||
mPasteCount = 0;
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +134,11 @@ void NodeEditor::deleteSelection() {
|
|||
if (mGraph.hasLink(*mSelectedLink)) mGraph.disconnect(*mSelectedLink);
|
||||
mSelectedLink.reset();
|
||||
}
|
||||
if (!mSelection.empty()) mGraph.removeNodes(std::exchange(mSelection, {}));
|
||||
if (!mSelection.empty()) {
|
||||
const std::vector<NodeId> removed = mSelection.selected();
|
||||
mSelection.clear();
|
||||
mGraph.removeNodes(removed);
|
||||
}
|
||||
selectionChanged();
|
||||
}
|
||||
|
||||
|
|
@ -220,9 +218,9 @@ void NodeEditor::setNodeFilter(std::function<bool(NodeId)> filter) {
|
|||
|
||||
void NodeEditor::refreshNodeFilter() {
|
||||
std::vector<NodeId> shown;
|
||||
for (NodeId id : mSelection)
|
||||
for (NodeId id : mSelection.selected())
|
||||
if (isShown(id)) shown.push_back(id);
|
||||
if (shown != mSelection || (mSelectedLink && !isShown(*mSelectedLink))) select(shown);
|
||||
if (shown != mSelection.selected() || (mSelectedLink && !isShown(*mSelectedLink))) select(shown);
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
|
|
@ -343,13 +341,11 @@ std::optional<Link> NodeEditor::linkAt(Vec2F graphPoint) const {
|
|||
halnf closestDistance = tolerance;
|
||||
for (const Link& link : mGraph.links()) {
|
||||
if (!isShown(link)) continue;
|
||||
const std::vector<Vec2F> points = linkCurvePoints(toLocal(portPosition(link.from)), toLocal(portPosition(link.to)));
|
||||
for (size_t step = 1; step < points.size(); step++) {
|
||||
const halnf d = distanceToSegment(local, points[step - 1], points[step]);
|
||||
if (d <= closestDistance) {
|
||||
closestDistance = d;
|
||||
closest = link;
|
||||
}
|
||||
const std::vector<Vec2F> points = graph::linkPoints(toLocal(portPosition(link.from)), toLocal(portPosition(link.to)), linkShape());
|
||||
const halnf distance = graph::distanceToLink(local, points);
|
||||
if (distance <= closestDistance) {
|
||||
closestDistance = distance;
|
||||
closest = link;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
|
|
@ -474,23 +470,19 @@ void NodeEditor::refreshHoverCursor(Vec2F graphPoint) {
|
|||
}
|
||||
|
||||
void NodeEditor::pressOnNode(NodeId id, bool additive) {
|
||||
if (additive && isSelected(id)) {
|
||||
std::vector<NodeId> remaining = mSelection;
|
||||
std::erase(remaining, id);
|
||||
select(remaining);
|
||||
return;
|
||||
}
|
||||
|
||||
// An additive press toggles the node; taking it out of the selection does not start a move.
|
||||
if (additive) {
|
||||
std::vector<NodeId> extended = mSelection;
|
||||
extended.push_back(id);
|
||||
select(extended);
|
||||
const bool wasSelected = isSelected(id);
|
||||
mSelection.toggle(id);
|
||||
mSelectedLink.reset();
|
||||
selectionChanged();
|
||||
if (wasSelected) return;
|
||||
} else if (!isSelected(id)) {
|
||||
select({ id });
|
||||
}
|
||||
|
||||
mMoveStart.clear();
|
||||
for (NodeId selected : mSelection) {
|
||||
for (NodeId selected : mSelection.selected()) {
|
||||
mGraph.bringToFront(selected);
|
||||
mMoveStart.emplace_back(selected, mGraph.findNode(selected)->position);
|
||||
}
|
||||
|
|
@ -600,7 +592,7 @@ void NodeEditor::finishLink() {
|
|||
|
||||
void NodeEditor::finishMarquee(bool additive) {
|
||||
const RectF area = marqueeRect();
|
||||
std::vector<NodeId> picked = additive ? mSelection : std::vector<NodeId>{};
|
||||
std::vector<NodeId> picked = additive ? mSelection.selected() : std::vector<NodeId>{};
|
||||
|
||||
for (const Node& node : mGraph.nodes()) {
|
||||
if (!isShown(node.id)) continue;
|
||||
|
|
@ -613,7 +605,7 @@ void NodeEditor::finishMarquee(bool additive) {
|
|||
|
||||
bool NodeEditor::isMarqueeVisible() const { return mGesture == Gesture::Marquee && mDraggedPastThreshold; }
|
||||
|
||||
RectF NodeEditor::marqueeRect() const { return normalized(mPressGraphPoint, mPointerGraphPoint); }
|
||||
RectF NodeEditor::marqueeRect() const { return graph::marquee(mPressGraphPoint, mPointerGraphPoint); }
|
||||
|
||||
void NodeEditor::handleGesture(Event& event) {
|
||||
const TouchGesture* gesture = event.gesture();
|
||||
|
|
@ -628,13 +620,7 @@ void NodeEditor::handleWheel(Event& event) {
|
|||
event.accept();
|
||||
}
|
||||
|
||||
void NodeEditor::handleKey(Event& event) {
|
||||
const ShortcutMap<Command>::Binding* binding = mShortcuts.find(event);
|
||||
if (!binding) return;
|
||||
|
||||
execute(binding->command);
|
||||
event.accept();
|
||||
}
|
||||
void NodeEditor::handleKey(Event& event) { mCommands.handle(event); }
|
||||
|
||||
NodeEditor::NodePresentation NodeEditor::present(const Node& node) const {
|
||||
if (mPresenter) return mPresenter(node);
|
||||
|
|
@ -654,7 +640,7 @@ bool NodeEditor::isVisibleOnScreen(const Node& node) const {
|
|||
}
|
||||
|
||||
bool NodeEditor::isVisibleOnScreen(const Link& link) const {
|
||||
const RectF span = normalized(toLocal(portPosition(link.from)), toLocal(portPosition(link.to)));
|
||||
const RectF span = graph::marquee(toLocal(portPosition(link.from)), toLocal(portPosition(link.to)));
|
||||
const halnf bend = std::max(span.size.x * 0.5f, 40.0f * zoom());
|
||||
return overlaps(insetRect(localRect(), -kCullMargin), insetRect(span, -bend));
|
||||
}
|
||||
|
|
@ -709,26 +695,14 @@ void NodeEditor::paintLinks(Canvas& painter) const {
|
|||
else paintLink(painter, pointer, anchor, color, mStyle.linkThickness);
|
||||
}
|
||||
|
||||
std::vector<Vec2F> NodeEditor::linkCurvePoints(Vec2F from, Vec2F to) const {
|
||||
const halnf bend = std::max(std::abs(to.x - from.x) * 0.5f, 40.0f * zoom());
|
||||
const Vec2F controlFrom = from + Vec2F(bend, 0.0f);
|
||||
const Vec2F controlTo = to - Vec2F(bend, 0.0f);
|
||||
|
||||
const int segments = detail() == Detail::Full ? kLinkSegments : kSimplifiedLinkSegments;
|
||||
std::vector<Vec2F> points;
|
||||
points.reserve(size_t(segments) + 1);
|
||||
for (int step = 0; step <= segments; step++) {
|
||||
const halnf t = halnf(step) / halnf(segments);
|
||||
const halnf u = 1 - t;
|
||||
points.push_back(
|
||||
from * (u * u * u) + controlFrom * (3 * u * u * t) + controlTo * (3 * u * t * t) + to * (t * t * t)
|
||||
);
|
||||
}
|
||||
return points;
|
||||
graph::LinkShape NodeEditor::linkShape() const {
|
||||
// Node links leave their ports further out than tree links (nodes are wide), scaled with the zoom, and use fewer
|
||||
// segments when the nodes are only outlines.
|
||||
return { kLinkBend * zoom(), detail() == Detail::Full ? kLinkSegments : kSimplifiedLinkSegments };
|
||||
}
|
||||
|
||||
void NodeEditor::paintLink(Canvas& painter, Vec2F from, Vec2F to, const RGBA& color, halnf thickness) const {
|
||||
painter.polyline(linkCurvePoints(from, to), color, thickness * std::max(zoom(), 0.5f));
|
||||
graph::paintLink(painter, from, to, linkShape(), color, thickness * std::max(zoom(), 0.5f));
|
||||
}
|
||||
|
||||
void NodeEditor::paintNode(Canvas& painter, const Node& node) const {
|
||||
|
|
@ -927,10 +901,5 @@ void NodeEditor::paintPortLabels(Canvas& painter, const Node& node, PortDirectio
|
|||
|
||||
void NodeEditor::paintMarquee(Canvas& painter) const {
|
||||
const RectF area = marqueeRect();
|
||||
const RectF local = makeRect(toLocal(area.pos), area.size * zoom());
|
||||
RGBA fill = theme().palette.accent;
|
||||
fill.a = 0.15f;
|
||||
|
||||
painter.rect(local, fill, theme().metrics.indicatorCornerRadius);
|
||||
painter.outline(local, theme().palette.accent, theme().metrics.indicatorCornerRadius, 1);
|
||||
graph::paintMarquee(painter, makeRect(toLocal(area.pos), area.size * zoom()), theme());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#include "TreeView.hpp"
|
||||
#include "GraphGeometry.hpp"
|
||||
#include "Canvas.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
|
|
@ -31,6 +32,7 @@ namespace tp::ui {
|
|||
|
||||
namespace {
|
||||
constexpr int kCurveSegments = 16;
|
||||
constexpr halnf kLinkBend = 16;
|
||||
constexpr halnf kLabelSize = 20;
|
||||
constexpr halnf kMinimumLabelSize = 7;
|
||||
constexpr halnf kContentPadding = 8;
|
||||
|
|
@ -55,11 +57,6 @@ namespace {
|
|||
|
||||
Vec2F mix(Vec2F from, Vec2F to, halnf t) { return from + (to - from) * t; }
|
||||
|
||||
RGBA withAlpha(RGBA color, halnf alpha) {
|
||||
color.a *= alpha;
|
||||
return color;
|
||||
}
|
||||
|
||||
void paintFitted(
|
||||
Canvas& painter, const std::string& text, const RectF& box, halnf size, TextAlign align, const RGBA& color
|
||||
) {
|
||||
|
|
@ -148,7 +145,7 @@ void TreeCard::paint(Canvas& painter) {
|
|||
insetRect(rect, 6),
|
||||
kLabelSize,
|
||||
TextAlign::centered(),
|
||||
withAlpha(style.palette.onAccent, (1 - mDetail) * mOpacity)
|
||||
Palette::faded(style.palette.onAccent, (1 - mDetail) * mOpacity)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -159,7 +156,7 @@ void TreeCard::paint(Canvas& painter) {
|
|||
const halnf pulse = mBadgePulsing ? 0.55f + 0.45f * std::sin(mClockMs / 260.0f) : 1.0f;
|
||||
const halnf dotRadius = 4 * scale;
|
||||
const Vec2F dot = { rect.size.x - 11 * scale, mDetail > 0.5f ? header / 2 : 10 * scale };
|
||||
if (dotRadius >= 1) painter.circle(dot, dotRadius, withAlpha(*mBadge, pulse * badgeOpacity * mOpacity));
|
||||
if (dotRadius >= 1) painter.circle(dot, dotRadius, Palette::faded(*mBadge, pulse * badgeOpacity * mOpacity));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,27 +164,27 @@ void TreeCard::paintHeaderedBody(Canvas& painter, const RectF& rect, halnf radiu
|
|||
const Theme& style = theme();
|
||||
const halnf header = std::min(kHeaderHeight, rect.size.y);
|
||||
|
||||
painter.rect(rect, withAlpha(accent(), mOpacity), radius);
|
||||
painter.rect(rect, Palette::faded(accent(), mOpacity), radius);
|
||||
if (mDetail <= 0.001f) return;
|
||||
|
||||
const RectF body = makeRect(0, header, rect.size.x, rect.size.y - header);
|
||||
painter.rect(body, withAlpha(style.palette.raisedSurface, mDetail * mOpacity), radius);
|
||||
painter.rect(body, Palette::faded(style.palette.raisedSurface, mDetail * mOpacity), radius);
|
||||
const halnf badgeSpace = mBadge ? 16.0f : 0.0f;
|
||||
const RectF titleBox = makeRect(10, 0, rect.size.x - 20 - badgeSpace, header);
|
||||
paintFitted(
|
||||
painter, mTitle, titleBox, style.metrics.fontSize, TextAlign::leftCenter(), withAlpha(style.palette.onAccent, mDetail * mOpacity)
|
||||
painter, mTitle, titleBox, style.metrics.fontSize, TextAlign::leftCenter(), Palette::faded(style.palette.onAccent, mDetail * mOpacity)
|
||||
);
|
||||
}
|
||||
|
||||
void TreeCard::paintPlainBody(Canvas& painter, const RectF& rect, halnf radius) {
|
||||
const Theme& style = theme();
|
||||
|
||||
painter.rect(rect, withAlpha(Palette::mix(accent(), style.palette.raisedSurface, mDetail), mOpacity), radius);
|
||||
painter.rect(rect, Palette::faded(Palette::mix(accent(), style.palette.raisedSurface, mDetail), mOpacity), radius);
|
||||
if (mDetail <= 0.001f) return;
|
||||
|
||||
const halnf inset = std::min(kPlainAccentInset, rect.size.y / 4);
|
||||
const RectF strip = makeRect(inset, inset, kPlainAccentWidth, std::max(0.0f, rect.size.y - inset * 2));
|
||||
painter.rect(strip, withAlpha(accent(), mDetail * mOpacity), kPlainAccentWidth / 2);
|
||||
painter.rect(strip, Palette::faded(accent(), mDetail * mOpacity), kPlainAccentWidth / 2);
|
||||
}
|
||||
|
||||
void TreeCard::paintOverlay(Canvas& painter) {
|
||||
|
|
@ -200,17 +197,17 @@ void TreeCard::paintOverlay(Canvas& painter) {
|
|||
painter.text(
|
||||
"+" + std::to_string(mHidden),
|
||||
corner,
|
||||
TextStyle{ style.metrics.smallFontSize - 1, withAlpha(style.palette.onAccent, mStack * mOpacity * (1 - mDetail * 0.4f)), TextAlign::rightCenter() }
|
||||
TextStyle{ style.metrics.smallFontSize - 1, Palette::faded(style.palette.onAccent, mStack * mOpacity * (1 - mDetail * 0.4f)), TextAlign::rightCenter() }
|
||||
);
|
||||
}
|
||||
const halnf stroke = std::max(1.0f, 2.0f * scale);
|
||||
if (mSelected) {
|
||||
painter.outline(rect, withAlpha(style.palette.accent, mOpacity), radius, mCurrent ? stroke * 1.25f : stroke);
|
||||
painter.outline(rect, Palette::faded(style.palette.accent, mOpacity), radius, mCurrent ? stroke * 1.25f : stroke);
|
||||
const halnf ring = 3 * scale;
|
||||
if (mCurrent && ring >= 1)
|
||||
painter.outline(insetRect(rect, -ring), withAlpha(style.palette.accent, 0.35f * mOpacity), radius + ring, 1);
|
||||
painter.outline(insetRect(rect, -ring), Palette::faded(style.palette.accent, 0.35f * mOpacity), radius + ring, 1);
|
||||
} else if (mHover > 0.001f)
|
||||
painter.outline(rect, withAlpha(style.palette.text, mHover * 0.7f * mOpacity), radius, stroke * 0.75f);
|
||||
painter.outline(rect, Palette::faded(style.palette.text, mHover * 0.7f * mOpacity), radius, stroke * 0.75f);
|
||||
}
|
||||
|
||||
TreeView::TreeView(const TreeModel& model) :
|
||||
|
|
@ -256,8 +253,7 @@ void TreeView::modelChanged() {
|
|||
mExpansion.retain(mStructure);
|
||||
if (first) mExpansion.openTo(mStructure, std::nullopt, mStructure.height());
|
||||
|
||||
std::erase_if(mSelected, [this](TreeItemId id) { return !mStructure.indexOf(id); });
|
||||
if (mSelection && !mSelected.contains(*mSelection)) mSelection.reset();
|
||||
mSelection.retain([this](const TreeItemId& id) { return mStructure.indexOf(id).has_value(); });
|
||||
if (mHovered && !mStructure.indexOf(*mHovered)) mHovered.reset();
|
||||
if (mTugged && !mStructure.indexOf(*mTugged)) mTugged.reset();
|
||||
if (mPivot && !mStructure.indexOf(*mPivot)) mPivot.reset();
|
||||
|
|
@ -275,7 +271,7 @@ void TreeView::modelChanged() {
|
|||
syncCards();
|
||||
repack();
|
||||
syncItems(!ui());
|
||||
if (mSelection) pivotOn(mSelection, size() / 2.0f, true);
|
||||
if (mSelection.current()) pivotOn(mSelection.current(), size() / 2.0f, true);
|
||||
viewChanged();
|
||||
}
|
||||
|
||||
|
|
@ -412,7 +408,7 @@ void TreeView::arrangeCards(LayoutContext& context, const RectF& area) {
|
|||
const bool resized = !mFitPending && !(area.size == mLastSize);
|
||||
mLastSize = area.size;
|
||||
if (mFitPending) fitInitially(area.size);
|
||||
else if (resized && mSelection) pivotOn(mSelection, area.size / 2.0f, false);
|
||||
else if (resized && mSelection.current()) pivotOn(mSelection.current(), area.size / 2.0f, false);
|
||||
}
|
||||
updateRects();
|
||||
const halnf detail = detailAmount();
|
||||
|
|
@ -441,8 +437,8 @@ void TreeView::arrangeCards(LayoutContext& context, const RectF& area) {
|
|||
card->mHidden = item.descendants;
|
||||
card->mDetail = detail;
|
||||
card->mOpacity = opacity;
|
||||
card->mSelected = mSelected.contains(card->id());
|
||||
card->mCurrent = mSelection == card->id();
|
||||
card->mSelected = mSelection.isSelected(card->id());
|
||||
card->mCurrent = mSelection.current() == card->id();
|
||||
const auto hover = mHover.find(card->id());
|
||||
card->mHover = hover == mHover.end() ? 0 : hover->second.value();
|
||||
card->content().setVisible(detail >= kContentShownDetail && isInteractive(*index));
|
||||
|
|
@ -574,8 +570,8 @@ void TreeView::frame(bool animated) {
|
|||
requestLayout();
|
||||
return;
|
||||
}
|
||||
if (mSelection) {
|
||||
pivotOn(mSelection, mLastSize / 2.0f, animated);
|
||||
if (mSelection.current()) {
|
||||
pivotOn(mSelection.current(), mLastSize / 2.0f, animated);
|
||||
return;
|
||||
}
|
||||
const Vec2F available = mLastSize - Vec2F(mStyle.frameMargin, mStyle.frameMargin) * 2.0f;
|
||||
|
|
@ -621,7 +617,7 @@ void TreeView::openTo(std::optional<TreeItemId> scope, size_t depth) {
|
|||
}
|
||||
|
||||
std::optional<TreeView::Configuration> TreeView::neighbour(int direction) const {
|
||||
const std::optional<size_t> root = scopeIndex(mSelection);
|
||||
const std::optional<size_t> root = scopeIndex(mSelection.current());
|
||||
Configuration next{ mExpansion };
|
||||
if (direction > 0) {
|
||||
const size_t open = mExpansion.openDepth(mStructure, root);
|
||||
|
|
@ -661,13 +657,13 @@ void TreeView::commit(const Configuration& configuration, halnf preview) {
|
|||
const bool opened = mCandidateDirectionCommitted > 0;
|
||||
mExpansion = configuration.expansion;
|
||||
retarget(false);
|
||||
if (opened && mSelection) followSubtree(*mSelection);
|
||||
if (opened && mSelection.current()) followSubtree(*mSelection.current());
|
||||
}
|
||||
|
||||
void TreeView::scaleAround(Vec2F local, halnf value) {
|
||||
const halnf after = clampScale(value);
|
||||
if (mSelection && mStructure.indexOf(*mSelection)) {
|
||||
if (!mPivot || *mPivot != *mSelection || mPivotWorld) pivotOn(mSelection, std::nullopt, false);
|
||||
if (mSelection.current() && mStructure.indexOf(*mSelection.current())) {
|
||||
if (!mPivot || *mPivot != *mSelection.current() || mPivotWorld) pivotOn(mSelection.current(), std::nullopt, false);
|
||||
} else if (!mPivotWorld || length(mPivotScreen.target() - local) > mStyle.dragThreshold) pivotOnPoint(local);
|
||||
mScale.animateTo(after);
|
||||
viewChanged();
|
||||
|
|
@ -765,7 +761,7 @@ halnf TreeView::fullViewScale() const {
|
|||
}
|
||||
|
||||
std::optional<TreeItemId> TreeView::fullViewCandidate(Vec2F local) const {
|
||||
if (mSelection && mStructure.indexOf(*mSelection)) return mSelection;
|
||||
if (mSelection.current() && mStructure.indexOf(*mSelection.current())) return mSelection.current();
|
||||
if (const std::optional<TreeItemId> under = itemAt(local)) return under;
|
||||
if (const std::optional<size_t> nearest = nearestShown(local)) return mStructure.items()[*nearest].id;
|
||||
return std::nullopt;
|
||||
|
|
@ -831,7 +827,7 @@ void TreeView::enterFullView(TreeItemId id, bool animated) {
|
|||
mFullStep.stop();
|
||||
mFullSettling.reset();
|
||||
mFull = id;
|
||||
if (mSelection != id) {
|
||||
if (mSelection.current() != id) {
|
||||
select(id);
|
||||
if (mOnSelected) mOnSelected(id);
|
||||
selectionChanged();
|
||||
|
|
@ -883,7 +879,7 @@ void TreeView::pull(Vec2F local, halnf amount) {
|
|||
mCandidateDirection = direction;
|
||||
mLevelStep.stop();
|
||||
repack();
|
||||
if (mSelection) pivotOn(mSelection, std::nullopt, false);
|
||||
if (mSelection.current()) pivotOn(mSelection.current(), std::nullopt, false);
|
||||
else if (const std::optional<size_t> anchor = nearestShown(local))
|
||||
pivotOn(mStructure.items()[*anchor].id, std::nullopt, false);
|
||||
}
|
||||
|
|
@ -910,21 +906,32 @@ void TreeView::releasePull() {
|
|||
}
|
||||
|
||||
std::vector<TreeItemId> TreeView::selectedItems() const {
|
||||
// In tree order, whatever order they were selected in.
|
||||
std::vector<TreeItemId> ids;
|
||||
for (const TreeStructure::Item& item : mStructure.items())
|
||||
if (mSelected.contains(item.id)) ids.push_back(item.id);
|
||||
if (mSelection.isSelected(item.id)) ids.push_back(item.id);
|
||||
return ids;
|
||||
}
|
||||
|
||||
std::vector<TreeItemId> TreeView::treeOrder() const {
|
||||
std::vector<TreeItemId> ids;
|
||||
for (const TreeStructure::Item& item : mStructure.items())
|
||||
ids.push_back(item.id);
|
||||
return ids;
|
||||
}
|
||||
|
||||
void TreeView::setSelection(const std::vector<TreeItemId>& ids, std::optional<TreeItemId> current) {
|
||||
std::unordered_set<TreeItemId> selected;
|
||||
std::vector<TreeItemId> known;
|
||||
for (TreeItemId id : ids)
|
||||
if (mStructure.indexOf(id)) selected.insert(id);
|
||||
if (current && !selected.contains(*current)) current.reset();
|
||||
if (selected == mSelected && current == mSelection) return;
|
||||
const bool moved = current != mSelection;
|
||||
mSelected = std::move(selected);
|
||||
mSelection = current;
|
||||
if (mStructure.indexOf(id)) known.push_back(id);
|
||||
if (current && std::find(known.begin(), known.end(), *current) == known.end()) current.reset();
|
||||
const std::optional<TreeItemId> before = mSelection.current();
|
||||
const std::vector<TreeItemId> selectedBefore = selectedItems();
|
||||
if (known.empty()) mSelection.clear();
|
||||
else mSelection.assign(known, current);
|
||||
if (mSelection.current() == before && selectedItems() == selectedBefore) return;
|
||||
const bool moved = mSelection.current() != before;
|
||||
current = mSelection.current();
|
||||
if (moved && current) {
|
||||
reveal(*current);
|
||||
if (mLastSize.x > 0) pivotOn(current, mLastSize / 2.0f, true);
|
||||
|
|
@ -949,11 +956,7 @@ void TreeView::select(std::optional<TreeItemId> id) {
|
|||
}
|
||||
|
||||
void TreeView::selectAll() {
|
||||
std::vector<TreeItemId> ids;
|
||||
for (const TreeStructure::Item& item : mStructure.items())
|
||||
ids.push_back(item.id);
|
||||
mSelected = std::unordered_set<TreeItemId>(ids.begin(), ids.end());
|
||||
if (!mSelection && !ids.empty()) mSelection = ids.front();
|
||||
mSelection.selectAll(treeOrder());
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
selectionChanged();
|
||||
|
|
@ -969,46 +972,48 @@ void TreeView::selectionChanged() {
|
|||
}
|
||||
|
||||
void TreeView::selectByUser(TreeItemId id) {
|
||||
const bool changed = mSelected.size() != 1 || !mSelected.contains(id) || mSelection != id;
|
||||
const bool changed = mSelection.selected().size() != 1 || !mSelection.isSelected(id) || mSelection.current() != id;
|
||||
select(id);
|
||||
if (mOnSelected) mOnSelected(id);
|
||||
if (changed) selectionChanged();
|
||||
}
|
||||
|
||||
void TreeView::toggleByUser(TreeItemId id) {
|
||||
std::vector<TreeItemId> ids = selectedItems();
|
||||
std::optional<TreeItemId> current = mSelection;
|
||||
if (mSelected.contains(id)) {
|
||||
std::erase(ids, id);
|
||||
if (current == id) current = ids.empty() ? std::nullopt : std::optional(ids.back());
|
||||
} else {
|
||||
ids.push_back(id);
|
||||
current = id;
|
||||
const std::optional<TreeItemId> before = mSelection.current();
|
||||
mSelection.toggle(id);
|
||||
const std::optional<TreeItemId> current = mSelection.current();
|
||||
if (current && current != before) {
|
||||
reveal(*current);
|
||||
if (mLastSize.x > 0) pivotOn(current, mLastSize / 2.0f, true);
|
||||
}
|
||||
setSelection(ids, current);
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
if (current && mOnSelected) mOnSelected(*current);
|
||||
selectionChanged();
|
||||
}
|
||||
|
||||
std::optional<RectF> TreeView::selectionBox() const {
|
||||
if (mGesture != Gesture::Box || !mDragged) return std::nullopt;
|
||||
const Vec2F low = { std::min(mPressLocal.x, mBoxCorner.x), std::min(mPressLocal.y, mBoxCorner.y) };
|
||||
const Vec2F high = { std::max(mPressLocal.x, mBoxCorner.x), std::max(mPressLocal.y, mBoxCorner.y) };
|
||||
return makeRect(low, high - low);
|
||||
return graph::marquee(mPressLocal, mBoxCorner);
|
||||
}
|
||||
|
||||
void TreeView::updateBox(Vec2F local) {
|
||||
mBoxCorner = local;
|
||||
const std::optional<RectF> box = selectionBox();
|
||||
if (!box) return;
|
||||
std::unordered_set<TreeItemId> selected = mBoxBase;
|
||||
for (size_t item = 0; item < mRects.size(); item++)
|
||||
if (isInteractive(item) && overlaps(*box, mRects[item])) selected.insert(mStructure.items()[item].id);
|
||||
std::optional<TreeItemId> current = mSelection;
|
||||
if (current && !selected.contains(*current)) current.reset();
|
||||
if (!current && !selected.empty()) current = *selected.begin();
|
||||
mSelected = std::move(selected);
|
||||
mSelection = current;
|
||||
std::vector<TreeItemId> selected = mBoxBase;
|
||||
for (size_t item = 0; item < mRects.size(); item++) {
|
||||
const TreeItemId id = mStructure.items()[item].id;
|
||||
if (isInteractive(item) && overlaps(*box, mRects[item]) && std::find(selected.begin(), selected.end(), id) == selected.end()) {
|
||||
selected.push_back(id);
|
||||
}
|
||||
}
|
||||
// The current item stays current while the box covers it, else the first boxed item becomes current.
|
||||
std::optional<TreeItemId> current = mSelection.current();
|
||||
if (current && std::find(selected.begin(), selected.end(), *current) == selected.end()) current.reset();
|
||||
if (!current && !selected.empty()) current = selected.front();
|
||||
if (selected.empty()) mSelection.clear();
|
||||
else mSelection.assign(std::move(selected), current);
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
}
|
||||
|
|
@ -1052,19 +1057,18 @@ std::optional<TreeItemId> TreeView::itemAt(Vec2F local) const {
|
|||
std::optional<TreeView::PortHit> TreeView::portAt(Vec2F local) const {
|
||||
if (isFullViewShown()) return std::nullopt;
|
||||
if (portVisibility(mStyle.layout.card.y * scale()) < 0.5f) return std::nullopt;
|
||||
std::optional<PortHit> nearest;
|
||||
halnf best = mStyle.portHitRadius;
|
||||
std::vector<PortHit> ports;
|
||||
std::vector<Vec2F> positions;
|
||||
for (size_t item = 0; item < mRects.size(); item++) {
|
||||
if (!isInteractive(item)) continue;
|
||||
const TreeItemId id = mStructure.items()[item].id;
|
||||
for (Port port : { Port::Parent, Port::Children }) {
|
||||
const halnf distance = length(*portPosition(id, port) - local);
|
||||
if (distance > best) continue;
|
||||
best = distance;
|
||||
nearest = PortHit{ id, port };
|
||||
ports.push_back({ id, port });
|
||||
positions.push_back(*portPosition(id, port));
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
const std::optional<size_t> nearest = graph::nearestWithin(positions, local, mStyle.portHitRadius);
|
||||
return nearest ? std::optional<PortHit>(ports[*nearest]) : std::nullopt;
|
||||
}
|
||||
|
||||
size_t TreeView::hiddenDescendants(TreeItemId id) const {
|
||||
|
|
@ -1154,13 +1158,13 @@ void TreeView::onEvent(Event& event) {
|
|||
const std::optional<Key> key = event.key();
|
||||
if (mFull) break;
|
||||
if (key == Key::Delete || key == Key::Backspace) {
|
||||
if (mSelected.empty() || !mOnDeleteRequested) break;
|
||||
if (mSelection.empty() || !mOnDeleteRequested) break;
|
||||
requestDelete();
|
||||
event.accept();
|
||||
break;
|
||||
}
|
||||
if (key == Key::Escape) {
|
||||
if (mSelected.empty()) break;
|
||||
if (mSelection.empty()) break;
|
||||
select(std::nullopt);
|
||||
selectionChanged();
|
||||
event.accept();
|
||||
|
|
@ -1172,8 +1176,8 @@ void TreeView::onEvent(Event& event) {
|
|||
break;
|
||||
}
|
||||
if (key == Key::Enter || key == Key::Space) {
|
||||
if (!mSelection) break;
|
||||
setOpen(*mSelection, !mExpansion.isOpen(*mSelection));
|
||||
if (!mSelection.current()) break;
|
||||
setOpen(*mSelection.current(), !mExpansion.isOpen(*mSelection.current()));
|
||||
event.accept();
|
||||
break;
|
||||
}
|
||||
|
|
@ -1216,7 +1220,7 @@ void TreeView::pressAt(Event& event, Vec2F local) {
|
|||
toggleByUser(*item);
|
||||
return;
|
||||
}
|
||||
mPressedSelected = mSelection == item && mSelected.size() == 1;
|
||||
mPressedSelected = mSelection.current() == item && mSelection.selected().size() == 1;
|
||||
if (!mPressedSelected) selectByUser(*item);
|
||||
mTugged = item;
|
||||
mTug.jumpTo({ 0, 0 });
|
||||
|
|
@ -1224,7 +1228,7 @@ void TreeView::pressAt(Event& event, Vec2F local) {
|
|||
return;
|
||||
}
|
||||
|
||||
mBoxBase = toggling ? mSelected : std::unordered_set<TreeItemId>{};
|
||||
mBoxBase = toggling ? selectedItems() : std::vector<TreeItemId>{};
|
||||
mBoxCorner = local;
|
||||
mPendingGesture = Gesture::Box;
|
||||
}
|
||||
|
|
@ -1303,13 +1307,12 @@ void TreeView::dragAt(Event& event, Vec2F local) {
|
|||
}
|
||||
|
||||
void TreeView::clickAt(Event& event, Vec2F local) {
|
||||
fprintf(stderr, "clickAt pending=%d selected=%zu boxBase=%zu mods=%d%d local=%g,%g\n", (int) mPendingGesture, mSelected.size(), mBoxBase.size(), event.modifiers().control, event.modifiers().shift, local.x, local.y);
|
||||
event.accept();
|
||||
if (mPendingGesture == Gesture::Tug && mPressedSelected && mSelection) {
|
||||
setOpen(*mSelection, !mExpansion.isOpen(*mSelection));
|
||||
if (mPendingGesture == Gesture::Tug && mPressedSelected && mSelection.current()) {
|
||||
setOpen(*mSelection.current(), !mExpansion.isOpen(*mSelection.current()));
|
||||
}
|
||||
if (mPendingGesture == Gesture::Box) {
|
||||
if (mBoxBase.empty() && !mSelected.empty()) {
|
||||
if (mBoxBase.empty() && !mSelection.empty()) {
|
||||
select(std::nullopt);
|
||||
selectionChanged();
|
||||
}
|
||||
|
|
@ -1363,8 +1366,8 @@ void TreeView::onHoverChanged() {
|
|||
}
|
||||
|
||||
void TreeView::moveSelection(Key key) {
|
||||
if (!mSelection) return;
|
||||
const std::optional<size_t> index = mStructure.indexOf(*mSelection);
|
||||
if (!mSelection.current()) return;
|
||||
const std::optional<size_t> index = mStructure.indexOf(*mSelection.current());
|
||||
if (!index) return;
|
||||
const TreeStructure::Item& item = mStructure.items()[*index];
|
||||
const std::vector<size_t>& siblings =
|
||||
|
|
@ -1436,26 +1439,14 @@ void TreeView::paintStacks(Canvas& painter) const {
|
|||
const RGBA color = Palette::mix(card->accent(), theme().palette.window, 0.45f);
|
||||
painter.rect(
|
||||
translated(rect, { offset, offset }),
|
||||
withAlpha(color, card->mStack * card->mOpacity),
|
||||
Palette::faded(color, card->mStack * card->mOpacity),
|
||||
std::min(kCardRadius, rect.size.y / 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void TreeView::paintCurve(Canvas& painter, Vec2F from, Vec2F to, const RGBA& color, halnf thickness) const {
|
||||
const halnf bend = std::max(std::abs(to.x - from.x) * 0.5f, 16.0f);
|
||||
const Vec2F controlFrom = from + Vec2F(bend, 0.0f);
|
||||
const Vec2F controlTo = to - Vec2F(bend, 0.0f);
|
||||
std::vector<Vec2F> points;
|
||||
points.reserve(kCurveSegments + 1);
|
||||
for (int step = 0; step <= kCurveSegments; step++) {
|
||||
const halnf t = halnf(step) / kCurveSegments;
|
||||
const halnf u = 1 - t;
|
||||
points.push_back(
|
||||
from * (u * u * u) + controlFrom * (3 * u * u * t) + controlTo * (3 * u * t * t) + to * (t * t * t)
|
||||
);
|
||||
}
|
||||
painter.polyline(points, color, thickness);
|
||||
graph::paintLink(painter, from, to, { kLinkBend, kCurveSegments }, color, thickness);
|
||||
}
|
||||
|
||||
void TreeView::paintLinks(Canvas& painter) const {
|
||||
|
|
@ -1470,11 +1461,11 @@ void TreeView::paintLinks(Canvas& painter) const {
|
|||
const Vec2F from = { parent.pos.x + parent.size.x, parent.pos.y + parent.size.y / 2 };
|
||||
const Vec2F to = { child.pos.x, child.pos.y + child.size.y / 2 };
|
||||
if (!overlaps(visible, boundsOf({ from, to }))) continue;
|
||||
const bool related = mSelected.contains(entry.id) || mSelected.contains(mStructure.items()[entry.parent].id);
|
||||
const halnf alpha = opacity * (related || mSelected.empty() ? 0.9f : 0.45f);
|
||||
const bool related = mSelection.isSelected(entry.id) || mSelection.isSelected(mStructure.items()[entry.parent].id);
|
||||
const halnf alpha = opacity * (related || mSelection.empty() ? 0.9f : 0.45f);
|
||||
const RGBA color = related ? style.palette.text : style.palette.mutedText;
|
||||
const halnf thickness = mStyle.linkThickness * std::max(0.6f, decorationScale(child.size.y));
|
||||
paintCurve(painter, from, to, withAlpha(color, alpha), thickness * (related ? 1.6f : 1.0f));
|
||||
paintCurve(painter, from, to, Palette::faded(color, alpha), thickness * (related ? 1.6f : 1.0f));
|
||||
}
|
||||
|
||||
for (const auto& [fromId, toId] : mModel.crossLinks()) {
|
||||
|
|
@ -1483,12 +1474,12 @@ void TreeView::paintLinks(Canvas& painter) const {
|
|||
if (!from || !to || !isInteractive(*from) || !isInteractive(*to)) continue;
|
||||
const RectF first = mRects[*from];
|
||||
const RectF second = mRects[*to];
|
||||
const bool related = mSelected.contains(fromId) || mSelected.contains(toId);
|
||||
const bool related = mSelection.isSelected(fromId) || mSelection.isSelected(toId);
|
||||
paintCurve(
|
||||
painter,
|
||||
{ first.pos.x + first.size.x, first.pos.y + first.size.y * 0.75f },
|
||||
{ second.pos.x, second.pos.y + second.size.y * 0.75f },
|
||||
withAlpha(style.palette.warningText, related || mSelected.empty() ? 0.8f : 0.35f),
|
||||
Palette::faded(style.palette.warningText, related || mSelection.empty() ? 0.8f : 0.35f),
|
||||
mStyle.linkThickness
|
||||
);
|
||||
}
|
||||
|
|
@ -1507,14 +1498,11 @@ void TreeView::paintOverlay(Canvas& painter) {
|
|||
for (Port port : { Port::Parent, Port::Children }) {
|
||||
const Vec2F center = *portPosition(id, port);
|
||||
if (!contains(visible, center)) continue;
|
||||
painter.circle(center, portRadius, withAlpha(style.palette.window, ports));
|
||||
painter.circleOutline(center, portRadius, withAlpha(style.palette.mutedText, ports), 1.5f * ports);
|
||||
painter.circle(center, portRadius, Palette::faded(style.palette.window, ports));
|
||||
painter.circleOutline(center, portRadius, Palette::faded(style.palette.mutedText, ports), 1.5f * ports);
|
||||
}
|
||||
}
|
||||
if (const std::optional<RectF> box = selectionBox()) {
|
||||
painter.rect(*box, withAlpha(style.palette.accent, 0.12f), 2);
|
||||
painter.outline(*box, withAlpha(style.palette.accent, 0.8f), 2, 1);
|
||||
}
|
||||
if (const std::optional<RectF> box = selectionBox()) graph::paintMarquee(painter, *box, style);
|
||||
if (!mLink) return;
|
||||
|
||||
const Vec2F anchor = *portPosition(mLink->from.item, mLink->from.port);
|
||||
|
|
|
|||
41
Widgets/src/components/nodes/public/GraphGeometry.hpp
Normal file
41
Widgets/src/components/nodes/public/GraphGeometry.hpp
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#pragma once
|
||||
|
||||
#include "CanvasTypes.hpp"
|
||||
#include "Geometry.hpp"
|
||||
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
namespace tp {
|
||||
class Canvas;
|
||||
}
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
struct Theme;
|
||||
|
||||
// The drawing and hit-testing node views share: links are horizontal S-curves from an output on the right of one
|
||||
// card to an input on the left of another, ports are round handles, and a marquee is the dragged selection box.
|
||||
namespace graph {
|
||||
// How far a link's control points reach out of its ends, in view pixels: half the horizontal distance, never
|
||||
// less than minimumBend so short or backward links still leave their ports sideways.
|
||||
struct LinkShape {
|
||||
halnf minimumBend = 16;
|
||||
int segments = 24;
|
||||
};
|
||||
|
||||
// The polyline approximating a link from one port to another, in the same coordinates as the ports.
|
||||
[[nodiscard]] std::vector<Vec2F> linkPoints(Vec2F from, Vec2F to, const LinkShape& shape);
|
||||
void paintLink(Canvas& canvas, Vec2F from, Vec2F to, const LinkShape& shape, const RGBA& color, halnf thickness);
|
||||
// The distance from a point to a link's polyline.
|
||||
[[nodiscard]] halnf distanceToLink(Vec2F point, const std::vector<Vec2F>& link);
|
||||
|
||||
// The index of the nearest position within reach, among ports given by position.
|
||||
[[nodiscard]] std::optional<size_t> nearestWithin(const std::vector<Vec2F>& positions, Vec2F point, halnf reach);
|
||||
|
||||
// The box spanned by a press point and the pointer, in either order.
|
||||
[[nodiscard]] RectF marquee(Vec2F press, Vec2F pointer);
|
||||
// The selection box look, shared by every view that drags one.
|
||||
void paintMarquee(Canvas& canvas, const RectF& box, const Theme& theme);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
#include "Animation.hpp"
|
||||
#include "NodeGraph.hpp"
|
||||
#include "Commands.hpp"
|
||||
#include "GraphGeometry.hpp"
|
||||
#include "SelectionModel.hpp"
|
||||
#include "Shortcut.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
|
|
@ -79,7 +82,7 @@ namespace tp::ui {
|
|||
[[nodiscard]] bool isShown(NodeId id) const { return !mNodeFilter || mNodeFilter(id); }
|
||||
[[nodiscard]] bool isShown(const Link& link) const { return isShown(link.from.node) && isShown(link.to.node); }
|
||||
|
||||
[[nodiscard]] const std::vector<NodeId>& selection() const { return mSelection; }
|
||||
[[nodiscard]] const std::vector<NodeId>& selection() const { return mSelection.selected(); }
|
||||
[[nodiscard]] bool isSelected(NodeId id) const;
|
||||
void select(const std::vector<NodeId>& ids);
|
||||
void clearSelection() { select({}); }
|
||||
|
|
@ -88,8 +91,11 @@ namespace tp::ui {
|
|||
[[nodiscard]] bool isSelected(const Link& link) const;
|
||||
void selectLink(const Link& link);
|
||||
|
||||
void execute(Command command);
|
||||
[[nodiscard]] ShortcutMap<Command>& shortcuts() { return mShortcuts; }
|
||||
// Runs a command if it is available now (Copy needs a selection, Paste a clipboard).
|
||||
void execute(Command command) { mCommands.run(command); }
|
||||
// The editor's commands: labels, shortcuts and availability for menus (MenuEntry::command) and the keyboard.
|
||||
[[nodiscard]] Commands<Command>& commands() { return mCommands; }
|
||||
[[nodiscard]] ShortcutMap<Command>& shortcuts() { return mCommands.shortcuts(); }
|
||||
[[nodiscard]] bool hasClipboard() const { return !mClipboard.isEmpty(); }
|
||||
|
||||
[[nodiscard]] Vec2F pan() const { return mPan.value(); }
|
||||
|
|
@ -153,7 +159,7 @@ namespace tp::ui {
|
|||
std::optional<PortRef> hovered;
|
||||
};
|
||||
|
||||
void bindDefaultShortcuts();
|
||||
void defineCommands();
|
||||
[[nodiscard]] std::optional<std::pair<Vec2F, halnf>> framingView() const;
|
||||
void viewChanged();
|
||||
void holdZoomAnchor(bool zooming);
|
||||
|
|
@ -183,7 +189,7 @@ namespace tp::ui {
|
|||
|
||||
void paintLinks(Canvas& painter) const;
|
||||
void paintLink(Canvas& painter, Vec2F from, Vec2F to, const RGBA& color, halnf thickness) const;
|
||||
[[nodiscard]] std::vector<Vec2F> linkCurvePoints(Vec2F from, Vec2F to) const;
|
||||
[[nodiscard]] graph::LinkShape linkShape() const;
|
||||
void paintNode(Canvas& painter, const Node& node) const;
|
||||
void paintSimplifiedNode(Canvas& painter, const Node& node, const NodePresentation& presentation) const;
|
||||
void paintTextLine(Canvas& painter, const RectF& box, halnf textWidth, const RGBA& color) const;
|
||||
|
|
@ -209,9 +215,10 @@ namespace tp::ui {
|
|||
std::function<void()> mOnEmptyDoubleClicked;
|
||||
size_t mPaintedNodes = 0;
|
||||
bool mFramePending = false;
|
||||
ShortcutMap<Command> mShortcuts;
|
||||
Commands<Command> mCommands;
|
||||
|
||||
std::vector<NodeId> mSelection;
|
||||
// The selected nodes, the same model the tree view and lists use; a selected link is separate (one at a time).
|
||||
SelectionModel<NodeId> mSelection{ SelectionModel<NodeId>::Mode::Multiple };
|
||||
std::optional<Link> mSelectedLink;
|
||||
NodeGraphFragment mClipboard;
|
||||
ualni mPasteCount = 0;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include "InputCodes.hpp"
|
||||
#include "Layout.hpp"
|
||||
#include "ScrollPhysics.hpp"
|
||||
#include "SelectionModel.hpp"
|
||||
#include "TreeLayout.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
|
|
@ -143,9 +144,9 @@ namespace tp::ui {
|
|||
void modelChanged();
|
||||
void refreshCards();
|
||||
|
||||
[[nodiscard]] std::optional<TreeItemId> selection() const { return mSelection; }
|
||||
[[nodiscard]] std::optional<TreeItemId> selection() const { return mSelection.current(); }
|
||||
[[nodiscard]] std::vector<TreeItemId> selectedItems() const;
|
||||
[[nodiscard]] bool isSelected(TreeItemId id) const { return mSelected.contains(id); }
|
||||
[[nodiscard]] bool isSelected(TreeItemId id) const { return mSelection.isSelected(id); }
|
||||
void select(std::optional<TreeItemId> id);
|
||||
void setSelection(const std::vector<TreeItemId>& ids, std::optional<TreeItemId> current);
|
||||
void selectAll();
|
||||
|
|
@ -277,6 +278,7 @@ namespace tp::ui {
|
|||
void toggleByUser(TreeItemId id);
|
||||
void updateBox(Vec2F local);
|
||||
void selectionChanged();
|
||||
[[nodiscard]] std::vector<TreeItemId> treeOrder() const;
|
||||
void reveal(TreeItemId id);
|
||||
void moveSelection(Key key);
|
||||
|
||||
|
|
@ -321,9 +323,10 @@ namespace tp::ui {
|
|||
std::optional<Vec2F> mPivotWorld;
|
||||
AnimatedVec2 mPivotScreen{ Vec2F(0.0f, 0.0f), Motion::smooth(), 0.1f };
|
||||
|
||||
std::optional<TreeItemId> mSelection;
|
||||
std::unordered_set<TreeItemId> mSelected;
|
||||
std::unordered_set<TreeItemId> mBoxBase;
|
||||
// The selected items and the current one (the one the camera follows and keys move from).
|
||||
SelectionModel<TreeItemId> mSelection{ SelectionModel<TreeItemId>::Mode::Multiple };
|
||||
// What a box drag adds to: the selection when it started with Control or Shift, else nothing.
|
||||
std::vector<TreeItemId> mBoxBase;
|
||||
std::optional<TreeItemId> mHovered;
|
||||
std::unordered_map<TreeItemId, AnimatedFloat> mHover;
|
||||
|
||||
|
|
|
|||
47
Widgets/src/components/nodes/tests/GraphGeometryTests.cpp
Normal file
47
Widgets/src/components/nodes/tests/GraphGeometryTests.cpp
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#include "GraphGeometry.hpp"
|
||||
|
||||
#include "UnitTest++/UnitTest++.h"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
SUITE(GraphGeometry) {
|
||||
TEST(LinksStartAndEndAtTheirPortsAndLeaveThemSideways) {
|
||||
const graph::LinkShape shape{ 16, 20 };
|
||||
const std::vector<Vec2F> points = graph::linkPoints({ 0, 0 }, { 100, 50 }, shape);
|
||||
REQUIRE CHECK_EQUAL(21u, points.size());
|
||||
CHECK_CLOSE(0, points.front().x, 0.001f);
|
||||
CHECK_CLOSE(50, points.back().y, 0.001f);
|
||||
// Leaving the output, the curve heads right before it bends down.
|
||||
CHECK(points[1].x > points[0].x);
|
||||
CHECK(points[1].y - points[0].y < points[1].x - points[0].x);
|
||||
}
|
||||
|
||||
TEST(BackwardLinksStillBendOutByTheMinimum) {
|
||||
const graph::LinkShape shape{ 40, 24 };
|
||||
const std::vector<Vec2F> points = graph::linkPoints({ 100, 0 }, { 0, 0 }, shape);
|
||||
halnf rightmost = 0;
|
||||
for (const Vec2F& point : points)
|
||||
rightmost = std::max(rightmost, point.x);
|
||||
CHECK(rightmost > 100);
|
||||
}
|
||||
|
||||
TEST(DistanceToALinkMeasuresToItsCurve) {
|
||||
const std::vector<Vec2F> straight = graph::linkPoints({ 0, 0 }, { 100, 0 }, { 16, 10 });
|
||||
CHECK_CLOSE(5, graph::distanceToLink({ 50, 5 }, straight), 0.01f);
|
||||
CHECK(graph::distanceToLink({ 50, 40 }, straight) > 30);
|
||||
}
|
||||
|
||||
TEST(NearestPortWithinReach) {
|
||||
const std::vector<Vec2F> ports = { { 0, 0 }, { 20, 0 }, { 40, 0 } };
|
||||
CHECK(graph::nearestWithin(ports, { 18, 2 }, 5) == std::optional<size_t>(1));
|
||||
CHECK(!graph::nearestWithin(ports, { 10, 10 }, 5));
|
||||
}
|
||||
|
||||
TEST(MarqueeIsTheSameWhicheverWayItIsDragged) {
|
||||
const RectF forward = graph::marquee({ 10, 20 }, { 50, 60 });
|
||||
const RectF backward = graph::marquee({ 50, 60 }, { 10, 20 });
|
||||
CHECK_CLOSE(forward.pos.x, backward.pos.x, 0.001f);
|
||||
CHECK_CLOSE(40, backward.size.y, 0.001f);
|
||||
}
|
||||
}
|
||||
|
|
@ -702,3 +702,50 @@ SUITE(GalleryFilesPage) {
|
|||
CHECK_EQUAL("Chose " + page.browser.directory(), page.status.text());
|
||||
}
|
||||
}
|
||||
|
||||
SUITE(GalleryFormsAndPanels) {
|
||||
size_t tabNamed(Gallery& gallery, const std::string& name) {
|
||||
for (size_t tab = 0; tab < gallery.tabs.tabCount(); tab++) {
|
||||
if (gallery.tabs.tabButton(tab).text() == name) return tab;
|
||||
}
|
||||
return gallery.tabs.tabCount();
|
||||
}
|
||||
|
||||
TEST(FormsPageEditsTheCurrentContactThroughTheList) {
|
||||
Gallery gallery;
|
||||
UiDriver driver(gallery, { 1200, 800 });
|
||||
REQUIRE CHECK(tabNamed(gallery, "Forms") < gallery.tabs.tabCount());
|
||||
driver.click(gallery.tabs.tabButton(tabNamed(gallery, "Forms")));
|
||||
FormsPage& page = gallery.formsPage;
|
||||
|
||||
driver.click(page.list.row(1));
|
||||
driver.key(Key::Down);
|
||||
REQUIRE CHECK(page.current() != nullptr);
|
||||
CHECK_EQUAL("Alan Turing", page.current()->name);
|
||||
CHECK(driver.paint().hasText("Alan Turing"));
|
||||
|
||||
driver.click(*page.details.editor("Starred"));
|
||||
CHECK(page.current()->starred);
|
||||
page.commands.run(FormsPage::Command::Remove);
|
||||
CHECK_EQUAL(5u, page.contacts.size());
|
||||
}
|
||||
|
||||
TEST(PanelsPageNotesAndDialogWork) {
|
||||
Gallery gallery;
|
||||
UiDriver driver(gallery, { 1200, 800 });
|
||||
REQUIRE CHECK(tabNamed(gallery, "Panels") < gallery.tabs.tabCount());
|
||||
driver.click(gallery.tabs.tabButton(tabNamed(gallery, "Panels")));
|
||||
PanelsPage& page = gallery.panelsPage;
|
||||
|
||||
driver.click(page.addNote);
|
||||
driver.click(page.addNote);
|
||||
CHECK_EQUAL("2 notes", page.noteCount.text());
|
||||
driver.click(page.clearNotes);
|
||||
CHECK_EQUAL("0 notes", page.noteCount.text());
|
||||
|
||||
driver.click(page.openDialog);
|
||||
REQUIRE CHECK(page.dialog.isOpen());
|
||||
driver.click(*page.dialog.buttons().front());
|
||||
CHECK_EQUAL("Renamed to Background", page.status.text());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -381,6 +381,113 @@ SUITE(OtherLayouts) {
|
|||
checkRect(makeRect(110, 0, 100, 40), cells[1].rect());
|
||||
checkRect(makeRect(0, 50, 100, 30), cells[2].rect());
|
||||
}
|
||||
|
||||
TEST(GridColumnWidthsFitLabelsAndFillTheRest) {
|
||||
Widget root;
|
||||
root.emplaceLayout<GridLayout>(1, 10).setColumnWidths({ AxisSizing::fit(), AxisSizing::fill() });
|
||||
Box cells[4] = { Box({ 40, 20 }), Box({ 10, 20 }), Box({ 70, 20 }), Box({ 10, 30 }) };
|
||||
for (Box& cell : cells)
|
||||
root.addChild(cell);
|
||||
|
||||
UiDriver driver(root, { 300, 200 });
|
||||
|
||||
// The label column is as wide as its widest label, the text column takes everything else.
|
||||
checkRect(makeRect(0, 0, 70, 20), cells[0].rect());
|
||||
checkRect(makeRect(80, 0, 220, 20), cells[1].rect());
|
||||
checkRect(makeRect(0, 30, 70, 30), cells[2].rect());
|
||||
checkRect(makeRect(80, 30, 220, 30), cells[3].rect());
|
||||
}
|
||||
|
||||
TEST(GridFixedAndWeightedColumns) {
|
||||
Widget root;
|
||||
root.emplaceLayout<GridLayout>(1).setColumnWidths(
|
||||
{ AxisSizing::fixed(50), AxisSizing::fill(1), AxisSizing::fill(3) }
|
||||
);
|
||||
Box cells[3] = { Box({ 10, 10 }), Box({ 10, 10 }), Box({ 10, 10 }) };
|
||||
for (Box& cell : cells)
|
||||
root.addChild(cell);
|
||||
|
||||
UiDriver driver(root, { 250, 100 });
|
||||
|
||||
// Like rows and columns, fill columns start at their content (10 each) and share the leftover 180 by weight 1:3.
|
||||
checkRect(makeRect(0, 0, 50, 10), cells[0].rect());
|
||||
checkRect(makeRect(50, 0, 55, 10), cells[1].rect());
|
||||
checkRect(makeRect(105, 0, 145, 10), cells[2].rect());
|
||||
}
|
||||
|
||||
TEST(GridMeasuresTheSumOfItsColumns) {
|
||||
Widget root;
|
||||
root.emplaceLayout<ColumnLayout>().setCrossAlign(CrossAlign::Start);
|
||||
Widget table;
|
||||
table.emplaceLayout<GridLayout>(1, 5).setColumnWidths({ AxisSizing::fit(), AxisSizing::fit() });
|
||||
Box cells[2] = { Box({ 30, 10 }), Box({ 45, 10 }) };
|
||||
for (Box& cell : cells)
|
||||
table.addChild(cell);
|
||||
root.addChild(table);
|
||||
|
||||
UiDriver driver(root, { 400, 100 });
|
||||
|
||||
CHECK_CLOSE(30 + 5 + 45, table.rect().size.x, 0.01f);
|
||||
}
|
||||
|
||||
TEST(RowOverflowCutsTheRunFromItsEndAndShowsTheIndicator) {
|
||||
Widget root;
|
||||
RowLayout& row = root.emplaceLayout<RowLayout>();
|
||||
Box items[4] = { Box({ 50, 10 }), Box({ 50, 10 }), Box({ 50, 10 }), Box({ 50, 10 }) };
|
||||
Box more({ 20, 10 });
|
||||
Box trailing({ 30, 10 });
|
||||
for (Box& item : items)
|
||||
root.addChild(item);
|
||||
root.addChild(more);
|
||||
root.addChild(trailing);
|
||||
size_t reported = 99;
|
||||
row.setOverflow(0, 4, &more);
|
||||
row.setOnOverflowChanged([&reported](size_t shown) { reported = shown; });
|
||||
|
||||
// 200 for the run + 30 trailing do not fit 160: with the indicator (20) and trailing (30), two items fit.
|
||||
UiDriver driver(root, { 160, 40 });
|
||||
CHECK_EQUAL(2u, row.shownOverflowItems());
|
||||
CHECK_EQUAL(2u, reported);
|
||||
CHECK(items[1].isVisible());
|
||||
CHECK(!items[2].isVisible() && !items[3].isVisible());
|
||||
CHECK(more.isVisible());
|
||||
CHECK(trailing.rect().pos.x + trailing.rect().size.x <= 160.01f);
|
||||
|
||||
driver.resize({ 400, 40 });
|
||||
driver.layout();
|
||||
CHECK_EQUAL(4u, row.shownOverflowItems());
|
||||
CHECK(items[3].isVisible());
|
||||
CHECK(!more.isVisible());
|
||||
}
|
||||
|
||||
TEST(RowWithoutOverflowKeepsEveryChild) {
|
||||
Widget root;
|
||||
root.emplaceLayout<RowLayout>();
|
||||
Box items[3] = { Box({ 80, 10 }), Box({ 80, 10 }), Box({ 80, 10 }) };
|
||||
for (Box& item : items)
|
||||
root.addChild(item);
|
||||
|
||||
UiDriver driver(root, { 100, 40 });
|
||||
|
||||
for (Box& item : items)
|
||||
CHECK(item.isVisible());
|
||||
}
|
||||
|
||||
TEST(ClearingColumnWidthsRestoresEqualColumns) {
|
||||
Widget root;
|
||||
GridLayout& grid = root.emplaceLayout<GridLayout>(1);
|
||||
grid.setColumnWidths({ AxisSizing::fixed(20), AxisSizing::fill() });
|
||||
grid.setColumnWidths({});
|
||||
Box cells[2] = { Box({ 10, 10 }), Box({ 10, 10 }) };
|
||||
for (Box& cell : cells)
|
||||
root.addChild(cell);
|
||||
|
||||
UiDriver driver(root, { 200, 100 });
|
||||
|
||||
CHECK_EQUAL(2u, grid.columnWidths().empty() ? 2u : 0u);
|
||||
checkRect(makeRect(0, 0, 100, 10), cells[0].rect());
|
||||
checkRect(makeRect(100, 0, 100, 10), cells[1].rect());
|
||||
}
|
||||
}
|
||||
|
||||
SUITE(IncrementalLayout) {
|
||||
|
|
|
|||
|
|
@ -61,9 +61,9 @@ SUITE(ThemeEditorPage) {
|
|||
TEST(TabShowsEveryColorWithSwatchAndHex) {
|
||||
ThemeScene scene;
|
||||
CHECK_EQUAL("Theme", scene.gallery.tabs.tabButton(kThemeTab).text());
|
||||
CHECK_EQUAL(themeColorFields().size(), scene.page.colorRows.size());
|
||||
CHECK_EQUAL(themeColorFields().size(), scene.page.colors.count());
|
||||
CHECK_EQUAL("Editing window", scene.page.editingTitle.text());
|
||||
CHECK_EQUAL(colorHex(Theme::standard().palette.window), scene.page.colorRows[0]->hex.text());
|
||||
CHECK_EQUAL(colorHex(Theme::standard().palette.window), scene.page.colors.row(0).trailing().text);
|
||||
CHECK(scene.driver.paint().hasText("accent"));
|
||||
}
|
||||
|
||||
|
|
@ -73,11 +73,13 @@ SUITE(ThemeEditorPage) {
|
|||
scene.page.selectColor(scrim);
|
||||
CHECK_EQUAL("Editing scrim", scene.page.editingTitle.text());
|
||||
CHECK_CLOSE(Theme::standard().palette.scrim.a, scene.page.alpha.value(), 0.001f);
|
||||
CHECK(scene.page.colorRows[scrim]->name.isSelected());
|
||||
CHECK(!scene.page.colorRows[0]->name.isSelected());
|
||||
CHECK(scene.page.colors.row(scrim).isHighlighted());
|
||||
CHECK(!scene.page.colors.row(0).isHighlighted());
|
||||
|
||||
scene.driver.click(scene.page.colorRows[0]->name);
|
||||
scene.driver.click(scene.page.colors.row(0));
|
||||
CHECK_EQUAL(0u, scene.page.selectedColor());
|
||||
scene.driver.key(Key::Down);
|
||||
CHECK_EQUAL(1u, scene.page.selectedColor());
|
||||
}
|
||||
|
||||
TEST(WheelAndAlphaEditTheLiveTheme) {
|
||||
|
|
@ -90,7 +92,7 @@ SUITE(ThemeEditorPage) {
|
|||
scene.driver.click(wheel.center());
|
||||
const RGBA edited = scene.driver.ui().theme().palette.text;
|
||||
CHECK(!(edited == Theme::standard().palette.text));
|
||||
CHECK_EQUAL(colorHex(edited), scene.page.colorRows[text]->hex.text());
|
||||
CHECK_EQUAL(colorHex(edited), scene.page.colors.row(text).trailing().text);
|
||||
|
||||
scene.driver.settle(); scene.driver.click((scene.page.alpha.slider()).windowPointForValue(0.5f));
|
||||
CHECK_CLOSE(0.5f, scene.driver.ui().theme().palette.text.a, 0.03f);
|
||||
|
|
@ -99,7 +101,7 @@ SUITE(ThemeEditorPage) {
|
|||
|
||||
TEST(MetricSlidersEditTheLiveTheme) {
|
||||
ThemeScene scene;
|
||||
LabeledSlider& corner = *scene.page.metricSliders[1];
|
||||
LabeledSlider& corner = static_cast<LabeledSlider&>(*scene.page.metrics.editor("cornerRadius"));
|
||||
CHECK_CLOSE(Theme::standard().metrics.cornerRadius, corner.value(), 0.001f);
|
||||
|
||||
scene.driver.settle(); scene.driver.click((corner.slider()).windowPointForValue(2));
|
||||
|
|
|
|||
|
|
@ -85,6 +85,40 @@ SUITE(LabelWidget) {
|
|||
}
|
||||
|
||||
SUITE(ButtonWidget) {
|
||||
TEST(GlyphButtonPaintsItsGlyphInsideTheButton) {
|
||||
GlyphButton button(Glyph::ChevronRight);
|
||||
Column scene(button);
|
||||
UiDriver driver(scene.root);
|
||||
|
||||
const CanvasCommand* chevron = nullptr;
|
||||
for (const CanvasCommand& command : driver.paint().commands()) {
|
||||
if (command.kind == CanvasCommand::Kind::Polyline) chevron = &command;
|
||||
}
|
||||
REQUIRE CHECK(chevron != nullptr);
|
||||
const RectF bounds = button.windowRect();
|
||||
CHECK(chevron->rect.pos.x >= bounds.pos.x && chevron->rect.pos.x + chevron->rect.size.x <= bounds.pos.x + bounds.size.x);
|
||||
CHECK_CLOSE(driver.ui().theme().metrics.glyphStroke, chevron->width, 0.001f);
|
||||
}
|
||||
|
||||
TEST(GlyphButtonGlyphBrightensWhileHovered) {
|
||||
GlyphButton button(Glyph::Close);
|
||||
Column scene(button);
|
||||
UiDriver driver(scene.root);
|
||||
const Palette& palette = driver.ui().theme().palette;
|
||||
const auto glyphColor = [&driver]() {
|
||||
for (const CanvasCommand& command : driver.paint().commands()) {
|
||||
if (command.kind == CanvasCommand::Kind::Line) return command.color;
|
||||
}
|
||||
return RGBA{};
|
||||
};
|
||||
|
||||
CHECK_CLOSE(palette.mutedText.r, glyphColor().r, 0.01f);
|
||||
driver.moveTo(button);
|
||||
driver.settle();
|
||||
CHECK_CLOSE(palette.text.r, glyphColor().r, 0.01f);
|
||||
CHECK_CLOSE(palette.controlHovered.r, button.visual().color().r, 0.01f);
|
||||
}
|
||||
|
||||
TEST(ClickInvokesActionOnce) {
|
||||
int clicks = 0;
|
||||
Button button("Press", [&clicks]() { clicks++; });
|
||||
|
|
@ -286,7 +320,7 @@ SUITE(SliderWidget) {
|
|||
CHECK_EQUAL(3u, ticks);
|
||||
}
|
||||
|
||||
TEST(DraggingSetsValueAndCapturesPointer) {
|
||||
TEST(DraggingSetsValueAndKeepsFollowingThePointer) {
|
||||
Slider slider(0, 100, 0);
|
||||
std::vector<halnf> changes;
|
||||
slider.setOnChanged([&changes](halnf value) { changes.push_back(value); });
|
||||
|
|
@ -299,9 +333,10 @@ SUITE(SliderWidget) {
|
|||
driver.moveTo({ rect.pos.x + inset, rect.center().y });
|
||||
driver.press();
|
||||
CHECK(slider.isDragging());
|
||||
CHECK(driver.ui().pointerCapture() == &slider);
|
||||
|
||||
// The router's drag keeps delivering to the slider once the pointer leaves it.
|
||||
driver.moveTo({ rect.pos.x + rect.size.x / 2, rect.center().y + 200 });
|
||||
CHECK(driver.ui().pointerCapture() == &slider);
|
||||
CHECK_CLOSE(50, slider.value(), 1);
|
||||
|
||||
driver.moveTo({ rect.pos.x + rect.size.x + 500, rect.center().y });
|
||||
|
|
@ -435,9 +470,50 @@ SUITE(StructuralWidgets) {
|
|||
Column scene(panel);
|
||||
UiDriver driver(scene.root);
|
||||
|
||||
const auto& commands = driver.paint().commands();
|
||||
REQUIRE CHECK(!commands.empty());
|
||||
CHECK_CLOSE(driver.ui().theme().palette.raisedSurface.r, commands.front().color.r, 0.001f);
|
||||
const CanvasCommand* surface = nullptr;
|
||||
for (const CanvasCommand& command : driver.paint().commands()) {
|
||||
if (!surface && command.kind == CanvasCommand::Kind::Rect) surface = &command;
|
||||
}
|
||||
REQUIRE CHECK(surface != nullptr);
|
||||
CHECK_CLOSE(driver.ui().theme().palette.raisedSurface.r, surface->color.r, 0.001f);
|
||||
}
|
||||
|
||||
TEST(RaisedPanelCastsTheThemeShadowBelowItsSurface) {
|
||||
Panel panel(Panel::Surface::Raised);
|
||||
panel.setSizing(AxisSizing::fixed(100), AxisSizing::fixed(50));
|
||||
Column scene(panel);
|
||||
UiDriver driver(scene.root);
|
||||
const Theme& style = driver.ui().theme();
|
||||
|
||||
const std::vector<CanvasCommand>& commands = driver.paint().commands();
|
||||
const auto shadow = std::find_if(commands.begin(), commands.end(), [](const CanvasCommand& command) {
|
||||
return command.kind == CanvasCommand::Kind::Path;
|
||||
});
|
||||
const auto surface = std::find_if(commands.begin(), commands.end(), [&](const CanvasCommand& command) {
|
||||
return command.kind == CanvasCommand::Kind::Rect && command.color == style.palette.raisedSurface;
|
||||
});
|
||||
REQUIRE CHECK(shadow != commands.end());
|
||||
REQUIRE CHECK(surface != commands.end());
|
||||
CHECK(shadow < surface);
|
||||
CHECK(shadow->color == style.palette.shadow);
|
||||
CHECK_CLOSE(panel.windowRect().pos.y + style.metrics.elevation, shadow->rect.pos.y, 0.5f);
|
||||
}
|
||||
|
||||
TEST(FlatOrUnraisedPanelsCastNoShadow) {
|
||||
Panel flat(Panel::Surface::Raised);
|
||||
flat.setElevated(false);
|
||||
Panel base(Panel::Surface::Base);
|
||||
for (Panel* panel : { &flat, &base }) {
|
||||
panel->setSizing(AxisSizing::fixed(100), AxisSizing::fixed(50));
|
||||
}
|
||||
Column scene(flat, base);
|
||||
UiDriver driver(scene.root);
|
||||
|
||||
for (const CanvasCommand& command : driver.paint().commands()) {
|
||||
CHECK(command.kind != CanvasCommand::Kind::Path);
|
||||
}
|
||||
CHECK(!flat.isElevated());
|
||||
CHECK(!base.isElevated());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -756,18 +832,6 @@ SUITE(ScrollViewWidget) {
|
|||
CHECK_CLOSE(50.0f, scene.scroll.currentOffset(), 0.5f);
|
||||
}
|
||||
|
||||
TEST(FlingUsesTheAverageSpeedOfTheLastHundredMilliseconds) {
|
||||
using Sample = ScrollView::DragSample;
|
||||
const std::vector<Sample> steady = { { 0, 0 }, { 20, 20 }, { 40, 40 }, { 60, 60 }, { 80, 80 } };
|
||||
CHECK_CLOSE(1.0f, ScrollView::averageVelocity(steady, 80), 1e-4f);
|
||||
const std::vector<Sample> jitter = { { 0, 0 }, { 20, 20 }, { 40, 40 }, { 60, 60 }, { 80, 80 }, { 82, 100 } };
|
||||
CHECK_CLOSE(100.0f / 82.0f, ScrollView::averageVelocity(jitter, 82), 1e-3f);
|
||||
const std::vector<Sample> old = { { 0, 0 }, { 50, 100 }, { 200, 120 }, { 250, 220 } };
|
||||
CHECK_CLOSE(2.0f, ScrollView::averageVelocity(old, 250), 1e-4f);
|
||||
CHECK_CLOSE(0.0f, ScrollView::averageVelocity(old, 400), 1e-6f);
|
||||
CHECK_CLOSE(0.0f, ScrollView::averageVelocity({ { 10, 5 } }, 10), 1e-6f);
|
||||
}
|
||||
|
||||
TEST(ASuddenLastMoveDoesNotDominateTheFling) {
|
||||
ScrollScene scene;
|
||||
UiDriver driver(scene.root, { 200, 300 });
|
||||
|
|
@ -1058,6 +1122,66 @@ SUITE(ScrollViewWidget) {
|
|||
CHECK_CLOSE(100, scene.scroll.offset(), 0.01f);
|
||||
}
|
||||
|
||||
TEST(HorizontalScrollViewTakesHorizontalWheelAndTrackpad) {
|
||||
Widget root;
|
||||
root.emplaceLayout<StackLayout>();
|
||||
ScrollView strip{ Axis::Horizontal };
|
||||
Box wide({ 1000, 40 });
|
||||
strip.setContent(&wide);
|
||||
root.addChild(strip);
|
||||
UiDriver driver(root, { 200, 40 });
|
||||
driver.moveTo({ 100, 20 });
|
||||
|
||||
// Before the Viewport, a horizontal scroller read only the vertical wheel amount.
|
||||
driver.wheel({ -1, 0 });
|
||||
driver.settle();
|
||||
const halnf afterNotch = strip.offset();
|
||||
CHECK(afterNotch > 0);
|
||||
|
||||
driver.wheel({ -0.5f, 0.0f }, ScrollSource::Touchpad);
|
||||
driver.settle();
|
||||
CHECK(strip.offset() > afterNotch);
|
||||
|
||||
driver.wheel({ 0, -1 });
|
||||
driver.settle();
|
||||
CHECK(strip.offset() > afterNotch + 1);
|
||||
}
|
||||
|
||||
TEST(ShiftWheelScrollsAVerticalScrollViewsHorizontalParent) {
|
||||
Widget root;
|
||||
root.emplaceLayout<StackLayout>();
|
||||
ScrollView outer{ Axis::Horizontal };
|
||||
Widget page;
|
||||
page.setSizing(AxisSizing::fixed(1000), AxisSizing::fill());
|
||||
page.emplaceLayout<StackLayout>();
|
||||
ScrollView inner;
|
||||
Box tall({ 100, 2000 });
|
||||
inner.setContent(&tall);
|
||||
page.addChild(inner);
|
||||
outer.setContent(&page);
|
||||
root.addChild(outer);
|
||||
UiDriver driver(root, { 200, 300 });
|
||||
driver.moveTo({ 100, 150 });
|
||||
|
||||
driver.withModifiers(KeyModifiers::shiftOnly());
|
||||
driver.wheel({ 0, -1 });
|
||||
driver.settle();
|
||||
|
||||
CHECK(outer.offset() > 0);
|
||||
CHECK_CLOSE(0, inner.offset(), 0.01f);
|
||||
}
|
||||
|
||||
TEST(RevealRangeUsesTheThemeMargin) {
|
||||
ScrollScene scene;
|
||||
UiDriver driver(scene.root, { 200, 300 });
|
||||
const halnf margin = driver.ui().theme().interaction.revealMargin;
|
||||
|
||||
scene.scroll.revealRange(600, 50);
|
||||
driver.settle();
|
||||
|
||||
CHECK_CLOSE(600 + 50 + margin - 300, scene.scroll.offset(), 0.5f);
|
||||
}
|
||||
|
||||
TEST(ContentIsClippedToViewport) {
|
||||
ScrollScene scene;
|
||||
UiDriver driver(scene.root, { 200, 300 });
|
||||
|
|
@ -1219,7 +1343,7 @@ SUITE(CarouselTabViewWidget) {
|
|||
|
||||
void trackpad(halnf horizontal) {
|
||||
driver.moveTo(pages[0].parent()->parent()->windowRect().center());
|
||||
driver.wheel({ horizontal, 0.0f });
|
||||
driver.wheel({ horizontal, 0.0f }, ScrollSource::Touchpad);
|
||||
}
|
||||
|
||||
[[nodiscard]] bool shows(size_t index) { return driver.paint().hasText(pages[index].text()); }
|
||||
|
|
@ -1278,8 +1402,10 @@ SUITE(CarouselTabViewWidget) {
|
|||
TEST(SelectionFrameSlidesBetweenTabsWithThePages) {
|
||||
CarouselScene scene;
|
||||
scene.driver.setAutoSettle(false);
|
||||
const RectF first = scene.tabs.tabButton(0).rect();
|
||||
const RectF second = scene.tabs.tabButton(1).rect();
|
||||
// The indicator is painted by the tab bar, so it is in the bar's frame, like the strip of tab buttons.
|
||||
const Vec2F strip = scene.tabs.tabStrip().rect().pos;
|
||||
const RectF first = translated(scene.tabs.tabButton(0).rect(), strip);
|
||||
const RectF second = translated(scene.tabs.tabButton(1).rect(), strip);
|
||||
CHECK_CLOSE(first.pos.x, scene.tabs.selectionIndicatorRect().pos.x, 0.5f);
|
||||
|
||||
scene.tabs.showNext();
|
||||
|
|
@ -1307,18 +1433,31 @@ SUITE(CarouselTabViewWidget) {
|
|||
driver.moveTo(scroller.windowRect().center());
|
||||
driver.wheel({0, -1});
|
||||
driver.settle();
|
||||
CHECK(scroller.offset() > 0);
|
||||
const halnf afterVertical = scroller.offset();
|
||||
CHECK(afterVertical > 0);
|
||||
CHECK_EQUAL(0, tabs.currentIndex());
|
||||
|
||||
// A horizontal scroller now takes horizontal scrolling too, so the page stays while it can scroll.
|
||||
driver.wheel({-1, 0});
|
||||
driver.settle();
|
||||
CHECK_EQUAL(1, tabs.currentIndex());
|
||||
CHECK(scroller.offset() > afterVertical);
|
||||
CHECK_EQUAL(0, tabs.currentIndex());
|
||||
|
||||
// A page that does not scroll sideways lets horizontal and Shift+wheel scrolling turn the pages.
|
||||
tabs.setCurrentIndex(1);
|
||||
driver.settle();
|
||||
driver.moveTo(plain.windowRect().center());
|
||||
driver.withModifiers(KeyModifiers::shiftOnly());
|
||||
driver.wheel({ 0, -1 });
|
||||
driver.settle();
|
||||
CHECK_EQUAL(0, tabs.currentIndex());
|
||||
|
||||
tabs.setCurrentIndex(1);
|
||||
driver.settle();
|
||||
driver.moveTo(plain.windowRect().center());
|
||||
driver.wheel({ -1, 0 });
|
||||
driver.settle();
|
||||
CHECK_EQUAL(0, tabs.currentIndex());
|
||||
}
|
||||
|
||||
TEST(TheSmallestSwipeMovesThePagesRightAway) {
|
||||
|
|
@ -1335,6 +1474,25 @@ SUITE(CarouselTabViewWidget) {
|
|||
CHECK(scene.pages[1].parent()->isVisible());
|
||||
}
|
||||
|
||||
TEST(AWholeTrackpadStepPullsAndAMouseNotchTurnsAPage) {
|
||||
// The old carousel told them apart by whether the amount was a whole number, so a touchpad step of exactly 1
|
||||
// turned a whole page. The event now says where the scroll came from.
|
||||
CarouselScene scene;
|
||||
scene.driver.setAutoSettle(false);
|
||||
|
||||
scene.trackpad(-1.0f);
|
||||
scene.driver.advance(16);
|
||||
CHECK(scene.tabs.isPulling());
|
||||
CHECK_EQUAL(0, scene.tabs.currentIndex());
|
||||
scene.driver.settle();
|
||||
|
||||
scene.driver.moveTo(scene.pages[0].parent()->parent()->windowRect().center());
|
||||
scene.driver.wheel({ -1, 0 });
|
||||
CHECK(!scene.tabs.isPulling());
|
||||
scene.driver.settle();
|
||||
CHECK_EQUAL(1, scene.tabs.currentIndex());
|
||||
}
|
||||
|
||||
TEST(ASmallSwipeResistsAndSpringsBackWithoutOvershoot) {
|
||||
CarouselScene scene;
|
||||
scene.driver.setAutoSettle(false);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "ColorPicker.hpp"
|
||||
#include "Canvas.hpp"
|
||||
#include "Glyph.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "StackLayout.hpp"
|
||||
|
|
@ -43,15 +44,29 @@ void ColorCell::paint(Canvas& painter) {
|
|||
if (mKind == Kind::Add) {
|
||||
painter.rect(cell, background(), kCellRadius);
|
||||
painter.outline(cell, palette.controlBorder, kCellRadius, 1);
|
||||
const Vec2F center = cell.center();
|
||||
painter.line(center - Vec2F(kPlusArm, 0.0f), center + Vec2F(kPlusArm, 0.0f), palette.text, 1.5f);
|
||||
painter.line(center - Vec2F(0.0f, kPlusArm), center + Vec2F(0.0f, kPlusArm), palette.text, 1.5f);
|
||||
paintGlyph(painter, Glyph::Plus, glyphBox(cell, kPlusArm * 2), palette.text, theme());
|
||||
return;
|
||||
}
|
||||
|
||||
painter.rect(cell, mColor, kCellRadius);
|
||||
if (mSelected) painter.outline(insetRect(cell, 1), palette.text, kCellRadius, 2);
|
||||
else if (isHovered()) painter.outline(cell, palette.mutedText, kCellRadius, 1);
|
||||
if (mSelected) {
|
||||
painter.outline(insetRect(cell, 1), palette.text, kCellRadius, 2);
|
||||
return;
|
||||
}
|
||||
const halnf reveal = mVisual.reveal();
|
||||
if (reveal > 0.01f) painter.outline(cell, Palette::faded(palette.mutedText, reveal), kCellRadius, 1);
|
||||
}
|
||||
|
||||
void ColorCell::onHoverChanged() {
|
||||
Control::onHoverChanged();
|
||||
if (mVisual.refresh()) startTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void ColorCell::onTick(halnf deltaMs) {
|
||||
Control::onTick(deltaMs);
|
||||
if (!mVisual.advance(deltaMs) && !isTransitioning()) stopTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
ColorPickerButton::ColorPickerButton() {
|
||||
|
|
@ -99,14 +114,15 @@ void ColorPickerButton::buildNewColorPopup() {
|
|||
}
|
||||
|
||||
void ColorPickerButton::rebuildCells() {
|
||||
mGrid.removeAllChildren();
|
||||
// The grid owns the swatches; the add cell is a member and stays last.
|
||||
mGrid.clearOwnedChildren();
|
||||
mGrid.removeChild(mAddCell);
|
||||
mSwatches.clear();
|
||||
|
||||
for (const RGBA& color : mPalette) {
|
||||
auto cell = std::make_unique<ColorCell>(ColorCell::Kind::Swatch, color);
|
||||
cell->setOnActivate([this, color] { pick(color); });
|
||||
mGrid.addChild(*cell);
|
||||
mSwatches.push_back(std::move(cell));
|
||||
ColorCell& cell = mGrid.emplaceChild<ColorCell>(ColorCell::Kind::Swatch, color);
|
||||
cell.setOnActivate([this, color] { pick(color); });
|
||||
mSwatches.push_back(&cell);
|
||||
}
|
||||
mGrid.addChild(mAddCell);
|
||||
refreshSelection();
|
||||
|
|
@ -169,7 +185,7 @@ void ColorPickerButton::pick(const RGBA& color) {
|
|||
}
|
||||
|
||||
void ColorPickerButton::refreshSelection() {
|
||||
for (const std::unique_ptr<ColorCell>& cell : mSwatches)
|
||||
for (ColorCell* cell : mSwatches)
|
||||
cell->setSelected(mColor && *mColor == cell->color());
|
||||
}
|
||||
|
||||
|
|
|
|||
199
Widgets/src/components/views/private/ListRow.cpp
Normal file
199
Widgets/src/components/views/private/ListRow.cpp
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
#include "ListRow.hpp"
|
||||
|
||||
#include "Canvas.hpp"
|
||||
#include "ComponentSizes.hpp"
|
||||
#include "Theme.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kSubtitleGap = 2;
|
||||
constexpr halnf kChevronSide = 9;
|
||||
constexpr halnf kSwitchWidthFactor = 1.9f;
|
||||
constexpr halnf kSwitchKnobInset = 3;
|
||||
constexpr halnf kTrailingGap = 4;
|
||||
|
||||
halnf densityFactor(ListRow::Density density) {
|
||||
switch (density) {
|
||||
case ListRow::Density::Compact: return 1.0f;
|
||||
case ListRow::Density::Regular: return 1.25f;
|
||||
case ListRow::Density::Comfortable: return 1.6f;
|
||||
}
|
||||
return 1.0f;
|
||||
}
|
||||
}
|
||||
|
||||
ListRow::ListRow(std::string title) :
|
||||
mTitle(std::move(title)) {
|
||||
setDebugName("list row: " + mTitle);
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
}
|
||||
|
||||
void ListRow::setTitle(std::string title) {
|
||||
if (title == mTitle) return;
|
||||
mTitle = std::move(title);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void ListRow::setSubtitle(std::string subtitle) {
|
||||
if (subtitle == mSubtitle) return;
|
||||
mSubtitle = std::move(subtitle);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void ListRow::setLeading(Leading leading) {
|
||||
mLeading = std::move(leading);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void ListRow::setTrailing(Trailing trailing) {
|
||||
mTrailing = std::move(trailing);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void ListRow::setDensity(Density density) {
|
||||
if (density == mDensity) return;
|
||||
mDensity = density;
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void ListRow::setMuted(bool muted) {
|
||||
if (muted == mMuted) return;
|
||||
mMuted = muted;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void ListRow::setSelected(bool selected) {
|
||||
if (selected == mSelected) return;
|
||||
mSelected = selected;
|
||||
refreshVisualState();
|
||||
}
|
||||
|
||||
void ListRow::setHighlighted(bool highlighted) {
|
||||
if (highlighted == mHighlighted) return;
|
||||
mHighlighted = highlighted;
|
||||
refreshVisualState();
|
||||
}
|
||||
|
||||
halnf ListRow::heightFor(Density density, bool hasSubtitle) {
|
||||
const Metrics metrics;
|
||||
halnf height = sizes::menuItemHeight * densityFactor(density);
|
||||
if (hasSubtitle) height += metrics.lineHeight * 0.85f + kSubtitleGap;
|
||||
return height;
|
||||
}
|
||||
|
||||
halnf ListRow::leadingWidth() const {
|
||||
if (mLeading.kind == Leading::Kind::None) return 0;
|
||||
return theme().metrics.indicatorSize + theme().metrics.spacing;
|
||||
}
|
||||
|
||||
halnf ListRow::trailingWidth() const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
switch (mTrailing.kind) {
|
||||
case Trailing::Kind::None: return 0;
|
||||
case Trailing::Kind::Text: return textWidth(mTrailing.text, metrics.smallFontSize) + metrics.spacing * 4;
|
||||
case Trailing::Kind::Chevron: return kChevronSide + metrics.spacing * 2;
|
||||
case Trailing::Kind::Switch: return metrics.indicatorSize * kSwitchWidthFactor + metrics.spacing * 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
Extent ListRow::measureContent(Axis axis, halnf) const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
if (axis == Axis::Vertical) return Extent::rigid(heightFor(mDensity, !mSubtitle.empty()));
|
||||
|
||||
const halnf text = std::max(textWidth(mTitle, metrics.fontSize), textWidth(mSubtitle, metrics.smallFontSize));
|
||||
return Extent::natural(metrics.controlPadding.total(Axis::Horizontal) + leadingWidth() + text + trailingWidth());
|
||||
}
|
||||
|
||||
RGBA ListRow::backgroundTarget() const {
|
||||
const Palette& palette = theme().palette;
|
||||
const RGBA clear = Palette::faded(palette.menuHighlight, 0);
|
||||
if (!isEffectivelyEnabled()) return mSelected ? Palette::faded(palette.neutralSelection, 0.5f) : clear;
|
||||
if (isPressed()) return palette.controlPressed;
|
||||
if (mSelected) return palette.neutralSelection;
|
||||
if (mHighlighted || isHovered()) return palette.menuHighlight;
|
||||
return clear;
|
||||
}
|
||||
|
||||
void ListRow::paint(Canvas& painter) {
|
||||
const Theme& style = theme();
|
||||
const Metrics& metrics = style.metrics;
|
||||
const RectF area = localRect();
|
||||
painter.rect(area, background(), metrics.cornerRadius);
|
||||
if (showsFocusRing()) painter.outline(area, style.palette.focusRing, metrics.cornerRadius, 2);
|
||||
|
||||
const RectF content = metrics.controlPadding.shrink(area);
|
||||
RGBA titleColor = isEffectivelyEnabled() ? style.palette.text : style.palette.disabledText;
|
||||
if (mMuted && isEffectivelyEnabled()) titleColor = style.palette.mutedText;
|
||||
|
||||
const halnf leading = leadingWidth();
|
||||
if (leading > 0) {
|
||||
const RectF box = makeRect(content.pos.x, 0, metrics.indicatorSize, area.size.y);
|
||||
paintLeading(painter, box, titleColor);
|
||||
}
|
||||
|
||||
const halnf trailing = trailingWidth();
|
||||
const halnf textLeft = content.pos.x + leading;
|
||||
const halnf textWidth = std::max(0.0f, content.pos.x + content.size.x - trailing - textLeft);
|
||||
if (mSubtitle.empty()) {
|
||||
painter.text(mTitle, makeRect(textLeft, 0, textWidth, area.size.y), TextStyle{ metrics.fontSize, titleColor, TextAlign::leftCenter() });
|
||||
} else {
|
||||
// Title and subtitle stack around the middle of the row.
|
||||
const halnf subtitleHeight = metrics.lineHeight * 0.85f;
|
||||
const halnf top = (area.size.y - metrics.lineHeight - kSubtitleGap - subtitleHeight) / 2;
|
||||
painter.text(mTitle, makeRect(textLeft, top, textWidth, metrics.lineHeight), TextStyle{ metrics.fontSize, titleColor, TextAlign::leftCenter() });
|
||||
painter.text(
|
||||
mSubtitle,
|
||||
makeRect(textLeft, top + metrics.lineHeight + kSubtitleGap, textWidth, subtitleHeight),
|
||||
TextStyle{ metrics.smallFontSize, style.palette.mutedText, TextAlign::leftCenter() }
|
||||
);
|
||||
}
|
||||
|
||||
if (trailing > 0) paintTrailing(painter, makeRect(content.pos.x + content.size.x - trailing + kTrailingGap, 0, trailing - kTrailingGap, area.size.y));
|
||||
}
|
||||
|
||||
void ListRow::paintLeading(Canvas& painter, const RectF& box, const RGBA& color) const {
|
||||
const Theme& style = theme();
|
||||
const RectF square = glyphBox(box, style.metrics.indicatorSize);
|
||||
switch (mLeading.kind) {
|
||||
case Leading::Kind::None:
|
||||
case Leading::Kind::Space: return;
|
||||
case Leading::Kind::Glyph: paintGlyph(painter, mLeading.glyph, glyphBox(box, style.metrics.indicatorSize * 0.8f), style.palette.mutedText, style); return;
|
||||
case Leading::Kind::Check: paintGlyph(painter, Glyph::Check, glyphBox(box, style.metrics.indicatorSize * 0.7f), color, style); return;
|
||||
case Leading::Kind::Image: painter.drawImage(square, mLeading.image); return;
|
||||
case Leading::Kind::Swatch:
|
||||
painter.rect(glyphBox(box, style.metrics.indicatorSize * 0.85f), mLeading.color, style.metrics.indicatorCornerRadius);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void ListRow::paintTrailing(Canvas& painter, const RectF& box) const {
|
||||
const Theme& style = theme();
|
||||
const Metrics& metrics = style.metrics;
|
||||
const halnf middle = box.pos.y + box.size.y / 2;
|
||||
const halnf right = box.pos.x + box.size.x;
|
||||
|
||||
switch (mTrailing.kind) {
|
||||
case Trailing::Kind::None: return;
|
||||
case Trailing::Kind::Text:
|
||||
painter.text(mTrailing.text, box, TextStyle{ metrics.smallFontSize, style.palette.mutedText, TextAlign::rightCenter() });
|
||||
return;
|
||||
case Trailing::Kind::Chevron:
|
||||
paintGlyph(painter, Glyph::ChevronRight, makeRect(right - kChevronSide, middle - kChevronSide / 2, kChevronSide, kChevronSide), style.palette.mutedText, style);
|
||||
return;
|
||||
case Trailing::Kind::Switch:
|
||||
{
|
||||
const halnf height = metrics.indicatorSize;
|
||||
const halnf width = height * kSwitchWidthFactor;
|
||||
const RectF track = makeRect(right - width, middle - height / 2, width, height);
|
||||
painter.rect(track, mTrailing.on ? style.palette.toggleOn : style.palette.track, height / 2);
|
||||
const Vec2F knob = { mTrailing.on ? track.pos.x + width - height / 2 : track.pos.x + height / 2, middle };
|
||||
painter.circle(knob, height / 2 - kSwitchKnobInset, mTrailing.on ? style.palette.onToggle : style.palette.mutedText);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
192
Widgets/src/components/views/private/ListView.cpp
Normal file
192
Widgets/src/components/views/private/ListView.cpp
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
#include "ListView.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Event.hpp"
|
||||
#include "StackLayout.hpp"
|
||||
#include "Ui.hpp"
|
||||
|
||||
#include <numeric>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kRowGap = 1;
|
||||
constexpr halnf kListPadding = 4;
|
||||
|
||||
// Rows that are children of the list's column; their index is their position among the rows.
|
||||
ListRow* rowAround(const Widget* target, const Widget& column) {
|
||||
for (const Widget* current = target; current && current != &column; current = current->parent()) {
|
||||
if (current->parent() == &column) return dynamic_cast<ListRow*>(const_cast<Widget*>(current));
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
ListView::ListView(Mode mode) :
|
||||
mSelection(mode) {
|
||||
setDebugName("list view");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
emplaceLayout<StackLayout>();
|
||||
|
||||
mColumn.setDebugName("list rows");
|
||||
mColumn.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mColumn.emplaceLayout<ColumnLayout>(kRowGap).setPadding(Insets::uniform(kListPadding));
|
||||
mScroll.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mScroll.setContent(&mColumn);
|
||||
addChild(mScroll);
|
||||
|
||||
mEmpty.setTone(Label::Tone::Muted);
|
||||
mEmpty.setDebugName("list empty state");
|
||||
mEmpty.setVisible(false);
|
||||
addChild(mEmpty);
|
||||
|
||||
mSelection.setOnChanged([this]() {
|
||||
refreshRowStates();
|
||||
if (mOnSelectionChanged) mOnSelectionChanged();
|
||||
});
|
||||
}
|
||||
|
||||
void ListView::setFitsContent(bool fits) {
|
||||
setSizing(AxisSizing::fill(), fits ? AxisSizing::fit() : AxisSizing::fill());
|
||||
mScroll.setSizing(AxisSizing::fill(), fits ? AxisSizing::fit() : AxisSizing::fill());
|
||||
}
|
||||
|
||||
void ListView::setDensity(ListRow::Density density) {
|
||||
mDensity = density;
|
||||
for (ListRow* row : mRows)
|
||||
row->setDensity(density);
|
||||
}
|
||||
|
||||
void ListView::setModel(Model model) {
|
||||
mModel = std::move(model);
|
||||
// Items that no longer exist leave the selection.
|
||||
mSelection.retain([this](const size_t& index) { return index < mModel.count; });
|
||||
rebuildRows();
|
||||
}
|
||||
|
||||
void ListView::refresh() {
|
||||
for (size_t index = 0; index < mRows.size(); index++) {
|
||||
if (mModel.fill) mModel.fill(index, *mRows[index]);
|
||||
mRows[index]->setEnabled(!mModel.isEnabled || mModel.isEnabled(index));
|
||||
}
|
||||
refreshRowStates();
|
||||
}
|
||||
|
||||
void ListView::rebuildRows() {
|
||||
// Rows are reused: only the difference in count is created or destroyed.
|
||||
while (mRows.size() > mModel.count) {
|
||||
mColumn.removeChild(*mRows.back());
|
||||
mRows.pop_back();
|
||||
}
|
||||
while (mRows.size() < mModel.count) {
|
||||
ListRow& row = mColumn.emplaceChild<ListRow>();
|
||||
row.setDensity(mDensity);
|
||||
row.setOnClicked([this, &row](const Event& event) {
|
||||
for (size_t index = 0; index < mRows.size(); index++) {
|
||||
if (mRows[index] == &row) rowClicked(index, event);
|
||||
}
|
||||
});
|
||||
mRows.push_back(&row);
|
||||
}
|
||||
mEmpty.setVisible(mModel.count == 0);
|
||||
mScroll.setVisible(mModel.count > 0);
|
||||
refresh();
|
||||
}
|
||||
|
||||
void ListView::refreshRowStates() {
|
||||
for (size_t index = 0; index < mRows.size(); index++) {
|
||||
mRows[index]->setSelected(mSelection.mode() == Mode::Multiple ? mSelection.isSelected(index) : false);
|
||||
mRows[index]->setHighlighted(mSelection.current() == index);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<size_t> ListView::order() const {
|
||||
std::vector<size_t> indices(mModel.count);
|
||||
std::iota(indices.begin(), indices.end(), 0);
|
||||
return indices;
|
||||
}
|
||||
|
||||
void ListView::select(size_t index) {
|
||||
if (index >= mModel.count) return;
|
||||
mSelection.select(index);
|
||||
revealCurrent();
|
||||
}
|
||||
|
||||
void ListView::rowClicked(size_t index, const Event& event) {
|
||||
if (index >= mModel.count) return;
|
||||
if (mSelection.mode() == Mode::Multiple) mSelection.click(index, event.modifiers(), order());
|
||||
else mSelection.select(index);
|
||||
if (mActivatesOnClick || event.clickCount() >= 2) activate(index);
|
||||
}
|
||||
|
||||
void ListView::activate(size_t index) {
|
||||
if (index >= mModel.count || (mModel.isEnabled && !mModel.isEnabled(index))) return;
|
||||
if (mOnActivated) mOnActivated(index);
|
||||
}
|
||||
|
||||
void ListView::revealCurrent() {
|
||||
const std::optional<size_t> current = mSelection.current();
|
||||
if (!current || *current >= mRows.size()) return;
|
||||
if (ui()) ui()->updateLayout();
|
||||
const RectF rect = mRows[*current]->rect();
|
||||
mScroll.revealRange(rect.pos.y, rect.size.y);
|
||||
}
|
||||
|
||||
ListNavigator::Items ListView::navigationItems() const {
|
||||
ListNavigator::Items items;
|
||||
items.count = mModel.count;
|
||||
items.isAvailable = [this](size_t index) { return !mModel.isEnabled || mModel.isEnabled(index); };
|
||||
items.text = [this](size_t index) { return index < mRows.size() ? mRows[index]->title() : std::string(); };
|
||||
return items;
|
||||
}
|
||||
|
||||
size_t ListView::pageSize() const {
|
||||
const halnf rowHeight = ListRow::heightFor(mDensity, false) + kRowGap;
|
||||
return std::max<size_t>(1, (size_t) (mScroll.viewportLength() / rowHeight) - 1);
|
||||
}
|
||||
|
||||
bool ListView::handleKey(Event& event) {
|
||||
if (mModel.count == 0) return false;
|
||||
|
||||
if (event.type() == Event::Type::TextInput) {
|
||||
const halnf now = ui() ? ui()->clockMs() : 0;
|
||||
const std::optional<size_t> found = mNavigator.typeAhead(navigationItems(), current(), event.text(), now);
|
||||
if (!found) return false;
|
||||
select(*found);
|
||||
return true;
|
||||
}
|
||||
if (event.type() != Event::Type::KeyDown) return false;
|
||||
|
||||
const std::optional<Key> key = event.key();
|
||||
if (!key) return false;
|
||||
if ((*key == Key::Enter || *key == Key::KeypadEnter) && current()) {
|
||||
activate(*current());
|
||||
return true;
|
||||
}
|
||||
if (*key == Key::Space && current() && mSelection.mode() == Mode::Multiple) {
|
||||
mSelection.toggle(*current());
|
||||
return true;
|
||||
}
|
||||
|
||||
const std::optional<size_t> next = ListNavigator::step(navigationItems(), current(), *key, pageSize());
|
||||
if (!next) {
|
||||
// Arrows at an end are still the list's keys, so focus does not wander.
|
||||
return *key == Key::Up || *key == Key::Down || *key == Key::Home || *key == Key::End;
|
||||
}
|
||||
if (event.modifiers().shift && mSelection.mode() == Mode::Multiple) mSelection.extendTo(*next, order());
|
||||
else select(*next);
|
||||
revealCurrent();
|
||||
return true;
|
||||
}
|
||||
|
||||
void ListView::onEvent(Event& event) {
|
||||
if (handleKey(event)) event.accept();
|
||||
}
|
||||
|
||||
void ListView::filterDescendantEvent(Event& event) {
|
||||
// Keys reach the list even when a row has keyboard focus.
|
||||
if ((event.type() == Event::Type::KeyDown || event.type() == Event::Type::TextInput) && rowAround(event.target(), mColumn)) {
|
||||
if (handleKey(event)) event.accept();
|
||||
}
|
||||
}
|
||||
|
|
@ -9,75 +9,46 @@ using namespace tp::ui;
|
|||
|
||||
namespace {
|
||||
constexpr halnf kHeaderGap = 12;
|
||||
constexpr halnf kBodyGap = 16;
|
||||
constexpr halnf kForward = 1.0f;
|
||||
constexpr halnf kBackward = -0.3f;
|
||||
}
|
||||
|
||||
StackPage::StackPage(PageStack& stack, std::string title) :
|
||||
mStack(stack),
|
||||
mTitle(std::move(title)) {
|
||||
setDebugName("stack page: " + mTitle.text());
|
||||
TitledPanel(std::move(title), Surface::None),
|
||||
mStack(stack) {
|
||||
setDebugName("stack page: " + this->title());
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
emplaceLayout<ColumnLayout>(kHeaderGap).setCrossAlign(CrossAlign::Center);
|
||||
static_cast<ColumnLayout*>(layout())->setGap(kHeaderGap);
|
||||
static_cast<ColumnLayout*>(layout())->setCrossAlign(CrossAlign::Center);
|
||||
|
||||
mHeader.setDebugName("stack page header");
|
||||
mHeader.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mHeader.emplaceLayout<RowLayout>(kHeaderGap).setCrossAlign(CrossAlign::Center);
|
||||
// The header leads with the back button; the title sits at the right, across from it.
|
||||
header().setDebugName("stack page header");
|
||||
header().layout()->setPadding(Insets());
|
||||
static_cast<RowLayout*>(header().layout())->setGap(kHeaderGap);
|
||||
mBack.setStyle(Button::Style::Flat);
|
||||
mBack.setOnActivate([this]() { mStack.back(); });
|
||||
mTitle.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mTitle.setAlignment(TextAlign::rightCenter());
|
||||
mHeader.addChild(mBack);
|
||||
mHeader.addChild(mTitle);
|
||||
titleLabel().setAlignment(TextAlign::rightCenter());
|
||||
setLeadingHeader(&mBack);
|
||||
|
||||
mBody.setDebugName("stack page body");
|
||||
mBody.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mBody.emplaceLayout<ColumnLayout>(16).setPadding(Insets::symmetric(0, 4));
|
||||
mScroll.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mScroll.setContent(&mBody);
|
||||
|
||||
addChild(mHeader);
|
||||
addChild(mScroll);
|
||||
}
|
||||
|
||||
AxisSizing StackPage::horizontalSizing() const {
|
||||
return mMaximumWidth > 0 ? AxisSizing::fill().atMost(mMaximumWidth) : AxisSizing::fill();
|
||||
body().setDebugName("stack page body");
|
||||
setBodyPadding(Insets::symmetric(0, 4));
|
||||
setBodyGap(kBodyGap);
|
||||
setScrollsBody(true);
|
||||
}
|
||||
|
||||
void StackPage::setMaximumWidth(halnf width) {
|
||||
mMaximumWidth = width;
|
||||
mHeader.setSizing(horizontalSizing(), AxisSizing::fit());
|
||||
mScroll.setSizing(horizontalSizing(), AxisSizing::fill());
|
||||
if (!mScrollsContent) mBody.setSizing(horizontalSizing(), AxisSizing::fill());
|
||||
}
|
||||
|
||||
void StackPage::setScrollsContent(bool scrolls) {
|
||||
if (scrolls == mScrollsContent) return;
|
||||
mScrollsContent = scrolls;
|
||||
|
||||
if (scrolls) {
|
||||
removeChild(mBody);
|
||||
mBody.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mScroll.setContent(&mBody);
|
||||
addChild(mScroll);
|
||||
return;
|
||||
}
|
||||
|
||||
mScroll.setContent(nullptr);
|
||||
removeChild(mScroll);
|
||||
mBody.setSizing(horizontalSizing(), AxisSizing::fill());
|
||||
addChild(mBody);
|
||||
const AxisSizing horizontal = width > 0 ? AxisSizing::fill().atMost(width) : AxisSizing::fill();
|
||||
header().setSizing(horizontal, AxisSizing::fit());
|
||||
if (scrollsBody()) bodyScroller().setSizing(horizontal, AxisSizing::fill());
|
||||
else body().setSizing(horizontal, AxisSizing::fill());
|
||||
}
|
||||
|
||||
void StackPage::setHeaderContent(Widget* content) {
|
||||
mHeader.removeAllChildren();
|
||||
mHeader.addChild(mBack);
|
||||
mHeader.addChild(content ? *content : static_cast<Widget&>(mTitle));
|
||||
}
|
||||
|
||||
void StackPage::setContent(Widget& content) {
|
||||
mBody.removeAllChildren();
|
||||
mBody.addChild(content);
|
||||
if (mHeaderContent) removeHeaderAction(*mHeaderContent);
|
||||
mHeaderContent = content;
|
||||
titleLabel().setVisible(content == nullptr);
|
||||
if (content) addHeaderAction(*content);
|
||||
}
|
||||
|
||||
PageStack::PageStack(Surface surface) :
|
||||
|
|
@ -121,7 +92,7 @@ StackPage& PageStack::push(std::string title) {
|
|||
|
||||
StackPage& PageStack::push(std::string title, Widget& content) {
|
||||
StackPage& page = push(std::move(title));
|
||||
page.setContent(content);
|
||||
page.setContent(&content);
|
||||
return page;
|
||||
}
|
||||
|
||||
|
|
|
|||
135
Widgets/src/components/views/private/PropertyList.cpp
Normal file
135
Widgets/src/components/views/private/PropertyList.cpp
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
#include "PropertyList.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "StackLayout.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kRowGap = 6;
|
||||
constexpr ualni kColumns = 2;
|
||||
}
|
||||
|
||||
PropertyList::PropertyList() {
|
||||
setDebugName("property list");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
// Names in a column as wide as the longest name, editors in the rest.
|
||||
GridLayout& grid = emplaceLayout<GridLayout>(kColumns, kRowGap);
|
||||
grid.setColumnWidths({ AxisSizing::fit(), AxisSizing::fill() });
|
||||
}
|
||||
|
||||
void PropertyList::addName(const std::string& name, bool labelled) {
|
||||
// The name cell comes first in the row; controls that show their own name (a checkbox, a labelled slider) leave it
|
||||
// empty so the editor still lines up with the others.
|
||||
if (labelled) emplaceChild<Label>(name).setTone(Label::Tone::Muted);
|
||||
else emplaceChild<Spacer>(0.0f);
|
||||
}
|
||||
|
||||
Widget& PropertyList::bind(std::string name, Widget& editor, std::function<void()> pull) {
|
||||
editor.setSizing(AxisSizing::fill(), editor.sizing().vertical);
|
||||
mRows.push_back({ std::move(name), &editor, std::move(pull) });
|
||||
return editor;
|
||||
}
|
||||
|
||||
void PropertyList::edited() {
|
||||
if (mOnEdited) mOnEdited();
|
||||
}
|
||||
|
||||
Label& PropertyList::addValue(std::string name, TextGetter value) {
|
||||
addName(name, true);
|
||||
Label& label = emplaceChild<Label>();
|
||||
label.setWrap(true);
|
||||
bind(std::move(name), label, [&label, value = std::move(value)]() { label.setText(value()); });
|
||||
return label;
|
||||
}
|
||||
|
||||
LabeledSlider& PropertyList::addSlider(std::string name, halnf minimum, halnf maximum, NumberGetter get, NumberSetter set) {
|
||||
return addSlider(std::move(name), minimum, maximum, std::move(get), std::move(set), SliderOptions());
|
||||
}
|
||||
|
||||
LabeledSlider& PropertyList::addSlider(
|
||||
std::string name, halnf minimum, halnf maximum, NumberGetter get, NumberSetter set, SliderOptions options
|
||||
) {
|
||||
// The slider shows its own name and value above the track.
|
||||
addName(name, false);
|
||||
LabeledSlider& slider = emplaceChild<LabeledSlider>(name, minimum, maximum, minimum);
|
||||
if (options.format) slider.setValueFormatter(options.format);
|
||||
if (options.step > 0) slider.slider().setStep(options.step);
|
||||
slider.setScale(options.scale);
|
||||
slider.setOnChanged([this, set = std::move(set)](halnf value) {
|
||||
if (mPulling) return;
|
||||
set(value);
|
||||
edited();
|
||||
});
|
||||
bind(std::move(name), slider, [&slider, get = std::move(get)]() { slider.setValue(get()); });
|
||||
return slider;
|
||||
}
|
||||
|
||||
Checkbox& PropertyList::addCheckbox(std::string name, std::function<bool()> get, std::function<void(bool)> set) {
|
||||
addName(name, false);
|
||||
Checkbox& box = emplaceChild<Checkbox>(name);
|
||||
box.setOnChanged([this, set = std::move(set)](bool checked) {
|
||||
if (mPulling) return;
|
||||
set(checked);
|
||||
edited();
|
||||
});
|
||||
bind(std::move(name), box, [&box, get = std::move(get)]() { box.setChecked(get()); });
|
||||
return box;
|
||||
}
|
||||
|
||||
Dropdown& PropertyList::addChoice(
|
||||
std::string name, std::vector<std::string> options, std::function<long()> get, std::function<void(long)> set
|
||||
) {
|
||||
addName(name, true);
|
||||
Dropdown& choice = emplaceChild<Dropdown>(std::move(options));
|
||||
choice.setOnSelected([this, set = std::move(set)](long index) {
|
||||
if (mPulling) return;
|
||||
set(index);
|
||||
edited();
|
||||
});
|
||||
bind(std::move(name), choice, [&choice, get = std::move(get)]() { choice.setSelectedIndex(get()); });
|
||||
return choice;
|
||||
}
|
||||
|
||||
TextField& PropertyList::addText(std::string name, TextGetter get, std::function<void(const std::string&)> set) {
|
||||
addName(name, true);
|
||||
TextField& field = emplaceChild<TextField>();
|
||||
field.setOnCommitted([this, set = std::move(set)](const std::string& text) {
|
||||
if (mPulling) return;
|
||||
set(text);
|
||||
edited();
|
||||
});
|
||||
bind(std::move(name), field, [&field, get = std::move(get)]() {
|
||||
// A field being edited keeps what the user is typing.
|
||||
if (!field.isEditing()) field.setText(get());
|
||||
});
|
||||
return field;
|
||||
}
|
||||
|
||||
Widget& PropertyList::addCustom(std::string name, Widget& editor, std::function<void()> pull) {
|
||||
addName(name, true);
|
||||
addChild(editor);
|
||||
return bind(std::move(name), editor, std::move(pull));
|
||||
}
|
||||
|
||||
void PropertyList::addSeparator() {
|
||||
emplaceChild<Separator>();
|
||||
emplaceChild<Separator>();
|
||||
}
|
||||
|
||||
void PropertyList::refresh() {
|
||||
mPulling = true;
|
||||
for (const Row& row : mRows) {
|
||||
if (row.pull) row.pull();
|
||||
}
|
||||
mPulling = false;
|
||||
}
|
||||
|
||||
Widget* PropertyList::editor(const std::string& name) const {
|
||||
for (const Row& row : mRows) {
|
||||
if (row.name == name) return row.editor;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
#include "ReorderableList.hpp"
|
||||
#include "Canvas.hpp"
|
||||
|
||||
#include "Elevation.hpp"
|
||||
#include "Event.hpp"
|
||||
#include "Glyph.hpp"
|
||||
#include "Layout.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
#include "Viewport.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
|
@ -12,12 +16,12 @@ using namespace tp;
|
|||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kGripDotRadius = 1.3f;
|
||||
constexpr halnf kGripDotSpacing = 4;
|
||||
constexpr halnf kIndicatorThickness = 2;
|
||||
constexpr halnf kLiftedShadowOffset = 3;
|
||||
constexpr halnf kGripGlyphSize = 10;
|
||||
constexpr halnf kOutlineThickness = 3;
|
||||
}
|
||||
|
||||
// Places the items top to bottom. While an item is lifted, the others take the order the ReorderSession's slot gives
|
||||
// and the lifted one sits where the pointer holds it.
|
||||
class ReorderableList::ListLayout : public Layout {
|
||||
public:
|
||||
explicit ListLayout(ReorderableList& list) :
|
||||
|
|
@ -50,17 +54,18 @@ protected:
|
|||
std::vector<size_t> order(items.size());
|
||||
for (size_t index = 0; index < items.size(); index++)
|
||||
order[index] = index;
|
||||
if (mList.mDragging) {
|
||||
order.erase(order.begin() + (std::ptrdiff_t) mList.mDragged);
|
||||
order.insert(order.begin() + (std::ptrdiff_t) mList.mDrop, mList.mDragged);
|
||||
if (mList.isDragging()) {
|
||||
const size_t dragged = mList.mSession->origin().index;
|
||||
order.erase(order.begin() + (std::ptrdiff_t) dragged);
|
||||
order.insert(order.begin() + (std::ptrdiff_t) mList.mDrop, dragged);
|
||||
}
|
||||
|
||||
halnf y = area.pos.y;
|
||||
for (const size_t index : order) {
|
||||
Widget& item = *items[index];
|
||||
const halnf height = context.measure(item, Axis::Vertical, itemWidth).preferred;
|
||||
const halnf top = mList.mDragging && index == mList.mDragged ? mList.mDragY : y;
|
||||
context.place(item, makeRect(area.pos.x, top, itemWidth, height));
|
||||
const bool lifted = mList.isDragging() && index == mList.mSession->origin().index;
|
||||
context.place(item, makeRect(area.pos.x, lifted ? mList.mDragY : y, itemWidth, height));
|
||||
y += height + mList.mGap;
|
||||
}
|
||||
}
|
||||
|
|
@ -89,11 +94,8 @@ void ReorderableList::addItem(Widget& item) {
|
|||
|
||||
void ReorderableList::clearItems() {
|
||||
cancelDrag();
|
||||
if (mPressed || mHeld) {
|
||||
if (ui()) ui()->releasePointer(*this);
|
||||
mPressed = false;
|
||||
mHeld = false;
|
||||
}
|
||||
mPressedIndex.reset();
|
||||
mHeld = false;
|
||||
mHoveredGrip = -1;
|
||||
removeAllChildren();
|
||||
mItems.clear();
|
||||
|
|
@ -110,16 +112,22 @@ bool ReorderableList::isOnGrip(Vec2F localPoint, size_t index) const { return co
|
|||
|
||||
bool ReorderableList::hitTest(Vec2F localPoint) const { return contains(localRect(), localPoint); }
|
||||
|
||||
bool ReorderableList::acceptsDrag(PointerButton button) const {
|
||||
// In grip mode any row drags (vertically), in long-press mode only after the hold claims the drag.
|
||||
return button == PointerButton::Left && mPressedIndex && usesGrips();
|
||||
}
|
||||
|
||||
void ReorderableList::onEvent(Event& event) {
|
||||
if (event.type() == Event::Type::PointerMove && !mPressed && !mDragging) updateGripHover(event.position());
|
||||
handlePointer(event);
|
||||
if (event.type() == Event::Type::PointerMove && !mPressedIndex && !isDragging()) updateGripHover(event.position());
|
||||
handlePress(event);
|
||||
handleDrag(event);
|
||||
}
|
||||
|
||||
void ReorderableList::filterDescendantEvent(Event& event) {
|
||||
if (!mPressed && !mDragging && hasNestedListAround(event.target())) return;
|
||||
if (event.type() == Event::Type::PointerMove && !mPressed && !mDragging)
|
||||
if (!mPressedIndex && !isDragging() && hasNestedListAround(event.target())) return;
|
||||
if (event.type() == Event::Type::PointerMove && !mPressedIndex && !isDragging())
|
||||
updateGripHover(toLocal(event.windowPosition()));
|
||||
handlePointer(event);
|
||||
handlePress(event);
|
||||
}
|
||||
|
||||
bool ReorderableList::hasNestedListAround(const Widget* target) const {
|
||||
|
|
@ -143,81 +151,50 @@ void ReorderableList::updateGripHover(Vec2F localPoint) {
|
|||
requestPaint();
|
||||
}
|
||||
|
||||
void ReorderableList::handlePointer(Event& event) {
|
||||
void ReorderableList::handlePress(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
{
|
||||
if (event.button() != PointerButton::Left || mDragging) return;
|
||||
if (event.button() != PointerButton::Left || isDragging()) return;
|
||||
const Vec2F local = toLocal(event.windowPosition());
|
||||
const long index = itemIndexAt(local);
|
||||
if (index < 0) return;
|
||||
|
||||
mPressed = true;
|
||||
mHeld = false;
|
||||
// In grip mode a press drags once the router sees a vertical drag; otherwise a long press must lift the item.
|
||||
mPressedIndex = (size_t) index;
|
||||
mHeld = false;
|
||||
mPressLocalY = local.y;
|
||||
mPressedOnGrip = usesGrips() && isOnGrip(local, (size_t) index);
|
||||
mPressPoint = event.windowPosition();
|
||||
if (mPressedOnGrip) {
|
||||
if (ui()) ui()->capturePointer(*this);
|
||||
event.accept();
|
||||
}
|
||||
if (mPressedOnGrip) event.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
case Event::Type::LongPress:
|
||||
{
|
||||
if (!mPressed) return;
|
||||
const Vec2F offset = event.windowPosition() - mPressPoint;
|
||||
const bool moved = std::fabs(offset.x) >= kDragThreshold || std::fabs(offset.y) >= kDragThreshold;
|
||||
if (mPressedIndex >= mItems.size()) {
|
||||
mPressed = false;
|
||||
return;
|
||||
}
|
||||
if (!mDragging && mHeld && moved) {
|
||||
beginDrag(event.windowPosition());
|
||||
updateDrag(event.windowPosition());
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
if (!mDragging && !usesGrips()) return;
|
||||
if (!mDragging) {
|
||||
const bool vertical = std::fabs(offset.y) >= kDragThreshold && std::fabs(offset.y) >= std::fabs(offset.x);
|
||||
if (!vertical) return;
|
||||
beginDrag(event.windowPosition());
|
||||
}
|
||||
updateDrag(event.windowPosition());
|
||||
if (mDragMode == DragMode::Grip || !mPressedIndex || isDragging() || *mPressedIndex >= mItems.size()) return;
|
||||
if (mDragMode == DragMode::None && !mOnHeld) return;
|
||||
mHeld = true;
|
||||
// The held item, not the scroller around it, follows the pointer from here.
|
||||
if (mDragMode == DragMode::LongPress && ui()) ui()->claimDrag(*this);
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (event.button() != PointerButton::Left || !mPressed) return;
|
||||
mPressed = false;
|
||||
if (!mDragging) {
|
||||
if (mPressedOnGrip && ui()) ui()->releasePointer(*this);
|
||||
if (mHeld && ui()) ui()->releasePointer(*this);
|
||||
if (mHeld) {
|
||||
mHeld = false;
|
||||
event.accept();
|
||||
if (mOnHeld && mPressedIndex < mItems.size()) mOnHeld(mPressedIndex);
|
||||
}
|
||||
{
|
||||
if (event.button() != PointerButton::Left || isDragging()) return;
|
||||
const std::optional<size_t> pressed = mPressedIndex;
|
||||
const bool held = mHeld;
|
||||
mPressedIndex.reset();
|
||||
mHeld = false;
|
||||
if (!held || !pressed) return;
|
||||
event.accept();
|
||||
if (mOnHeld && *pressed < mItems.size()) mOnHeld(*pressed);
|
||||
return;
|
||||
}
|
||||
finishDrag();
|
||||
event.accept();
|
||||
return;
|
||||
|
||||
case Event::Type::LongPress:
|
||||
if (mDragMode == DragMode::Grip || !mPressed || mDragging || mPressedIndex >= mItems.size()) return;
|
||||
if (mDragMode == DragMode::None && !mOnHeld) return;
|
||||
mHeld = true;
|
||||
if (ui()) ui()->capturePointer(*this);
|
||||
event.accept();
|
||||
return;
|
||||
|
||||
case Event::Type::KeyDown:
|
||||
if (!mDragging || !event.isKey(Key::Escape)) return;
|
||||
if (!isDragging() || !event.isKey(Key::Escape)) return;
|
||||
cancelDrag();
|
||||
event.accept();
|
||||
return;
|
||||
|
|
@ -226,41 +203,117 @@ void ReorderableList::handlePointer(Event& event) {
|
|||
}
|
||||
}
|
||||
|
||||
void ReorderableList::beginDrag(Vec2F windowPoint) {
|
||||
mDragging = true;
|
||||
mHoveredGrip = -1;
|
||||
mDragged = mPressedIndex;
|
||||
mDrop = mPressedIndex;
|
||||
void ReorderableList::handleDrag(Event& event) {
|
||||
const Dragged* drag = event.drag();
|
||||
if (!drag || drag->button != PointerButton::Left) return;
|
||||
|
||||
const RectF item = mItems[mDragged]->targetRect();
|
||||
mGrabOffset = mPressLocalY - item.pos.y;
|
||||
switch (drag->phase) {
|
||||
case DragPhase::Started:
|
||||
if (!mPressedIndex || *mPressedIndex >= mItems.size()) {
|
||||
mPressedIndex.reset();
|
||||
return;
|
||||
}
|
||||
// Without grips, only a held item lifts: a plain press stays a click or a scroll.
|
||||
if (!usesGrips() && !mHeld) return;
|
||||
// In grip mode only vertical movement lifts, so a sideways swipe does not reorder.
|
||||
if (usesGrips() && std::fabs(drag->delta.y) < std::fabs(drag->delta.x)) {
|
||||
mPressedIndex.reset();
|
||||
if (ui()) ui()->cancelDrag();
|
||||
return;
|
||||
}
|
||||
beginDrag();
|
||||
updateDrag(event.windowPosition());
|
||||
break;
|
||||
case DragPhase::Moved:
|
||||
if (!isDragging()) return;
|
||||
updateDrag(event.windowPosition());
|
||||
break;
|
||||
case DragPhase::Finished:
|
||||
if (!isDragging()) return;
|
||||
finishDrag();
|
||||
break;
|
||||
case DragPhase::Cancelled:
|
||||
if (!isDragging()) return;
|
||||
cancelDrag();
|
||||
break;
|
||||
}
|
||||
event.accept();
|
||||
}
|
||||
|
||||
void ReorderableList::beginDrag() {
|
||||
const size_t dragged = *mPressedIndex;
|
||||
const RectF item = mItems[dragged]->targetRect();
|
||||
mSession.emplace(ReorderSlot{ 0, dragged }, Vec2F(0.0f, mPressLocalY - item.pos.y));
|
||||
mDrop = dragged;
|
||||
mHoveredGrip = -1;
|
||||
mDragY = item.pos.y;
|
||||
mListHeight = size().y;
|
||||
mItems[mDragged]->setGeometryAnimated(false);
|
||||
mItems[dragged]->setGeometryAnimated(false);
|
||||
|
||||
if (ui()) {
|
||||
ui()->capturePointer(*this);
|
||||
mFocusBeforeDrag = ui()->keyboardFocus();
|
||||
ui()->setKeyboardFocus(this);
|
||||
}
|
||||
setCursor(CursorShape::Grabbing);
|
||||
startTicking();
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
ReorderLane ReorderableList::laneWithoutDragged() const {
|
||||
// The other items where they were before the lift, in window coordinates; the session decides the slot from them.
|
||||
ReorderLane lane;
|
||||
lane.axis = Axis::Vertical;
|
||||
lane.area = windowRect();
|
||||
const Vec2F origin = windowPosition();
|
||||
halnf top = 0;
|
||||
for (size_t index = 0; index < mItems.size(); index++) {
|
||||
const halnf height = itemHeight(index);
|
||||
if (index != mSession->origin().index) lane.items.push_back(makeRect(origin.x, origin.y + top, size().x, height));
|
||||
top += height + mGap;
|
||||
}
|
||||
return lane;
|
||||
}
|
||||
|
||||
void ReorderableList::updateDrag(Vec2F windowPoint) {
|
||||
mPointer = windowPoint;
|
||||
const size_t dragged = mSession->origin().index;
|
||||
const halnf height = itemHeight(dragged);
|
||||
const halnf pointer = toLocal(windowPoint).y;
|
||||
const halnf limit = std::max(0.0f, mListHeight - itemHeight(mDragged));
|
||||
mDragY = std::clamp(pointer - mGrabOffset, 0.0f, limit);
|
||||
const halnf limit = std::max(0.0f, mListHeight - height);
|
||||
mDragY = std::clamp(pointer - mSession->grabOffset().y, 0.0f, limit);
|
||||
|
||||
// The session's lifted-rect rule: a neighbour makes room once the lifted item's edge passes its middle.
|
||||
const RectF lifted = makeRect(windowPosition() + Vec2F(0.0f, mDragY), { size().x, height });
|
||||
mSession->updateLifted(lifted, { laneWithoutDragged() });
|
||||
mDropTarget = dropTargetAt(pointer);
|
||||
mDrop = mDropTarget >= 0 ? mDragged : slotFor(mDragY, mDragY + itemHeight(mDragged));
|
||||
mDrop = mDropTarget >= 0 || !mSession->target() ? dragged : mSession->target()->index;
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void ReorderableList::onTick(halnf deltaMs) {
|
||||
if (!isDragging()) {
|
||||
stopTicking();
|
||||
return;
|
||||
}
|
||||
// Near the edge of the scroller around the list, keep scrolling so off-screen slots can be reached.
|
||||
if (Viewport* view = scroller()) {
|
||||
if (view->autoScrollAt(view->toLocal(mPointer), deltaMs)) updateDrag(mPointer);
|
||||
}
|
||||
}
|
||||
|
||||
Viewport* ReorderableList::scroller() const {
|
||||
for (Widget* current = parent(); current; current = current->parent()) {
|
||||
if (auto* view = dynamic_cast<Viewport*>(current)) return view;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void ReorderableList::finishDrag() {
|
||||
const size_t from = mDragged;
|
||||
const size_t from = mSession->origin().index;
|
||||
const size_t to = mDrop;
|
||||
const long target = mDropTarget;
|
||||
const RectF lifted = mItems[from]->rect();
|
||||
|
||||
endDragging();
|
||||
if (target >= 0) {
|
||||
|
|
@ -272,6 +325,8 @@ void ReorderableList::finishDrag() {
|
|||
Widget* moved = mItems[from];
|
||||
mItems.erase(mItems.begin() + (std::ptrdiff_t) from);
|
||||
mItems.insert(mItems.begin() + (std::ptrdiff_t) to, moved);
|
||||
// The item glides from where it was dropped into its slot, like a card landing on the board.
|
||||
moved->animateGeometryFrom(lifted);
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
|
||||
|
|
@ -279,25 +334,26 @@ void ReorderableList::finishDrag() {
|
|||
}
|
||||
|
||||
void ReorderableList::cancelDrag() {
|
||||
if (!mDragging) {
|
||||
mPressed = false;
|
||||
return;
|
||||
}
|
||||
mPressedIndex.reset();
|
||||
mHeld = false;
|
||||
if (!isDragging()) return;
|
||||
|
||||
mPressed = false;
|
||||
const RectF lifted = mItems[mSession->origin().index]->rect();
|
||||
Widget* item = mItems[mSession->origin().index];
|
||||
endDragging();
|
||||
item->animateGeometryFrom(lifted);
|
||||
if (ui() && ui()->isDragging()) ui()->cancelDrag();
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void ReorderableList::endDragging() {
|
||||
mDragging = false;
|
||||
mItems[mSession->origin().index]->setGeometryAnimated(true);
|
||||
mSession.reset();
|
||||
mPressedIndex.reset();
|
||||
mHeld = false;
|
||||
mDropTarget = -1;
|
||||
mItems[mDragged]->setGeometryAnimated(true);
|
||||
if (ui()) {
|
||||
ui()->releasePointer(*this);
|
||||
if (ui()->keyboardFocus() == this) ui()->setKeyboardFocus(mFocusBeforeDrag);
|
||||
}
|
||||
if (ui() && ui()->keyboardFocus() == this) ui()->setKeyboardFocus(mFocusBeforeDrag);
|
||||
mFocusBeforeDrag = nullptr;
|
||||
setCursor(CursorShape::Default);
|
||||
}
|
||||
|
|
@ -314,86 +370,59 @@ halnf ReorderableList::itemHeight(size_t index) const { return mItems[index]->ta
|
|||
|
||||
long ReorderableList::dropTargetAt(halnf pointerY) const {
|
||||
if (!mCanAcceptDrop) return -1;
|
||||
const size_t dragged = mSession->origin().index;
|
||||
for (size_t index = 0; index < mItems.size(); index++) {
|
||||
if (index == mDragged) continue;
|
||||
if (index == dragged) continue;
|
||||
const RectF rect = mItems[index]->targetRect();
|
||||
const halnf inset = rect.size.y * 0.25f;
|
||||
const bool overMiddle = pointerY >= rect.pos.y + inset && pointerY <= rect.pos.y + rect.size.y - inset;
|
||||
if (overMiddle && mCanAcceptDrop(mDragged, index)) return (long) index;
|
||||
if (overMiddle && mCanAcceptDrop(dragged, index)) return (long) index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t ReorderableList::slotFor(halnf draggedTop, halnf draggedBottom) const {
|
||||
size_t slot = mDragged;
|
||||
halnf top = 0;
|
||||
for (size_t index = 0; index < mItems.size(); index++) {
|
||||
const halnf height = itemHeight(index);
|
||||
const halnf center = top + height / 2;
|
||||
if (index < mDragged && draggedTop < center) slot--;
|
||||
if (index > mDragged && draggedBottom > center) slot++;
|
||||
top += height + mGap;
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
void ReorderableList::paint(Canvas& painter) {
|
||||
const Theme& style = theme();
|
||||
|
||||
for (size_t index = 0; index < mItems.size() && usesGrips(); index++) {
|
||||
if (mDragging && index == mDragged) continue;
|
||||
if (isDragging() && index == mSession->origin().index) continue;
|
||||
const RectF grip = gripRect(index);
|
||||
const bool highlighted = !mDragging && mHoveredGrip == (long) index;
|
||||
const bool highlighted = !isDragging() && mHoveredGrip == (long) index;
|
||||
if (highlighted) painter.rect(grip, style.palette.controlHovered, style.metrics.indicatorCornerRadius);
|
||||
paintGrip(painter, grip, highlighted ? style.palette.text : style.palette.disabledText);
|
||||
paintGlyph(
|
||||
painter, Glyph::Grip, glyphBox(grip, kGripGlyphSize), highlighted ? style.palette.text : style.palette.disabledText, style
|
||||
);
|
||||
}
|
||||
|
||||
if (!mDragging) return;
|
||||
if (!isDragging()) return;
|
||||
|
||||
if (mDropTarget >= 0) {
|
||||
const RectF target = mItems[(size_t) mDropTarget]->rect();
|
||||
painter.outline(target, style.palette.accent, style.metrics.cornerRadius, 3);
|
||||
painter.outline(target, style.palette.accent, style.metrics.cornerRadius, kOutlineThickness);
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t dragged = mSession->origin().index;
|
||||
halnf slotTop = 0;
|
||||
size_t slot = 0;
|
||||
for (size_t index = 0; index < mItems.size() && slot < mDrop; index++) {
|
||||
if (index == mDragged) continue;
|
||||
if (index == dragged) continue;
|
||||
slotTop += itemHeight(index) + mGap;
|
||||
slot++;
|
||||
}
|
||||
const halnf height = itemHeight(mDragged);
|
||||
painter.outline(
|
||||
makeRect(0, slotTop, size().x - gripWidth(), height), style.palette.focusRing, style.metrics.cornerRadius
|
||||
makeRect(0, slotTop, size().x - gripWidth(), itemHeight(dragged)), style.palette.focusRing, style.metrics.cornerRadius
|
||||
);
|
||||
}
|
||||
|
||||
const Widget* ReorderableList::raisedChild() const { return mDragging ? mItems[mDragged] : nullptr; }
|
||||
const Widget* ReorderableList::raisedChild() const { return isDragging() ? mItems[mSession->origin().index] : nullptr; }
|
||||
|
||||
void ReorderableList::paintRaisedBackdrop(Canvas& painter, const RectF& childRect) {
|
||||
// The lifted item has the same look as a lifted card: the theme's elevation shadow on a raised surface.
|
||||
const Theme& style = theme();
|
||||
const RectF lifted = makeRect(0, childRect.pos.y, size().x, childRect.size.y);
|
||||
|
||||
painter.rect(
|
||||
makeRect(lifted.pos.x, lifted.pos.y + kLiftedShadowOffset, lifted.size.x, lifted.size.y),
|
||||
style.palette.shadow,
|
||||
style.metrics.cornerRadius
|
||||
);
|
||||
paintElevationShadow(painter, lifted, style.metrics.cornerRadius, style);
|
||||
painter.rect(lifted, style.palette.raisedSurface, style.metrics.cornerRadius);
|
||||
painter.outline(lifted, style.palette.controlBorder, style.metrics.cornerRadius);
|
||||
if (usesGrips()) paintGrip(painter, gripRectFor(lifted), style.palette.text);
|
||||
}
|
||||
|
||||
void ReorderableList::paintGrip(Canvas& painter, const RectF& row, const RGBA& color) const {
|
||||
const Vec2F center = row.center();
|
||||
for (int line = -1; line <= 1; line++) {
|
||||
for (int column = 0; column < 2; column++) {
|
||||
painter.circle(
|
||||
{ center.x + ((halnf) column - 0.5f) * kGripDotSpacing, center.y + (halnf) line * kGripDotSpacing },
|
||||
kGripDotRadius,
|
||||
color
|
||||
);
|
||||
}
|
||||
}
|
||||
if (usesGrips()) paintGlyph(painter, Glyph::Grip, glyphBox(gripRectFor(lifted), kGripGlyphSize), style.palette.text, style);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,58 +10,6 @@
|
|||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kRowPadding = 8;
|
||||
constexpr halnf kDetailGap = 2;
|
||||
}
|
||||
|
||||
SearchResultRow::SearchResultRow(SearchField& field, size_t index, SearchResult result) :
|
||||
mField(field),
|
||||
mIndex(index),
|
||||
mResult(std::move(result)) {
|
||||
setDebugName("search result: " + mResult.title);
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
setEnabled(mResult.enabled);
|
||||
}
|
||||
|
||||
void SearchResultRow::setHighlighted(bool highlighted) {
|
||||
if (highlighted == mHighlighted) return;
|
||||
mHighlighted = highlighted;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
Extent SearchResultRow::measureContent(Axis axis, halnf) const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
if (axis == Axis::Vertical) {
|
||||
halnf height = metrics.lineHeight + kRowPadding;
|
||||
if (!mResult.detail.empty()) height += metrics.lineHeight * 0.85f + kDetailGap;
|
||||
return Extent::rigid(height);
|
||||
}
|
||||
const halnf widest =
|
||||
std::max(textWidth(mResult.title, metrics.fontSize), textWidth(mResult.detail, metrics.smallFontSize));
|
||||
return Extent::natural(widest + kRowPadding * 2);
|
||||
}
|
||||
|
||||
void SearchResultRow::paint(Canvas& painter) {
|
||||
const Theme& style = theme();
|
||||
const Metrics& metrics = style.metrics;
|
||||
painter.rect(localRect(), background(), metrics.cornerRadius);
|
||||
|
||||
const RGBA titleColor = isEffectivelyEnabled() ? style.palette.text : style.palette.disabledText;
|
||||
const halnf width = size().x - kRowPadding * 2;
|
||||
painter.text(mResult.title, makeRect(kRowPadding, kRowPadding / 2, width, metrics.lineHeight), TextStyle{ metrics.fontSize, titleColor, TextAlign::leftCenter() });
|
||||
if (mResult.detail.empty()) return;
|
||||
|
||||
painter.text(mResult.detail, makeRect(kRowPadding, kRowPadding / 2 + metrics.lineHeight + kDetailGap, width, metrics.lineHeight), TextStyle{ metrics.smallFontSize, style.palette.mutedText, TextAlign::leftCenter() });
|
||||
}
|
||||
|
||||
void SearchResultRow::onActivated() { mField.choose(mIndex); }
|
||||
|
||||
RGBA SearchResultRow::backgroundTarget() const {
|
||||
if (mHighlighted && isEffectivelyEnabled()) return theme().palette.menuHighlight;
|
||||
return Control::backgroundTarget();
|
||||
}
|
||||
|
||||
SearchField::SearchField() {
|
||||
setDebugName("search field");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
|
|
@ -70,7 +18,12 @@ SearchField::SearchField() {
|
|||
|
||||
mDropdown.setDebugName("search results");
|
||||
mDropdown.setBorder(true);
|
||||
mDropdown.emplaceLayout<ColumnLayout>(0).setPadding(Insets::uniform(4));
|
||||
mDropdown.emplaceLayout<StackLayout>();
|
||||
mList.setFitsContent(true);
|
||||
mList.setShowsScrollBar(false);
|
||||
mList.setActivatesOnClick(true);
|
||||
mList.setOnActivated([this](size_t index) { choose(index); });
|
||||
mDropdown.addChild(mList);
|
||||
mInput.setOnEdited([this](const std::string&) { refreshResults(); });
|
||||
mInput.setOnEditingStarted([this]() { refreshResults(); });
|
||||
}
|
||||
|
|
@ -89,25 +42,31 @@ void SearchField::focus() {
|
|||
}
|
||||
|
||||
void SearchField::refreshResults() {
|
||||
std::vector<SearchResult> results = mQuery ? mQuery(mInput.text()) : std::vector<SearchResult>{};
|
||||
if (results.size() > mMaximumResults) results.resize(mMaximumResults);
|
||||
mResults = mQuery ? mQuery(mInput.text()) : std::vector<SearchResult>{};
|
||||
if (mResults.size() > mMaximumResults) mResults.resize(mMaximumResults);
|
||||
|
||||
mDropdown.removeAllChildren();
|
||||
mRows.clear();
|
||||
for (size_t index = 0; index < results.size(); index++) {
|
||||
mRows.push_back(std::make_unique<SearchResultRow>(*this, index, std::move(results[index])));
|
||||
mDropdown.addChild(*mRows.back());
|
||||
ListView::Model model;
|
||||
model.count = mResults.size();
|
||||
model.fill = [this](size_t index, ListRow& row) {
|
||||
row.setTitle(mResults[index].title);
|
||||
row.setSubtitle(mResults[index].detail);
|
||||
};
|
||||
model.isEnabled = [this](size_t index) { return mResults[index].enabled; };
|
||||
mList.setModel(std::move(model));
|
||||
// The first result that can be chosen is current, so Enter chooses it at once.
|
||||
mList.clearSelection();
|
||||
for (size_t index = 0; index < mResults.size(); index++) {
|
||||
if (!mResults[index].enabled) continue;
|
||||
mList.select(index);
|
||||
break;
|
||||
}
|
||||
mHighlighted = nextEnabled(-1, 1);
|
||||
for (size_t index = 0; index < mRows.size(); index++)
|
||||
mRows[index]->setHighlighted(long(index) == mHighlighted);
|
||||
|
||||
if (mRows.empty()) hideResults();
|
||||
if (mResults.empty()) hideResults();
|
||||
else showResults();
|
||||
}
|
||||
|
||||
void SearchField::showResults() {
|
||||
if (mRows.empty() || !ui()) return;
|
||||
if (mResults.empty() || !ui()) return;
|
||||
|
||||
ui()->updateLayout();
|
||||
const halnf width = std::max(windowRect().size.x, mMinimumResultsWidth);
|
||||
|
|
@ -131,24 +90,8 @@ void SearchField::hideResults() {
|
|||
if (isShowingResults()) ui()->closePopup(mDropdown);
|
||||
}
|
||||
|
||||
long SearchField::nextEnabled(long from, int step) const {
|
||||
for (long index = from + step; index >= 0 && index < long(mRows.size()); index += step) {
|
||||
if (mRows[size_t(index)]->result().enabled) return index;
|
||||
}
|
||||
return from >= 0 && from < long(mRows.size()) ? from : -1;
|
||||
}
|
||||
|
||||
void SearchField::moveHighlight(int step) {
|
||||
const long next = nextEnabled(mHighlighted, step);
|
||||
if (next < 0) return;
|
||||
|
||||
if (mHighlighted >= 0) mRows[size_t(mHighlighted)]->setHighlighted(false);
|
||||
mHighlighted = next;
|
||||
mRows[size_t(mHighlighted)]->setHighlighted(true);
|
||||
}
|
||||
|
||||
void SearchField::choose(size_t index) {
|
||||
if (index >= mRows.size() || !mRows[index]->result().enabled) return;
|
||||
if (index >= mResults.size() || !mResults[index].enabled) return;
|
||||
|
||||
hideResults();
|
||||
if (mOnChosen) mOnChosen(index);
|
||||
|
|
@ -157,18 +100,13 @@ void SearchField::choose(size_t index) {
|
|||
void SearchField::filterDescendantEvent(Event& event) {
|
||||
if (event.type() != Event::Type::KeyDown || !isShowingResults()) return;
|
||||
|
||||
const std::optional<Key> pressed = event.key();
|
||||
if (!pressed) return;
|
||||
|
||||
switch (*pressed) {
|
||||
case Key::Down: moveHighlight(1); break;
|
||||
case Key::Up: moveHighlight(-1); break;
|
||||
case Key::Enter:
|
||||
case Key::KeypadEnter:
|
||||
if (mHighlighted >= 0) choose(size_t(mHighlighted));
|
||||
break;
|
||||
case Key::Escape: hideResults(); break;
|
||||
default: return;
|
||||
// The field keeps the keyboard; the results list moves and chooses with the same keys as any list.
|
||||
if (event.isKey(Key::Escape)) {
|
||||
hideResults();
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
event.accept();
|
||||
const bool listKey = event.isKey(Key::Up) || event.isKey(Key::Down) || event.isKey(Key::Enter) ||
|
||||
event.isKey(Key::KeypadEnter) || event.isKey(Key::PageUp) || event.isKey(Key::PageDown);
|
||||
if (listKey && mList.handleKey(event)) event.accept();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,7 @@ using namespace tp;
|
|||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kRowHeightFactor = 1.6f;
|
||||
constexpr halnf kChevronWidth = 24;
|
||||
constexpr halnf kPageWidth = 760;
|
||||
constexpr halnf kSwitchWidthFactor = 1.9f;
|
||||
constexpr halnf kPagePadding = 16;
|
||||
|
||||
class SettingsSection : public Panel {
|
||||
|
|
@ -33,68 +30,20 @@ SettingsRow::SettingsRow(SettingsView& view, Menu& menu, const MenuItem& item) :
|
|||
mMenu(menu),
|
||||
mEntry(item.entry()) {
|
||||
setDebugName("settings row: " + mEntry.text);
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
setDensity(Density::Comfortable);
|
||||
setMuted(mEntry.muted);
|
||||
refresh();
|
||||
}
|
||||
|
||||
void SettingsRow::refresh() {
|
||||
if (mEntry.isEnabled) setEnabled(mEntry.isEnabled());
|
||||
std::string text = mEntry.dynamicText ? mEntry.dynamicText() : mEntry.text;
|
||||
if (text != mText) {
|
||||
mText = std::move(text);
|
||||
requestLayout();
|
||||
}
|
||||
setTitle(mEntry.dynamicText ? mEntry.dynamicText() : mEntry.text);
|
||||
if (mEntry.kind == MenuEntry::Kind::Submenu) setTrailing(Trailing::chevron());
|
||||
else if (mEntry.kind == MenuEntry::Kind::Toggle) setTrailing(Trailing::toggle(mEntry.isChecked && mEntry.isChecked()));
|
||||
else if (!mEntry.shortcut.empty()) setTrailing(Trailing::textOf(mEntry.shortcut));
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
Extent SettingsRow::measureContent(Axis axis, halnf) const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
if (axis == Axis::Vertical) return Extent::rigid(sizes::menuItemHeight * kRowHeightFactor);
|
||||
return Extent::natural(
|
||||
metrics.controlPadding.total(Axis::Horizontal) + textWidth(mText, metrics.fontSize) + kChevronWidth * 2
|
||||
);
|
||||
}
|
||||
|
||||
void SettingsRow::paint(Canvas& painter) {
|
||||
const Theme& style = theme();
|
||||
const RectF content = style.metrics.controlPadding.shrink(localRect());
|
||||
RGBA textColor = isEffectivelyEnabled() ? style.palette.text : style.palette.disabledText;
|
||||
if (mEntry.muted && isEffectivelyEnabled()) textColor = style.palette.mutedText;
|
||||
|
||||
painter.rect(localRect(), background(), style.metrics.cornerRadius);
|
||||
painter.text(
|
||||
mText, makeRect(content.pos.x, 0, content.size.x, size().y), TextStyle{ style.metrics.fontSize, textColor, TextAlign::leftCenter() }
|
||||
);
|
||||
|
||||
const halnf middle = size().y / 2;
|
||||
const halnf right = content.pos.x + content.size.x;
|
||||
if (mEntry.kind == MenuEntry::Kind::Submenu) {
|
||||
const Vec2F tip = { right - 4, middle };
|
||||
painter.polyline({ tip + Vec2F(-5.0f, -7.0f), tip, tip + Vec2F(-5.0f, 7.0f) }, style.palette.mutedText, 2);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mEntry.kind == MenuEntry::Kind::Toggle) {
|
||||
const bool on = mEntry.isChecked && mEntry.isChecked();
|
||||
const halnf height = style.metrics.indicatorSize;
|
||||
const halnf width = height * kSwitchWidthFactor;
|
||||
const RectF track = makeRect(right - width, middle - height / 2, width, height);
|
||||
painter.rect(track, on ? style.palette.toggleOn : style.palette.track, height / 2);
|
||||
const halnf knob = height / 2 - 3;
|
||||
const Vec2F center = { on ? track.pos.x + width - height / 2 : track.pos.x + height / 2, middle };
|
||||
painter.circle(center, knob, on ? style.palette.onToggle : style.palette.mutedText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mEntry.shortcut.empty()) {
|
||||
painter.text(
|
||||
mEntry.shortcut,
|
||||
makeRect(content.pos.x, 0, content.size.x, size().y),
|
||||
TextStyle{ style.metrics.smallFontSize, style.palette.mutedText, TextAlign::rightCenter() }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsRow::onActivated() { mView.activated(mMenu, mEntry); }
|
||||
|
||||
SettingsPage::SettingsPage(SettingsView& view, std::string title) :
|
||||
|
|
@ -104,15 +53,14 @@ SettingsPage::SettingsPage(SettingsView& view, std::string title) :
|
|||
}
|
||||
|
||||
SettingsRow* SettingsPage::row(const std::string& text) const {
|
||||
for (const auto& row : mRows)
|
||||
if (row->text() == text) return row.get();
|
||||
for (SettingsRow* row : mRows)
|
||||
if (row->text() == text) return row;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void SettingsPage::clear() {
|
||||
// Removing the body's children destroys the sections it owns, and their rows with them.
|
||||
clearContent();
|
||||
for (const auto& section : mSections)
|
||||
section->removeAllChildren();
|
||||
mRows.clear();
|
||||
mSections.clear();
|
||||
mMenu = nullptr;
|
||||
|
|
@ -130,12 +78,10 @@ void SettingsPage::showMenu(Menu& menu) {
|
|||
continue;
|
||||
}
|
||||
if (!section) {
|
||||
mSections.push_back(std::make_unique<SettingsSection>());
|
||||
section = mSections.back().get();
|
||||
body().addChild(*section);
|
||||
section = &body().emplaceChild<SettingsSection>();
|
||||
mSections.push_back(section);
|
||||
}
|
||||
mRows.push_back(std::make_unique<SettingsRow>(mView, menu, *item));
|
||||
section->addChild(*mRows.back());
|
||||
mRows.push_back(§ion->emplaceChild<SettingsRow>(mView, menu, *item));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +95,7 @@ void SettingsPage::refresh() {
|
|||
}
|
||||
|
||||
void SettingsPage::refreshRows() {
|
||||
for (const auto& row : mRows)
|
||||
for (SettingsRow* row : mRows)
|
||||
row->refresh();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ using namespace tp;
|
|||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
// Room left beside a revealed tab so the neighbouring tab peeks in and shows the strip scrolls further.
|
||||
constexpr halnf kTabRevealMargin = 24;
|
||||
constexpr halnf kTabBodyGap = 12;
|
||||
constexpr halnf kCarouselBodyGap = 6;
|
||||
constexpr halnf kCarouselHeaderInset = 6;
|
||||
|
|
@ -227,6 +229,13 @@ TabView::TabView(Style style) :
|
|||
else mTabBar = std::make_unique<Panel>(Panel::Surface::None);
|
||||
mTabBar->setSizing(AxisSizing::fit(), AxisSizing::fit());
|
||||
mTabBar->emplaceLayout<RowLayout>(2).setPadding(Insets::symmetric(4, 2));
|
||||
if (style == Style::Carousel) {
|
||||
mStrip.setButtonFactory([](std::string text) { return std::make_unique<CarouselTabButton>(std::move(text)); });
|
||||
}
|
||||
mStrip.setOnIndexChanged([this]() {
|
||||
if (mStrip.selectedIndex()) setCurrentIndex(*mStrip.selectedIndex());
|
||||
});
|
||||
mTabBar->addChild(mStrip);
|
||||
mTabScroller.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mTabScroller.setWheelStep(40);
|
||||
mTabScroller.setShowsScrollBar(false);
|
||||
|
|
@ -245,6 +254,9 @@ void TabView::setTabAlignment(TabAlignment alignment) {
|
|||
mTabAlignment = alignment;
|
||||
auto* row = dynamic_cast<RowLayout*>(mTabBar->layout());
|
||||
if (row) row->setMainAlign(alignment == TabAlignment::Center ? MainAlign::Center : MainAlign::Start);
|
||||
auto* strip = dynamic_cast<RowLayout*>(mStrip.layout());
|
||||
if (strip) strip->setMainAlign(alignment == TabAlignment::Center ? MainAlign::Center : MainAlign::Start);
|
||||
mStrip.setSizing(alignment == TabAlignment::Center ? AxisSizing::fill() : AxisSizing::fit(), AxisSizing::fit());
|
||||
mTabBar->setSizing(alignment == TabAlignment::Center ? AxisSizing::fill() : AxisSizing::fit(), AxisSizing::fit());
|
||||
}
|
||||
|
||||
|
|
@ -274,20 +286,8 @@ void TabView::buildCarouselHeader() {
|
|||
addChild(mHeader);
|
||||
}
|
||||
|
||||
std::unique_ptr<Button> TabView::makeTabButton(std::string title, size_t index) {
|
||||
auto activate = [this, index]() { setCurrentIndex(index); };
|
||||
std::unique_ptr<Button> button;
|
||||
if (mStyle == Style::Carousel) button = std::make_unique<CarouselTabButton>(std::move(title), activate);
|
||||
else button = std::make_unique<Button>(std::move(title), activate);
|
||||
button->setStyle(Button::Style::Tab);
|
||||
return button;
|
||||
}
|
||||
|
||||
void TabView::addTab(std::string title, Widget& page) {
|
||||
const size_t index = mTabs.size();
|
||||
|
||||
std::unique_ptr<Button> button = makeTabButton(std::move(title), index);
|
||||
mTabBar->addChild(*button);
|
||||
mStrip.addSegment(std::move(title), mTabs.size());
|
||||
|
||||
std::unique_ptr<Widget> slot;
|
||||
if (mStyle == Style::Carousel) {
|
||||
|
|
@ -299,7 +299,7 @@ void TabView::addTab(std::string title, Widget& page) {
|
|||
slot->addChild(page);
|
||||
}
|
||||
|
||||
mTabs.push_back({ std::move(button), &page, std::move(slot) });
|
||||
mTabs.push_back({ &page, std::move(slot) });
|
||||
Widget& pageHolder = holder(mTabs.back());
|
||||
pageHolder.setVisible(false);
|
||||
mPageArea.addChild(pageHolder);
|
||||
|
|
@ -314,16 +314,14 @@ void TabView::removeTab(Widget& page) {
|
|||
|
||||
finishTransitions();
|
||||
const long removedIndex = (long) (position - mTabs.begin());
|
||||
mTabBar->removeChild(*position->button);
|
||||
mStrip.removeSegmentAt((size_t) removedIndex);
|
||||
for (size_t index = 0; index < mStrip.segmentCount(); index++)
|
||||
mStrip.setSegmentValue(index, index);
|
||||
mPageArea.removeChild(holder(*position));
|
||||
if (position->slot) position->slot->removeChild(page);
|
||||
page.setVisible(true);
|
||||
mTabs.erase(position);
|
||||
|
||||
for (size_t index = 0; index < mTabs.size(); index++) {
|
||||
mTabs[index].button->setOnActivate([this, index]() { setCurrentIndex(index); });
|
||||
}
|
||||
|
||||
if (mTabs.empty()) {
|
||||
mCurrent = -1;
|
||||
return;
|
||||
|
|
@ -336,13 +334,9 @@ void TabView::removeTab(Widget& page) {
|
|||
|
||||
void TabView::revealTab(size_t index) {
|
||||
if (index >= mTabs.size() || !mTabScroller.isScrollable()) return;
|
||||
const RectF tab = mTabs[index].button->rect();
|
||||
const halnf view = mTabScroller.viewportLength();
|
||||
const halnf offset = mTabScroller.offset();
|
||||
const halnf start = tab.pos.x;
|
||||
const halnf margin = 24;
|
||||
if (start - margin < offset) mTabScroller.scrollTo(start - margin);
|
||||
else if (start + tab.size.x + margin > offset + view) mTabScroller.scrollTo(start + tab.size.x + margin - view);
|
||||
// The tab's rect in the tab bar, which is the scroller's content.
|
||||
const RectF tab = translated(mStrip.segment(index).rect(), mStrip.rect().pos);
|
||||
mTabScroller.revealRange(tab.pos.x, tab.size.x, kTabRevealMargin);
|
||||
}
|
||||
|
||||
void TabView::setCurrentIndex(size_t index) {
|
||||
|
|
@ -419,8 +413,7 @@ void TabView::currentChanged(size_t index) {
|
|||
if ((long) index == mCurrent) return;
|
||||
|
||||
mCurrent = (long) index;
|
||||
for (size_t tab = 0; tab < mTabs.size(); tab++)
|
||||
mTabs[tab].button->setSelected(tab == index);
|
||||
mStrip.selectIndex(index);
|
||||
revealTab(index);
|
||||
|
||||
if (mOnChanged) mOnChanged(index);
|
||||
|
|
@ -450,8 +443,8 @@ std::vector<TabView::Indicator> TabView::indicators() const {
|
|||
const halnf fraction = position - base;
|
||||
const size_t from = cyclicIndex((long) base);
|
||||
const size_t to = cyclicIndex((long) base + 1);
|
||||
const RectF fromRect = mTabs[from].button->rect();
|
||||
const RectF toRect = mTabs[to].button->rect();
|
||||
const RectF fromRect = translated(mStrip.segment(from).rect(), mStrip.rect().pos);
|
||||
const RectF toRect = translated(mStrip.segment(to).rect(), mStrip.rect().pos);
|
||||
|
||||
if (fraction <= kPositionEpsilon) return { { fromRect, 1 } };
|
||||
if (fraction >= 1 - kPositionEpsilon) return { { toRect, 1 } };
|
||||
|
|
@ -512,9 +505,9 @@ bool TabView::handleWheel(const Event& event) {
|
|||
if (amount == 0 && event.modifiers().shift) amount = event.scrollDelta().y;
|
||||
if (amount == 0) return false;
|
||||
|
||||
const bool mouseNotch = amount == std::round(amount);
|
||||
if (mouseNotch) scrollBy(-std::lround(amount));
|
||||
else pull(-amount * kPagesPerTrackpadUnit);
|
||||
// A touchpad scrolls continuously and pulls the pages along; a wheel notch turns whole pages.
|
||||
if (event.isTouchpadScroll()) pull(-amount * kPagesPerTrackpadUnit);
|
||||
else scrollBy(amount < 0 ? 1 : -1);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -535,34 +528,33 @@ RGBA ColorWheel::color() const {
|
|||
|
||||
Extent ColorWheel::measureContent(Axis, halnf) const { return { 80, 180, kUnbounded, 0 }; }
|
||||
|
||||
void ColorWheel::onEvent(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
if (event.button() != PointerButton::Left) return;
|
||||
mDragTarget = targetAt(event.position());
|
||||
if (mDragTarget == DragTarget::None) return;
|
||||
ui()->capturePointer(*this);
|
||||
pickAt(event.position());
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (mDragTarget == DragTarget::None) return;
|
||||
pickAt(event.position());
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (mDragTarget == DragTarget::None) return;
|
||||
mDragTarget = DragTarget::None;
|
||||
ui()->releasePointer(*this);
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
default: return;
|
||||
namespace {
|
||||
// The pick interaction of the color pickers: a press picks where it lands (if it lands on a part), a router drag
|
||||
// keeps picking, and the release ends it. Returns whether the event was handled.
|
||||
template <typename Target, typename TargetAt, typename Pick>
|
||||
bool followPick(Event& event, Target& target, TargetAt targetAt, Pick pick) {
|
||||
if (event.type() == Event::Type::PointerDown && event.button() == PointerButton::Left) {
|
||||
target = targetAt(event.position());
|
||||
if (target == Target::None) return false;
|
||||
pick(event.position());
|
||||
return true;
|
||||
}
|
||||
const Dragged* drag = event.drag();
|
||||
if (!drag || target == Target::None) return false;
|
||||
if (drag->phase == DragPhase::Started || drag->phase == DragPhase::Moved) pick(event.position());
|
||||
else target = Target::None;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void ColorWheel::onEvent(Event& event) {
|
||||
const bool handled = followPick(
|
||||
event, mDragTarget, [this](Vec2F point) { return targetAt(point); }, [this](Vec2F point) { pickAt(point); }
|
||||
);
|
||||
if (event.type() == Event::Type::PointerUp && !(ui() && ui()->isDragging())) mDragTarget = DragTarget::None;
|
||||
if (handled) event.accept();
|
||||
}
|
||||
|
||||
void ColorWheel::paint(Canvas& painter) {
|
||||
constexpr int4 kHueSegments = 6;
|
||||
const RectF rect = wheelRect();
|
||||
|
|
@ -662,35 +654,20 @@ Vec2F ColorSquare::windowPointForHue(halnf hueRadians) const {
|
|||
return windowRect().pos + strip.pos + Vec2F{ strip.size.x * hueRadians / kTwoPi, strip.size.y / 2 };
|
||||
}
|
||||
|
||||
void ColorSquare::onEvent(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
if (event.button() != PointerButton::Left) return;
|
||||
if (contains(makeRect(squareRect().pos - Vec2F{ 1, 1 }, squareRect().size + Vec2F{ 2, 2 }), event.position()))
|
||||
mDragTarget = DragTarget::Square;
|
||||
else if (contains(makeRect(hueRect().pos - Vec2F{ 1, 1 }, hueRect().size + Vec2F{ 2, 2 }), event.position()))
|
||||
mDragTarget = DragTarget::Hue;
|
||||
else return;
|
||||
ui()->capturePointer(*this);
|
||||
pickAt(event.position());
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (mDragTarget == DragTarget::None) return;
|
||||
pickAt(event.position());
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (mDragTarget == DragTarget::None) return;
|
||||
mDragTarget = DragTarget::None;
|
||||
ui()->releasePointer(*this);
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
default: return;
|
||||
ColorSquare::DragTarget ColorSquare::targetAt(Vec2F localPoint) const {
|
||||
if (contains(makeRect(squareRect().pos - Vec2F{ 1, 1 }, squareRect().size + Vec2F{ 2, 2 }), localPoint)) {
|
||||
return DragTarget::Square;
|
||||
}
|
||||
if (contains(makeRect(hueRect().pos - Vec2F{ 1, 1 }, hueRect().size + Vec2F{ 2, 2 }), localPoint)) return DragTarget::Hue;
|
||||
return DragTarget::None;
|
||||
}
|
||||
|
||||
void ColorSquare::onEvent(Event& event) {
|
||||
const bool handled = followPick(
|
||||
event, mDragTarget, [this](Vec2F point) { return targetAt(point); }, [this](Vec2F point) { pickAt(point); }
|
||||
);
|
||||
if (event.type() == Event::Type::PointerUp && !(ui() && ui()->isDragging())) mDragTarget = DragTarget::None;
|
||||
if (handled) event.accept();
|
||||
}
|
||||
|
||||
void ColorSquare::pickAt(Vec2F localPoint) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
#include "InteractionVisual.hpp"
|
||||
|
||||
#include "Containers.hpp"
|
||||
#include "Control.hpp"
|
||||
#include "Menus.hpp"
|
||||
|
|
@ -28,11 +30,15 @@ namespace tp::ui {
|
|||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Canvas& painter) override;
|
||||
void onHoverChanged() override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
|
||||
private:
|
||||
Kind mKind;
|
||||
RGBA mColor;
|
||||
bool mSelected = false;
|
||||
// The hover outline of a swatch fades in and out.
|
||||
InteractionVisual mVisual{ *this };
|
||||
};
|
||||
|
||||
class ColorPickerButton : public PopupButton {
|
||||
|
|
@ -78,7 +84,8 @@ namespace tp::ui {
|
|||
|
||||
Panel mPopup{ Panel::Surface::Raised };
|
||||
Widget mGrid;
|
||||
std::vector<std::unique_ptr<ColorCell>> mSwatches;
|
||||
// The swatches in palette order; the grid owns them.
|
||||
std::vector<ColorCell*> mSwatches;
|
||||
ColorCell mAddCell{ ColorCell::Kind::Add };
|
||||
|
||||
Panel mNewColorPopup{ Panel::Surface::Raised };
|
||||
|
|
|
|||
97
Widgets/src/components/views/public/ListRow.hpp
Normal file
97
Widgets/src/components/views/public/ListRow.hpp
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
#pragma once
|
||||
|
||||
#include "CanvasTypes.hpp"
|
||||
#include "Control.hpp"
|
||||
#include "Glyph.hpp"
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
// One row of a list: a leading mark, a title with an optional subtitle, and trailing content. File rows, search
|
||||
// results, menu items, settings rows and application lists are configurations of it, so they share one size, one
|
||||
// hover/selection look and one set of offsets.
|
||||
class ListRow : public Control {
|
||||
public:
|
||||
// What sits before the title.
|
||||
struct Leading {
|
||||
enum class Kind { None, Space, Glyph, Check, Image, Swatch };
|
||||
|
||||
Kind kind = Kind::None;
|
||||
Glyph glyph = Glyph::File;
|
||||
ImageHandle image;
|
||||
RGBA color;
|
||||
|
||||
static Leading none() { return {}; }
|
||||
// Empty room of a mark, so titles line up with rows that have one (an unchecked menu toggle).
|
||||
static Leading space() { return { Kind::Space }; }
|
||||
static Leading glyphOf(Glyph glyph) { return { Kind::Glyph, glyph }; }
|
||||
static Leading check() { return { Kind::Check }; }
|
||||
static Leading imageOf(ImageHandle image) { return { Kind::Image, Glyph::File, image }; }
|
||||
static Leading swatch(RGBA color) { return { Kind::Swatch, Glyph::File, {}, color }; }
|
||||
};
|
||||
|
||||
// What sits after the title.
|
||||
struct Trailing {
|
||||
enum class Kind { None, Text, Chevron, Switch };
|
||||
|
||||
Kind kind = Kind::None;
|
||||
std::string text;
|
||||
bool on = false;
|
||||
|
||||
static Trailing none() { return {}; }
|
||||
// Muted right-aligned text: a size, a shortcut, a count.
|
||||
static Trailing textOf(std::string text) { return { Kind::Text, std::move(text) }; }
|
||||
// A chevron: the row opens something (a submenu, a page).
|
||||
static Trailing chevron() { return { Kind::Chevron }; }
|
||||
static Trailing toggle(bool on) { return { Kind::Switch, {}, on }; }
|
||||
};
|
||||
|
||||
// How dense the row is: menu rows are compact, list and settings rows taller.
|
||||
enum class Density { Compact, Regular, Comfortable };
|
||||
|
||||
explicit ListRow(std::string title = {});
|
||||
|
||||
void setTitle(std::string title);
|
||||
[[nodiscard]] const std::string& title() const { return mTitle; }
|
||||
void setSubtitle(std::string subtitle);
|
||||
[[nodiscard]] const std::string& subtitle() const { return mSubtitle; }
|
||||
void setLeading(Leading leading);
|
||||
[[nodiscard]] const Leading& leading() const { return mLeading; }
|
||||
void setTrailing(Trailing trailing);
|
||||
[[nodiscard]] const Trailing& trailing() const { return mTrailing; }
|
||||
void setDensity(Density density);
|
||||
// Muted rows show their title in the muted text color (hidden files, secondary entries).
|
||||
void setMuted(bool muted);
|
||||
|
||||
// Selected rows keep the selection color; highlighted rows show the keyboard-current or hovered color.
|
||||
void setSelected(bool selected);
|
||||
[[nodiscard]] bool isSelected() const { return mSelected; }
|
||||
void setHighlighted(bool highlighted);
|
||||
[[nodiscard]] bool isHighlighted() const { return mHighlighted; }
|
||||
|
||||
[[nodiscard]] static halnf heightFor(Density density, bool hasSubtitle);
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Canvas& painter) override;
|
||||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
|
||||
private:
|
||||
[[nodiscard]] halnf leadingWidth() const;
|
||||
[[nodiscard]] halnf trailingWidth() const;
|
||||
void paintLeading(Canvas& painter, const RectF& box, const RGBA& color) const;
|
||||
void paintTrailing(Canvas& painter, const RectF& box) const;
|
||||
|
||||
std::string mTitle;
|
||||
std::string mSubtitle;
|
||||
Leading mLeading;
|
||||
Trailing mTrailing;
|
||||
Density mDensity = Density::Regular;
|
||||
bool mMuted = false;
|
||||
bool mSelected = false;
|
||||
bool mHighlighted = false;
|
||||
};
|
||||
}
|
||||
90
Widgets/src/components/views/public/ListView.hpp
Normal file
90
Widgets/src/components/views/public/ListView.hpp
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
#pragma once
|
||||
|
||||
#include "Containers.hpp"
|
||||
#include "Label.hpp"
|
||||
#include "ListNavigator.hpp"
|
||||
#include "ListRow.hpp"
|
||||
#include "SelectionModel.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
// A scrolling list of rows over any number of items. The owner says how many items there are and fills a ListRow for
|
||||
// each; the list keeps the rows (reusing them when the items change), the selection, the keyboard (Up/Down, Home/End,
|
||||
// PageUp/PageDown, Enter activates, Space toggles in multiple mode, typing jumps to a title) and an empty state.
|
||||
// list.setModel({ count, [](size_t index, ListRow& row) { row.setTitle(...); } });
|
||||
class ListView : public Widget {
|
||||
public:
|
||||
struct Model {
|
||||
size_t count = 0;
|
||||
// Fills a row for an item; called for every item when the model is set and on refresh().
|
||||
std::function<void(size_t index, ListRow& row)> fill;
|
||||
// Whether an item can be chosen; all can when unset.
|
||||
std::function<bool(size_t index)> isEnabled;
|
||||
};
|
||||
|
||||
using Mode = SelectionModel<size_t>::Mode;
|
||||
|
||||
explicit ListView(Mode mode = Mode::Single);
|
||||
|
||||
void setModel(Model model);
|
||||
// Fills every row again from the model, keeping the selection.
|
||||
void refresh();
|
||||
[[nodiscard]] size_t count() const { return mModel.count; }
|
||||
[[nodiscard]] ListRow& row(size_t index) { return *mRows[index]; }
|
||||
|
||||
void setEmptyText(std::string text) { mEmpty.setText(std::move(text)); }
|
||||
[[nodiscard]] Label& emptyLabel() { return mEmpty; }
|
||||
void setDensity(ListRow::Density density);
|
||||
void setShowsScrollBar(bool shows) { mScroll.setShowsScrollBar(shows); }
|
||||
// Whether the list grows with its rows (a popup) or fills its parent and scrolls (a panel).
|
||||
void setFitsContent(bool fits);
|
||||
|
||||
[[nodiscard]] SelectionModel<size_t>& selection() { return mSelection; }
|
||||
[[nodiscard]] std::optional<size_t> current() const { return mSelection.current(); }
|
||||
// Makes an item current and selected and scrolls it into view.
|
||||
void select(size_t index);
|
||||
void clearSelection() { mSelection.clear(); }
|
||||
|
||||
// Called when an item is activated: a click, Enter, or a double click when activation needs one.
|
||||
void setOnActivated(std::function<void(size_t index)> callback) { mOnActivated = std::move(callback); }
|
||||
void setOnSelectionChanged(std::function<void()> callback) { mOnSelectionChanged = std::move(callback); }
|
||||
// With a single-click list (menus, results) a click activates; otherwise a click selects and a double click opens.
|
||||
void setActivatesOnClick(bool activates) { mActivatesOnClick = activates; }
|
||||
// The list handles navigation keys even when another widget (a search field) keeps the keyboard.
|
||||
bool handleKey(Event& event);
|
||||
|
||||
[[nodiscard]] ScrollView& scroller() { return mScroll; }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] bool acceptsKeyboardFocus() const override { return true; }
|
||||
void onEvent(Event& event) override;
|
||||
void filterDescendantEvent(Event& event) override;
|
||||
|
||||
private:
|
||||
void rebuildRows();
|
||||
void refreshRowStates();
|
||||
void rowClicked(size_t index, const Event& event);
|
||||
void activate(size_t index);
|
||||
void revealCurrent();
|
||||
[[nodiscard]] ListNavigator::Items navigationItems() const;
|
||||
[[nodiscard]] std::vector<size_t> order() const;
|
||||
[[nodiscard]] size_t pageSize() const;
|
||||
|
||||
Model mModel;
|
||||
SelectionModel<size_t> mSelection;
|
||||
ListNavigator mNavigator;
|
||||
ScrollView mScroll;
|
||||
Widget mColumn;
|
||||
Label mEmpty;
|
||||
std::vector<ListRow*> mRows;
|
||||
ListRow::Density mDensity = ListRow::Density::Regular;
|
||||
bool mActivatesOnClick = false;
|
||||
std::function<void(size_t)> mOnActivated;
|
||||
std::function<void()> mOnSelectionChanged;
|
||||
};
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
#include "Button.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Label.hpp"
|
||||
#include "TitledPanel.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
|
@ -13,35 +14,24 @@ namespace tp::ui {
|
|||
|
||||
class PageStack;
|
||||
|
||||
class StackPage : public Widget {
|
||||
// One page of a PageStack: a titled panel whose header leads with a back button and whose body scrolls.
|
||||
class StackPage : public TitledPanel {
|
||||
public:
|
||||
StackPage(PageStack& stack, std::string title);
|
||||
|
||||
[[nodiscard]] const std::string& title() const { return mTitle.text(); }
|
||||
void setTitle(std::string title) { mTitle.setText(std::move(title)); }
|
||||
[[nodiscard]] Widget& header() { return mHeader; }
|
||||
[[nodiscard]] Widget& body() { return mBody; }
|
||||
[[nodiscard]] Button& backButton() { return mBack; }
|
||||
[[nodiscard]] const Label& titleLabel() const { return mTitle; }
|
||||
|
||||
void setMaximumWidth(halnf width);
|
||||
void setContent(Widget& content);
|
||||
void setScrollsContent(bool scrolls);
|
||||
[[nodiscard]] bool scrollsContent() const { return mScrollsContent; }
|
||||
void setScrollsContent(bool scrolls) { setScrollsBody(scrolls); }
|
||||
[[nodiscard]] bool scrollsContent() const { return scrollsBody(); }
|
||||
// Shows a widget in the header in place of the title (a search field); null brings the title back.
|
||||
void setHeaderContent(Widget* content);
|
||||
void clearContent() { mBody.removeAllChildren(); }
|
||||
void clearContent() { body().removeAllChildren(); }
|
||||
|
||||
private:
|
||||
[[nodiscard]] AxisSizing horizontalSizing() const;
|
||||
|
||||
PageStack& mStack;
|
||||
halnf mMaximumWidth = 0;
|
||||
bool mScrollsContent = true;
|
||||
Widget mHeader;
|
||||
Button mBack{ "Back" };
|
||||
Label mTitle;
|
||||
ScrollView mScroll;
|
||||
Widget mBody;
|
||||
Widget* mHeaderContent = nullptr;
|
||||
};
|
||||
|
||||
class PageStack : public Panel {
|
||||
|
|
|
|||
77
Widgets/src/components/views/public/PropertyList.hpp
Normal file
77
Widgets/src/components/views/public/PropertyList.hpp
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
#pragma once
|
||||
|
||||
#include "Label.hpp"
|
||||
#include "Menus.hpp"
|
||||
#include "Slider.hpp"
|
||||
#include "TextField.hpp"
|
||||
#include "Toggles.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
// A form over a model: rows of a name and an editor, each bound once to a getter and a setter. refresh() pulls every
|
||||
// value from the model into its editor; editing pushes the value back through its setter. The model stays the
|
||||
// owner of the data, and pulling never runs a setter.
|
||||
// form.addValue("Album", [&] { return track.album; });
|
||||
// form.addSlider("Size", 1, 100, [&] { return brush.size; }, [&](halnf size) { brush.size = size; });
|
||||
// form.refresh();
|
||||
class PropertyList : public Widget {
|
||||
public:
|
||||
using TextGetter = std::function<std::string()>;
|
||||
using NumberGetter = std::function<halnf()>;
|
||||
using NumberSetter = std::function<void(halnf)>;
|
||||
|
||||
struct SliderOptions {
|
||||
std::function<std::string(halnf)> format;
|
||||
halnf step = 0;
|
||||
Slider::Scale scale = Slider::Scale::Linear;
|
||||
};
|
||||
|
||||
PropertyList();
|
||||
|
||||
// A read-only "name: value" row.
|
||||
Label& addValue(std::string name, TextGetter value);
|
||||
LabeledSlider& addSlider(std::string name, halnf minimum, halnf maximum, NumberGetter get, NumberSetter set);
|
||||
LabeledSlider& addSlider(
|
||||
std::string name, halnf minimum, halnf maximum, NumberGetter get, NumberSetter set, SliderOptions options
|
||||
);
|
||||
Checkbox& addCheckbox(std::string name, std::function<bool()> get, std::function<void(bool)> set);
|
||||
Dropdown& addChoice(std::string name, std::vector<std::string> options, std::function<long()> get, std::function<void(long)> set);
|
||||
// A text editor that pushes its text when it is committed (Enter or focus leaving).
|
||||
TextField& addText(std::string name, TextGetter get, std::function<void(const std::string&)> set);
|
||||
// A row with any editor; refresh calls pull, the editor's own callbacks push.
|
||||
Widget& addCustom(std::string name, Widget& editor, std::function<void()> pull = {});
|
||||
void addSeparator();
|
||||
|
||||
// Pulls every value from the model into its editor.
|
||||
void refresh();
|
||||
// Called after any edit made through the list (to refresh summaries, previews or other rows).
|
||||
void setOnEdited(std::function<void()> callback) { mOnEdited = std::move(callback); }
|
||||
|
||||
[[nodiscard]] size_t rowCount() const { return mRows.size(); }
|
||||
[[nodiscard]] const std::string& name(size_t row) const { return mRows[row].name; }
|
||||
[[nodiscard]] Widget& editor(size_t row) const { return *mRows[row].editor; }
|
||||
// The row with a name, or null.
|
||||
[[nodiscard]] Widget* editor(const std::string& name) const;
|
||||
|
||||
private:
|
||||
struct Row {
|
||||
std::string name;
|
||||
Widget* editor = nullptr;
|
||||
std::function<void()> pull;
|
||||
};
|
||||
|
||||
void addName(const std::string& name, bool labelled);
|
||||
Widget& bind(std::string name, Widget& editor, std::function<void()> pull);
|
||||
void edited();
|
||||
|
||||
std::vector<Row> mRows;
|
||||
// Pulling sets editors, which call their change callbacks; those must not write the value back.
|
||||
bool mPulling = false;
|
||||
std::function<void()> mOnEdited;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,18 +1,24 @@
|
|||
#pragma once
|
||||
|
||||
#include "ReorderSession.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class Viewport;
|
||||
|
||||
// A column of items the user can reorder by dragging (from a grip, or anywhere after a long press). The lifted item
|
||||
// follows the pointer with the theme elevation, the others make room by the ReorderSession midpoint rule, the
|
||||
// nearest scroller scrolls when the pointer nears its edge, and the item glides into its slot when dropped.
|
||||
class ReorderableList : public Widget {
|
||||
public:
|
||||
enum class DragMode { Grip, LongPress, None };
|
||||
|
||||
static constexpr halnf kGripWidth = 14;
|
||||
static constexpr halnf kDragThreshold = 5;
|
||||
|
||||
explicit ReorderableList(halnf gap = 2);
|
||||
|
||||
|
|
@ -31,11 +37,11 @@ namespace tp::ui {
|
|||
}
|
||||
void setDragMode(DragMode mode);
|
||||
[[nodiscard]] DragMode dragMode() const { return mDragMode; }
|
||||
[[nodiscard]] long dropTarget() const { return mDragging ? mDropTarget : -1; }
|
||||
[[nodiscard]] long dropTarget() const { return isDragging() ? mDropTarget : -1; }
|
||||
|
||||
[[nodiscard]] bool isDragging() const { return mDragging; }
|
||||
[[nodiscard]] long draggedIndex() const { return mDragging ? (long) mDragged : -1; }
|
||||
[[nodiscard]] long dropIndex() const { return mDragging ? (long) mDrop : -1; }
|
||||
[[nodiscard]] bool isDragging() const { return mSession.has_value(); }
|
||||
[[nodiscard]] long draggedIndex() const { return isDragging() ? (long) mSession->origin().index : -1; }
|
||||
[[nodiscard]] long dropIndex() const { return isDragging() ? (long) mDrop : -1; }
|
||||
[[nodiscard]] RectF gripRect(size_t index) const;
|
||||
|
||||
void cancelDrag();
|
||||
|
|
@ -44,30 +50,33 @@ namespace tp::ui {
|
|||
void onEvent(Event& event) override;
|
||||
void onHoverChanged() override;
|
||||
void filterDescendantEvent(Event& event) override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void paint(Canvas& painter) override;
|
||||
[[nodiscard]] const Widget* raisedChild() const override;
|
||||
void paintRaisedBackdrop(Canvas& painter, const RectF& childRect) override;
|
||||
[[nodiscard]] bool hitTest(Vec2F localPoint) const override;
|
||||
[[nodiscard]] bool acceptsKeyboardFocus() const override { return mDragging; }
|
||||
[[nodiscard]] bool acceptsDrag(PointerButton button) const override;
|
||||
[[nodiscard]] bool acceptsKeyboardFocus() const override { return isDragging(); }
|
||||
|
||||
private:
|
||||
class ListLayout;
|
||||
friend class ListLayout;
|
||||
|
||||
void handlePointer(Event& event);
|
||||
void handlePress(Event& event);
|
||||
void handleDrag(Event& event);
|
||||
void updateGripHover(Vec2F localPoint);
|
||||
void paintGrip(Canvas& painter, const RectF& grip, const RGBA& color) const;
|
||||
[[nodiscard]] RectF gripRectFor(const RectF& row) const;
|
||||
[[nodiscard]] bool isOnGrip(Vec2F localPoint, size_t index) const;
|
||||
[[nodiscard]] bool hasNestedListAround(const Widget* target) const;
|
||||
void beginDrag(Vec2F windowPoint);
|
||||
void beginDrag();
|
||||
void updateDrag(Vec2F windowPoint);
|
||||
void finishDrag();
|
||||
void endDragging();
|
||||
[[nodiscard]] long itemIndexAt(Vec2F localPoint) const;
|
||||
[[nodiscard]] size_t slotFor(halnf draggedTop, halnf draggedBottom) const;
|
||||
[[nodiscard]] ReorderLane laneWithoutDragged() const;
|
||||
[[nodiscard]] halnf itemHeight(size_t index) const;
|
||||
[[nodiscard]] long dropTargetAt(halnf pointerY) const;
|
||||
[[nodiscard]] Viewport* scroller() const;
|
||||
[[nodiscard]] bool usesGrips() const { return mDragMode == DragMode::Grip; }
|
||||
[[nodiscard]] halnf gripWidth() const { return usesGrips() ? kGripWidth : 0; }
|
||||
|
||||
|
|
@ -77,22 +86,21 @@ namespace tp::ui {
|
|||
std::function<void(size_t, size_t)> mOnDroppedOnto;
|
||||
std::function<bool(size_t, size_t)> mCanAcceptDrop;
|
||||
std::function<void(size_t)> mOnHeld;
|
||||
bool mHeld = false;
|
||||
DragMode mDragMode = DragMode::Grip;
|
||||
long mDropTarget = -1;
|
||||
|
||||
bool mPressed = false;
|
||||
bool mDragging = false;
|
||||
// The press that may become a drag: which item, and whether it was on the grip or held long enough.
|
||||
std::optional<size_t> mPressedIndex;
|
||||
bool mPressedOnGrip = false;
|
||||
size_t mPressedIndex = 0;
|
||||
size_t mDragged = 0;
|
||||
size_t mDrop = 0;
|
||||
Vec2F mPressPoint = { 0, 0 };
|
||||
halnf mGrabOffset = 0;
|
||||
bool mHeld = false;
|
||||
halnf mPressLocalY = 0;
|
||||
halnf mListHeight = 0;
|
||||
Widget* mFocusBeforeDrag = nullptr;
|
||||
|
||||
std::optional<ReorderSession> mSession;
|
||||
size_t mDrop = 0;
|
||||
long mDropTarget = -1;
|
||||
halnf mDragY = 0;
|
||||
halnf mListHeight = 0;
|
||||
Vec2F mPointer = { 0, 0 };
|
||||
Widget* mFocusBeforeDrag = nullptr;
|
||||
long mHoveredGrip = -1;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue