Coverage for paintmaskanimator/cut_folder_dialog.py: 64%

844 statements  

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

1"""Cut-folder export dialog and its block-template editor. 

2 

3:class:`TemplateEditor` is a single-line, wrapping editor whose content is a 

4list of :class:`~.cut_folder.Token` -- literal characters and field blocks. 

5Typing filters a suggestion list of blocks; text that matches no block is kept 

6as literal separators. The caret moves through characters and whole blocks, 

7and the platform's word-movement keys (⌥←/→ on macOS, Ctrl+←/→ elsewhere) jump 

8by *chunk*: a block, a run of letters/digits, or a run of one symbol. 

9""" 

10from __future__ import annotations 

11 

12from dataclasses import replace 

13from pathlib import Path 

14 

15from PySide6.QtCore import QPoint, QRect, QRectF, QSize, Qt, Signal 

16from PySide6.QtGui import ( 

17 QColor, QFontMetrics, QKeySequence, QPainter, QPalette, QPen, 

18) 

19from PySide6.QtWidgets import ( 

20 QAbstractItemView, QButtonGroup, QComboBox, QDialog, QFileDialog, 

21 QFormLayout, QFrame, QHBoxLayout, QInputDialog, QLabel, QLineEdit, 

22 QListWidget, QListWidgetItem, QMessageBox, QPushButton, QSizePolicy, QStyle, 

23 QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, 

24) 

25 

26from . import config 

27from . import cut_folder as cf 

28from .cut_folder import Token 

29from .i18n import tr 

30 

31CONFIG_KEY = "cut_folder_export" 

32 

33 

34def field_label(name): 

35 return { 

36 "title": tr("作品名"), 

37 "scene": tr("シーン"), 

38 "episode": tr("話数"), 

39 "cut": tr("カット名"), 

40 "cell": tr("セル名"), 

41 "number": tr("セル番号"), 

42 }[name] 

43 

44 

45def _chunk_kind(token): 

46 if token.is_field: 

47 return ("block", id(token)) 

48 character = token.text or "" 

49 if character.isalnum(): 

50 return ("word",) 

51 return ("symbol", character) 

52 

53 

54# --- editor ------------------------------------------------------------------ 

55 

56class _SuggestionList(QListWidget): 

57 """Overlay list of blocks; never takes focus so the editor keeps typing.""" 

58 

59 picked = Signal(str) 

60 

61 def __init__(self, parent): 

62 super().__init__(parent) 

63 self.setFocusPolicy(Qt.FocusPolicy.NoFocus) 

64 self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) 

65 self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 

66 self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 

67 self.setFrameShape(QFrame.Shape.StyledPanel) 

68 self.itemClicked.connect(self._clicked) 

69 self.hide() 

70 

71 def _clicked(self, item): 

72 name = item.data(Qt.ItemDataRole.UserRole) 

73 if name: 

74 self.picked.emit(name) 

75 

76 def fill(self, header, entries): 

77 self.clear() 

78 head = QListWidgetItem(header) 

79 head.setFlags(Qt.ItemFlag.NoItemFlags) 

80 muted = self.palette().color(QPalette.ColorRole.PlaceholderText) 

81 head.setForeground(muted) 

82 self.addItem(head) 

83 for name, label, sample in entries: 

84 item = QListWidgetItem(f"{label} {sample}" if sample else label) 

85 item.setData(Qt.ItemDataRole.UserRole, name) 

86 self.addItem(item) 

87 self.setCurrentRow(1 if entries else -1) 

88 rows = self.count() 

89 height = sum(self.sizeHintForRow(r) for r in range(rows)) + 2 * self.frameWidth() + 4 

90 width = max(200, self.sizeHintForColumn(0) + 24) 

91 self.resize(width, height) 

92 

93 def move_selection(self, step): 

94 rows = self.count() 

95 if rows <= 1: 

96 return 

97 row = self.currentRow() 

98 row = 1 + ((max(row, 1) - 1 + step) % (rows - 1)) 

99 self.setCurrentRow(row) 

100 

101 def current_name(self): 

102 item = self.currentItem() 

103 return item.data(Qt.ItemDataRole.UserRole) if item else None 

104 

105 

106class TemplateEditor(QWidget): 

107 """Edits one template as characters and blocks with a free caret.""" 

108 

109 changed = Signal() 

110 blockClicked = Signal(int, QRect) 

111 

112 PAD_X = 6 

113 PAD_Y = 4 

114 CHIP_PAD = 7 

115 GAP = 2 

116 

117 def __init__(self, allowed_fields, sample_for=None, is_missing=None, parent=None): 

118 super().__init__(parent) 

119 self.allowed_fields = tuple(allowed_fields) 

120 self.sample_for = sample_for or (lambda _name: "") 

121 self.is_missing = is_missing or (lambda _name: False) 

122 self.tokens = [] 

123 self.caret = 0 

124 self.pending = "" 

125 self.preedit = "" 

126 self._suggestions = None 

127 self._layout_cache = None 

128 self.placeholder = "" 

129 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) 

130 self.setAttribute(Qt.WidgetAttribute.WA_InputMethodEnabled, True) 

131 self.setCursor(Qt.CursorShape.IBeamCursor) 

132 policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) 

133 policy.setHeightForWidth(True) 

134 self.setSizePolicy(policy) 

135 

136 # model ------------------------------------------------------------------ 

137 def set_template(self, template): 

138 self.tokens = cf.explode(template) 

139 self.caret = len(self.tokens) 

140 self.pending = "" 

141 self._relayout() 

142 

143 def template(self): 

144 return cf.compact(self.tokens) 

145 

146 def replace_token(self, index, token): 

147 if 0 <= index < len(self.tokens): 147 ↛ 148line 147 didn't jump to line 148 because the condition on line 147 was never true

148 self.tokens[index] = token 

149 self._relayout() 

150 self.changed.emit() 

151 

152 def remove_token(self, index): 

153 if 0 <= index < len(self.tokens): 

154 del self.tokens[index] 

155 self.caret = min(index, len(self.tokens)) 

156 self._relayout() 

157 self.changed.emit() 

158 

159 def _insert(self, tokens): 

