Skip to content

Commit 0ee8481

Browse files
authored
Merge pull request #3054 from alicevision/copilot/add-multiselection-image-gallery
Add multi-selection to ImageGallery
2 parents a4ffad7 + 444512c commit 0ee8481

8 files changed

Lines changed: 207 additions & 22 deletions

File tree

meshroom/ui/commands.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,10 @@ def undoImpl(self):
533533

534534

535535
class RemoveImagesCommand(GraphCommand):
536+
"""
537+
Remove all the images from one or several CameraInit nodes as a single operation.
538+
Both the viewpoints and intrinsics lists are reset to their default values.
539+
"""
536540
def __init__(self, graph, cameraInitNodes, parent=None):
537541
super().__init__(graph, parent)
538542
self.cameraInits = cameraInitNodes
@@ -560,6 +564,43 @@ def undoImpl(self):
560564
self.graph.node(cameraInit).intrinsics.value = self.intrinsics[cameraInit]
561565

562566

567+
class RemoveSelectedImagesCommand(GraphCommand):
568+
"""
569+
Remove a specific subset of images (viewpoints and their orphaned intrinsics) from a single
570+
CameraInit node as a single operation.
571+
"""
572+
def __init__(self, graph, cameraInitNode, imagesToRemove, parent=None):
573+
super().__init__(graph, parent)
574+
self.cameraInitNode = cameraInitNode
575+
576+
# Save current state of viewpoints and intrinsics
577+
self.oldViewpoints = cameraInitNode.attribute("viewpoints").getSerializedValue()
578+
self.oldIntrinsics = cameraInitNode.attribute("intrinsics").getSerializedValue()
579+
580+
# Build a set of viewIds to remove based on the provided images list and then the new viewpoints list
581+
removeViewIds = {image.viewId.value for image in imagesToRemove}
582+
self.newViewpoints = [vp for vp in self.oldViewpoints if vp.get("viewId") not in removeViewIds]
583+
584+
# Compute set of intrinsicIds that are still referenced by the remaining viewpoints and then
585+
# the new intrinsics list
586+
keptIntrinsicIds = {vp.get("intrinsicId") for vp in self.newViewpoints}
587+
self.newIntrinsics = [intr for intr in self.oldIntrinsics if intr.get("intrinsicId") in keptIntrinsicIds]
588+
589+
self.title = f"Remove {len(removeViewIds)} Image{'(s)' if len(removeViewIds) > 1 else ''}"
590+
self.setText(self.title)
591+
592+
def redoImpl(self):
593+
with GraphModification(self.graph):
594+
self.cameraInitNode.viewpoints.value = self.newViewpoints
595+
self.cameraInitNode.intrinsics.value = self.newIntrinsics
596+
return True
597+
598+
def undoImpl(self):
599+
with GraphModification(self.graph):
600+
self.cameraInitNode.viewpoints.value = self.oldViewpoints
601+
self.cameraInitNode.intrinsics.value = self.oldIntrinsics
602+
603+
563604
class MoveNodeCommand(GraphCommand):
564605
""" Move a node to a given position. """
565606
def __init__(self, graph, node, position, parent=None):

meshroom/ui/graph.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1413,6 +1413,13 @@ def removeImage(self, image):
14131413
# After every check we finally remove the attribute
14141414
self.removeAttribute(image)
14151415

1416+
@Slot(list)
1417+
def removeImages(self, images: list):
1418+
""" Remove a list of images as a single operation. """
1419+
if not images:
1420+
return
1421+
self.push(commands.RemoveSelectedImagesCommand(self._graph, self.cameraInit, images))
1422+
14161423
@Slot()
14171424
def removeAllImages(self):
14181425
with self.groupedGraphModification("Remove All Images"):

