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
« 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.
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
12from dataclasses import replace
13from pathlib import Path
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)
26from . import config
27from . import cut_folder as cf
28from .cut_folder import Token
29from .i18n import tr
31CONFIG_KEY = "cut_folder_export"
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]
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)
54# --- editor ------------------------------------------------------------------
56class _SuggestionList(QListWidget):
57 """Overlay list of blocks; never takes focus so the editor keeps typing."""
59 picked = Signal(str)
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()
71 def _clicked(self, item):
72 name = item.data(Qt.ItemDataRole.UserRole)
73 if name:
74 self.picked.emit(name)
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)
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)
101 def current_name(self):
102 item = self.currentItem()
103 return item.data(Qt.ItemDataRole.UserRole) if item else None
106class TemplateEditor(QWidget):
107 """Edits one template as characters and blocks with a free caret."""
109 changed = Signal()
110 blockClicked = Signal(int, QRect)
112 PAD_X = 6
113 PAD_Y = 4
114 CHIP_PAD = 7
115 GAP = 2
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)
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()
143 def template(self):
144 return cf.compact(self.tokens)
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()
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()
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()
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()
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()
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)
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
201 @staticmethod
202 def _shown_char(token):
203 return "␣" if token.text == " " else (token.text or "")
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
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
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 }
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]
253 def _relayout(self):
254 self._layout_cache = None
255 self.updateGeometry()
256 self.update()
257 self._place_suggestions()
259 def hasHeightForWidth(self):
260 return True
262 def heightForWidth(self, width):
263 return self._compute_layout(width)["height"]
265 def sizeHint(self):
266 return QSize(240, self.heightForWidth(max(self.width(), 240)))
268 def minimumSizeHint(self):
269 fm = QFontMetrics(self.font())
270 return QSize(120, fm.height() + 8 + 2 * self.PAD_Y)
272 def resizeEvent(self, event):
273 self._layout_cache = None
274 super().resizeEvent(event)
275 self._place_suggestions()
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)
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)
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
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)
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)
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)
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)
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
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()
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
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
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
420 def _move(self, index):
421 self.caret = max(0, min(index, len(self.tokens)))
422 self.update()
423 self._update_suggestions()
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)
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()
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()
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)
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()
532 def focusOutEvent(self, event):
533 super().focusOutEvent(event)
534 self.preedit = ""
535 self.commit_pending()
536 self._hide_suggestions()
537 self._relayout()
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 ]
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_()
566 def _picked(self, name):
567 self.setFocus()
568 self.insert_field(name)
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())
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()
584# --- block settings popup ----------------------------------------------------
586class BlockSettingsPopup(QFrame):
587 """Popup for one block: prefix/suffix, digit count or case, delete."""
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)
599 title = QLabel(field_label(token.field))
600 font = title.font()
601 font.setBold(True)
602 title.setFont(font)
603 layout.addWidget(title)
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)
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")
628 remove = QPushButton(tr("ブロックを削除"))
629 remove.setAutoDefault(False)
630 remove.clicked.connect(self._remove)
631 layout.addWidget(remove)
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)
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))
658 def _remove(self):
659 self.editor.remove_token(self.index)
660 self.close()
663# --- dialog ------------------------------------------------------------------
665class CutFolderExportDialog(QDialog):
666 """Configure a cut-folder export while watching the resulting tree."""
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 }
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)
686 body = QHBoxLayout()
687 root.addLayout(body, 1)
688 left = QVBoxLayout()
689 body.addLayout(left, 1)
690 right = QVBoxLayout()
691 body.addLayout(right, 2)
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)
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("空欄ならカットフォルダー直下")
719 left.addWidget(QLabel(tr("カットフォルダー")))
720 left.addWidget(self.folder_editor)
721 left.addWidget(QLabel(tr("セルフォルダー")))
722 left.addWidget(self.cell_folder_editor)
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)
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)
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 = []
760 left.addStretch(1)
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)
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)
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)
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)
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
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))
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()
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()
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()
855 # values -----------------------------------------------------------------
856 def values(self):
857 return {name: line.text().strip() for name, line in self.value_edits.items()}
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("未入力")
866 def _is_missing(self, name):
867 return not self.value_edits[name].text().strip()
869 def _values_changed(self):
870 for editor in self._all_editors():
871 editor.update()
872 self._refresh_preview()
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 ]
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 )
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()
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()
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)
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)
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)
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)
988 # preview ----------------------------------------------------------------
989 def plan(self):
990 return cf.plan_export(self.current_layout(), self.values(), self.cels)
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)
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)
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)
1027 folders = {}
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
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()
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)
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)
1069 def _accept_if_valid(self):
1070 if self.plan().ok and self.destination.text().strip() and self.cels:
1071 self.accept()