160 self.tokens[self.caret:self.caret] = tokens 

161 self.caret += len(tokens) 

162 self._relayout() 

163 self.changed.emit() 

164 

165 def commit_pending(self): 

166 if self.pending: 

167 text, self.pending = self.pending, "" 

168 self._insert([Token(text=character) for character in text]) 

169 self._update_suggestions() 

170 

171 def insert_field(self, name): 

172 if self.pending and not self._matches(self.pending): 172 ↛ 174line 172 didn't jump to line 174 because the condition on line 172 was always true

173 self.commit_pending() 

174 self.pending = "" 

175 defaults = {"episode": 2, "cut": 3, "number": 4} 

176 self._insert([Token(field=name, digits=defaults.get(name))]) 

177 self._update_suggestions() 

178 

179 # geometry --------------------------------------------------------------- 

180 def _chip_label(self, token): 

181 label = field_label(token.field) 

182 details = [] 

183 if token.field in cf.NUMERIC_FIELDS: 183 ↛ 185line 183 didn't jump to line 185 because the condition on line 183 was always true

184 details.append(tr("{n}桁").format(n=token.digits) if token.digits else tr("可変")) 

185 if token.case == "upper": 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true

186 details.append(tr("大文字")) 

187 elif token.case == "lower": 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true

188 details.append(tr("小文字")) 

189 return label, "・".join(details) 

190 

191 def _chip_width(self, token, fm): 

192 label, detail = self._chip_label(token) 

193 width = fm.horizontalAdvance(label) + 2 * self.CHIP_PAD 

194 for affix in (token.prefix, token.suffix): 

195 if affix: 195 ↛ 196line 195 didn't jump to line 196 because the condition on line 195 was never true

196 width += fm.horizontalAdvance(affix) + 10 

197 if detail: 197 ↛ 199line 197 didn't jump to line 199 because the condition on line 197 was always true

198 width += fm.horizontalAdvance(detail) + 10 

199 return width 

200 

201 @staticmethod 

202 def _shown_char(token): 

203 return "␣" if token.text == " " else (token.text or "") 

204 

205 def _compute_layout(self, width): 

206 fm = QFontMetrics(self.font()) 

207 line_h = fm.height() + 8 

208 x, y = self.PAD_X, self.PAD_Y 

209 right = max(width - self.PAD_X, self.PAD_X + 40) 

210 items = [] # (kind, index, rect) 

211 carets = {} 

212 pending_text = self.pending + self.preedit 

213 pending_rect = None 

214 

215 def place(w): 

216 nonlocal x, y 

217 if x + w > right and x > self.PAD_X: 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true

218 x = self.PAD_X 

219 y += line_h 

220 rect = QRect(x, y, w, line_h - 2) 

221 x += w 

222 return rect 

223 

224 for index in range(len(self.tokens) + 1): 224 ↛ 240line 224 didn't jump to line 240 because the loop on line 224 didn't complete

225 if index == self.caret and pending_text and self.hasFocus(): 

226 pending_rect = place(fm.horizontalAdvance(pending_text) + 2) 

227 if index == len(self.tokens): 

228 carets[index] = QPoint(x, y) 

229 break 

230 token = self.tokens[index] 

231 if token.is_field: 

232 rect = place(self._chip_width(token, fm) + 2 * self.GAP) 

233 items.append(("chip", index, rect.adjusted(self.GAP, 1, -self.GAP, -1))) 

234 else: 

235 rect = place(fm.horizontalAdvance(self._shown_char(token)) + 1) 

236 items.append(("char", index, rect)) 

237 # The caret *before* a token sits at its left edge -- on the line 

238 # the token wrapped to -- unless pending text is shown there. 

239 carets[index] = rect.topLeft() 

240 if pending_rect is not None: 

241 carets[self.caret] = QPoint(pending_rect.right() + 1, pending_rect.top()) 

242 height = y + line_h + self.PAD_Y 

243 return { 

244 "items": items, "carets": carets, "pending": pending_rect, 

245 "height": max(height, line_h + 2 * self.PAD_Y), "line_h": line_h, 

246 } 

247 

248 def _layout(self): 

249 if self._layout_cache is None or self._layout_cache[0] != self.width(): 

250 self._layout_cache = (self.width(), self._compute_layout(self.width())) 

251 return self._layout_cache[1] 

252 

253 def _relayout(self): 

254 self._layout_cache = None 

255 self.updateGeometry() 

256 self.update() 

257 self._place_suggestions() 

258 

259 def hasHeightForWidth(self): 

260 return True 

261 

262 def heightForWidth(self, width): 

263 return self._compute_layout(width)["height"] 

264 

265 def sizeHint(self): 

266 return QSize(240, self.heightForWidth(max(self.width(), 240))) 

267 

268 def minimumSizeHint(self): 

269 fm = QFontMetrics(self.font()) 

270 return QSize(120, fm.height() + 8 + 2 * self.PAD_Y) 

271 

272 def resizeEvent(self, event): 

273 self._layout_cache = None 

274 super().resizeEvent(event) 

275 self._place_suggestions() 

276 

277 def caret_rect(self): 

278 layout = self._layout() 

279 point = layout["carets"].get(self.caret, QPoint(self.PAD_X, self.PAD_Y)) 

280 return QRect(point.x(), point.y(), 1, layout["line_h"] - 2) 

281 

282 # painting --------------------------------------------------------------- 

283 def paintEvent(self, _event): 

284 painter = QPainter(self) 

285 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 

286 pal = self.palette() 

287 fm = QFontMetrics(self.font()) 

288 frame = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) 

289 painter.setPen(QPen(pal.color(QPalette.ColorRole.Highlight) if self.hasFocus() 

290 else pal.color(QPalette.ColorRole.Mid), 1.5 if self.hasFocus() else 1)) 

291 painter.setBrush(pal.color(QPalette.ColorRole.Base)) 

292 painter.drawRoundedRect(frame, 6, 6) 

293 

294 accent = pal.color(QPalette.ColorRole.Highlight) 

295 text_color = pal.color(QPalette.ColorRole.Text) 

296 chip_bg = QColor(accent) 

297 chip_bg.setAlpha(55) 