meshroom/ui/qml/ImageGallery/ImageDelegate.qml

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ Item {
2424
property variant parentModel
2525
property int selectedIndex: parentModel ? parentModel.selectedIndex : -1
2626
property bool isCurrentItem: cellID >= 0 && cellID === selectedIndex
27+
property var selectedIndices: parentModel ? parentModel.selectedIndices : []
28+
property bool isInMultiSelection: cellID >= 0 && selectedIndices.indexOf(cellID) >= 0
2729

2830
signal pressed(var mouse)
29-
signal removeRequest()
31+
signal removeSelectedRequest()
3032
signal removeAllImagesRequest()
3133

3234
default property alias children: imageMA.children
@@ -102,9 +104,9 @@ Item {
102104
}
103105
}
104106
MenuItem {
105-
text: "Remove"
106-
enabled: !root.readOnly
107-
onClicked: removeRequest()
107+
text: "Remove Selected Image" + (root.selectedIndices.length > 1 ? "s " : " ") + "(" + root.selectedIndices.length + ")"
108+
enabled: !root.readOnly && root.selectedIndices.length > 0
109+
onClicked: removeSelectedRequest()
108110
}
109111
MenuItem {
110112
text: "Remove All Images"
@@ -155,7 +157,7 @@ Item {
155157
Layout.fillWidth: true
156158
visible: root.displayThumbnail
157159
border.color: isCurrentItem ? grid_imageLabel.palette.highlight : Qt.darker(grid_imageLabel.palette.highlight)
158-
border.width: imageMA.containsMouse || root.isCurrentItem ? 2 : 0
160+
border.width: imageMA.containsMouse || root.isCurrentItem || root.isInMultiSelection ? 2 : 0
159161
Image {
160162
id: grid_thumbnail
161163
anchors.fill: parent
@@ -206,7 +208,7 @@ Item {
206208
horizontalAlignment: Text.AlignHCenter
207209
text: Filepath.basename(root.source)
208210
background: Rectangle {
209-
color: root.isCurrentItem ? parent.palette.highlight : "transparent"
211+
color: root.isCurrentItem ? parent.palette.highlight : (root.isInMultiSelection ? Qt.alpha(parent.palette.highlight, 0.5) : "transparent")
210212
}
211213
}
212214

@@ -243,7 +245,7 @@ Item {
243245
visible: root.displayThumbnail
244246

245247
border.color: isCurrentItem ? list_imageLabel.palette.highlight : Qt.darker(list_imageLabel.palette.highlight)
246-
border.width: imageMA.containsMouse || root.isCurrentItem ? 2 : 0
248+
border.width: imageMA.containsMouse || root.isCurrentItem || root.isInMultiSelection ? 2 : 0
247249

248250
Image {
249251
id: list_thumbnail
@@ -301,7 +303,7 @@ Item {
301303
verticalAlignment: Text.AlignVCenter
302304
text: Filepath.basename(root.source)
303305
background: Rectangle {
304-
color: root.isCurrentItem ? parent.palette.highlight : "transparent"
306+
color: root.isCurrentItem ? parent.palette.highlight : (root.isInMultiSelection ? Qt.alpha(parent.palette.highlight, 0.5) : "transparent")
305307
}
306308
}
307309

meshroom/ui/qml/ImageGallery/ImageGallery.qml

Lines changed: 120 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Panel {
4141
property int nbMeshroomScenes: 0
4242
property int nbDraggedFiles: 0
4343

44-
signal removeImageRequest(var attribute)
44+
signal removeSelectedImagesRequest(var objects)
4545
signal allViewpointsCleared()
4646
signal filesDropped(var drop)
4747

@@ -53,6 +53,7 @@ Panel {
5353

5454
function onCameraInitChanged() {
5555
nodesCB.currentIndex = root.cameraInitIndex
56+
sortedModel.clearMultiSelection(false)
5657
}
5758
}
5859

@@ -230,6 +231,43 @@ Panel {
230231
}
231232

232233
property int selectedIndex: -1
234+
property var selectedIndices: []
235+
236+
function toggleIndex(idx) {
237+
var newArr = selectedIndices.slice()
238+
var pos = newArr.indexOf(idx)
239+
if (pos >= 0) {
240+
newArr.splice(pos, 1)
241+
} else {
242+
newArr.push(idx)
243+
}
244+
selectedIndices = newArr
245+
}
246+
247+
function selectRange(from, to) {
248+
var newArr = []
249+
var start = Math.min(from, to)
250+
var end = Math.max(from, to)
251+
for (var i = start; i <= end; i++) {
252+
newArr.push(i)
253+
}
254+
selectedIndices = newArr
255+
}
256+
257+
function clearMultiSelection(keepPosition) {
258+
if (keepPosition) {
259+
// Pick the lowest selected index as the landing position; after removal
260+
// the next surviving item slides up to that slot.
261+
// Clamp to the last remaining item in case the selection was at the tail.
262+
var sortedSel = selectedIndices.slice().sort(function(a, b){ return a - b })
263+
var remainingCount = count - selectedIndices.length
264+
selectedIndex = Math.min(sortedSel[0], remainingCount - 1)
265+
selectedIndices = [selectedIndex]
266+
} else {
267+
selectedIndex = -1
268+
selectedIndices = []
269+
}
270+
}
233271

234272
delegate: ImageDelegate {
235273
id: imageDelegate
@@ -247,21 +285,93 @@ Panel {
247285

248286
parentModel: sortedModel
249287

250-
onPressed: {
288+
onPressed: function(mouse) {
289+
if (mouse.button !== Qt.LeftButton)
290+
return
251291
if (layoutLoader.item) {
252-
layoutLoader.item.currentIndex = DelegateModel.filteredIndex
253-
sortedModel.selectedIndex = DelegateModel.filteredIndex
292+
var idx = DelegateModel.filteredIndex
293+
if (mouse.modifiers & Qt.ShiftModifier && sortedModel.selectedIndex >= 0) {
294+
// Range select from last selectedIndex to clicked item
295+
sortedModel.selectRange(sortedModel.selectedIndex, idx)
296+
} else if (mouse.modifiers & Qt.ControlModifier) {
297+
// Toggle this item's selection
298+
sortedModel.toggleIndex(idx)
299+
// If the item is being removed from the selection, then we should return
300+
// before setting the current index: this prevents highlighting the item which is being
301+
// removed, as it could be confusing for the user
302+
if (sortedModel.selectedIndices.indexOf(idx) < 0) {
303+
if (sortedModel.selectedIndices.length === 0) {
304+
// Last item deselected: clear the viewer entirely
305+
sortedModel.selectedIndex = -1
306+
layoutLoader.item.currentIndex = -1
307+
_currentScene.selectedViewId = "-1"
308+
} else if (idx === sortedModel.selectedIndex) {
309+
// The currently viewed item was deselected: move to the
310+
// closest remaining selected item.
311+
var remaining = sortedModel.selectedIndices
312+
var next = remaining[0]
313+
var minDist = Math.abs(remaining[0] - idx)
314+
for (var r = 1; r < remaining.length; r++) {
315+
var dist = Math.abs(remaining[r] - idx)
316+
if (dist < minDist) {
317+
minDist = dist
318+
next = remaining[r]
319+
}
320+
}
321+
sortedModel.selectedIndex = next
322+
layoutLoader.item.currentIndex = next
323+
}
324+
return
325+
}
326+
} else {
327+
// Normal click: clear multi-selection, select only this item
328+
sortedModel.selectedIndices = [idx]
329+
}
330+
// Update selectedIndex before currentIndex to prevent onCurrentItemChanged
331+
// from incorrectly resetting the multi-selection
332+
sortedModel.selectedIndex = idx
333+
layoutLoader.item.currentIndex = idx
254334
}
255335
}
256336

257-
function sendRemoveRequest() {
337+
function sendRemoveSelectedRequest() {
258338
if (readOnly)
259339
return
260340

261-
root.removeImageRequest(object)
262-
341+
// Capture delegate-scope references immediately: this prevents falling into
342+
// cases where "sortedModel" is unresolvable because the delegate has been destroyed before
343+
// the line accessing "sortedModel" is reached
344+
var model = sortedModel
345+
var view = root.galleryGrid
346+
347+
// If all the images are selected, we can just remove all of them at once
348+
if (model.selectedIndices.length === m.viewpoints.count) {
349+
removeAllImages()
350+
return
351+
}
352+
353+
var objects = []
354+
for (var i = 0; i < model.selectedIndices.length; i++) {
355+
var obj = model.getObjectAt(model.selectedIndices[i])
356+
if (obj)
357+
objects.push(obj)
358+
}
359+
if (objects.length > 0) {
360+
root.removeSelectedImagesRequest(objects)
361+
model.clearMultiSelection(true)
362+
363+
// Restore a sensible position once the model has finished updating
364+
var targetIndex = model.selectedIndex
365+
Qt.callLater(function() {
366+
if (targetIndex >= 0 && view) {
367+
view.currentIndex = targetIndex
368+
view.makeCurrentItemVisible()
369+
}
370+
})
371+
}
372+
263373
// If the last image has been removed, make sure the viewpoints and intrinsics are reset
264-
if (m.viewpoints.count === 0)
374+
if (m.viewpoints !== undefined && m.viewpoints.count === 0)
265375
root.allViewpointsCleared()
266376
}
267377

@@ -270,12 +380,12 @@ Panel {
270380
_currentScene.selectedViewId = "-1"
271381
}
272382

273-
onRemoveRequest: sendRemoveRequest()
383+
onRemoveSelectedRequest: sendRemoveSelectedRequest()
274384
Keys.onPressed: function(event) {
275385
if (event.key === Qt.Key_Delete && event.modifiers === Qt.ShiftModifier) {
276386
removeAllImages()
277387
} else if (event.key === Qt.Key_Delete) {
278-
sendRemoveRequest()
388+
sendRemoveSelectedRequest()
279389
}
280390
}
281391
onRemoveAllImagesRequest: {
@@ -364,7 +474,6 @@ Panel {
364474
item.thumbnailSizeSlider = thumbnailSizeSlider
365475

366476
// Connect signals
367-
item.removeImageRequest.connect(root.removeImageRequest)
368477
item.allViewpointsCleared.connect(root.allViewpointsCleared)
369478

370479
// Restore currentIndex (before connecting signals to avoid unwanted selection change)

meshroom/ui/qml/ImageGallery/ImageGridView.qml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ GridView {
2222
property var sortedModel: null
2323

2424
// Signals
25-
signal removeImageRequest(var attribute)
2625
signal allViewpointsCleared()
2726

2827
ScrollBar.vertical: MScrollBar {
@@ -67,6 +66,12 @@ GridView {
6766
if (tempCameraInit !== null && root.currentIndex == 0)
6867
_currentScene.selectedViewId = -1
6968
_currentScene.selectedViewId = root.currentItem.viewpoint.get("viewId").value
69+
if (sortedModel && sortedModel.selectedIndex !== root.currentIndex) {
70+
sortedModel.selectedIndex = root.currentIndex
71+
sortedModel.selectedIndices = [root.currentIndex]
72+
}
73+
} else {
74+
_currentScene.selectedViewId = "-1"
7075
}
7176
}
7277

@@ -118,6 +123,10 @@ GridView {
118123
if (searchBar)
119124
searchBar.forceActiveFocus()
120125
event.accepted = true
126+
} else if (event.key === Qt.Key_Escape) {
127+
if (sortedModel)
128+
sortedModel.selectedIndices = [sortedModel.selectedIndex]
129+
event.accepted = true
121130
}
122131
}
123132
}

meshroom/ui/qml/ImageGallery/ImageListView.qml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ ListView {
2424
property real cellHeight: thumbnailSizeSlider ? thumbnailSizeSlider.value / 2 : 80
2525

2626
// Signals
27-
signal removeImageRequest(var attribute)
2827
signal allViewpointsCleared()
2928

3029
ScrollBar.vertical: MScrollBar {
@@ -68,6 +67,12 @@ ListView {
6867
if (tempCameraInit !== null && root.currentIndex == 0)
6968
_currentScene.selectedViewId = -1
7069
_currentScene.selectedViewId = root.currentItem.viewpoint.get("viewId").value
70+
if (sortedModel && sortedModel.selectedIndex !== root.currentIndex) {
71+
sortedModel.selectedIndex = root.currentIndex
72+
sortedModel.selectedIndices = [root.currentIndex]
73+
}
74+
} else {
75+
_currentScene.selectedViewId = "-1"
7176
}
7277
}
7378

@@ -113,6 +118,10 @@ ListView {
113118
if (searchBar)
114119
searchBar.forceActiveFocus()
115120
event.accepted = true
121+
} else if (event.key === Qt.Key_Escape) {
122+
if (sortedModel)
123+
sortedModel.selectedIndices = [sortedModel.selectedIndex]
124+
event.accepted = true
116125
}
117126
}
118127
}

meshroom/ui/qml/Utils/SortFilterDelegateModel.qml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ DelegateModel {
7474
return -1
7575
}
7676

77+
/// Get the model.object for the item at 'filteredIndex'
78+
function getObjectAt(filteredIndex) {
79+
if (filteredIndex >= 0 && filteredIndex < filteredItems.count) {
80+
return filteredItems.get(filteredIndex).model.object
81+
}
82+
return null
83+
}
84+
7785
/**
7886
* Return whether 'value' respects 'filter' condition
7987
*

0 commit comments

Comments
 (0)