Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/desktop/qml/components/graph/GraphMinimap.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import QtQuick
import "../.."
import "../../js/graphModel.js" as GM

Rectangle {
id: root
property var host
property real viewportWidth: 1
property real viewportHeight: 1
readonly property var bounds: {
if (!host) return null
host.layoutRev; host.graphRev
return GM.sectionBounds(host.canvasGraph(), host.specs, host.nodeIds, host.nodeSizes)
}
readonly property real mapScale: bounds ? Math.min((width - 20) / Math.max(1, bounds.w), (height - 20) / Math.max(1, bounds.h)) : 1
readonly property real offsetX: bounds ? (width - bounds.w * mapScale) / 2 - bounds.x * mapScale : 0
readonly property real offsetY: bounds ? (height - bounds.h * mapScale) / 2 - bounds.y * mapScale : 0
width: 170; height: 110
z: 250
color: Qt.alpha(AppTheme.bgAlt, 0.94)
border.color: AppTheme.border
radius: 6
clip: true
Canvas {
id: map
anchors.fill: parent
Connections {
target: root.host
function onLayoutRevChanged() { map.requestPaint() }
function onGraphRevChanged() { map.requestPaint() }
function onSelectedIdsChanged() { map.requestPaint() }
function onPanXChanged() { map.requestPaint() }
function onPanYChanged() { map.requestPaint() }
function onZoomChanged() { map.requestPaint() }
}
onPaint: {
var ctx = getContext("2d")
ctx.reset()
if (!root.host || !root.bounds) return
var host = root.host, s = root.mapScale, ox = root.offsetX, oy = root.offsetY
host.canvasGraph().nodes.forEach(function(n) {
var b = GM.nodeRect(n, host.specs, host.nodeSizes)
ctx.fillStyle = host.isSelected(n.id) ? AppTheme.accentHi : GM.categoryColor((host.specs[n.type] || {}).category)
ctx.globalAlpha = 0.7
ctx.fillRect(ox + b.x * s, oy + b.y * s, Math.max(2, b.w * s), Math.max(2, b.h * s))
})
ctx.globalAlpha = 1
ctx.strokeStyle = AppTheme.text
ctx.lineWidth = 1
ctx.strokeRect(ox - host.panX / host.zoom * s, oy - host.panY / host.zoom * s,
root.viewportWidth / host.zoom * s, root.viewportHeight / host.zoom * s)
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
function navigate(x, y) {
if (!root.host || !root.bounds) return
root.host.panX = root.viewportWidth / 2 - (x - root.offsetX) / root.mapScale * root.host.zoom
root.host.panY = root.viewportHeight / 2 - (y - root.offsetY) / root.mapScale * root.host.zoom
}
onPressed: function(m) { navigate(m.x, m.y) }
onPositionChanged: function(m) { if (pressed) navigate(m.x, m.y) }
onReleased: root.host.saveViewport()
}
}
160 changes: 108 additions & 52 deletions apps/desktop/qml/components/graph/GraphNode.qml

Large diffs are not rendered by default.

114 changes: 114 additions & 0 deletions apps/desktop/qml/components/graph/ImageViewer.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import "../.."
import ".."

// A separate native window. open() works independently of popup parenting.
Window {
id: root
property var sources: []
property int currentIndex: 0
property string comparisonSource: ""
property string currentSource: sources && currentIndex >= 0 && currentIndex < sources.length
? String(sources[currentIndex]) : ""
property bool comparing: comparisonSource !== ""
property bool fullscreen: false
property bool _wasMaximized: false

title: "Image Studio · " + (currentSource ? (currentIndex + 1) + " / " + sources.length : "Results")
width: 1180
height: 800
minimumWidth: 640
minimumHeight: 420
color: AppTheme.bg
visible: false

function open() {
currentIndex = sources && sources.length ? Math.max(0, Math.min(sources.length - 1, currentIndex)) : 0
mainImage.resetView(); referenceImage.resetView()
show()
raise()
requestActivate()
}
function previous() { if (currentIndex > 0) --currentIndex }
function next() { if (sources && currentIndex + 1 < sources.length) ++currentIndex }
function toggleFullscreen() {
if (visibility === Window.FullScreen) {
if (_wasMaximized) showMaximized()
else showNormal()
} else {
_wasMaximized = visibility === Window.Maximized
showFullScreen()
}
}
onVisibilityChanged: function(visibility) { fullscreen = visibility === Window.FullScreen }
onSourcesChanged: {
currentIndex = sources && sources.length ? Math.max(0, Math.min(sources.length - 1, currentIndex)) : 0
}
Component.onCompleted: AppTheme.applyPalette(root)

Shortcut { sequence: "Left"; enabled: root.visible; onActivated: root.previous() }
Shortcut { sequence: "Right"; enabled: root.visible; onActivated: root.next() }
Shortcut { sequence: "F11"; enabled: root.visible; onActivated: root.toggleFullscreen() }
Shortcut {
sequence: "Escape"; enabled: root.visible
onActivated: { if (root.fullscreen) root.toggleFullscreen(); else root.close() }
}
Shortcut { sequence: "+"; enabled: root.visible; onActivated: mainImage.zoomAt(mainImage.zoomFactor * 1.25, mainImage.width / 2, mainImage.height / 2) }
Shortcut { sequence: "-"; enabled: root.visible; onActivated: mainImage.zoomAt(mainImage.zoomFactor / 1.25, mainImage.width / 2, mainImage.height / 2) }
Shortcut { sequence: "0"; enabled: root.visible; onActivated: { mainImage.resetView(); referenceImage.resetView() } }

ColumnLayout {
anchors.fill: parent
anchors.margins: AppTheme.padSmall
spacing: AppTheme.gapTight
RowLayout {
Layout.fillWidth: true
AppButton { objectName: "previousImageButton"; text: "←"; accessibleDescription: "Previous image"; enabled: root.currentIndex > 0; onClicked: root.previous() }
Label { text: root.currentSource ? (root.currentIndex + 1) + " / " + root.sources.length : "No results"; color: AppTheme.text }
AppButton { objectName: "nextImageButton"; text: "→"; accessibleDescription: "Next image"; enabled: root.sources && root.currentIndex + 1 < root.sources.length; onClicked: root.next() }
Item { Layout.fillWidth: true }
AppButton { text: "−"; accessibleDescription: "Zoom out"; enabled: mainImage.zoomFactor > 1; onClicked: mainImage.zoomAt(mainImage.zoomFactor / 1.25, mainImage.width / 2, mainImage.height / 2) }
Label { text: mainImage.zoomFactor.toFixed(1) + "×"; color: AppTheme.textDim }
AppButton { text: "+"; accessibleDescription: "Zoom in"; enabled: mainImage.zoomFactor < 16; onClicked: mainImage.zoomAt(mainImage.zoomFactor * 1.25, mainImage.width / 2, mainImage.height / 2) }
AppButton { objectName: "fitImageButton"; text: "Fit"; onClicked: { mainImage.resetView(); referenceImage.resetView() } }
AppButton { text: root.fullscreen ? "Window" : "Fullscreen"; onClicked: root.toggleFullscreen() }
AppButton { text: "Close"; onClicked: root.close() }
}
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: AppTheme.gap
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
Label { visible: root.comparing; text: "Current result"; color: AppTheme.textDim }
ImageViewport { id: mainImage; objectName: "mainImageViewport"; Layout.fillWidth: true; Layout.fillHeight: true; fileUrl: root.currentSource }
}
ColumnLayout {
visible: root.comparing
Layout.fillWidth: true
Layout.fillHeight: true
Label { text: "Comparison"; color: AppTheme.textDim }
ImageViewport { id: referenceImage; objectName: "comparisonImageViewport"; Layout.fillWidth: true; Layout.fillHeight: true; fileUrl: root.comparisonSource }
}
}
Label {
Layout.fillWidth: true
text: root.currentSource
textFormat: Text.PlainText
elide: Text.ElideMiddle
color: AppTheme.textDim
font.pixelSize: AppTheme.fontSmall
}
Label {
Layout.fillWidth: true
text: "Wheel to zoom · Drag to pan · ← → for batch · Double-click to fit"
color: AppTheme.textFaint
font.pixelSize: AppTheme.fontSmall
wrapMode: Text.Wrap
}
}
}
94 changes: 94 additions & 0 deletions apps/desktop/qml/components/graph/ImageViewport.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import QtQuick
import QtQuick.Controls
import "../.."