298 warn_bg = QColor(230, 160, 40, 90) 

299 char_bg = QColor(text_color) 

300 char_bg.setAlpha(18) 

301 layout = self._layout() 

302 baseline_off = (layout["line_h"] - 2 + fm.ascent() - fm.descent()) // 2 

303 

304 for kind, index, rect in layout["items"]: 304 ↛ 305line 304 didn't jump to line 305 because the loop on line 304 never started

305 token = self.tokens[index] 

306 if kind == "char": 

307 painter.fillRect(rect, char_bg) 

308 painter.setPen(text_color) 

309 painter.drawText(rect.left(), rect.top() + baseline_off, self._shown_char(token)) 

310 continue 

311 missing = token.field in cf.VALUE_FIELDS and self.is_missing(token.field) 

312 painter.setPen(Qt.PenStyle.NoPen) 

313 painter.setBrush(warn_bg if missing else chip_bg) 

314 painter.drawRoundedRect(QRectF(rect), 5, 5) 

315 x = rect.left() + self.CHIP_PAD 

316 base = rect.top() + baseline_off - 1 

317 label, detail = self._chip_label(token) 

318 if token.prefix: 

319 x = self._draw_affix(painter, fm, token.prefix, x, rect, base, text_color) 

320 painter.setPen(text_color) 

321 painter.drawText(x, base, label) 

322 x += fm.horizontalAdvance(label) 

323 if token.suffix: 

324 x = self._draw_affix(painter, fm, token.suffix, x + 5, rect, base, text_color) - 5 

325 if detail: 

326 x += 5 

327 muted = QColor(text_color) 

328 muted.setAlpha(150) 

329 painter.setPen(QPen(muted, 1)) 

330 painter.drawLine(x, rect.top() + 5, x, rect.bottom() - 5) 

331 painter.drawText(x + 5, base, detail) 

332 

333 if not self.tokens and not self.pending and not self.preedit and self.placeholder: 333 ↛ 334line 333 didn't jump to line 334 because the condition on line 333 was never true

334 painter.setPen(pal.color(QPalette.ColorRole.PlaceholderText)) 

335 painter.drawText(self.PAD_X + 2, self.PAD_Y + baseline_off, self.placeholder) 

336 

337 pending_rect = layout["pending"] 

338 if pending_rect is not None: 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true

339 painter.setPen(text_color) 

340 painter.drawText(pending_rect.left() + 1, pending_rect.top() + baseline_off, 

341 self.pending + self.preedit) 

342 underline = QPen(accent, 1, Qt.PenStyle.DotLine) 

343 painter.setPen(underline) 

344 painter.drawLine(pending_rect.left(), pending_rect.bottom() - 1, 

345 pending_rect.right(), pending_rect.bottom() - 1) 

346 

347 if self.hasFocus(): 347 ↛ exitline 347 didn't return from function 'paintEvent' because the condition on line 347 was always true

348 caret = self.caret_rect() 

349 painter.fillRect(QRect(caret.left(), caret.top() + 2, 1, caret.height() - 4), text_color) 

350 

351 def _draw_affix(self, painter, fm, affix, x, rect, base, color): 

352 width = fm.horizontalAdvance(affix) + 6 

353 box = QRectF(x - 1, rect.top() + 3, width, rect.height() - 6) 

354 dashed = QColor(color) 

355 dashed.setAlpha(140) 

356 painter.setPen(QPen(dashed, 1, Qt.PenStyle.DashLine)) 

357 painter.setBrush(Qt.BrushStyle.NoBrush) 

358 painter.drawRoundedRect(box, 3, 3) 

359 painter.setPen(color) 

360 painter.drawText(x + 2, base, affix) 

361 return x + width + 4 

362 

363 # mouse ------------------------------------------------------------------ 

364 def mousePressEvent(self, event): 

365 if event.button() != Qt.MouseButton.LeftButton: 

366 return super().mousePressEvent(event) 

367 self.commit_pending() 

368 self.setFocus(Qt.FocusReason.MouseFocusReason) 

369 pos = event.position().toPoint() 

370 for kind, index, rect in self._layout()["items"]: 

371 if not rect.contains(pos): 

372 continue 

373 if kind == "chip": 

374 self.caret = index + 1 

375 self._hide_suggestions() 

376 self.update() 

377 self.blockClicked.emit(index, QRect(self.mapToGlobal(rect.topLeft()), rect.size())) 

378 return 

379 self.caret = index if pos.x() < rect.center().x() else index + 1 

380 break 

381 else: 

382 self.caret = self._caret_from_point(pos) 

383 self.update() 

384 self._update_suggestions() 

385 

386 def _caret_from_point(self, pos): 

387 layout = self._layout() 

388 line_h = layout["line_h"] 

389 best = len(self.tokens) 

390 best_distance = None 

391 for index, point in layout["carets"].items(): 

392 if not (point.y() <= pos.y() < point.y() + line_h): 

393 continue 

394 distance = abs(point.x() - pos.x()) 

395 if best_distance is None or distance < best_distance: 

396 best, best_distance = index, distance 

397 return best 

398 

399 # keyboard --------------------------------------------------------------- 

400 def _chunk_left(self, index): 

401 if index <= 0: 401 ↛ 402line 401 didn't jump to line 402 because the condition on line 401 was never true

402 return 0 

403 kind = _chunk_kind(self.tokens[index - 1]) 

404 if kind[0] == "block": 

405 return index - 1 

406 while index > 0 and _chunk_kind(self.tokens[index - 1]) == kind: 

407 index -= 1 

408 return index 

409 

410 def _chunk_right(self, index): 

411 if index >= len(self.tokens): 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true

412 return len(self.tokens) 

413 kind = _chunk_kind(self.tokens[index]) 

414 if kind[0] == "block": 

415 return index + 1 

416 while index < len(self.tokens) and _chunk_kind(self.tokens[index]) == kind: 

417 index += 1 

418 return index 

419 

420 def _move(self, index): 

421 self.caret = max(0, min(index, len(self.tokens))) 

422 self.update() 

423 self._update_suggestions() 

424 

425 def keyPressEvent(self, event): 

426 key = event.key() 

