diff: move selected_lines() and selected_text() to DiffTextEdit
[git-cola.git] / cola / widgets / imageview.py
blobb4dd180a141c927e6064c4090ac622861be3d8e6
1 # Copyright (c) 2018 David Aguilar <davvid@gmail.com>
3 # Git Cola is GPL licensed, but this file has a more permissive license.
4 # This file is dual-licensed Git Cola GPL + pyqimageview MIT.
5 # imageview.py was originally based on the pyqimageview:
6 # https://github.com/nevion/pyqimageview/
8 # The MIT License (MIT)
10 # Copyright (c) 2014 Jason Newton <nevion@gmail.com>
12 # Permission is hereby granted, free of charge, to any person obtaining a copy
13 # of this software and associated documentation files (the "Software"), to deal
14 # in the Software without restriction, including without limitation the rights
15 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16 # copies of the Software, and to permit persons to whom the Software is
17 # furnished to do so, subject to the following conditions:
19 # The above copyright notice and this permission notice shall be included in
20 # all copies or substantial portions of the Software.
22 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28 # SOFTWARE.
29 from __future__ import absolute_import, division, print_function, unicode_literals
30 import argparse
31 import os
32 import sys
34 from qtpy import QtCore
35 from qtpy import QtGui
36 from qtpy import QtWidgets
37 from qtpy.QtCore import Qt
38 from qtpy.QtCore import Signal
40 try:
41 import numpy as np
43 have_numpy = True
44 except ImportError:
45 have_numpy = False
47 from .. import qtcompat
49 main_loop_type = 'qt'
52 def clamp(x, lo, hi):
53 return max(min(x, hi), lo)
56 class ImageView(QtWidgets.QGraphicsView):
57 image_changed = Signal()
59 def __init__(self, parent=None):
60 super(ImageView, self).__init__(parent)
62 scene = QtWidgets.QGraphicsScene(self)
63 self.graphics_pixmap = QtWidgets.QGraphicsPixmapItem()
64 scene.addItem(self.graphics_pixmap)
65 self.setScene(scene)
67 self.zoom_factor = 1.125
68 self.rubberband = None
69 self.panning = False
70 self.first_show_occured = False
71 self.last_scene_roi = None
72 self.start_drag = QtCore.QPoint()
73 self.checkerboard = None
75 CHECK_MEDIUM = 8
76 CHECK_GRAY = 0x80
77 CHECK_LIGHT = 0xCC
78 check_pattern = QtGui.QImage(
79 CHECK_MEDIUM * 2, CHECK_MEDIUM * 2, QtGui.QImage.Format_RGB888
81 color_gray = QtGui.QColor(CHECK_GRAY, CHECK_GRAY, CHECK_GRAY)
82 color_light = QtGui.QColor(CHECK_LIGHT, CHECK_LIGHT, CHECK_LIGHT)
83 painter = QtGui.QPainter(check_pattern)
84 painter.fillRect(0, 0, CHECK_MEDIUM, CHECK_MEDIUM, color_gray)
85 painter.fillRect(
86 CHECK_MEDIUM, CHECK_MEDIUM, CHECK_MEDIUM, CHECK_MEDIUM, color_gray
88 painter.fillRect(0, CHECK_MEDIUM, CHECK_MEDIUM, CHECK_MEDIUM, color_light)
89 painter.fillRect(CHECK_MEDIUM, 0, CHECK_MEDIUM, CHECK_MEDIUM, color_light)
90 self.check_pattern = check_pattern
91 self.check_brush = QtGui.QBrush(check_pattern)
93 def load(self, filename):
94 image = QtGui.QImage()
95 image.load(filename)
96 ok = not image.isNull()
97 if ok:
98 self.pixmap = image
99 return ok
101 @property
102 def pixmap(self):
103 return self.graphics_pixmap.pixmap()
105 @pixmap.setter
106 def pixmap(self, image, image_format=None):
107 pixmap = None
108 if have_numpy and isinstance(image, np.ndarray):
109 if image.ndim == 3:
110 if image.shape[2] == 3:
111 if image_format is None:
112 image_format = QtGui.QImage.Format_RGB888
113 q_image = QtGui.QImage(
114 image.data, image.shape[1], image.shape[0], image_format
116 pixmap = QtGui.QPixmap.fromImage(q_image)
117 elif image.shape[2] == 4:
118 if image_format is None:
119 image_format = QtGui.QImage.Format_RGB32
120 q_image = QtGui.QImage(
121 image.data, image.shape[1], image.shape[0], image_format
123 pixmap = QtGui.QPixmap.fromImage(q_image)
124 else:
125 raise TypeError(image)
126 elif image.ndim == 2:
127 if image_format is None:
128 image_format = QtGui.QImage.Format_RGB888
129 q_image = QtGui.QImage(
130 image.data, image.shape[1], image.shape[0], image_format
132 pixmap = QtGui.QPixmap.fromImage(q_image)
133 else:
134 raise ValueError(image)
136 elif isinstance(image, QtGui.QImage):
137 pixmap = QtGui.QPixmap.fromImage(image)
138 elif isinstance(image, QtGui.QPixmap):
139 pixmap = image
140 else:
141 raise TypeError(image)
143 if pixmap.hasAlpha():
144 checkerboard = QtGui.QImage(
145 pixmap.width(), pixmap.height(), QtGui.QImage.Format_ARGB32
147 self.checkerboard = checkerboard
148 painter = QtGui.QPainter(checkerboard)
149 painter.fillRect(checkerboard.rect(), self.check_brush)
150 painter.drawPixmap(0, 0, pixmap)
151 pixmap = QtGui.QPixmap.fromImage(checkerboard)
153 self.graphics_pixmap.setPixmap(pixmap)
154 self.update_scene_rect()
155 self.fitInView(self.image_scene_rect, flags=Qt.KeepAspectRatio)
156 self.graphics_pixmap.update()
157 self.image_changed.emit()
159 # image property alias
160 @property
161 def image(self):
162 return self.pixmap
164 @image.setter
165 def image(self, image):
166 self.pixmap = image
168 def update_scene_rect(self):
169 pixmap = self.pixmap
170 self.setSceneRect(
171 QtCore.QRectF(
172 QtCore.QPointF(0, 0), QtCore.QPointF(pixmap.width(), pixmap.height())
176 @property
177 def image_scene_rect(self):
178 return QtCore.QRectF(
179 self.graphics_pixmap.pos(), QtCore.QSizeF(self.pixmap.size())
182 def resizeEvent(self, event):
183 super(ImageView, self).resizeEvent(event)
184 self.update_scene_rect()
185 event.accept()
186 self.fitInView(self.last_scene_roi, Qt.KeepAspectRatio)
187 self.update()
189 def zoomROICentered(self, p, zoom_level_delta):
190 roi = self.current_scene_ROI
191 if not roi:
192 return
193 roi_dims = QtCore.QPointF(roi.width(), roi.height())
194 roi_scalef = 1
196 if zoom_level_delta > 0:
197 roi_scalef = 1.0 / self.zoom_factor
198 elif zoom_level_delta < 0:
199 roi_scalef = self.zoom_factor
201 nroi_dims = roi_dims * roi_scalef
202 nroi_dims.setX(max(nroi_dims.x(), 1))
203 nroi_dims.setY(max(nroi_dims.y(), 1))
205 nroi_center = p
206 nroi_dimsh = nroi_dims / 2.0
207 nroi_topleft = nroi_center - nroi_dimsh
208 nroi = QtCore.QRectF(
209 nroi_topleft.x(), nroi_topleft.y(), nroi_dims.x(), nroi_dims.y()
211 self.fitInView(nroi, Qt.KeepAspectRatio)
212 self.update()
214 def zoomROITo(self, p, zoom_level_delta):
215 roi = self.current_scene_ROI
216 if not roi:
217 return
218 roi_dims = QtCore.QPointF(roi.width(), roi.height())
219 roi_topleft = roi.topLeft()
220 roi_scalef = 1.0
222 if zoom_level_delta > 0:
223 roi_scalef = 1.0 / self.zoom_factor
224 elif zoom_level_delta < 0:
225 roi_scalef = self.zoom_factor
227 nroi_dims = roi_dims * roi_scalef
228 nroi_dims.setX(max(nroi_dims.x(), 1.0))
229 nroi_dims.setY(max(nroi_dims.y(), 1.0))
231 prel_scaled_x = (p.x() - roi_topleft.x()) / roi_dims.x()
232 prel_scaled_y = (p.y() - roi_topleft.y()) / roi_dims.y()
233 nroi_topleft_x = p.x() - prel_scaled_x * nroi_dims.x()
234 nroi_topleft_y = p.y() - prel_scaled_y * nroi_dims.y()
236 nroi = QtCore.QRectF(
237 nroi_topleft_x, nroi_topleft_y, nroi_dims.x(), nroi_dims.y()
239 self.fitInView(nroi, Qt.KeepAspectRatio)
240 self.update()
242 def _scene_ROI(self, geometry):
243 return QtCore.QRectF(
244 self.mapToScene(geometry.topLeft()), self.mapToScene(geometry.bottomRight())
247 @property
248 def current_scene_ROI(self):
249 return self.last_scene_roi
251 def mousePressEvent(self, event):
252 super(ImageView, self).mousePressEvent(event)
253 button = event.button()
254 modifier = event.modifiers()
256 # pan
257 if modifier == Qt.ControlModifier and button == Qt.LeftButton:
258 self.start_drag = event.pos()
259 self.panning = True
261 # initiate/show ROI selection
262 if modifier == Qt.ShiftModifier and button == Qt.LeftButton:
263 self.start_drag = event.pos()
264 if self.rubberband is None:
265 self.rubberband = QtWidgets.QRubberBand(
266 QtWidgets.QRubberBand.Rectangle, self.viewport()
268 self.rubberband.setGeometry(QtCore.QRect(self.start_drag, QtCore.QSize()))
269 self.rubberband.show()
271 def mouseMoveEvent(self, event):
272 super(ImageView, self).mouseMoveEvent(event)
273 # update selection display
274 if self.rubberband is not None:
275 self.rubberband.setGeometry(
276 QtCore.QRect(self.start_drag, event.pos()).normalized()
279 if self.panning:
280 end_drag = event.pos()
281 pan_vector = end_drag - self.start_drag
282 scene2view = self.transform()
283 # skip shear
284 sx = scene2view.m11()
285 sy = scene2view.m22()
286 scene_pan_x = pan_vector.x() / sx
287 scene_pan_y = pan_vector.y() / sy
288 scene_pan_vector = QtCore.QPointF(scene_pan_x, scene_pan_y)
289 roi = self.current_scene_ROI
290 top_left = roi.topLeft()
291 new_top_left = top_left - scene_pan_vector
292 scene_rect = self.sceneRect()
293 new_top_left.setX(
294 clamp(new_top_left.x(), scene_rect.left(), scene_rect.right())
296 new_top_left.setY(
297 clamp(new_top_left.y(), scene_rect.top(), scene_rect.bottom())
299 nroi = QtCore.QRectF(new_top_left, roi.size())
300 self.fitInView(nroi, Qt.KeepAspectRatio)
301 self.start_drag = end_drag
302 self.update()
304 def mouseReleaseEvent(self, event):
305 super(ImageView, self).mouseReleaseEvent(event)
306 # consume rubber band selection
307 if self.rubberband is not None:
308 self.rubberband.hide()
310 # set view to ROI
311 rect = self.rubberband.geometry().normalized()
313 if rect.width() > 5 and rect.height() > 5:
314 roi = QtCore.QRectF(
315 self.mapToScene(rect.topLeft()), self.mapToScene(rect.bottomRight())
317 self.fitInView(roi, Qt.KeepAspectRatio)
319 self.rubberband = None
321 if self.panning:
322 self.panning = False
323 self.update()
325 def wheelEvent(self, event):
326 delta = qtcompat.wheel_delta(event)
327 # adjust zoom
328 if abs(delta) > 0:
329 scene_pos = self.mapToScene(event.pos())
330 if delta >= 0:
331 sign = 1
332 else:
333 sign = -1
334 self.zoomROITo(scene_pos, sign)
336 def reset(self):
337 self.update_scene_rect()
338 self.fitInView(self.image_scene_rect, flags=Qt.KeepAspectRatio)
339 self.update()
341 # override arbitrary and unwanted margins:
342 # https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
343 def fitInView(self, rect, flags=Qt.IgnoreAspectRatio):
344 if self.scene() is None or not rect or rect.isNull():
345 return
346 self.last_scene_roi = rect
347 unity = self.transform().mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
348 self.scale(1.0 / unity.width(), 1.0 / unity.height())
349 viewrect = self.viewport().rect()
350 scene_rect = self.transform().mapRect(rect)
351 xratio = viewrect.width() / scene_rect.width()
352 yratio = viewrect.height() / scene_rect.height()
353 if flags == Qt.KeepAspectRatio:
354 xratio = yratio = min(xratio, yratio)
355 elif flags == Qt.KeepAspectRatioByExpanding:
356 xratio = yratio = max(xratio, yratio)
357 self.scale(xratio, yratio)
358 self.centerOn(rect.center())
361 class AppImageView(ImageView):
362 def __init__(self, parent=None):
363 ImageView.__init__(self, parent=parent)
364 self.main_widget = None
366 def mousePressEvent(self, event):
367 ImageView.mousePressEvent(self, event)
369 def mouseMoveEvent(self, event):
370 ImageView.mouseMoveEvent(self, event)
371 pos = event.pos()
372 scene_pos = self.mapToScene(pos)
373 msg = 'ui: %d, %d image: %d, %d' % (
374 pos.y(),
375 pos.x(),
376 int(scene_pos.y()),
377 int(scene_pos.x()),
379 self.main_widget.statusBar().showMessage(msg)
382 class ImageViewerWindow(QtWidgets.QMainWindow):
383 def __init__(self, image, input_path):
384 QtWidgets.QMainWindow.__init__(self)
385 self.image = image
386 self.input_path = input_path
387 self.image_view = AppImageView(parent=self)
388 self.image_view.main_widget = self
389 self.statusBar().showMessage('')
391 padding = self.frameGeometry().size() - self.geometry().size()
392 self.resize(image.size() + padding)
394 central = QtWidgets.QWidget(self)
395 self.vbox = QtWidgets.QVBoxLayout(central)
396 self.vbox.setContentsMargins(0, 0, 0, 0)
397 self.setCentralWidget(central)
398 self.layout().setContentsMargins(0, 0, 0, 0)
400 Expanding = QtWidgets.QSizePolicy.Expanding
401 height_for_width = self.image_view.sizePolicy().hasHeightForWidth()
402 policy = QtWidgets.QSizePolicy(Expanding, Expanding)
403 policy.setHorizontalStretch(1)
404 policy.setVerticalStretch(1)
405 policy.setHeightForWidth(height_for_width)
406 self.image_view.setSizePolicy(policy)
408 self.image_view.setMouseTracking(True)
409 self.image_view.setFocusPolicy(Qt.NoFocus)
410 self.image_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
411 self.image_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
412 self.vbox.addWidget(self.image_view)
414 screen = QtWidgets.QDesktopWidget().screenGeometry(self)
415 size = self.geometry()
416 self.move(
417 (screen.width() - size.width()) // 4, (screen.height() - size.height()) // 4
420 self.update_view()
421 self.image_view.reset()
423 def hideEvent(self, _event):
424 QtWidgets.QMainWindow.hide(self)
426 def update_view(self):
427 self.image_view.image = self.image
428 self.setWindowTitle(self.make_window_title())
430 def make_window_title(self):
431 return os.path.basename(self.input_path)
433 def keyPressEvent(self, event):
434 key = event.key()
435 global main_loop_type # pylint: disable=global-statement
436 if key == Qt.Key_Escape:
437 if main_loop_type == 'qt':
438 QtWidgets.QApplication.quit()
439 elif main_loop_type == 'ipython':
440 self.hide()
441 # import IPython
442 # IPython.get_ipython().ask_exit()
445 # pylint: disable=unused-argument
446 def sigint_handler(*args):
447 """Handler for the SIGINT signal."""
448 sys.stderr.write('\r')
449 QtWidgets.QApplication.quit()
452 def main():
453 parser = argparse.ArgumentParser(description='image viewer')
454 parser.add_argument('image', help='path to the image')
455 opts = parser.parse_args()
457 input_image = opts.image
458 image = QtGui.QImage()
459 image.load(input_image)
461 app = QtWidgets.QApplication(sys.argv)
462 try:
463 import signal # pylint: disable=all
465 signal.signal(signal.SIGINT, sigint_handler)
466 except ImportError:
467 pass
468 window = ImageViewerWindow(image, input_image)
469 window.show()
470 app.exec_()
473 if __name__ == '__main__':
474 main()