// A fit-to-view image with pointer-centred wheel zoom and bounded drag pan.
Rectangle {
id: root
property string fileUrl: ""
property real zoomFactor: 1
property real panX: 0
property real panY: 0
property size imageSize: picture.sourceSize
property int imageStatus: picture.status
property real fitWidth: picture.implicitWidth > 0 && picture.implicitHeight > 0
? Math.min(width, height * picture.implicitWidth / picture.implicitHeight) : width
property real fitHeight: picture.implicitWidth > 0 && picture.implicitHeight > 0
? Math.min(height, width * picture.implicitHeight / picture.implicitWidth) : height
property real maxPanX: Math.max(0, (fitWidth * zoomFactor - width) / 2)
property real maxPanY: Math.max(0, (fitHeight * zoomFactor - height) / 2)

color: AppTheme.bgAlt
clip: true
border.color: AppTheme.border
Accessible.name: "Image preview"
Accessible.description: fileUrl

function resetView() { zoomFactor = 1; panX = 0; panY = 0 }
function clampPan() {
panX = Math.max(-maxPanX, Math.min(maxPanX, panX))
panY = Math.max(-maxPanY, Math.min(maxPanY, panY))
}
function zoomAt(factor, pointX, pointY) {
var next = Math.max(1, Math.min(16, factor))
var ratio = next / zoomFactor
panX = (pointX - width / 2) * (1 - ratio) + panX * ratio
panY = (pointY - height / 2) * (1 - ratio) + panY * ratio
zoomFactor = next
clampPan()
}
onFileUrlChanged: resetView()
onWidthChanged: clampPan()
onHeightChanged: clampPan()

Image {
id: picture
objectName: "viewerImage"
source: root.fileUrl
asynchronous: true
// Retain original pixels for zoom inspection, rather than a thumbnail.
cache: false
fillMode: Image.PreserveAspectFit
width: root.fitWidth * root.zoomFactor
height: root.fitHeight * root.zoomFactor
x: (root.width - width) / 2 + root.panX
y: (root.height - height) / 2 + root.panY
smooth: root.zoomFactor < 4
onStatusChanged: if (status === Image.Ready) root.clampPan()
}

BusyIndicator { anchors.centerIn: parent; running: picture.status === Image.Loading }
Label {
anchors.centerIn: parent
width: Math.max(0, parent.width - 32)
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
text: root.fileUrl === "" ? "No image selected" : "Image unavailable\nThe result file may have moved or been removed."
visible: root.fileUrl === "" || picture.status === Image.Error
color: AppTheme.textDim
font.pixelSize: AppTheme.fontBody
}
MouseArea {
id: pointer
objectName: "imagePanArea"
anchors.fill: parent
acceptedButtons: Qt.LeftButton
cursorShape: root.zoomFactor > 1 ? (pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor) : Qt.ArrowCursor
property real lastX: 0
property real lastY: 0
onPressed: function(mouse) { lastX = mouse.x; lastY = mouse.y }
onPositionChanged: function(mouse) {
if (!pressed) return
root.panX += mouse.x - lastX
root.panY += mouse.y - lastY
lastX = mouse.x; lastY = mouse.y
root.clampPan()
}
onWheel: function(wheel) {
var delta = wheel.angleDelta.y || wheel.pixelDelta.y
root.zoomAt(root.zoomFactor * Math.pow(1.2, delta / 120), wheel.x, wheel.y)
wheel.accepted = true
}
onDoubleClicked: root.resetView()
}
}
Loading
Loading