427 suggestions_open = self._suggestions is not None and self._suggestions.isVisible() 

428 movement = [ 

429 (QKeySequence.StandardKey.MoveToPreviousWord, lambda: self._chunk_left(self.caret)), 

430 (QKeySequence.StandardKey.MoveToNextWord, lambda: self._chunk_right(self.caret)), 

431 (QKeySequence.StandardKey.MoveToStartOfLine, lambda: 0), 

432 (QKeySequence.StandardKey.MoveToEndOfLine, lambda: len(self.tokens)), 

433 (QKeySequence.StandardKey.MoveToStartOfBlock, lambda: 0), 

434 (QKeySequence.StandardKey.MoveToEndOfBlock, lambda: len(self.tokens)), 

435 (QKeySequence.StandardKey.MoveToPreviousChar, lambda: self.caret - 1), 

436 (QKeySequence.StandardKey.MoveToNextChar, lambda: self.caret + 1), 

437 ] 

438 for sequence, target in movement: 

439 if event.matches(sequence): 439 ↛ 440line 439 didn't jump to line 440 because the condition on line 439 was never true

440 self.commit_pending() 

441 self._move(target()) 

442 return 

443 if key in (Qt.Key.Key_Home, Qt.Key.Key_End): 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true

444 self.commit_pending() 

445 self._move(0 if key == Qt.Key.Key_Home else len(self.tokens)) 

446 return 

447 if event.matches(QKeySequence.StandardKey.DeleteStartOfWord) and not self.pending: 447 ↛ 448line 447 didn't jump to line 448 because the condition on line 447 was never true

448 start = self._chunk_left(self.caret) 

449 self._delete_range(start, self.caret) 

450 return 

451 if event.matches(QKeySequence.StandardKey.DeleteEndOfWord) and not self.pending: 451 ↛ 452line 451 didn't jump to line 452 because the condition on line 451 was never true

452 self._delete_range(self.caret, self._chunk_right(self.caret)) 

453 return 

454 if key == Qt.Key.Key_Backspace: 

455 if self.pending: 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true

456 self.pending = self.pending[:-1] 

457 self._relayout() 

458 self._update_suggestions() 

459 elif self.caret > 0: 459 ↛ 461line 459 didn't jump to line 461 because the condition on line 459 was always true

460 self._delete_range(self.caret - 1, self.caret) 

461 return 

462 if key == Qt.Key.Key_Delete: 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true

463 if not self.pending and self.caret < len(self.tokens): 

464 self._delete_range(self.caret, self.caret + 1) 

465 return 

466 if key in (Qt.Key.Key_Up, Qt.Key.Key_Down): 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true

467 if not suggestions_open: 

468 self._update_suggestions(force=True) 

469 elif self._suggestions is not None: 

470 self._suggestions.move_selection(1 if key == Qt.Key.Key_Down else -1) 

471 return 

472 if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): 

473 if self.pending and not self._matches(self.pending): 473 ↛ 475line 473 didn't jump to line 475 because the condition on line 473 was always true

474 self.commit_pending() 

475 elif suggestions_open and self._suggestions is not None: 

476 name = self._suggestions.current_name() 

477 if name: 

478 self.insert_field(name) 

479 return 

480 if key == Qt.Key.Key_Escape: 480 ↛ 481line 480 didn't jump to line 481 because the condition on line 480 was never true

481 if suggestions_open or self.pending: 

482 self.pending = "" 

483 self._hide_suggestions() 

484 self._relayout() 

485 return 

486 return super().keyPressEvent(event) 

487 if key in (Qt.Key.Key_Tab, Qt.Key.Key_Backtab): 487 ↛ 488line 487 didn't jump to line 488 because the condition on line 487 was never true

488 self.commit_pending() 

489 return super().keyPressEvent(event) 

490 typed = event.text() 

491 modifiers = event.modifiers() & ( 

492 Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.MetaModifier 

493 ) 

494 if typed and typed.isprintable() and not modifiers: 494 ↛ 499line 494 didn't jump to line 499 because the condition on line 494 was always true

495 self.pending += typed 

496 self._relayout() 

497 self._update_suggestions() 

498 return 

499 super().keyPressEvent(event) 

500 

501 def _delete_range(self, start, end): 

502 if start >= end: 502 ↛ 503line 502 didn't jump to line 503 because the condition on line 502 was never true

503 return 

504 del self.tokens[start:end] 

505 self.caret = start 

506 self._relayout() 

507 self.changed.emit() 

508 self._update_suggestions() 

509 

510 def inputMethodEvent(self, event): 

511 if event.commitString(): 

512 self.pending += event.commitString() 

513 self.preedit = event.preeditString() 

514 self._relayout() 

515 self._update_suggestions() 

516 event.accept() 

517 

518 def inputMethodQuery(self, query): 

519 if query == Qt.InputMethodQuery.ImCursorRectangle: 

520 return self.caret_rect() 

521 if query == Qt.InputMethodQuery.ImEnabled: 

522 return True 

523 return super().inputMethodQuery(query) 

524 

525 def focusInEvent(self, event): 

526 super().focusInEvent(event) 

527 self.update() 

528 if event.reason() in (Qt.FocusReason.TabFocusReason, Qt.FocusReason.BacktabFocusReason): 528 ↛ 529line 528 didn't jump to line 529 because the condition on line 528 was never true

529 self.caret = len(self.tokens) 

530 self._update_suggestions() 

531 

532 def focusOutEvent(self, event): 

533 super().focusOutEvent(event) 

534 self.preedit = "" 

535 self.commit_pending() 

536 self._hide_suggestions() 

537 self._relayout() 

538 

539 # suggestions ------------------------------------------------------------ 

540 def _matches(self, query): 

541 query = query.casefold() 

542 return [ 

543 name for name in self.allowed_fields 

544 if query in field_label(name).casefold() or name.startswith(query) 

545 ] 

546 

547 def _update_suggestions(self, force=False): 

548 if not self.hasFocus() or self.window() is None: 548 ↛ 549line 548 didn't jump to line 549 because the condition on line 548 was never true

549 return 

550 if self._suggestions is None: 

551 self._suggestions = _SuggestionList(self.window()) 

