Coverage for paintmaskanimator/subview.py: 69%

475 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""Linked reference-image viewer used by the dockable sub-view panel.""" 

2 

3import math 

4from pathlib import Path 

5from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, Qt, Signal 

6from PySide6.QtGui import ( 

7 QColor, 

8 QCursor, 

9 QImage, 

10 QImageReader, 

11 QPainter, 

12 QPainterPath, 

13 QPen, 

14 QPixmap, 

15 QTransform, 

16) 

17from PySide6.QtWidgets import ( 

18 QApplication, 

19 QFileDialog, 

20 QHBoxLayout, 

21 QLabel, 

22 QPushButton, 

23 QSizePolicy, 

24 QSlider, 

25 QToolButton, 

26 QVBoxLayout, 

27 QWidget, 

28) 

29from .i18n import tr 

30from .optional_deps import PILImage, PSDImage 

31from . import imaging 

32from .utils import drag_zoom_factor, install_app_event_filter, magnifier_cursor 

33 

34 

35SUBVIEW_EXTENSIONS = {".png", ".jpg", ".jpeg", ".tga", ".psd"} 

36 

37 

38class SubViewWidget(QWidget): 

39 """画像ファイルをコピーせず、元パスへのリンクとして表示する。""" 

40 

41 colorPicked = Signal(QColor) 

42 pathChanged = Signal(str) 

43 visibilityChanged = Signal(bool) 

44 

45 def __init__(self, parent=None): 

46 super().__init__(parent) 

47 self.setAcceptDrops(True) 

48 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) 

49 self.setMinimumSize(180, 140) 

50 self.setWindowTitle(tr("サブビュー")) 

51 self._image = QImage() 

52 self._path = None 

53 self._files = [] 

54 self._index = -1 

55 self._zoom = 1.0 

56 self._rotation = 0.0 

57 self._pan = QPointF() 

58 self._space_down = False 

59 self._control_down = False 

60 self._drag_mode = None 

61 self._press_pos = QPointF() 

62 self._zoom_anchor = QPointF() 

63 self._last_pos = QPointF() 

64 self._moved = False 

65 self._color_provider = None 

66 self._pick_position = None 

67 self._pick_before_color = QColor() 

68 self._pick_after_color = QColor() 

69 

70 self.previous_button = QToolButton(self) 

71 self.previous_button.setText("◀") 

72 self.previous_button.setToolTip(tr("前の画像")) 

73 self.next_button = QToolButton(self) 

74 self.next_button.setText("▶") 

75 self.next_button.setToolTip(tr("次の画像")) 

76 self.open_button = QToolButton(self) 

77 self.open_button.setText(tr("開く")) 

78 self.open_button.setToolTip(tr("画像を開く")) 

79 self.fit_button = QToolButton(self) 

80 self.fit_button.setText(tr("全体")) 

81 self.fit_button.setToolTip(tr("全体を表示")) 

82 self.name_label = QLabel(tr("画像またはフォルダをドロップ"), self) 

83 self.name_label.setTextInteractionFlags( 

84 Qt.TextInteractionFlag.TextSelectableByMouse 

85 ) 

86 self.name_label.setSizePolicy( 

87 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred 

88 ) 

89 toolbar = QHBoxLayout() 

90 toolbar.setContentsMargins(4, 3, 4, 3) 

91 toolbar.addWidget(self.previous_button) 

92 toolbar.addWidget(self.next_button) 

93 toolbar.addWidget(self.open_button) 

94 toolbar.addWidget(self.fit_button) 

95 toolbar.addWidget(self.name_label, 1) 

96 self.viewer = _SubViewCanvas(self) 

97 self._update_viewer_cursor() 

98 layout = QVBoxLayout(self) 

99 layout.setContentsMargins(0, 0, 0, 0) 

100 layout.setSpacing(0) 

101 layout.addLayout(toolbar) 

102 layout.addWidget(self.viewer, 1) 

103 

104 controls = QHBoxLayout() 

105 controls.setContentsMargins(5, 3, 5, 4) 

106 self.zoom_slider = QSlider(Qt.Orientation.Horizontal) 

107 self.zoom_slider.setRange(5, 1600) 

108 self.zoom_slider.setValue(100) 

109 self.zoom_label = QLabel("100%") 

110 self.zoom_100_button = QPushButton(tr("100%表示")) 

111 self.rotation_slider = QSlider(Qt.Orientation.Horizontal) 

112 self.rotation_slider.setRange(-180, 180) 

113 self.rotation_slider.setValue(0) 

114 self.rotation_label = QLabel("0°") 

115 self.rotation_0_button = QPushButton("0°") 

116 for widget in ( 

117 QLabel(tr("拡大")), self.zoom_slider, self.zoom_label, 

118 self.zoom_100_button, QLabel(tr("回転")), self.rotation_slider, 

119 self.rotation_label, self.rotation_0_button, 

120 ): 

121 controls.addWidget(widget) 

122 layout.addLayout(controls) 

123 self.previous_button.clicked.connect(lambda: self.step(-1)) 

124 self.next_button.clicked.connect(lambda: self.step(1)) 

125 self.open_button.clicked.connect(self.choose_image) 

126 self.fit_button.clicked.connect(self.reset_view) 

127 self.zoom_slider.valueChanged.connect(self.set_zoom_percent) 

128 self.zoom_100_button.clicked.connect( 

129 lambda: self.zoom_slider.setValue(100) 

130 ) 

131 self.rotation_slider.valueChanged.connect(self.set_rotation) 

132 self.rotation_0_button.clicked.connect( 

133 lambda: self.rotation_slider.setValue(0) 

134 ) 

135 self._update_navigation() 

136 install_app_event_filter(self) 

137 

138 def eventFilter(self, watched, event): 

139 """サブビューが前面なら、子部品のフォーカスに関係なくSpaceを捕捉する。""" 

140 if not isinstance(watched, QObject): 140 ↛ 146line 140 didn't jump to line 146 because the condition on line 140 was never true

141 # PySide6 が watched を QObject ではない型(QWidgetItem など)として 

142 # 渡してくることがある。そのまま QObject.eventFilter に渡すと 

143 # TypeError になり、例外がイベントループの外――たまたま実行中だった 

144 # 任意の Python フレーム――へ抜けて落ちる。見分けられない相手なら、 

145 # フィルターせずに降りる。 

146 return False 

147 active = ( 

148 QApplication.activeWindow() 

149 if QApplication.instance() is not None 

150 else None 

151 ) 

152 belongs_to_subview = bool( 

153 isinstance(watched, QWidget) 

154 and (watched is self or self.isAncestorOf(watched)) 

155 ) 

156 if active is self or belongs_to_subview: 

157 if event.type() == QEvent.Type.ShortcutOverride and event.key() in ( 157 ↛ 160line 157 didn't jump to line 160 because the condition on line 157 was never true

158 Qt.Key.Key_Space, Qt.Key.Key_Control 

159 ): 

160 event.accept() 

161 return True 

162 if ( 162 ↛ 167line 162 didn't jump to line 167 because the condition on line 162 was never true

163 event.type() in (QEvent.Type.KeyPress, QEvent.Type.KeyRelease) 

164 and event.key() in (Qt.Key.Key_Space, Qt.Key.Key_Control) 

165 and not event.isAutoRepeat() 

166 ): 

167 self.handle_hold_key_event(event) 

168 event.accept() 

169 return True 

170 return super().eventFilter(watched, event) 

171 

172 def handle_hold_key_event(self, event): 

173 pressed = event.type() == QEvent.Type.KeyPress 

174 if event.key() == Qt.Key.Key_Space: 

175 self._space_down = pressed 

176 elif event.key() == Qt.Key.Key_Control: 

177 self._control_down = pressed 

178 self._update_viewer_cursor() 

179 

180 def _eyedropper_cursor(self): 

181 cached = getattr(self, "_eyedropper_cursor_cache", None) 

182 if cached is not None: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true

183 return cached 

184 pixmap = QPixmap(24, 24) 

185 pixmap.fill(Qt.GlobalColor.transparent) 

186 painter = QPainter(pixmap) 

187 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) 

188 painter.setPen(QPen(QColor("black"), 2)) 

189 painter.drawLine(5, 18, 16, 7) 

190 painter.drawLine(8, 21, 19, 10) 

191 painter.drawLine(15, 6, 20, 11) 

192 painter.drawEllipse(QPointF(5, 19), 2, 2) 

193 painter.end() 

194 self._eyedropper_cursor_cache = QCursor(pixmap, 5, 19) 

195 return self._eyedropper_cursor_cache 

196 

197 def _update_viewer_cursor(self): 

198 if self._drag_mode == "pan": 

199 self.viewer.setCursor(Qt.CursorShape.ClosedHandCursor) 

200 elif self._drag_mode == "zoom" or ( 200 ↛ 203line 200 didn't jump to line 203 because the condition on line 200 was never true

201 self._space_down and self._control_down 

202 ): 

203 self.viewer.setCursor(magnifier_cursor()) 

204 elif self._space_down: 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true

205 self.viewer.setCursor(Qt.CursorShape.OpenHandCursor) 

206 elif self._drag_mode == "pick": 

207 self.viewer.setCursor(self._eyedropper_cursor()) 

208 else: 

209 self.viewer.unsetCursor() 

210 

211 def set_color_provider(self, provider): 

212 """スポイト確定前の描画色を取得するコールバックを設定する。""" 

213 self._color_provider = provider 

214 

215 def current_paint_color(self): 

216 if callable(self._color_provider): 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true

217 try: 

218 return QColor(self._color_provider()) 

219 except (RuntimeError, TypeError, AttributeError): 

220 pass 

221 return QColor() 

222 

223 @staticmethod 

224 def supported_extensions(): 

225 return set(SUBVIEW_EXTENSIONS) 

226 

227 def choose_image(self): 

228 path, _selected = QFileDialog.getOpenFileName( 

229 self, tr("サブビュー画像を開く"), "", 

230 tr("対応画像 (*.png *.jpg *.jpeg *.tga *.psd);;" 

231 "PNG (*.png);;JPEG (*.jpg *.jpeg);;TGA (*.tga);;PSD (*.psd);;" 

232 "すべてのファイル (*)"), 

233 ) 

234 if path: 

235 self.load_path(path) 

236 

237 def load_path(self, path): 

238 candidate = Path(path) 

239 if candidate.is_dir(): 

240 files = self._images_in_folder(candidate) 

241 if not files: 241 ↛ 242line 241 didn't jump to line 242 because the condition on line 241 was never true

242 self.name_label.setText(tr("画像が見つかりません")) 

243 return False 

244 target = files[0] 

245 else: 

246 target = candidate 

247 files = self._images_in_folder(candidate.parent) 

248 if candidate not in files: 

249 files.append(candidate) 

250 files.sort(key=lambda item: item.name.casefold()) 

251 image = self._read_image(target) 

252 if image.isNull(): 

253 self.name_label.setText(tr("読み込めません: {name}").format(name=target.name)) 

254 return False 

255 self._files = files 

256 self._index = files.index(target) 

257 self._set_image(target, image) 

258 return True 

259 

260 @staticmethod 

261 def _read_image(path): 

262 """対応形式を読み込み、PSDは表示レイヤーの合成結果を返す。""" 

263 path = Path(path) 

264 if path.suffix.lower() != ".psd": 264 ↛ 277line 264 didn't jump to line 277 because the condition on line 264 was always true

265 reader = QImageReader(str(path)) 

266 reader.setAutoTransform(True) 

267 image = reader.read() 

268 if not image.isNull() or PILImage is None: 

269 return image 

270 # QtのTGAプラグインが対応しない圧縮・ピクセル形式はPillowで補完する。 

271 try: 

272 with PILImage.open(path) as source: 

273 source.load() 

274 return imaging.pil_rgba_to_qimage(source.convert("RGBA")) 

275 except (ValueError, TypeError, OSError, RuntimeError, AttributeError): 

276 return QImage() 

277 if PSDImage is None or PILImage is None: 

278 return QImage() 

279 try: 

280 psd = PSDImage.open(path) 

281 # PSDImage.composite() は非表示レイヤーを除外した表示合成を返す。 

282 rendered = psd.composite() 

283 if rendered is None: 

284 return QImage() 

285 return imaging.pil_rgba_to_qimage(rendered) 

286 except ( 

287 ValueError, KeyError, IndexError, TypeError, OSError, 

288 RuntimeError, AttributeError, 

289 ): 

290 return QImage() 

291 

292 def _images_in_folder(self, folder): 

293 extensions = self.supported_extensions() 

294 try: 

295 return sorted( 

296 (item for item in folder.iterdir() 

297 if item.is_file() and item.suffix.lower() in extensions), 

298 key=lambda item: item.name.casefold(), 

299 ) 

300 except OSError: 

301 return [] 

302 

303 def _set_image(self, path, image): 

304 self._path = Path(path) 

305 self._image = image.convertToFormat(QImage.Format.Format_ARGB32) 

306 self.name_label.setText(self._path.name) 

307 self.name_label.setToolTip(str(self._path)) 

308 self.reset_view() 

309 self._update_navigation() 

310 self.pathChanged.emit(str(self._path)) 

311 

312 def step(self, offset): 

313 if not self._files: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true

314 return 

315 index = self._index + int(offset) 

316 if not 0 <= index < len(self._files): 316 ↛ 317line 316 didn't jump to line 317 because the condition on line 316 was never true

317 return 

318 target = self._files[index] 

319 image = self._read_image(target) 

320 if image.isNull(): 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true

321 return 

322 self._index = index 

323 self._set_image(target, image) 

324 

325 def _update_navigation(self): 

326 self.previous_button.setEnabled(self._index > 0) 

327 self.next_button.setEnabled(0 <= self._index < len(self._files) - 1) 

328 

329 def reset_view(self): 

330 self.zoom_slider.blockSignals(True) 

331 self.zoom_slider.setValue(100) 

332 self.zoom_slider.blockSignals(False) 

333 self._zoom = 1.0 

334 self.zoom_label.setText("100%") 

335 self._pan = QPointF() 

336 self.viewer.update() 

337 

338 def set_zoom_percent(self, value): 

339 self._zoom = max(0.05, min(16.0, int(value) / 100.0)) 

340 self.zoom_label.setText(f"{int(value)}%") 

341 self.viewer.update() 

342 

343 def set_rotation(self, value): 

344 self._rotation = float(value) 

345 self.rotation_label.setText(f"{int(value)}°") 

346 self.viewer.update() 

347 

348 def showEvent(self, event): 

349 super().showEvent(event) 

350 self.visibilityChanged.emit(True) 

351 

352 def closeEvent(self, event): 

353 self.visibilityChanged.emit(False) 

354 super().closeEvent(event) 

355 

356 def dragEnterEvent(self, event): 

357 if event.mimeData().hasUrls(): 

358 event.acceptProposedAction() 

359 

360 def dropEvent(self, event): 

361 for url in event.mimeData().urls(): 

362 if url.isLocalFile() and self.load_path(url.toLocalFile()): 

363 event.acceptProposedAction() 

364 return 

365 

366 

367_PAN_BUTTONS = (Qt.MouseButton.RightButton, Qt.MouseButton.MiddleButton) 

368_HANDLED_BUTTONS = (Qt.MouseButton.LeftButton, *_PAN_BUTTONS) 

369 

370 

371class _SubViewCanvas(QWidget): 

372 def __init__(self, owner): 

373 super().__init__(owner) 

374 self.owner = owner 

375 self._wheel_percent = None 

376 self.setAcceptDrops(True) 

377 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) 

378 self.setMouseTracking(True) 

379 

380 def enterEvent(self, event): 

381 self.owner._update_viewer_cursor() 

382 super().enterEvent(event) 

383 

384 def _fit_scale(self): 

385 image = self.owner._image 

386 if image.isNull() or self.width() <= 0 or self.height() <= 0: 386 ↛ 387line 386 didn't jump to line 387 because the condition on line 386 was never true

387 return 0.0 

388 angle = math.radians(self.owner._rotation) 

389 cosine, sine = abs(math.cos(angle)), abs(math.sin(angle)) 

390 rotated_width = image.width() * cosine + image.height() * sine 

391 rotated_height = image.width() * sine + image.height() * cosine 

392 return min(self.width() / rotated_width, self.height() / rotated_height) 

393 

394 def _image_transform(self): 

395 image = self.owner._image 

396 transform = QTransform() 

397 if image.isNull(): 397 ↛ 398line 397 didn't jump to line 398 because the condition on line 397 was never true

398 return transform 

399 center = QPointF(self.rect().center()) + self.owner._pan 

400 scale = max(0.01, self._fit_scale() * self.owner._zoom) 

401 transform.translate(center.x(), center.y()) 

402 transform.rotate(self.owner._rotation) 

403 transform.scale(scale, scale) 

404 transform.translate(-image.width() / 2, -image.height() / 2) 

405 return transform 

406 

407 def _image_rect(self): 

408 image = self.owner._image 

409 if image.isNull(): 409 ↛ 410line 409 didn't jump to line 410 because the condition on line 409 was never true

410 return QRectF() 

411 return self._image_transform().mapRect( 

412 QRectF(0, 0, image.width(), image.height()) 

413 ) 

414 

415 def paintEvent(self, _event): 

416 painter = QPainter(self) 

417 painter.fillRect(self.rect(), QColor("#30343a")) 

418 if self.owner._image.isNull(): 

419 painter.setPen(QColor("#c3c8cc")) 

420 painter.drawText( 

421 self.rect(), Qt.AlignmentFlag.AlignCenter, 

422 tr("画像またはフォルダを\nここにドロップ"), 

423 ) 

424 return 

425 painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False) 

426 painter.setTransform(self._image_transform()) 

427 painter.drawImage(QPointF(0, 0), self.owner._image) 

428 painter.resetTransform() 

429 if self.owner._drag_mode == "pick" and self.owner._pick_position is not None: 429 ↛ 430line 429 didn't jump to line 430 because the condition on line 429 was never true

430 self._draw_color_loupe(painter) 

431 

432 def _draw_color_loupe(self, painter): 

433 position = QPointF(self.owner._pick_position) 

434 image_point = self._widget_to_image(position) 

435 if image_point is None: 

436 return 

437 radius = 42.0 

438 center = position + QPointF(58, -58) 

439 center.setX(max(radius + 4, min(self.width() - radius - 4, center.x()))) 

440 center.setY(max(radius + 4, min(self.height() - radius - 30, center.y()))) 

441 circle = QRectF( 

442 center.x() - radius, center.y() - radius, 

443 radius * 2, radius * 2, 

444 ) 

445 painter.save() 

446 clip = QPainterPath() 

447 clip.addEllipse(circle) 

448 painter.setClipPath(clip) 

449 painter.fillRect(circle, QColor("#20242a")) 

450 magnification = 10.0 

451 transform = QTransform() 

452 transform.translate(center.x(), center.y()) 

453 transform.scale(magnification, magnification) 

454 transform.translate(-image_point.x(), -image_point.y()) 

455 painter.setTransform(transform) 

456 painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False) 

457 painter.drawImage(QPointF(0, 0), self.owner._image) 

458 painter.restore() 

459 painter.setPen(QPen(QColor("white"), 2)) 

460 painter.setBrush(Qt.BrushStyle.NoBrush) 

461 painter.drawEllipse(circle) 

462 painter.drawLine( 

463 QPointF(center.x() - 7, center.y()), 

464 QPointF(center.x() + 7, center.y()), 

465 ) 

466 painter.drawLine( 

467 QPointF(center.x(), center.y() - 7), 

468 QPointF(center.x(), center.y() + 7), 

469 ) 

470 

471 swatch_y = circle.bottom() + 5 

472 before_rect = QRectF(center.x() - 40, swatch_y, 38, 20) 

473 after_rect = QRectF(center.x() + 2, swatch_y, 38, 20) 

474 before = self.owner._pick_before_color 

475 after = self.owner._pick_after_color 

476 painter.fillRect(before_rect, before if before.isValid() else QColor("#000000")) 

477 painter.fillRect(after_rect, after if after.isValid() else QColor("#000000")) 

478 painter.setPen(QPen(QColor("white"), 1)) 

479 painter.drawRect(before_rect) 

480 painter.drawRect(after_rect) 

481 painter.drawText(before_rect, Qt.AlignmentFlag.AlignCenter, tr("前")) 

482 painter.drawText(after_rect, Qt.AlignmentFlag.AlignCenter, tr("後")) 

483 

484 def dragEnterEvent(self, event): 

485 self.owner.dragEnterEvent(event) 

486 

487 def dropEvent(self, event): 

488 self.owner.dropEvent(event) 

489 

490 def keyPressEvent(self, event): 

491 if event.key() in (Qt.Key.Key_Space, Qt.Key.Key_Control) and not event.isAutoRepeat(): 

492 self.owner.handle_hold_key_event(event) 

493 event.accept() 

494 return 

495 super().keyPressEvent(event) 

496 

497 def keyReleaseEvent(self, event): 

498 if event.key() in (Qt.Key.Key_Space, Qt.Key.Key_Control) and not event.isAutoRepeat(): 

499 self.owner.handle_hold_key_event(event) 

500 event.accept() 

501 return 

502 super().keyReleaseEvent(event) 

503 

504 def mousePressEvent(self, event): 

505 self.setFocus(Qt.FocusReason.MouseFocusReason) 

506 if event.button() not in _HANDLED_BUTTONS: 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true

507 return 

508 self.owner._press_pos = event.position() 

509 self.owner._last_pos = event.position() 

510 self.owner._moved = False 

511 self.owner._zoom_anchor = QPointF(event.position()) 

512 if event.button() in _PAN_BUTTONS: 

513 # 右ドラッグ(中ボタンも)で画像を動かす。左クリックはスポイト。 

514 self.owner._drag_mode = "pan" 

515 self.setCursor(Qt.CursorShape.ClosedHandCursor) 

516 elif self.owner._space_down: 516 ↛ 517line 516 didn't jump to line 517 because the condition on line 516 was never true

517 modifiers = ( 

518 event.modifiers() | QApplication.keyboardModifiers() 

519 ) 

520 self.owner._drag_mode = ( 

521 "zoom" if ( 

522 self.owner._control_down 

523 or modifiers & Qt.KeyboardModifier.ControlModifier 

524 ) 

525 else "pan" 

526 ) 

527 else: 

528 self.owner._drag_mode = "pick" 

529 self.owner._pick_position = QPointF(event.position()) 

530 self.owner._pick_before_color = self.owner.current_paint_color() 

531 sampled = self._color_at(event.position()) 

532 self.owner._pick_after_color = sampled or QColor() 

533 self.owner._update_viewer_cursor() 

534 self.update() 

535 event.accept() 

536 

537 def mouseMoveEvent(self, event): 

538 mode = self.owner._drag_mode 

539 if mode is None: 539 ↛ 540line 539 didn't jump to line 540 because the condition on line 539 was never true

540 return 

541 delta = event.position() - self.owner._last_pos 

542 if (event.position() - self.owner._press_pos).manhattanLength() > 3: 542 ↛ 544line 542 didn't jump to line 544 because the condition on line 542 was always true

543 self.owner._moved = True 

544 if mode == "pan": 544 ↛ 547line 544 didn't jump to line 547 because the condition on line 544 was always true

545 self.owner._pan += delta 

546 self.update() 

547 elif mode == "zoom": 

548 factor = drag_zoom_factor(delta.x()) 

549 percent = int(round(self.owner._zoom * factor * 100)) 

550 # ドラッグを始めた位置を中心に拡大縮小する。 

551 self.zoom_to_percent( 

552 max(5, min(1600, percent)), self.owner._zoom_anchor 

553 ) 

554 elif mode == "pick": 

555 self.owner._pick_position = QPointF(event.position()) 

556 sampled = self._color_at(event.position()) 

557 self.owner._pick_after_color = sampled or QColor() 

558 self.update() 

559 self.owner._last_pos = event.position() 

560 event.accept() 

561 

562 def mouseReleaseEvent(self, event): 

563 if event.button() not in _HANDLED_BUTTONS: 563 ↛ 564line 563 didn't jump to line 564 because the condition on line 563 was never true

564 return 

565 if self.owner._drag_mode == "pick": 

566 self._pick_color(event.position()) 

567 self.owner._drag_mode = None 

568 self.owner._pick_position = None 

569 self.owner._update_viewer_cursor() 

570 self.update() 

571 event.accept() 

572 

573 def wheelEvent(self, event): 

574 delta = event.angleDelta().y() 

575 if not delta: 575 ↛ 576line 575 didn't jump to line 576 because the condition on line 575 was never true

576 event.ignore() 

577 return 

578 # トラックパッドの細かいイベントでも移動量に比例させる(1ノッチ=120)。 

579 # スライダーは整数%なので、端数込みの倍率を保持して次のイベントへ持ち越す。 

580 current = self.owner.zoom_slider.value() 

581 exact = self._wheel_percent 

582 if exact is None or round(exact) != current: 

583 exact = float(current) # ボタンやドラッグなど他の経路で変わった 

584 exact = max(5.0, min(1600.0, exact * 1.15 ** (delta / 120))) 

585 self._wheel_percent = exact 

586 if round(exact) != current: 

587 self.zoom_to_percent(round(exact), event.position()) 

588 event.accept() 

589 

590 def zoom_to_percent(self, percent, anchor=None): 

591 """倍率を変える。``anchor``(ウィジェット座標)の下の画素は動かさない。""" 

592 image = self.owner._image 

593 if anchor is None or image.isNull(): 

594 self.owner.zoom_slider.setValue(int(percent)) 

595 return 

596 anchor = QPointF(anchor) 

597 inverse, invertible = self._image_transform().inverted() 

598 self.owner.zoom_slider.setValue(int(percent)) 

599 if not invertible: 599 ↛ 600line 599 didn't jump to line 600 because the condition on line 599 was never true

600 return 

601 image_point = inverse.map(anchor) 

602 moved_to = self._image_transform().map(image_point) 

603 self.owner._pan += anchor - moved_to 

604 self.update() 

605 

606 def _pick_color(self, position): 

607 color = self._color_at(position) 

608 if color is not None: 608 ↛ exitline 608 didn't return from function '_pick_color' because the condition on line 608 was always true

609 self.owner.colorPicked.emit(color) 

610 

611 def _widget_to_image(self, position): 

612 image = self.owner._image 

613 if image.isNull(): 613 ↛ 614line 613 didn't jump to line 614 because the condition on line 613 was never true

614 return None 

615 inverse, invertible = self._image_transform().inverted() 

616 if not invertible: 616 ↛ 617line 616 didn't jump to line 617 because the condition on line 616 was never true

617 return None 

618 point = inverse.map(position) 

619 x, y = int(point.x()), int(point.y()) 

620 if not (0 <= x < image.width() and 0 <= y < image.height()): 620 ↛ 621line 620 didn't jump to line 621 because the condition on line 620 was never true

621 return None 

622 return QPointF(x + 0.5, y + 0.5) 

623 

624 def _color_at(self, position): 

625 image = self.owner._image 

626 point = self._widget_to_image(position) 

627 if point is None: 627 ↛ 628line 627 didn't jump to line 628 because the condition on line 627 was never true

628 return None 

629 x, y = int(point.x()), int(point.y()) 

630 x = max(0, min(image.width() - 1, x)) 

631 y = max(0, min(image.height() - 1, y)) 

632 return QColor.fromRgba(image.pixel(x, y))