From cbaf1b8a78aa5a0fd135ab04047eac78a0d48492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Cie=C5=9Blak?= Date: Mon, 8 May 2023 14:49:39 +0200 Subject: [PATCH] Storybook: Generic, figma-like inspection tool for checking structure/sizes/paddings Closes: #10574 --- storybook/main.qml | 28 ++++ storybook/src/Storybook/InspectionItem.qml | 121 ++++++++++++++++++ .../src/Storybook/InspectionItemsList.qml | 37 ++++++ storybook/src/Storybook/InspectionPanel.qml | 92 +++++++++++++ storybook/src/Storybook/InspectionUtils.qml | 97 ++++++++++++++ storybook/src/Storybook/InspectionWindow.qml | 95 ++++++++++++++ storybook/src/Storybook/PageToolBar.qml | 11 ++ storybook/src/Storybook/qmldir | 5 + 8 files changed, 486 insertions(+) create mode 100644 storybook/src/Storybook/InspectionItem.qml create mode 100644 storybook/src/Storybook/InspectionItemsList.qml create mode 100644 storybook/src/Storybook/InspectionPanel.qml create mode 100644 storybook/src/Storybook/InspectionUtils.qml create mode 100644 storybook/src/Storybook/InspectionWindow.qml diff --git a/storybook/main.qml b/storybook/main.qml index 2539102b16..d6390e6680 100644 --- a/storybook/main.qml +++ b/storybook/main.qml @@ -172,6 +172,30 @@ ApplicationWindow { pageTitle: currentPageModelItem.object.title }) } + + onInspectClicked: { + const getItems = typeName => + InspectionUtils.findItemsByTypeName( + viewLoader.item, typeName) + + const items = [ + ...getItems(root.currentPage), + ...getItems("Custom" + root.currentPage) + ] + + if (items.length === 0) { + console.warn(`Item of type "${root.currentPage}" not found. Nothing to inspect.`) + return + } + + const lca = InspectionUtils.lowestCommonAncestor( + items, viewLoader.item) + + inspectionWindow.inspect(lca.parent.contentItem === lca + ? lca.parent : lca) + inspectionWindow.show() + inspectionWindow.requestActivate() + } } } } @@ -217,6 +241,10 @@ ApplicationWindow { figmaToken: settingsLayout.figmaToken } + InspectionWindow { + id: inspectionWindow + } + Component { id: figmaWindow diff --git a/storybook/src/Storybook/InspectionItem.qml b/storybook/src/Storybook/InspectionItem.qml new file mode 100644 index 0000000000..8c47b0335c --- /dev/null +++ b/storybook/src/Storybook/InspectionItem.qml @@ -0,0 +1,121 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Rectangle { + id: root + + readonly property color visualItemColor: "black" + readonly property color nonVisualItemColor: "green" + + readonly property color visualItemSelectionColor: "red" + readonly property color nonVisualItemSelectionColor: "orange" + + readonly property bool selected: containsMouse || forceSelect + + readonly property color baseColor: + isVisual ? visualItemColor + : (showNonVisual ? nonVisualItemColor : "transparent") + + readonly property color selectionColor: isVisual ? visualItemSelectionColor + : nonVisualItemSelectionColor + + border.color: selected ? selectionColor : baseColor + border.width: selected ? 2 : 1 + color: 'transparent' + + required property string name + required property bool isVisual + property bool showNonVisual: false + property bool forceSelect: false + required property Item visualParent + + readonly property real topSpacing: mapToItem(visualParent, 0, 0).y + + readonly property real bottomSpacing: + visualParent.height - mapToItem(visualParent, 0, height).y + + readonly property real leftSpacing: mapToItem(visualParent, 0, 0).x + + readonly property real rightSpacing: + visualParent.width - mapToItem(visualParent, width, 0).x + + readonly property alias containsMouse: mouseArea.containsMouse + + component DistanceRectangle: Rectangle { + width: 1 + height: 1 + color: selectionColor + visible: root.selected + parent: root.parent + } + + // top + DistanceRectangle { + height: topSpacing + anchors.bottom: root.top + anchors.horizontalCenter: root.horizontalCenter + } + + // left + DistanceRectangle { + width: leftSpacing + anchors.right: root.left + anchors.verticalCenter: root.verticalCenter + } + + // right + DistanceRectangle { + width: rightSpacing + anchors.left: root.right + anchors.verticalCenter: root.verticalCenter + } + + // bottom + DistanceRectangle { + height: bottomSpacing + anchors.top: root.bottom + anchors.horizontalCenter: root.horizontalCenter + } + + Popup { + x: parent.width + padding / 2 + y: parent.height + padding / 2 + + visible: root.selected + margins: 0 + + ColumnLayout { + Label { + text: root.name + font.bold: true + } + Label { + text: `x: ${root.x}, y: ${root.y}` + } + Label { + text: `size: ${root.width} x ${root.height}` + } + Label { + text: `top space: ${root.topSpacing}` + } + Label { + text: `bottom space: ${root.bottomSpacing}` + } + Label { + text: `left space: ${root.leftSpacing}` + } + Label { + text: `right space: ${root.rightSpacing}` + } + } + } + + MouseArea { + id: mouseArea + + visible: isVisual || showNonVisual + anchors.fill: parent + hoverEnabled: true + } +} diff --git a/storybook/src/Storybook/InspectionItemsList.qml b/storybook/src/Storybook/InspectionItemsList.qml new file mode 100644 index 0000000000..03093f68ba --- /dev/null +++ b/storybook/src/Storybook/InspectionItemsList.qml @@ -0,0 +1,37 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 + + +ListView { + ScrollBar.vertical: ScrollBar {} + + readonly property color visualItemColor: "blue" + readonly property color nonVisualItemColor: "black" + readonly property color selectionColor: "red" + + delegate: Text { + width: ListView.view.width + height: 30 + + text: " ".repeat(model.level * 4) + " " + model.name + + readonly property color baseColor: model.visual ? visualItemColor + : nonVisualItemColor + + color: model.item.containsMouse ? selectionColor + : (model.visual ? "blue" : "black") + elide: Text.ElideRight + + Binding { + target: model.item + property: "forceSelect" + value: mouseArea.containsMouse + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + } + } +} diff --git a/storybook/src/Storybook/InspectionPanel.qml b/storybook/src/Storybook/InspectionPanel.qml new file mode 100644 index 0000000000..8971b530c5 --- /dev/null +++ b/storybook/src/Storybook/InspectionPanel.qml @@ -0,0 +1,92 @@ +import QtQuick 2.15 + + +Item { + id: root + + property bool propagateClipping: false + property bool showNonVisualItems: false + property alias showScreenshot: image.visible + + required property Item sourceItem + + readonly property ListModel model: ListModel {} + + implicitWidth: image.implicitWidth + implicitHeight: image.implicitHeight + + Component { + id: inspectionItemComponent + + InspectionItem {} + } + + Image { + id: image + } + + function itemsDepthFirst(root) { + const items = [] + + function iterate(item, parentIndex, level) { + if (!item.visible || item.opacity === 0) + return + + const idx = items.length + items.push({item, parentIndex, level}) + + for (let i = 0; i < item.children.length; i++) + iterate(item.children[i], idx, level + 1) + } + + iterate(root, -1, 0) + return items + } + + Component.onCompleted: { + root.sourceItem.grabToImage(result => image.source = result.url) + + const items = itemsDepthFirst(root.sourceItem) + + const placeholders = [] + const modelItems = [] + + items.forEach((entry) => { + const {item, parentIndex, level} = entry + const isRoot = parentIndex === -1 + + const parent = isRoot ? root : placeholders[parentIndex] + const visualParent = isRoot ? root : (parent.isVisual ? parent : parent.visualParent) + + const x = isRoot ? 0 : item.x + const y = isRoot ? 0 : item.y + const name = InspectionUtils.simpleName(item) + const visual = InspectionUtils.isVisual(item) + const clip = item.clip + + const props = { + name, x, y, + width: item.width, + height: item.height, + z: item.z, + isVisual: visual, + visualParent, + clip: Qt.binding(() => root.propagateClipping && item.clip), + showNonVisual: Qt.binding(() => root.showNonVisualItems) + } + + const placeholder = inspectionItemComponent.createObject( + parent, props) + + const modelEntryProps = { + name, visual, level, + item: placeholder + } + + modelItems.push(modelEntryProps) + placeholders.push(placeholder) + }) + + root.model.append(modelItems) + } +} diff --git a/storybook/src/Storybook/InspectionUtils.qml b/storybook/src/Storybook/InspectionUtils.qml new file mode 100644 index 0000000000..3f472d8ab5 --- /dev/null +++ b/storybook/src/Storybook/InspectionUtils.qml @@ -0,0 +1,97 @@ +pragma Singleton + +import QtQml 2.15 +import QtQuick 2.15 + +QtObject { + function isVisual(item) { + return item instanceof Text + || item instanceof Rectangle + || item instanceof Image + || item instanceof TextEdit + || item instanceof TextInput + } + + function baseName(item) { + const fullName = item.toString() + const underscoreIndex = fullName.indexOf("_") + + if (underscoreIndex !== -1) + return fullName.substring(0, underscoreIndex) + + const bracketIndex = fullName.indexOf("(") + + if (bracketIndex !== -1) + return fullName.substring(0, bracketIndex) + + return fullName + } + + function simpleName(item) { + if (item instanceof Text) + return "Text" + if (item instanceof Rectangle) + return "Rectangle" + if (item instanceof Image) + return "Image" + if (item instanceof TextEdit) + return "TextEdit" + if (item instanceof TextInput) + return "TextInput" + + const name = baseName(item) + + if (name.startsWith("QQuick")) + return name.substring(6) + + return name + } + + function findItemsByTypeName(root, typeName) { + const items = [] + const stack = [root] + + while (stack.length) { + const item = stack.pop() + + if (!item.visible || item.opacity === 0) + continue + + const name = baseName(item) + + if (name === typeName) { + items.push(item) + continue + } + + for (let i = 0; i < item.children.length; i++) + stack.push(item.children[i]) + } + + return items + } + + function pathToAncestor(item, ancestor) { + const path = [item] + + while (path[path.length - 1].parent !== ancestor) + path.push(path[path.length - 1].parent) + + return path + } + + function lowestCommonAncestor(items, commonAncestor) { + const paths = items.map(item => pathToAncestor(item, commonAncestor)) + + let candidate = null + + while (true) { + const top = paths.map(path => path.pop()) + + if (top.every(val => val && val === top[0])) + candidate = top.shift() + else + return candidate + } + } +} diff --git a/storybook/src/Storybook/InspectionWindow.qml b/storybook/src/Storybook/InspectionWindow.qml new file mode 100644 index 0000000000..3137611adc --- /dev/null +++ b/storybook/src/Storybook/InspectionWindow.qml @@ -0,0 +1,95 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + + +ApplicationWindow { + title: "Storybook Inspector" + + width: 1024 + height: 768 + + function inspect(sourceItem) { + const properties = { + sourceItem, + propagateClipping: Qt.binding(() => clipCheckBox.checked), + showNonVisualItems: Qt.binding(() => showNonVisualCheckBox.checked), + showScreenshot: Qt.binding(() => screenshotCheckBox.checked) + } + + loader.setSource("InspectionPanel.qml", properties) + } + + SplitView { + anchors.fill: parent + + InspectionItemsList { + id: itemsListView + + SplitView.preferredWidth: 300 + SplitView.fillHeight: true + + model: loader.item ? loader.item.model : null + + clip: true + } + + ColumnLayout { + SplitView.fillWidth: true + SplitView.fillHeight: true + + Flickable { + id: flickable + + Layout.fillWidth: true + Layout.fillHeight: true + + clip: true + + contentWidth: content.width + contentHeight: content.height + + Item { + id: content + + width: Math.max(flickable.width, loader.implicitWidth) + height: Math.max(flickable.height, loader.implicitHeight) + + Rectangle { + border.color: "gray" + color: "transparent" + anchors.fill: loader + } + + Loader { + id: loader + + anchors.centerIn: parent + } + } + } + + Pane { + Layout.fillWidth: true + + RowLayout { + CheckBox { + id: screenshotCheckBox + text: "Show screenshot" + checked: true + } + + CheckBox { + id: clipCheckBox + text: "Propagate clipping" + } + + CheckBox { + id: showNonVisualCheckBox + text: "Show non-visual items" + } + } + } + } + } +} diff --git a/storybook/src/Storybook/PageToolBar.qml b/storybook/src/Storybook/PageToolBar.qml index 60fff1aa39..0a7f412f66 100644 --- a/storybook/src/Storybook/PageToolBar.qml +++ b/storybook/src/Storybook/PageToolBar.qml @@ -10,6 +10,7 @@ ToolBar { property int figmaPagesCount: 0 signal figmaPreviewClicked + signal inspectClicked RowLayout { anchors.fill: parent @@ -60,5 +61,15 @@ ToolBar { onClicked: root.figmaPreviewClicked() } + + ToolSeparator {} + + ToolButton { + text: "Inspect" + + Layout.rightMargin: parent.spacing + + onClicked: root.inspectClicked() + } } } diff --git a/storybook/src/Storybook/qmldir b/storybook/src/Storybook/qmldir index 7774c35475..9aaa3b44a0 100644 --- a/storybook/src/Storybook/qmldir +++ b/storybook/src/Storybook/qmldir @@ -11,6 +11,10 @@ HotReloaderControls 1.0 HotReloaderControls.qml ImageSelectPopup 1.0 ImageSelectPopup.qml ImagesGridView 1.0 ImagesGridView.qml ImagesNavigationLayout 1.0 ImagesNavigationLayout.qml +InspectionItem 1.0 InspectionItem.qml +InspectionItemsList 1.0 InspectionItemsList.qml +InspectionPanel 1.0 InspectionPanel.qml +InspectionWindow 1.0 InspectionWindow.qml Logs 1.0 Logs.qml LogsAndControlsPanel 1.0 LogsAndControlsPanel.qml LogsView 1.0 LogsView.qml @@ -21,4 +25,5 @@ SettingsLayout 1.0 SettingsLayout.qml SingleItemProxyModel 1.0 SingleItemProxyModel.qml SourceCodeBox 1.0 SourceCodeBox.qml singleton FigmaUtils 1.0 FigmaUtils.qml +singleton InspectionUtils 1.0 InspectionUtils.qml singleton StorybookUtils 1.0 StorybookUtils.qml