552 self._suggestions.picked.connect(self._picked) 

553 query = self.pending + self.preedit 

554 matches = self._matches(query) if query else list(self.allowed_fields) 

555 if query and not matches: 

556 header = tr("「{text}」の後に追加").format(text=query.replace(" ", "␣")) 

557 matches = list(self.allowed_fields) 

558 else: 

559 header = tr("ブロック") 

560 entries = [(name, field_label(name), self.sample_for(name)) for name in matches] 

561 self._suggestions.fill(header, entries) 

562 self._place_suggestions() 

563 self._suggestions.show() 

564 self._suggestions.raise_() 

565 

566 def _picked(self, name): 

567 self.setFocus() 

568 self.insert_field(name) 

569 

570 def _place_suggestions(self): 

571 if self._suggestions is None: 

572 return 

573 caret = self.caret_rect() 

574 top_left = self.mapTo(self.window(), QPoint(caret.left() - 4, caret.bottom() + 4)) 

575 window_rect = self.window().rect() 

576 x = min(top_left.x(), window_rect.width() - self._suggestions.width() - 4) 

577 self._suggestions.move(max(4, x), top_left.y()) 

578 

579 def _hide_suggestions(self): 

580 if self._suggestions is not None: 580 ↛ exitline 580 didn't return from function '_hide_suggestions' because the condition on line 580 was always true

581 self._suggestions.hide() 

582 

583 

584# --- block settings popup ---------------------------------------------------- 

585 

586class BlockSettingsPopup(QFrame): 

587 """Popup for one block: prefix/suffix, digit count or case, delete.""" 

588 

589 def __init__(self, editor, index, parent=None): 

590 super().__init__(parent, Qt.WindowType.Popup) 

591 self.editor = editor 

592 self.index = index 

593 self.setFrameShape(QFrame.Shape.StyledPanel) 

594 token = editor.tokens[index] 

595 layout = QVBoxLayout(self) 

596 layout.setContentsMargins(10, 8, 10, 8) 

597 layout.setSpacing(6) 

598 

599 title = QLabel(field_label(token.field)) 

600 font = title.font() 

601 font.setBold(True) 

602 title.setFont(font) 

603 layout.addWidget(title) 

604 

605 affix_row = QHBoxLayout() 

606 self.prefix = QLineEdit(token.prefix) 

607 self.prefix.setPlaceholderText(tr("前 (例: c)")) 

608 self.suffix = QLineEdit(token.suffix) 

609 self.suffix.setPlaceholderText(tr("後")) 

610 for line in (self.prefix, self.suffix): 

611 line.textChanged.connect(self._apply) 

612 line.returnPressed.connect(self.close) 

613 affix_row.addWidget(line) 

614 layout.addWidget(QLabel(tr("前後に付ける文字"))) 

615 layout.addLayout(affix_row) 

616 

617 self.choice_group = None 

618 if token.field in cf.NUMERIC_FIELDS: 

619 layout.addWidget(QLabel(tr("桁数"))) 

620 choices = [(tr("可変") if d is None else tr("{n}桁").format(n=d), d) 

621 for d in cf.DIGIT_CHOICES] 

622 self._add_choices(layout, choices, token.digits, "digits") 

623 elif token.field in ("title", "cell"): 

624 layout.addWidget(QLabel(tr("表記"))) 

625 choices = [(tr("そのまま"), "keep"), (tr("大文字"), "upper"), (tr("小文字"), "lower")] 

626 self._add_choices(layout, choices, token.case, "case") 

627 

628 remove = QPushButton(tr("ブロックを削除")) 

629 remove.setAutoDefault(False) 

630 remove.clicked.connect(self._remove) 

631 layout.addWidget(remove) 

632 

633 def _add_choices(self, layout, choices, current, attribute): 

634 row = QHBoxLayout() 

635 row.setSpacing(3) 

636 self.choice_group = QButtonGroup(self) 

637 self.choice_attribute = attribute 

638 self.choice_values = {} 

639 for number, (label, value) in enumerate(choices): 

640 button = QToolButton() 

641 button.setText(label) 

642 button.setCheckable(True) 

643 button.setChecked(value == current) 

644 self.choice_group.addButton(button, number) 

645 self.choice_values[number] = value 

646 row.addWidget(button) 

647 row.addStretch(1) 

648 self.choice_group.idClicked.connect(lambda _id: self._apply()) 

649 layout.addLayout(row) 

650 

651 def _apply(self): 

652 token = self.editor.tokens[self.index] 

653 changes = {"prefix": self.prefix.text(), "suffix": self.suffix.text()} 

654 if self.choice_group is not None and self.choice_group.checkedId() >= 0: 

655 changes[self.choice_attribute] = self.choice_values[self.choice_group.checkedId()] 

656 self.editor.replace_token(self.index, replace(token, **changes)) 

657 

658 def _remove(self): 

659 self.editor.remove_token(self.index) 

660 self.close() 

661 

662 

663# --- dialog ------------------------------------------------------------------ 

664 

665class CutFolderExportDialog(QDialog): 

666 """Configure a cut-folder export while watching the resulting tree.""" 

667 

668 def __init__(self, cels, cut_hint="", parent=None): 

669 super().__init__(parent) 

670 self.setWindowTitle(tr("カットフォルダーへ書き出し")) 

671 self.cels = list(cels) 

672 self._loading = False 

673 saved = config.get_value(CONFIG_KEY, {}) or {} 

674 self.user_presets = { 

675 str(name): data for name, data in (saved.get("presets") or {}).items() 

676 } 

677 

678 root = QVBoxLayout(self) 

679 header = QHBoxLayout() 

680 header.addStretch(1) 

681 self.preset_combo = QComboBox() 

682 self.preset_combo.activated.connect(self._preset_activated) 

683 header.addWidget(self.preset_combo) 

684 root.addLayout(header) 

685 

686 body = QHBoxLayout() 

687 root.addLayout(body, 1) 

688 left = QVBoxLayout() 

689 body.addLayout(left, 1) 

690 right = QVBoxLayout() 

691 body.addLayout(right, 2) 

692 

693 # values 

694 values_form = QFormLayout() 

695 self.value_edits = {} 

696 saved_values = saved.get("values") or {} 

697 for name in ("title", "scene", "episode", "cut"): 

698 line = QLineEdit(str(saved_values.get(name, ""))) 

699 if name in cf.NUMERIC_FIELDS: 

700 line.setPlaceholderText(tr("番号のみ")) 

701 self.value_edits[name] = line 

702 values_form.addRow(field_label(name), line) 

703 if cut_hint: 

704 self.value_edits["cut"].setText(cut_hint) 

705 left.addLayout(values_form) 

706 rule = QFrame() 

707 rule.setFrameShape(QFrame.Shape.HLine) 

708 rule.setFrameShadow(QFrame.Shadow.Sunken) 

709 left.addWidget(rule) 

710 

711 cut_fields = [f for f in cf.FIELD_IDS if f not in cf.CEL_FIELDS] 

712 self.folder_editor = self._editor(cut_fields) 

713 self.cell_folder_editor = self._editor(cf.FIELD_IDS) 

714 self.cell_file_editor = self._editor(cf.FIELD_IDS) 

715 self.timesheet_editor = self._editor(cut_fields) 

716 self.timesheet_folder_editor = self._editor(cut_fields) 

717 self.timesheet_folder_editor.placeholder = tr("空欄ならカットフォルダー直下") 

718 

719 left.addWidget(QLabel(tr("カットフォルダー"))) 

720 left.addWidget(self.folder_editor) 

721 left.addWidget(QLabel(tr("セルフォルダー"))) 

722 left.addWidget(self.cell_folder_editor) 

723 

724 cell_row = QHBoxLayout() 

725 cell_row.addWidget(QLabel(tr("セル画像"))) 

726 cell_row.addStretch(1) 

727 self.format_combo = QComboBox() 

728 for extension in cf.IMAGE_EXTENSIONS: 

729 self.format_combo.addItem(f".{extension}", extension) 

730 self.format_combo.currentIndexChanged.connect(self._layout_changed) 

731 cell_row.addWidget(self.format_combo) 

732 left.addLayout(cell_row) 

733 left.addWidget(self.cell_file_editor) 

734 

735 left.addWidget(QLabel(tr("タイムシートのフォルダー"))) 

736 left.addWidget(self.timesheet_folder_editor) 

737 sheet_row = QHBoxLayout() 

738 sheet_row.addWidget(QLabel(tr("タイムシート"))) 

739 sheet_row.addStretch(1) 

740 self.timesheet_format_combo = QComboBox() 

741 for extension in cf.TIMESHEET_EXTENSIONS: 

742 self.timesheet_format_combo.addItem(f".{extension}", extension) 

743 self.timesheet_format_combo.currentIndexChanged.connect(self._layout_changed) 

744 sheet_row.addWidget(self.timesheet_format_combo) 

745 left.addLayout(sheet_row) 

746 left.addWidget(self.timesheet_editor) 

747 

748 extra_head = QHBoxLayout() 

749 extra_head.addWidget(QLabel(tr("空のフォルダー"))) 

750 extra_head.addStretch(1) 

751 add_extra = QPushButton(tr("+ 追加")) 

752 add_extra.setAutoDefault(False) 

753 add_extra.clicked.connect(lambda: self._add_extra_folder(())) 

754 extra_head.addWidget(add_extra) 

755 left.addLayout(extra_head) 

756 self.extra_box = QVBoxLayout() 

757 left.addLayout(self.extra_box) 

758 self.extra_editors = [] 

759 

760 left.addStretch(1) 

761 

762 # destination + preview 

763 destination_row = QHBoxLayout() 

764 destination_row.addWidget(QLabel(tr("保存先"))) 

765 self.destination = QLineEdit(str(saved.get("destination", "") or "")) 

766 self.destination.setPlaceholderText(tr("カットフォルダーを作る場所")) 

767 self.destination.textChanged.connect(self._refresh_preview) 

768 destination_row.addWidget(self.destination, 1) 

769 browse = QPushButton(tr("参照…")) 

770 browse.setAutoDefault(False) 

771 browse.clicked.connect(self._browse) 

772 destination_row.addWidget(browse) 

773 right.addLayout(destination_row) 

774 

775 right.addWidget(QLabel(tr("プレビュー"))) 

776 self.tree = QTreeWidget() 

777 self.tree.setHeaderHidden(True) 

778 self.tree.setUniformRowHeights(True) 

779 right.addWidget(self.tree, 1) 

780 self.problem_label = QLabel() 

781 self.problem_label.setWordWrap(True) 

782 problem_palette = self.problem_label.palette() 

783 problem_palette.setColor( 

784 QPalette.ColorRole.WindowText, 

785 problem_palette.color(QPalette.ColorRole.BrightText), 

786 ) 

787 self.problem_label.setPalette(problem_palette) 

788 right.addWidget(self.problem_label) 

789 

790 buttons = QHBoxLayout() 

791 buttons.addStretch(1) 

792 cancel = QPushButton(tr("キャンセル")) 

793 cancel.setAutoDefault(False) 

794 cancel.clicked.connect(self.reject) 

795 self.export_button = QPushButton(tr("書き出す")) 

796 self.export_button.setAutoDefault(False) 

797 self.export_button.clicked.connect(self._accept_if_valid) 

798 buttons.addWidget(cancel) 

799 buttons.addWidget(self.export_button) 

800 root.addLayout(buttons) 

801 

802 layout = None 

803 if saved.get("layout"): 803 ↛ 804line 803 didn't jump to line 804 because the condition on line 803 was never true

804 try: 

805 layout = cf.CutFolderLayout.from_json(saved["layout"]) 

806 except (ValueError, TypeError, KeyError, AttributeError): 

807 layout = None 

808 self._rebuild_preset_combo(saved.get("preset") or "PMA標準") 

809 self.set_layout(layout or cf.pma_standard_layout()) 

810 for line in self.value_edits.values(): 

811 line.textChanged.connect(self._values_changed) 

812 self.resize(1040, 680) 

813 

814 # construction helpers --------------------------------------------------- 

815 def _editor(self, allowed): 

816 editor = TemplateEditor(allowed, self._sample_for, self._is_missing, self) 

817 editor.changed.connect(self._layout_changed) 

818 editor.blockClicked.connect( 

819 lambda index, rect, e=editor: self._open_block_settings(e, index, rect) 

820 ) 

821 return editor 

822 

823 def _add_extra_folder(self, template): 

824 row = QHBoxLayout() 

825 editor = self._editor([f for f in cf.FIELD_IDS if f not in cf.CEL_FIELDS]) 

826 editor.set_template(template) 

827 remove = QToolButton() 

828 remove.setText("×") 

829 remove.setToolTip(tr("このフォルダーを削除")) 

830 row.addWidget(editor, 1) 

831 row.addWidget(remove) 

832 holder = QWidget() 

833 holder.setLayout(row) 

834 row.setContentsMargins(0, 0, 0, 0) 

835 self.extra_box.addWidget(holder) 

836 self.extra_editors.append((holder, editor)) 

837 

838 def drop(): 

839 self.extra_editors[:] = [(h, e) for h, e in self.extra_editors if h is not holder] 

840 holder.deleteLater() 

841 self._layout_changed() 

842 

843 remove.clicked.connect(drop) 

844 if not self._loading: 844 ↛ 845line 844 didn't jump to line 845 because the condition on line 844 was never true

845 editor.setFocus() 

846 self._layout_changed() 

847 

848 def _open_block_settings(self, editor, index, rect): 

849 popup = BlockSettingsPopup(editor, index, self) 

850 popup.adjustSize() 

851 popup.move(rect.bottomLeft() + QPoint(0, 4)) 

852 popup.show() 

853 popup.prefix.setFocus() 

854 

855 # values ----------------------------------------------------------------- 

856 def values(self): 

857 return {name: line.text().strip() for name, line in self.value_edits.items()} 

858 

859 def _sample_for(self, name): 

860 if name == "cell": 

861 return self.cels[0].cell if self.cels else "A" 

862 if name == "number": 

863 return "1" 

864 return self.value_edits[name].text().strip() or tr("未入力") 

865 

866 def _is_missing(self, name): 

867 return not self.value_edits[name].text().strip() 

868 

869 def _values_changed(self): 

870 for editor in self._all_editors(): 

871 editor.update() 

872 self._refresh_preview() 

873 

874 def _all_editors(self): 

875 return [ 

876 self.folder_editor, self.cell_folder_editor, self.cell_file_editor, 

877 self.timesheet_editor, self.timesheet_folder_editor, 

878 *[e for _h, e in self.extra_editors], 

879 ] 

880 

881 # layout ----------------------------------------------------------------- 

882 def current_layout(self): 

883 return cf.CutFolderLayout( 

884 folder=self.folder_editor.template(), 

885 cell_folder=self.cell_folder_editor.template(), 

886 cell_file=self.cell_file_editor.template(), 

887 timesheet=self.timesheet_editor.template(), 

888 timesheet_folder=self.timesheet_folder_editor.template(), 

889 extra_folders=[e.template() for _h, e in self.extra_editors if e.template()], 

890 image_format=self.format_combo.currentData(), 

891 timesheet_format=self.timesheet_format_combo.currentData(), 

892 ) 

893 

894 def set_layout(self, layout): 

895 self._loading = True 

896 try: 

897 self.folder_editor.set_template(layout.folder) 

898 self.cell_folder_editor.set_template(layout.cell_folder) 

899 self.cell_file_editor.set_template(layout.cell_file) 

900 self.timesheet_editor.set_template(layout.timesheet) 

901 self.timesheet_folder_editor.set_template(layout.timesheet_folder) 

902 index = self.format_combo.findData(layout.image_format) 

903 self.format_combo.setCurrentIndex(max(0, index)) 

904 index = self.timesheet_format_combo.findData(layout.timesheet_format) 

905 self.timesheet_format_combo.setCurrentIndex(max(0, index)) 

906 for holder, _editor in self.extra_editors: 906 ↛ 907line 906 didn't jump to line 907 because the loop on line 906 never started

907 holder.deleteLater() 

908 self.extra_editors = [] 

909 for template in layout.extra_folders: 

910 self._add_extra_folder(template) 

911 finally: 

912 self._loading = False 

913 self._refresh_preview() 

914 

915 def _layout_changed(self, *_args): 

916 if not self._loading: 916 ↛ 917line 916 didn't jump to line 917 because the condition on line 916 was never true

917 self._refresh_preview() 

918 

919 # presets ---------------------------------------------------------------- 

920 def _rebuild_preset_combo(self, select=None): 

921 self.preset_combo.blockSignals(True) 

922 self.preset_combo.clear() 

923 for name in cf.BUILTIN_PRESETS: 

924 self.preset_combo.addItem(tr("プリセット: {name}").format(name=tr(name)), ("builtin", name)) 

925 for name in self.user_presets: 

926 self.preset_combo.addItem(tr("プリセット: {name}").format(name=name), ("user", name)) 

927 self.preset_combo.insertSeparator(self.preset_combo.count()) 

928 self.preset_combo.addItem(tr("現在の構成をプリセットに保存…"), ("save", None)) 

929 if self.user_presets: 

930 self.preset_combo.addItem(tr("選択中のプリセットを削除"), ("delete", None)) 

931 for row in range(self.preset_combo.count()): 931 ↛ 936line 931 didn't jump to line 936 because the loop on line 931 didn't complete

932 data = self.preset_combo.itemData(row) 

933 if data and data[1] == select: 933 ↛ 931line 933 didn't jump to line 931 because the condition on line 933 was always true

934 self.preset_combo.setCurrentIndex(row) 

935 break 

936 self._current_preset = self.preset_combo.currentData() 

937 self.preset_combo.blockSignals(False) 

938 

939 def _preset_activated(self, row): 

940 kind, preset_name = self.preset_combo.itemData(row) or (None, None) 

941 if kind == "builtin": 

942 self.set_layout(cf.BUILTIN_PRESETS[str(preset_name)]()) 

943 elif kind == "user": 943 ↛ 945line 943 didn't jump to line 945 because the condition on line 943 was always true

944 self.set_layout(cf.CutFolderLayout.from_json(self.user_presets[str(preset_name)])) 

945 elif kind == "save": 

946 name, ok = QInputDialog.getText(self, tr("プリセットを保存"), tr("プリセット名")) 

947 name = name.strip() 

948 if ok and name: 

949 if name in cf.BUILTIN_PRESETS: 

950 QMessageBox.warning(self, tr("プリセットを保存"), tr("組み込みプリセットと同じ名前は使えません。")) 

951 else: 

952 self.user_presets[name] = self.current_layout().to_json() 

953 self._save_presets() 

954 self._rebuild_preset_combo(name) 

955 return 

956 elif kind == "delete": 

957 current = self._current_preset 

958 if current and current[0] == "user": 

959 self.user_presets.pop(str(current[1]), None) 

960 self._save_presets() 

961 self._rebuild_preset_combo("PMA標準") 

962 self.set_layout(cf.pma_standard_layout()) 

963 return 

964 if kind in ("builtin", "user"): 964 ↛ 967line 964 didn't jump to line 967 because the condition on line 964 was always true

965 self._current_preset = (kind, preset_name) 

966 else: 

967 self._rebuild_preset_combo(self._current_preset[1] if self._current_preset else None) 

968 

969 def _save_presets(self): 

970 data = config.get_value(CONFIG_KEY, {}) or {} 

971 data["presets"] = self.user_presets 

972 config.set_value(CONFIG_KEY, data) 

973 

974 def remember(self): 

975 """Persist the layout and values used, for the next export.""" 

976 data = config.get_value(CONFIG_KEY, {}) or {} 

977 values = self.values() 

978 values.pop("cut", None) 

979 data.update({ 

980 "layout": self.current_layout().to_json(), 

981 "values": values, 

982 "destination": self.destination.text().strip(), 

983 "preset": self._current_preset[1] if self._current_preset else None, 

984 "presets": self.user_presets, 

985 }) 

986 config.set_value(CONFIG_KEY, data) 

987 

988 # preview ---------------------------------------------------------------- 

989 def plan(self): 

990 return cf.plan_export(self.current_layout(), self.values(), self.cels) 

991 

992 def _problem_text(self, problem): 

993 kind = problem[0] 

994 if kind == "missing_value": 

995 return tr("「{name}」を入力してください。").format(name=field_label(problem[1])) 

996 if kind == "no_number": 996 ↛ 997line 996 didn't jump to line 997 because the condition on line 996 was never true

997 return tr("セル画像にセル番号のブロックがありません。") 

998 if kind == "duplicate": 998 ↛ 999line 998 didn't jump to line 999 because the condition on line 998 was never true

999 return tr("同じ名前のファイルができます:{path}").format(path=problem[1]) 

1000 if kind == "bad_name": 1000 ↛ 1009line 1000 didn't jump to line 1009 because the condition on line 1000 was always true

1001 where = { 

1002 "folder": tr("カットフォルダー"), 

1003 "cell_file": tr("セル画像"), 

1004 "timesheet": tr("タイムシート"), 

1005 "timesheet_folder": tr("タイムシートのフォルダー"), 

1006 "extra_folder": tr("空のフォルダー"), 

1007 }.get(problem[2], "") 

1008 return tr("{where}の名前「{name}」は使えません。").format(where=where, name=problem[1]) 

1009 return str(problem) 

1010 

1011 def _refresh_preview(self, *_args): 

1012 plan = self.plan() 

1013 self.tree.clear() 

1014 style = self.style() 

1015 dir_icon = style.standardIcon(QStyle.StandardPixmap.SP_DirIcon) 

1016 file_icon = style.standardIcon(QStyle.StandardPixmap.SP_FileIcon) 

1017 

1018 destination = self.destination.text().strip() 

1019 existing = bool(destination) and (Path(destination) / plan.folder_name).exists() 

1020 root_label = f"{plan.folder_name or '〈' + tr('カットフォルダー') + '〉'}/" 

1021 if existing: 1021 ↛ 1022line 1021 didn't jump to line 1022 because the condition on line 1021 was never true

1022 root_label += " " + tr("(既存のフォルダー)") 

1023 root = QTreeWidgetItem([root_label]) 

1024 root.setIcon(0, dir_icon) 

1025 self.tree.addTopLevelItem(root) 

1026 

1027 folders = {} 

1028 

1029 def folder_item(path): 

1030 if not path: 

1031 return root 

1032 if path in folders: 

1033 return folders[path] 

1034 parent_path, _, name = path.rpartition("/") 

1035 item = QTreeWidgetItem([name + "/"]) 

1036 item.setIcon(0, dir_icon) 

1037 folder_item(parent_path).addChild(item) 

1038 folders[path] = item 

1039 return item 

1040 

1041 for path in plan.folders: 

1042 folder_item(path) 

1043 for relative, _cel in plan.files: 

1044 directory, _, name = relative.rpartition("/") 

1045 item = QTreeWidgetItem([name]) 

1046 item.setIcon(0, file_icon) 

1047 folder_item(directory).addChild(item) 

1048 sheet_directory, _, sheet_name = plan.timesheet_path.rpartition("/") 

1049 sheet = QTreeWidgetItem([sheet_name]) 

1050 sheet.setIcon(0, file_icon) 

1051 folder_item(sheet_directory).addChild(sheet) 

1052 self.tree.expandAll() 

1053 

1054 messages = [self._problem_text(p) for p in plan.problems] 

1055 if not self.cels: 

1056 messages.append(tr("書き出せるセルがありません。")) 

1057 if not destination: 

1058 messages.append(tr("保存先を指定してください。")) 

1059 self.problem_label.setText("\n".join(messages)) 

1060 self.problem_label.setVisible(bool(messages)) 

1061 self.export_button.setEnabled(not messages) 

1062 

1063 def _browse(self): 

1064 start = self.destination.text().strip() 

1065 path = QFileDialog.getExistingDirectory(self, tr("保存先を選ぶ"), start) 

1066 if path: 

1067 self.destination.setText(path) 

1068 

1069 def _accept_if_valid(self): 

1070 if self.plan().ok and self.destination.text().strip() and self.cels: 

1071 self.accept()