classes.c94_render_content_panel
1import json 2import os 3import random 4import re 5import vanilla 6from AppKit import ( 7 NSColor, 8 NSOpenPanel, 9 NSFileHandlingPanelOKButton, 10 NSURL, 11 NSFont, 12) 13from typing import Callable 14 15from lib.content import changeCase 16from theme import PANEL_SECTION_MARGIN 17from .c92_render_panel_plugin import RenderPanelPlugin 18 19# ── Template token definitions ──────────────────────────────────────────────── 20_uc = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 21_lc = "abcdefghijklmnopqrstuvwxyz" 22_ucCyr = "АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЧЦШЩЬЫЪЭЮЯ" 23_lcCyr = "абвгдеёжзийклмнопрстуфхчцшщьыъэюя" 24 25TOKENS = { 26 "upper": _uc, 27 "lower": _lc, 28 "mixed": [u + l for u, l in zip(_uc, _lc)], 29 "num": "0123456789", 30 "symbols": "*#/\\·•@&", 31 "paren": "()[]{}⟨⟩", 32 "punct": ".,:;…!?¡¿", 33 "quotes": "‚„“”‘’", 34 "currency": "¢¤$€£¥", 35 "math": "+−×÷=≠><≈~^∅%‰", 36 "arrows": "↑↗→↘↓↙←↖↲↳", 37 "upperCyr": _ucCyr, 38 "lowerCyr": _lcCyr, 39 "mixedCyr": "".join(c for pair in zip(_ucCyr, _lcCyr) for c in pair), 40 "text": ( 41 "Angel Blind Clique Dunce Enact Furlong Gnome Human Inlet Justin " 42 "Knoll Linden Milliner Number Onset Pneumo Quanta Rhone Snout Tundra " 43 "Uncle Vulcan Whale Xmas Yunnan Zloty Adept Bodice Coast Docile Eosin " 44 "Focal Gondola Hoist Iodine Jocose Koala Loads Modal Nodule Oddball " 45 "Poncho Qophs Roman Sodium Tocsin Udder Vocal Woman Xenon Young Zodiac" 46 ).split(), 47} 48 49OT_FEATURE_GROUPS = [ 50 ( 51 "Ligatures & Context", 52 [ 53 ("liga", "Standard Ligatures"), 54 ("dlig", "Discretionary Ligatures"), 55 ("calt", "Contextual Alternates"), 56 ("clig", "Contextual Ligatures"), 57 ("hlig", "Historical Ligatures"), 58 ], 59 ), 60 ( 61 "Stylistic Sets", 62 [(f"ss{i:02d}", f"Stylistic Set {i:02d}") for i in range(1, 21)], 63 ), 64 ( 65 "Case", 66 [ 67 ("smcp", "Small Capitals"), 68 ("c2sc", "Capitals to Small Caps"), 69 ("case", "Case-Sensitive Forms"), 70 ("cpsp", "Capital Spacing"), 71 ("pcap", "Petite Capitals"), 72 ("unic", "Unicase"), 73 ], 74 ), 75 ( 76 "Numerals", 77 [ 78 ("lnum", "Lining Figures"), 79 ("onum", "Oldstyle Figures"), 80 ("tnum", "Tabular Figures"), 81 ("pnum", "Proportional Figures"), 82 ("zero", "Slashed Zero"), 83 ("frac", "Fractions"), 84 ("afrc", "Alternative Fractions"), 85 ("numr", "Numerators"), 86 ("dnom", "Denominators"), 87 ("sups", "Superscript"), 88 ("subs", "Subscript"), 89 ("sinf", "Scientific Inferiors"), 90 ("ordn", "Ordinals"), 91 ], 92 ), 93 ( 94 "Spacing & Kerning", 95 [ 96 ("kern", "Kerning"), 97 ("dist", "Distances"), 98 ], 99 ), 100 ( 101 "Character Variants", 102 [ 103 ("cv01", "Character Variant 01"), 104 ("cv02", "Character Variant 02"), 105 ("cv03", "Character Variant 03"), 106 ("cv04", "Character Variant 04"), 107 ("cv05", "Character Variant 05"), 108 ], 109 ), 110 ( 111 "Alternates & Decorative", 112 [ 113 ("salt", "Stylistic Alternates"), 114 ("swsh", "Swash"), 115 ("cswh", "Contextual Swash"), 116 ("titl", "Titling"), 117 ("init", "Initial Forms"), 118 ("aalt", "Access All Alternates"), 119 ("nalt", "Alternate Annotation Forms"), 120 ], 121 ), 122 ( 123 "Language & Historical", 124 [ 125 ("locl", "Localized Forms"), 126 ("hist", "Historical Forms"), 127 ], 128 ), 129] 130 131# Features that CoreText enables silently when no explicit value is set. 132# If the user has not selected one of these, it must be passed as False to 133# drawBot.openTypeFeatures() to prevent it from being applied by default. 134OT_DEFAULT_ON: frozenset[str] = frozenset({"calt", "clig", "kern", "liga", "locl"}) 135 136 137class RenderContentPanel(RenderPanelPlugin): 138 """Minimal RenderView side panel plugin with a text input field.""" 139 140 def __init__( 141 self, 142 defaultText: str = "Type your proof text here.", 143 panelWidth: int = 290, 144 statePath: str | None = None, 145 defaultDirectory: str = "/Users/christianjansky/Library/CloudStorage/Dropbox/KOMETA-Draw/01 Content", 146 ): 147 self._panelWidth = panelWidth 148 self._defaultText = defaultText 149 self._defaultDirectory = defaultDirectory 150 self._statePath = statePath or os.path.expanduser( 151 "~/Library/Application Support/KOMETA-Draw/render_view_content_panel.json" 152 ) 153 self._state = { 154 "text": defaultText, 155 "textCase": 0, 156 "openTypeFeatures": {}, 157 "openTypeFeaturesText": "", 158 "filterToFont": False, 159 "disableAllFeatures": False, 160 "lineBreakSep": "Line Break", 161 "pageBreak": "Triple Line Break", 162 "tokenJoiner": "Space", 163 "tokenSplit": "Spaces", 164 "tokenSurround": False, 165 "shuffle": False, 166 "before": "", 167 "after": "", 168 "combineMode": 1, 169 "omitMissingMode": 0, 170 "beforeGroupSize": 1, 171 "afterGroupSize": 1, 172 "beforeGroupJoin": "(none)", 173 "afterGroupJoin": "(none)", 174 } 175 self._onChange = None 176 self._persistenceEnabled = True 177 self._shuffleSeed: int = 0 178 self._refreshShuffledTokens() 179 self._loadState() 180 181 TEXT_CASES = ["Default", "Title", "UPPER", "lower"] 182 _CASE_KEYS = [None, "title", "upper", "lower"] 183 OMIT_MISSING_MODES = ["Glyphs", "Words"] 184 _OMIT_MISSING_KEYS = ["glyphs", "words"] 185 GROUP_JOIN_OPTIONS = [ 186 ("(none)", ""), 187 ("Space", " "), 188 ("Tab", "\t"), 189 ("Newline", "\n"), 190 ("Hyphen", "-"), 191 ("Slash", "/"), 192 ("Comma", ","), 193 ] 194 LINE_BREAK_OPTIONS = [("Line Break", "\n"), ("Space", " ")] 195 TOKEN_JOINER_OPTIONS = [("Space", " "), ("Line Break", "\n"), ("None", "")] 196 TOKEN_SPLIT_OPTIONS = [ 197 ("Spaces", "space"), 198 ("Characters", "chars"), 199 ("Line Breaks", "line"), 200 ] 201 PAGE_BREAK_OPTIONS = [ 202 ("Single Line Break", "\n"), 203 ("Double Line Break", "\n\n"), 204 ("Triple Line Break", "\n\n\n"), 205 ] 206 207 def getState(self) -> dict: 208 state = dict(self._state) 209 joiner = self._resolveTokenJoiner() 210 surround = bool(state.get("tokenSurround", False)) 211 caseIdx = int(state.get("textCase", 0)) 212 caseKey = ( 213 self._CASE_KEYS[caseIdx] if 0 <= caseIdx < len(self._CASE_KEYS) else None 214 ) 215 sep = self._resolveLineBreakSep() 216 raw_text = state.get("text", "") 217 # Split by the chosen page-break sequence 218 page_chunks = raw_text.split(self._resolvePageBreak()) 219 pages = [] 220 page_items = [] 221 for chunk in page_chunks: 222 expanded = self._compileTemplate(chunk, joiner=joiner, surround=surround) 223 text = changeCase(expanded, caseKey) if caseKey is not None else expanded 224 pages.append(text.replace("\n", sep)) 225 page_items.append( 226 self._compileTemplateItems(chunk, caseKey=caseKey, sep=sep) 227 ) 228 state["pages"] = pages 229 state["pageItems"] = page_items 230 state["text"] = pages[0] if pages else "" 231 state["tokenJoiner"] = joiner 232 state["tokenSurround"] = surround 233 state["before"] = self._expandPool( 234 self._state.get("before", ""), 235 joiner=joiner, 236 caseKey=caseKey, 237 sectionSeed=1, 238 groupSize=int(self._state.get("beforeGroupSize", 1)), 239 groupJoin=self._resolveGroupJoin( 240 self._state.get("beforeGroupJoin", "(none)") 241 ), 242 ) 243 state["after"] = self._expandPool( 244 self._state.get("after", ""), 245 joiner=joiner, 246 caseKey=caseKey, 247 sectionSeed=2, 248 groupSize=int(self._state.get("afterGroupSize", 1)), 249 groupJoin=self._resolveGroupJoin( 250 self._state.get("afterGroupJoin", "(none)") 251 ), 252 ) 253 state["beforeGroupSize"] = int(self._state.get("beforeGroupSize", 1)) 254 state["afterGroupSize"] = int(self._state.get("afterGroupSize", 1)) 255 state["combineMode"] = self._state.get("combineMode", 0) 256 # Derive OT feature dict from the text field string. 257 # Migration: if text is empty but legacy dict has truthy entries, populate text. 258 _text = self._state.get("openTypeFeaturesText", "") 259 if not _text.strip(): 260 _legacy_features = self._state.get("openTypeFeatures", {}) 261 _legacy_tags = [k for k, v in _legacy_features.items() if v] 262 if _legacy_tags: 263 _text = " ".join(_legacy_tags) 264 self._state["openTypeFeaturesText"] = _text 265 _enabled = set(self._parseFeatureTags(_text)) 266 if self._state.get("disableAllFeatures", False): 267 _ot_dict: dict[str, bool] = {tag: False for tag in OT_DEFAULT_ON} 268 else: 269 _ot_dict = {k: True for k in _enabled} 270 for _tag in OT_DEFAULT_ON: 271 if _tag not in _enabled: 272 _ot_dict[_tag] = False 273 state["openTypeFeatures"] = _ot_dict 274 state["activeFeatureDesc"] = self._describeActiveFeatures() 275 _modeIdx = int(state.get("omitMissingMode", 0)) 276 state["omitMissingMode"] = ( 277 self._OMIT_MISSING_KEYS[_modeIdx] 278 if 0 <= _modeIdx < len(self._OMIT_MISSING_KEYS) 279 else "glyphs" 280 ) 281 return state 282 283 def build(self, parent, onChange: Callable[..., None]): 284 self._onChange = onChange 285 286 ui = vanilla.Group((0, 0, -0, -0)) 287 margin = 10 288 sectionMargin = PANEL_SECTION_MARGIN 289 self._panel_ui = ui 290 291 ui.textLabel = vanilla.TextBox( 292 (margin, margin, -margin, 12), 293 "Text", 294 alignment="center", 295 sizeStyle="mini", 296 ) 297 298 ui.textHint = vanilla.Button( 299 (-margin - 14, margin - 2, 14, 14), 300 "?", 301 sizeStyle="mini", 302 callback=self._onTextHint, 303 ) 304 305 ui.textField = vanilla.TextEditor( 306 (margin, margin + 12 + margin, -margin, 100), 307 self._state.get("text", ""), 308 callback=self._onTextChanged, 309 ) 310 ui.textField.getNSTextView().setRichText_(False) 311 312 splitRowTop = margin + 12 + margin + 100 + 4 313 _splitLabelW = 44 314 ui.tokenSplitLabel = vanilla.TextBox( 315 (margin, splitRowTop + 1, _splitLabelW, 14), 316 "Split by", 317 sizeStyle="mini", 318 ) 319 ui.tokenSplitLabel.getNSTextField().setTextColor_(NSColor.secondaryLabelColor()) 320 _splitNames = [name for name, _ in self.TOKEN_SPLIT_OPTIONS] 321 _splitIdx = next( 322 ( 323 i 324 for i, (n, _) in enumerate(self.TOKEN_SPLIT_OPTIONS) 325 if n == self._state.get("tokenSplit", "Spaces") 326 ), 327 0, 328 ) 329 ui.tokenSplitPopup = vanilla.PopUpButton( 330 (margin + _splitLabelW + 2, splitRowTop, -margin, 16), 331 _splitNames, 332 sizeStyle="mini", 333 callback=self._onTokenSplitChanged, 334 ) 335 ui.tokenSplitPopup.set(_splitIdx) 336 _nsPopup = ui.tokenSplitPopup.getNSPopUpButton() 337 _nsPopup.setBordered_(False) 338 _nsPopup.setFont_(NSFont.systemFontOfSize_(10)) 339 340 joinRowTop = splitRowTop + 16 + 2 341 _joinLabelW = 44 342 ui.tokenJoinInlineLabel = vanilla.TextBox( 343 (margin, joinRowTop + 5, _joinLabelW, 14), 344 "Join by", 345 sizeStyle="mini", 346 ) 347 ui.tokenJoinInlineLabel.getNSTextField().setTextColor_( 348 NSColor.secondaryLabelColor() 349 ) 350 _delimX = margin + _joinLabelW + 2 351 _delimW = 140 352 ui.tokenJoiner = vanilla.ComboBox( 353 (_delimX, joinRowTop, _delimW, 19), 354 [name for name, _ in self.TOKEN_JOINER_OPTIONS], 355 continuous=True, 356 sizeStyle="mini", 357 callback=self._onTokenJoinerChanged, 358 ) 359 ui.tokenJoiner.set(self._state.get("tokenJoiner", "Space")) 360 ui.tokenSurroundCheckbox = vanilla.CheckBox( 361 (_delimX + _delimW + 10, joinRowTop + 3, -margin, 17), 362 "Surround", 363 value=self._state.get("tokenSurround", False), 364 sizeStyle="mini", 365 callback=self._onTokenSurroundChanged, 366 ) 367 368 buttonsTop = joinRowTop + 19 + 8 369 btnW = (self._panelWidth - 2 * margin - 4) // 2 370 ui.tokensButton = vanilla.Button( 371 (margin, buttonsTop, btnW, 18), 372 "Tokens ▾", 373 sizeStyle="small", 374 callback=self._onTokensButton, 375 ) 376 ui.openButton = vanilla.Button( 377 (margin + btnW + 4, buttonsTop, -margin, 18), 378 "Open File", 379 sizeStyle="small", 380 callback=self._onOpenFile, 381 ) 382 383 subsTop = buttonsTop + 18 + sectionMargin 384 ui.substitutionsLabel = vanilla.TextBox( 385 (margin, subsTop, -margin, 12), 386 "Substitutions", 387 alignment="center", 388 sizeStyle="mini", 389 ) 390 row0Top = subsTop + 12 + margin 391 ui.pageBreakLabel = vanilla.TextBox( 392 (margin, row0Top, 100, 17), 393 "Page Break", 394 sizeStyle="small", 395 ) 396 ui.pageBreak = vanilla.ComboBox( 397 (116, row0Top, -margin, 17), 398 [name for name, _ in self.PAGE_BREAK_OPTIONS], 399 continuous=True, 400 sizeStyle="small", 401 callback=self._onPageBreakChanged, 402 ) 403 ui.pageBreak.set(self._state.get("pageBreak", "Triple Line Break")) 404 405 row1Top = row0Top + 17 + 6 406 ui.contentNewlineLabel = vanilla.TextBox( 407 (margin, row1Top, 100, 17), 408 "Content Newline", 409 sizeStyle="small", 410 ) 411 ui.lineBreakSep = vanilla.ComboBox( 412 (116, row1Top, -margin, 17), 413 [name for name, _ in self.LINE_BREAK_OPTIONS], 414 continuous=True, 415 sizeStyle="small", 416 callback=self._onLineBreakSepChanged, 417 ) 418 ui.lineBreakSep.set(self._state.get("lineBreakSep", "Line Break")) 419 420 row3Top = row1Top + 17 + 6 421 ui.shuffleLabel = vanilla.TextBox( 422 (margin, row3Top, 100, 17), 423 "Shuffle", 424 sizeStyle="small", 425 ) 426 ui.shuffleCheckbox = vanilla.CheckBox( 427 (116, row3Top, 20, 17), 428 "", 429 value=self._state.get("shuffle", False), 430 sizeStyle="small", 431 callback=self._onShuffleChanged, 432 ) 433 ui.reshuffleButton = vanilla.Button( 434 (136, row3Top, -margin, 17), 435 "Reshuffle", 436 sizeStyle="small", 437 callback=self._onReshuffleTokens, 438 ) 439 440 baTop = row3Top + 17 + sectionMargin 441 ui.beforeAfterLabel = vanilla.TextBox( 442 (margin, baTop, -margin, 12), 443 "Before / After", 444 alignment="center", 445 sizeStyle="mini", 446 ) 447 448 # ── Grouping controls (two rows: Before row, After row) ────────── 449 _grpLabelW = 36 450 _grpStepperW = 24 451 _grpBtnW = 14 452 _grpJoinLabelW = 28 453 _grpJoinPopupW = 56 454 _grpRowH = 16 455 _grpGap = 3 456 457 # Before row 458 _baGrpY0 = baTop + 12 + 2 459 ui.beforeGroupLabel = vanilla.TextBox( 460 (margin, _baGrpY0 + 1, _grpLabelW, 14), 461 "Before", 462 sizeStyle="mini", 463 ) 464 ui.beforeGroupLabel.getNSTextField().setTextColor_( 465 NSColor.secondaryLabelColor() 466 ) 467 _x = margin + _grpLabelW + _grpGap 468 ui.beforeGroupMinus = vanilla.Button( 469 (_x, _baGrpY0, _grpBtnW, _grpRowH), 470 "−", 471 sizeStyle="mini", 472 callback=self._onBeforeGroupSizeChanged, 473 ) 474 _nsBtn = ui.beforeGroupMinus.getNSButton() 475 _nsBtn.setBordered_(False) 476 _nsBtn.setFont_(NSFont.systemFontOfSize_(10)) 477 _x += _grpBtnW 478 ui.beforeGroupSize = vanilla.EditText( 479 (_x, _baGrpY0 - 1, _grpStepperW, _grpRowH + 2), 480 str(self._state.get("beforeGroupSize", 1)), 481 sizeStyle="mini", 482 callback=self._onBeforeGroupSizeChanged, 483 ) 484 ui.beforeGroupSize.getNSTextField().setAlignment_(2) # center 485 _x += _grpStepperW 486 ui.beforeGroupPlus = vanilla.Button( 487 (_x, _baGrpY0, _grpBtnW, _grpRowH), 488 "+", 489 sizeStyle="mini", 490 callback=self._onBeforeGroupSizeChanged, 491 ) 492 _nsBtn = ui.beforeGroupPlus.getNSButton() 493 _nsBtn.setBordered_(False) 494 _nsBtn.setFont_(NSFont.systemFontOfSize_(10)) 495 _x += _grpBtnW + _grpGap 496 ui.beforeGroupJoinLabel = vanilla.TextBox( 497 (_x, _baGrpY0 + 1, _grpJoinLabelW, 14), 498 "Join", 499 sizeStyle="mini", 500 ) 501 ui.beforeGroupJoinLabel.getNSTextField().setTextColor_( 502 NSColor.secondaryLabelColor() 503 ) 504 _x += _grpJoinLabelW + _grpGap 505 _baJoinNames = [name for name, _ in self.GROUP_JOIN_OPTIONS] 506 _baJoinIdx = next( 507 ( 508 i 509 for i, (n, _) in enumerate(self.GROUP_JOIN_OPTIONS) 510 if n == self._state.get("beforeGroupJoin", "(none)") 511 ), 512 0, 513 ) 514 ui.beforeGroupJoin = vanilla.PopUpButton( 515 (_x, _baGrpY0 - 1, _grpJoinPopupW, _grpRowH + 2), 516 _baJoinNames, 517 sizeStyle="mini", 518 callback=self._onBeforeGroupJoinChanged, 519 ) 520 ui.beforeGroupJoin.set(_baJoinIdx) 521 _nsPopup = ui.beforeGroupJoin.getNSPopUpButton() 522 _nsPopup.setBordered_(False) 523 _nsPopup.setFont_(NSFont.systemFontOfSize_(10)) 524 525 # After row 526 _baGrpY1 = _baGrpY0 + _grpRowH + 2 527 ui.afterGroupLabel = vanilla.TextBox( 528 (margin, _baGrpY1 + 1, _grpLabelW, 14), 529 "After", 530 sizeStyle="mini", 531 ) 532 ui.afterGroupLabel.getNSTextField().setTextColor_(NSColor.secondaryLabelColor()) 533 _x = margin + _grpLabelW + _grpGap 534 ui.afterGroupMinus = vanilla.Button( 535 (_x, _baGrpY1, _grpBtnW, _grpRowH), 536 "−", 537 sizeStyle="mini", 538 callback=self._onAfterGroupSizeChanged, 539 ) 540 _nsBtn = ui.afterGroupMinus.getNSButton() 541 _nsBtn.setBordered_(False) 542 _nsBtn.setFont_(NSFont.systemFontOfSize_(10)) 543 _x += _grpBtnW 544 ui.afterGroupSize = vanilla.EditText( 545 (_x, _baGrpY1 - 1, _grpStepperW, _grpRowH + 2), 546 str(self._state.get("afterGroupSize", 1)), 547 sizeStyle="mini", 548 callback=self._onAfterGroupSizeChanged, 549 ) 550 ui.afterGroupSize.getNSTextField().setAlignment_(2) # center 551 _x += _grpStepperW 552 ui.afterGroupPlus = vanilla.Button( 553 (_x, _baGrpY1, _grpBtnW, _grpRowH), 554 "+", 555 sizeStyle="mini", 556 callback=self._onAfterGroupSizeChanged, 557 ) 558 _nsBtn = ui.afterGroupPlus.getNSButton() 559 _nsBtn.setBordered_(False) 560 _nsBtn.setFont_(NSFont.systemFontOfSize_(10)) 561 _x += _grpBtnW + _grpGap 562 ui.afterGroupJoinLabel = vanilla.TextBox( 563 (_x, _baGrpY1 + 1, _grpJoinLabelW, 14), 564 "Join", 565 sizeStyle="mini", 566 ) 567 ui.afterGroupJoinLabel.getNSTextField().setTextColor_( 568 NSColor.secondaryLabelColor() 569 ) 570 _x += _grpJoinLabelW + _grpGap 571 _aaJoinIdx = next( 572 ( 573 i 574 for i, (n, _) in enumerate(self.GROUP_JOIN_OPTIONS) 575 if n == self._state.get("afterGroupJoin", "(none)") 576 ), 577 0, 578 ) 579 ui.afterGroupJoin = vanilla.PopUpButton( 580 (_x, _baGrpY1 - 1, _grpJoinPopupW, _grpRowH + 2), 581 _baJoinNames, 582 sizeStyle="mini", 583 callback=self._onAfterGroupJoinChanged, 584 ) 585 ui.afterGroupJoin.set(_aaJoinIdx) 586 _nsPopup = ui.afterGroupJoin.getNSPopUpButton() 587 _nsPopup.setBordered_(False) 588 _nsPopup.setFont_(NSFont.systemFontOfSize_(10)) 589 590 baEditTop = _baGrpY1 + _grpRowH + 6 591 baEditH = 60 592 ui._beforeEditor = vanilla.TextEditor( 593 (0, 0, -0, -0), 594 self._state.get("before", ""), 595 callback=self._onBeforeChanged, 596 ) 597 ui._beforeEditor.getNSTextView().setRichText_(False) 598 ui._afterEditor = vanilla.TextEditor( 599 (0, 0, -0, -0), 600 self._state.get("after", ""), 601 callback=self._onAfterChanged, 602 ) 603 ui._afterEditor.getNSTextView().setRichText_(False) 604 ui.beforeAfterSplit = vanilla.SplitView( 605 (margin, baEditTop, -margin, baEditH), 606 [ 607 dict(view=ui._beforeEditor, identifier="before"), 608 dict(view=ui._afterEditor, identifier="after"), 609 ], 610 isVertical=True, 611 ) 612 self._before_ui = ui._beforeEditor 613 self._after_ui = ui._afterEditor 614 615 combineModeTop = baEditTop + baEditH + margin 616 ui.combineMode = vanilla.SegmentedButton( 617 (margin, combineModeTop, -margin, 18), 618 [dict(title="Off"), dict(title="Zip"), dict(title="Expand")], 619 sizeStyle="small", 620 callback=self._onCombineModeChanged, 621 ) 622 ui.combineMode.set(self._state.get("combineMode", 1)) 623 self._setCombineModePanelEnabled(self._state.get("combineMode", 1) != 0) 624 625 caseTop = combineModeTop + 18 + sectionMargin 626 627 ui.textCaseLabel = vanilla.TextBox( 628 (margin, caseTop, -margin, 12), 629 "Text Case", 630 alignment="center", 631 sizeStyle="mini", 632 ) 633 ui.textCase = vanilla.SegmentedButton( 634 (margin, caseTop + 12 + margin, -margin, 21), 635 [dict(title=case) for case in self.TEXT_CASES], 636 sizeStyle="small", 637 callback=self._onTextCaseChanged, 638 ) 639 ui.textCase.set(self._state.get("textCase", 0)) 640 641 filterModeTop = caseTop + 12 + margin + 21 + sectionMargin 642 ui.filterModeLabel = vanilla.TextBox( 643 (margin, filterModeTop, -margin, 12), 644 "Filter Missing", 645 alignment="center", 646 sizeStyle="mini", 647 ) 648 ui.omitMissingMode = vanilla.SegmentedButton( 649 (margin, filterModeTop + 12 + margin, -margin, 21), 650 [dict(title=m) for m in self.OMIT_MISSING_MODES], 651 sizeStyle="small", 652 callback=self._onOmitMissingModeChanged, 653 ) 654 ui.omitMissingMode.set(self._state.get("omitMissingMode", 0)) 655 656 otTop = filterModeTop + 12 + margin + 21 + sectionMargin 657 ui.openTypeLabel = vanilla.TextBox( 658 (margin, otTop, -margin, 12), 659 "OpenType Features", 660 alignment="center", 661 sizeStyle="mini", 662 ) 663 # Editable text field for feature tags (above the button) 664 _otTextY = otTop + 12 + margin 665 ui.openTypeTextField = vanilla.EditText( 666 (margin, _otTextY, -margin, 22), 667 self._state.get("openTypeFeaturesText", ""), 668 placeholder="liga ss01 kern", 669 sizeStyle="small", 670 callback=self._onOpenTypeTextChanged, 671 ) 672 # Button below the text field 673 _otBtnY = _otTextY + 22 + 4 674 ui.openTypeButton = vanilla.Button( 675 (margin, _otBtnY, -margin, 22), 676 "Features ▾", 677 sizeStyle="small", 678 callback=self._onOpenTypeButton, 679 ) 680 # Checkboxes below the button 681 _otCbY = _otBtnY + 22 + 4 682 _halfW = (self._panelWidth - 2 * margin) // 2 683 ui.openTypeFilter = vanilla.CheckBox( 684 (margin, _otCbY, _halfW, 15), 685 "Filter available", 686 value=self._state.get("filterToFont", False), 687 sizeStyle="mini", 688 callback=self._onFilterToFontChanged, 689 ) 690 ui.disableAllFeatures = vanilla.CheckBox( 691 (margin + _halfW, _otCbY, -margin, 15), 692 "Disable all", 693 value=self._state.get("disableAllFeatures", False), 694 sizeStyle="mini", 695 callback=self._onDisableAllFeaturesChanged, 696 ) 697 698 self._refreshShuffleUI() 699 self._refreshOTControlsEnabled() 700 parent.body = ui 701 702 def _onTextHint(self, sender): 703 pageBreakSymbol = self._resolvePageBreak().replace("\n", "↵") 704 rows = [ 705 ("↵", "Line break (within block)"), 706 (pageBreakSymbol, "Page break (new block)"), 707 ("{{…}}", "Insert full token"), 708 ("{{…(n)}}", "Insert first n items of token"), 709 ("{{…(~)}}", "Force shuffle this token"), 710 ("{{…(~n)}}", "Force shuffle, take n"), 711 ("{{…(!~)}}", "Opt out of shuffle"), 712 ("{{…(!~n)}}", "Opt out of shuffle, take n"), 713 ("{{X*n}}", "Repeat char/word n times"), 714 ('{{"…"*n}}', "Repeat string n times"), 715 ('{{…+"…"}}', "Suffix each token item"), 716 ('{{"…"+…}}', "Prefix each token item"), 717 ("Group N", "Combine N before/after items"), 718 ] 719 col1W, col2W, rowH = 56, 194, 16 720 marginX, marginY = 10, 10 721 popH = marginY + len(rows) * rowH + (len(rows) - 1) * 4 + marginY 722 self._hintPopover = vanilla.Popover((col1W + col2W + marginX * 2, popH)) 723 g = self._hintPopover 724 y = marginY 725 for i, (key, desc) in enumerate(rows): 726 setattr( 727 g, 728 f"key{i}", 729 vanilla.TextBox( 730 (marginX, y, col1W, rowH), 731 key, 732 sizeStyle="small", 733 ), 734 ) 735 setattr( 736 g, 737 f"desc{i}", 738 vanilla.TextBox( 739 (marginX + col1W, y, col2W, rowH), 740 desc, 741 sizeStyle="small", 742 ), 743 ) 744 y += rowH + 4 745 self._hintPopover.open(parentView=sender) 746 747 def _onOpenFile(self, sender): 748 panel = NSOpenPanel.openPanel() 749 panel.setAllowedFileTypes_(["txt"]) 750 panel.setAllowsMultipleSelection_(False) 751 panel.setCanChooseDirectories_(False) 752 if self._defaultDirectory: 753 url = NSURL.fileURLWithPath_(self._defaultDirectory) 754 panel.setDirectoryURL_(url) 755 if panel.runModal() == NSFileHandlingPanelOKButton: 756 url = panel.URLs()[0] 757 text = open(url.path(), "r", encoding="utf-8").read() 758 self._state["text"] = text 759 self._panel_ui.textField.set(text) 760 self._saveState() 761 self._triggerChange(immediate=True) 762 763 def _expandPool( 764 self, 765 text: str, 766 joiner: str | None = None, 767 caseKey: str | None = None, 768 sectionSeed: int = 0, 769 groupSize: int = 1, 770 groupJoin: str = "", 771 ) -> list[str]: 772 """Expand tokens in a before/after field and return as a pool of items. 773 774 Always uses a space as the internal pool joiner for expansion and 775 splitting, so that e.g. ``{{upper}}`` always yields 26 separate items 776 regardless of the main ``tokenJoiner`` setting (which may be empty). 777 Returns ``[""]`` for blank input so that zip/expand math stays 778 consistent. 779 780 Empty lines act as explicit blank items (``""``), allowing unpaired 781 before/after entries. For example, typing ``"\\n("`` in the Before 782 field produces ``["", "("]`` so the first main-text item gets no 783 prefix while the second gets ``(``. Trailing blank entries are 784 stripped automatically to absorb stray editor newlines, but leading 785 and interior blanks are preserved. 786 787 If *groupSize* > 1, consecutive items are combined into groups of N 788 joined by *groupJoin*, so that e.g. ``{{mixed}}`` with groupSize=2 789 yields ``["Ab", "Cd", ...]`` instead of 52 single characters. 790 """ 791 # Pool items are always space-separated, independent of the main 792 # tokenJoiner. This prevents {{upper}} from collapsing into a single 793 # 26-char string when the main joiner is set to "None". 794 # Newlines separate pool items; an empty line produces a blank item. 795 POOL_JOINER = " " 796 if not text.strip(): 797 return [""] 798 items: list[str] = [] 799 for line in text.split("\n"): 800 if line.strip(): 801 expanded = self._compileTemplate( 802 line.strip(), 803 joiner=POOL_JOINER, 804 surround=False, 805 sectionSeed=sectionSeed, 806 ) 807 items.extend(w for w in expanded.split() if w) 808 else: 809 items.append("") 810 # Strip trailing blank entries (stray editor newlines). 811 while items and items[-1] == "": 812 items.pop() 813 # Apply grouping before case transformation so grouped items are 814 # treated as single units when case is applied. 815 if groupSize > 1: 816 items = self._groupItems(items, groupSize, groupJoin) 817 if caseKey is not None: 818 items = [changeCase(item, caseKey) if item else item for item in items] 819 return items if items else [""] 820 821 def _refreshShuffledTokens(self): 822 self._shuffleSeed = random.randint(0, 2**31) 823 824 def _getShuffledItems( 825 self, key: str, items: list, occurrence: int = 0, sectionSeed: int = 0 826 ) -> list: 827 shuffled = list(items) 828 random.Random(self._shuffleSeed ^ hash(key) ^ occurrence ^ sectionSeed).shuffle( 829 shuffled 830 ) 831 return shuffled 832 833 def _shufflePlainSegment(self, text: str, sectionSeed: int = 0) -> str: 834 rng = random.Random(self._shuffleSeed ^ sectionSeed) 835 mode = self._resolveTokenSplit() 836 if mode == "chars": 837 chars = [c for c in text if not c.isspace()] 838 rng.shuffle(chars) 839 it = iter(chars) 840 return "".join(next(it) if not c.isspace() else c for c in text) 841 if mode == "line": 842 lines = text.split("\n") 843 non_empty = [l for l in lines if l.strip()] 844 rng.shuffle(non_empty) 845 it = iter(non_empty) 846 return "\n".join(next(it) if l.strip() else l for l in lines) 847 # default: "space" — shuffle words within each line 848 lines = text.split("\n") 849 result = [] 850 for line in lines: 851 words = [w for w in line.split(" ") if w] 852 if words: 853 rng.shuffle(words) 854 result.append(" ".join(words)) 855 else: 856 result.append(line) 857 return "\n".join(result) 858 859 def _compileTemplate( 860 self, 861 text: str, 862 joiner: str = " ", 863 surround: bool = False, 864 sectionSeed: int = 0, 865 ) -> str: 866 text = text.replace("“", '"').replace("”", '"') 867 shuffle_on = self._state.get("shuffle", False) 868 _token_names = "|".join(re.escape(k) for k in TOKENS) 869 combined_pattern = re.compile( 870 r"\{\{(?:" 871 r"(?P<tok>" + _token_names + r")(?P<mod>\((?:!?~\d*|\d+~?|)\))?" 872 r"|(?P<addl>" 873 + _token_names 874 + r')(?P<addlmod>\((?:!?~\d*|\d+~?|)\))?\+"(?P<addrsuf>[^"]*)"' 875 r'|"(?P<addlpre>[^"]*)"+\+(?P<addr>' 876 + _token_names 877 + r")(?P<addrmod>\((?:!?~\d*|\d+~?|)\))?" 878 r'|"(?P<qlit>[^"]*?)"\*(?P<qn>[1-9]\d*)' 879 r"|(?P<ulit>\w+)\*(?P<un>[1-9]\d*)" 880 r")\}\}" 881 ) 882 result = [] 883 last_end = 0 884 key_counts: dict[str, int] = {} 885 886 for m in combined_pattern.finditer(text): 887 plain = text[last_end : m.start()] 888 if plain: 889 if shuffle_on: 890 plain = self._shufflePlainSegment(plain, sectionSeed=sectionSeed) 891 result.append(plain) 892 893 if m.group("tok"): 894 key = m.group("tok") 895 param = m.group("mod") 896 base_items = ( 897 list(TOKENS[key]) 898 if isinstance(TOKENS[key], str) 899 else list(TOKENS[key]) 900 ) 901 if param: 902 inner = param.strip("()") 903 if inner.startswith("!~"): 904 opt_out, force_shuffle, count_str = True, False, inner[2:] 905 elif inner.startswith("~"): 906 opt_out, force_shuffle, count_str = False, True, inner[1:] 907 elif inner.endswith("~"): 908 opt_out, force_shuffle, count_str = False, True, inner[:-1] 909 else: 910 opt_out, force_shuffle, count_str = False, False, inner 911 else: 912 opt_out, force_shuffle, count_str = False, False, "" 913 914 use_shuffle = (shuffle_on or force_shuffle) and not opt_out 915 occurrence = key_counts.get(key, 0) 916 key_counts[key] = occurrence + 1 917 effective_items = ( 918 self._getShuffledItems(key, base_items, occurrence, sectionSeed) 919 if use_shuffle 920 else base_items 921 ) 922 sliced = ( 923 effective_items[: int(count_str)] if count_str else effective_items 924 ) 925 elif m.group("addl") or m.group("addr"): 926 # Addition: token+"suffix" or "prefix"+token 927 if m.group("addl"): 928 key = m.group("addl") 929 param = m.group("addlmod") 930 concat_pre, concat_suf = "", m.group("addrsuf") 931 else: 932 key = m.group("addr") 933 param = m.group("addrmod") 934 concat_pre, concat_suf = m.group("addlpre"), "" 935 base_items = ( 936 list(TOKENS[key]) 937 if isinstance(TOKENS[key], str) 938 else list(TOKENS[key]) 939 ) 940 if param: 941 inner = param.strip("()") 942 if inner.startswith("!~"): 943 opt_out, force_shuffle, count_str = True, False, inner[2:] 944 elif inner.startswith("~"): 945 opt_out, force_shuffle, count_str = False, True, inner[1:] 946 elif inner.endswith("~"): 947 opt_out, force_shuffle, count_str = False, True, inner[:-1] 948 else: 949 opt_out, force_shuffle, count_str = False, False, inner 950 else: 951 opt_out, force_shuffle, count_str = False, False, "" 952 use_shuffle = (shuffle_on or force_shuffle) and not opt_out 953 occurrence = key_counts.get(key, 0) 954 key_counts[key] = occurrence + 1 955 effective_items = ( 956 self._getShuffledItems(key, base_items, occurrence, sectionSeed) 957 if use_shuffle 958 else base_items 959 ) 960 base_sliced = ( 961 effective_items[: int(count_str)] if count_str else effective_items 962 ) 963 sliced = [concat_pre + item + concat_suf for item in base_sliced] 964 else: 965 # Repeat literal shorthand: {{X*N}} or {{"string"*N}} 966 literal = ( 967 m.group("qlit") if m.group("qlit") is not None else m.group("ulit") 968 ) 969 count = int( 970 m.group("qn") if m.group("qlit") is not None else m.group("un") 971 ) 972 sliced = [literal] * count 973 974 joined = joiner.join(sliced) 975 if surround and joiner and sliced: 976 joined = joiner + joined + joiner 977 result.append(joined) 978 last_end = m.end() 979 980 plain = text[last_end:] 981 if plain and shuffle_on: 982 plain = self._shufflePlainSegment(plain, sectionSeed=sectionSeed) 983 result.append(plain) 984 985 return "".join(result) 986 987 def _compileTemplateItems( 988 self, 989 text: str, 990 caseKey: str | None = None, 991 sep: str = " ", 992 ) -> list[str]: 993 text = text.replace("“", '"').replace("”", '"') 994 shuffle_on = self._state.get("shuffle", False) 995 _token_names = "|".join(re.escape(key) for key in TOKENS) 996 combined_pattern = re.compile( 997 r"\{\{(?:" 998 r"(?P<tok>" + _token_names + r")(?P<mod>\((?:!?~\d*|\d+~?|)\))?" 999 r"|(?P<addl>" 1000 + _token_names 1001 + r')(?P<addlmod>\((?:!?~\d*|\d+~?|)\))?\+"(?P<addrsuf>[^"]*)"' 1002 r'|"(?P<addlpre>[^"]*)"+\+(?P<addr>' 1003 + _token_names 1004 + r")(?P<addrmod>\((?:!?~\d*|\d+~?|)\))?" 1005 r'|"(?P<qlit>[^"]*?)"\*(?P<qn>[1-9]\d*)' 1006 r"|(?P<ulit>\w+)\*(?P<un>[1-9]\d*)" 1007 r")\}\}" 1008 ) 1009 items: list[str] = [] 1010 key_counts: dict[str, int] = {} 1011 last_end = 0 1012 1013 for match in combined_pattern.finditer(text): 1014 plain = text[last_end : match.start()] 1015 if plain.strip(): 1016 plain_words = [ 1017 changeCase(w, caseKey) if caseKey is not None else w 1018 for w in self._splitTextToTokens(plain) 1019 ] 1020 items.extend(plain_words) 1021 1022 if match.group("tok"): 1023 token_key = match.group("tok") 1024 base_items = ( 1025 list(TOKENS[token_key]) 1026 if isinstance(TOKENS[token_key], str) 1027 else list(TOKENS[token_key]) 1028 ) 1029 param = match.group("mod") 1030 if param: 1031 inner = param.strip("()") 1032 if inner.startswith("!~"): 1033 opt_out, force_shuffle, count_str = True, False, inner[2:] 1034 elif inner.startswith("~"): 1035 opt_out, force_shuffle, count_str = False, True, inner[1:] 1036 elif inner.endswith("~"): 1037 opt_out, force_shuffle, count_str = False, True, inner[:-1] 1038 else: 1039 opt_out, force_shuffle, count_str = False, False, inner 1040 else: 1041 opt_out, force_shuffle, count_str = False, False, "" 1042 1043 use_shuffle = (shuffle_on or force_shuffle) and not opt_out 1044 occurrence = key_counts.get(token_key, 0) 1045 key_counts[token_key] = occurrence + 1 1046 token_items = ( 1047 self._getShuffledItems(token_key, base_items, occurrence) 1048 if use_shuffle 1049 else list(base_items) 1050 ) 1051 if count_str: 1052 token_items = token_items[: int(count_str)] 1053 elif match.group("addl") or match.group("addr"): 1054 # Addition: token+"suffix" or "prefix"+token 1055 if match.group("addl"): 1056 token_key = match.group("addl") 1057 param = match.group("addlmod") 1058 concat_pre, concat_suf = "", match.group("addrsuf") 1059 else: 1060 token_key = match.group("addr") 1061 param = match.group("addrmod") 1062 concat_pre, concat_suf = match.group("addlpre"), "" 1063 base_items = ( 1064 list(TOKENS[token_key]) 1065 if isinstance(TOKENS[token_key], str) 1066 else list(TOKENS[token_key]) 1067 ) 1068 if param: 1069 inner = param.strip("()") 1070 if inner.startswith("!~"): 1071 opt_out, force_shuffle, count_str = True, False, inner[2:] 1072 elif inner.startswith("~"): 1073 opt_out, force_shuffle, count_str = False, True, inner[1:] 1074 elif inner.endswith("~"): 1075 opt_out, force_shuffle, count_str = False, True, inner[:-1] 1076 else: 1077 opt_out, force_shuffle, count_str = False, False, inner 1078 else: 1079 opt_out, force_shuffle, count_str = False, False, "" 1080 use_shuffle = (shuffle_on or force_shuffle) and not opt_out 1081 occurrence = key_counts.get(token_key, 0) 1082 key_counts[token_key] = occurrence + 1 1083 token_items = ( 1084 self._getShuffledItems(token_key, base_items, occurrence) 1085 if use_shuffle 1086 else list(base_items) 1087 ) 1088 if count_str: 1089 token_items = token_items[: int(count_str)] 1090 token_items = [concat_pre + item + concat_suf for item in token_items] 1091 else: 1092 # Repeat literal shorthand: {{X*N}} or {{"string"*N}} 1093 literal = ( 1094 match.group("qlit") 1095 if match.group("qlit") is not None 1096 else match.group("ulit") 1097 ) 1098 count = int( 1099 match.group("qn") 1100 if match.group("qlit") is not None 1101 else match.group("un") 1102 ) 1103 token_items = [literal] * count 1104 1105 if caseKey is not None: 1106 token_items = [changeCase(item, caseKey) for item in token_items] 1107 items.extend(token_items) 1108 last_end = match.end() 1109 1110 plain = text[last_end:] 1111 if plain.strip(): 1112 plain_words = [ 1113 changeCase(w, caseKey) if caseKey is not None else w 1114 for w in self._splitTextToTokens(plain) 1115 ] 1116 items.extend(plain_words) 1117 1118 return items 1119 1120 def _onTokensButton(self, sender): 1121 list_items = [] 1122 for key, value in TOKENS.items(): 1123 elems = list(value) 1124 preview = " ".join(elems) 1125 list_items.append({"token": "{{" + key + "}}", "preview": preview}) 1126 1127 self._tokenPopover = vanilla.Popover((280, 230)) 1128 self._tokenPopover.tokenList = vanilla.List( 1129 (0, 0, -0, -0), 1130 list_items, 1131 columnDescriptions=[ 1132 {"title": "Token", "key": "token", "width": 110}, 1133 {"title": "Preview", "key": "preview"}, 1134 ], 1135 selectionCallback=self._onTokenSelected, 1136 allowsMultipleSelection=False, 1137 allowsEmptySelection=True, 1138 showColumnTitles=True, 1139 ) 1140 self._tokenPopover.tokenList.setSelection([]) 1141 self._tokenPopover.open(parentView=sender) 1142 1143 def _onTokenSelected(self, sender): 1144 selection = sender.getSelection() 1145 if not selection: 1146 return 1147 idx = selection[0] 1148 token = sender.get()[idx]["token"] 1149 current = self._panel_ui.textField.get() 1150 new_text = current + token 1151 self._panel_ui.textField.set(new_text) 1152 self._state["text"] = new_text 1153 self._saveState() 1154 self._triggerChange(immediate=True) 1155 self._tokenPopover.close() 1156 1157 def _onTextCaseChanged(self, sender): 1158 self._state["textCase"] = sender.get() 1159 self._saveState() 1160 self._triggerChange(immediate=True) 1161 1162 def _onOmitMissingModeChanged(self, sender): 1163 self._state["omitMissingMode"] = sender.get() 1164 self._saveState() 1165 self._triggerChange(immediate=True) 1166 1167 @staticmethod 1168 def _parseFeatureTags(text: str) -> list[str]: 1169 """Parse a space-separated feature tag string into a deduplicated list. 1170 1171 Preserves insertion order, keeping the last occurrence of duplicates. 1172 Lowercases all tags and filters empty strings. 1173 """ 1174 if not text or not text.strip(): 1175 return [] 1176 seen: dict[str, bool] = {} 1177 for tag in text.split(): 1178 normalized = tag.strip().lower() 1179 if normalized: 1180 seen[normalized] = True 1181 return list(seen.keys()) 1182 1183 @staticmethod 1184 def _featureTagsToString(tags: list[str]) -> str: 1185 """Join a list of feature tags into a space-separated string.""" 1186 return " ".join(tags) 1187 1188 def _activeFeatureTags(self) -> list[str]: 1189 text = self._state.get("openTypeFeaturesText", "") 1190 return self._parseFeatureTags(text) 1191 1192 def _describeActiveFeatures(self) -> str: 1193 if self._state.get("disableAllFeatures", False): 1194 return "" 1195 active = self._activeFeatureTags() 1196 return " ".join(active).upper() if active else "" 1197 1198 def _updateOpenTypeTextField(self): 1199 """Sync the text field value from the current feature state.""" 1200 if hasattr(self, "_panel_ui"): 1201 tags = self._activeFeatureTags() 1202 text = self._featureTagsToString(tags) 1203 self._panel_ui.openTypeTextField.set(text) 1204 1205 def setActiveFontPath(self, path: str | None) -> None: 1206 """Notify the panel of the active font path used for feature filtering. 1207 1208 Called automatically by RenderPanelManager whenever the panel state is 1209 fetched (i.e. before every render). Eagerly caches the font's OT 1210 features using the explicit-path variant of listOpenTypeFeatures, which 1211 works without an active DrawBot drawing context. 1212 """ 1213 if path == getattr(self, "_activeFontPath", None): 1214 return 1215 self._activeFontPath = path 1216 self._cachedFontFeatures: set[str] | None = None 1217 if path: 1218 try: 1219 import drawBot 1220 1221 result = drawBot.listOpenTypeFeatures(path) 1222 if isinstance(result, dict): 1223 self._cachedFontFeatures = set(result.keys()) 1224 elif result: 1225 self._cachedFontFeatures = set(result) 1226 except Exception: 1227 pass 1228 1229 def _getFontFeatures(self) -> set[str] | None: 1230 """Return the cached OT feature set for the active font, or None.""" 1231 return getattr(self, "_cachedFontFeatures", None) 1232 1233 def _buildFeatureListItems(self) -> list[dict]: 1234 # Parse active tags from the text field (source of truth). 1235 active_tags = set(self._activeFeatureTags()) 1236 filter_to_font = self._state.get("filterToFont", False) 1237 # Use cached features; None means "no font known yet → show all". 1238 font_features = self._getFontFeatures() if filter_to_font else None 1239 items = [] 1240 for group_name, group_features in OT_FEATURE_GROUPS: 1241 group_rows = [] 1242 for tag, name in group_features: 1243 if font_features is not None and tag not in font_features: 1244 continue 1245 group_rows.append( 1246 { 1247 "check": "✓" if tag in active_tags else "", 1248 "tag": tag, 1249 "name": name, 1250 } 1251 ) 1252 if group_rows: 1253 items.append({"check": "", "tag": "", "name": f" {group_name}"}) 1254 items.extend(group_rows) 1255 return items 1256 1257 def _onOpenTypeButton(self, sender): 1258 self._updatingFeatureList = False 1259 items = self._buildFeatureListItems() 1260 self._featurePopover = vanilla.Popover((280, 380)) 1261 self._featurePopover.featureList = vanilla.List( 1262 (0, 0, -0, -28), 1263 items, 1264 columnDescriptions=[ 1265 {"title": "", "key": "check", "width": 16}, 1266 {"title": "Tag", "key": "tag", "width": 36}, 1267 {"title": "Feature", "key": "name"}, 1268 ], 1269 selectionCallback=self._onFeatureSelected, 1270 showColumnTitles=False, 1271 allowsMultipleSelection=False, 1272 allowsEmptySelection=True, 1273 ) 1274 self._featurePopover.clearButton = vanilla.Button( 1275 (8, -24, 74, 18), 1276 "Clear All", 1277 sizeStyle="mini", 1278 callback=self._onClearAllFeatures, 1279 ) 1280 self._featurePopover.open(parentView=sender) 1281 1282 def _onFeatureSelected(self, sender): 1283 if getattr(self, "_updatingFeatureList", False): 1284 return 1285 selection = sender.getSelection() 1286 if not selection: 1287 return 1288 idx = selection[0] 1289 items = sender.get() 1290 tag = items[idx].get("tag", "") 1291 if not tag: 1292 self._updatingFeatureList = True 1293 sender.setSelection([]) 1294 self._updatingFeatureList = False 1295 return 1296 # Toggle the tag in the text field's active list. 1297 current_tags = self._activeFeatureTags() 1298 if tag in current_tags: 1299 new_tags = [t for t in current_tags if t != tag] 1300 else: 1301 new_tags = current_tags + [tag] 1302 self._state["openTypeFeaturesText"] = self._featureTagsToString(new_tags) 1303 # Update popover list to reflect the toggle. 1304 new_items = self._buildFeatureListItems() 1305 self._updatingFeatureList = True 1306 sender.set(new_items) 1307 sender.setSelection([]) 1308 self._updatingFeatureList = False 1309 self._updateOpenTypeTextField() 1310 self._saveState() 1311 self._triggerChange(immediate=True) 1312 1313 def _onClearAllFeatures(self, sender): 1314 self._state["openTypeFeaturesText"] = "" 1315 new_items = self._buildFeatureListItems() 1316 self._updatingFeatureList = True 1317 self._featurePopover.featureList.set(new_items) 1318 self._updatingFeatureList = False 1319 self._updateOpenTypeTextField() 1320 self._saveState() 1321 self._triggerChange(immediate=True) 1322 1323 def _refreshOTControlsEnabled(self): 1324 disabled = bool(self._state.get("disableAllFeatures", False)) 1325 if hasattr(self, "_panel_ui"): 1326 self._panel_ui.openTypeTextField.enable(not disabled) 1327 self._panel_ui.openTypeButton.enable(not disabled) 1328 self._panel_ui.openTypeFilter.enable(not disabled) 1329 1330 def _onOpenTypeTextChanged(self, sender): 1331 self._state["openTypeFeaturesText"] = sender.get() 1332 self._saveState() 1333 self._triggerChange() 1334 1335 def _onDisableAllFeaturesChanged(self, sender): 1336 self._state["disableAllFeatures"] = bool(sender.get()) 1337 self._refreshOTControlsEnabled() 1338 self._saveState() 1339 self._triggerChange(immediate=True) 1340 1341 def _onFilterToFontChanged(self, sender): 1342 self._state["filterToFont"] = bool(sender.get()) 1343 self._saveState() 1344 if hasattr(self, "_featurePopover"): 1345 new_items = self._buildFeatureListItems() 1346 self._updatingFeatureList = True 1347 self._featurePopover.featureList.set(new_items) 1348 self._updatingFeatureList = False 1349 1350 def _onLineBreakSepChanged(self, sender): 1351 self._state["lineBreakSep"] = sender.get() 1352 self._saveState() 1353 self._triggerChange() 1354 1355 def _onPageBreakChanged(self, sender): 1356 self._state["pageBreak"] = sender.get() 1357 self._saveState() 1358 self._triggerChange(immediate=True) 1359 1360 def _onTokenJoinerChanged(self, sender): 1361 self._state["tokenJoiner"] = sender.get() 1362 self._saveState() 1363 self._triggerChange(immediate=True) 1364 1365 def _onTokenSplitChanged(self, sender): 1366 idx = sender.get() 1367 self._state["tokenSplit"] = self.TOKEN_SPLIT_OPTIONS[idx][0] 1368 self._saveState() 1369 self._triggerChange(immediate=True) 1370 1371 def _onTokenSurroundChanged(self, sender): 1372 self._state["tokenSurround"] = bool(sender.get()) 1373 self._saveState() 1374 self._triggerChange(immediate=True) 1375 1376 def _refreshShuffleUI(self): 1377 enabled = bool(self._state.get("shuffle", False)) 1378 self._panel_ui.reshuffleButton.show(enabled) 1379 1380 def _onShuffleChanged(self, sender): 1381 self._state["shuffle"] = bool(sender.get()) 1382 self._refreshShuffleUI() 1383 self._saveState() 1384 self._triggerChange(immediate=True) 1385 1386 def _onReshuffleTokens(self, sender): 1387 self._refreshShuffledTokens() 1388 self._triggerChange(immediate=True) 1389 1390 def _onBeforeChanged(self, sender): 1391 self._state["before"] = sender.get() 1392 self._saveState() 1393 self._triggerChange() 1394 1395 def _onAfterChanged(self, sender): 1396 self._state["after"] = sender.get() 1397 self._saveState() 1398 self._triggerChange() 1399 1400 def _setCombineModePanelEnabled(self, enabled: bool): 1401 self._before_ui.getNSTextView().setEditable_(enabled) 1402 self._after_ui.getNSTextView().setEditable_(enabled) 1403 1404 def _onCombineModeChanged(self, sender): 1405 mode = sender.get() 1406 self._state["combineMode"] = mode 1407 self._setCombineModePanelEnabled(mode != 0) 1408 self._saveState() 1409 self._triggerChange(immediate=True) 1410 1411 # ── Grouping callbacks ──────────────────────────────────────────────── 1412 1413 def _clampGroupSize(self, value: int) -> int: 1414 """Clamp a group size to the valid range [1, 50].""" 1415 return max(1, min(50, value)) 1416 1417 def _onBeforeGroupSizeChanged(self, sender): 1418 self._updateGroupSize("beforeGroupSize", sender) 1419 1420 def _onAfterGroupSizeChanged(self, sender): 1421 self._updateGroupSize("afterGroupSize", sender) 1422 1423 def _updateGroupSize(self, key: str, sender): 1424 ui = self._panel_ui 1425 prefix = "before" if key == "beforeGroupSize" else "after" 1426 editField = getattr(ui, f"{prefix}GroupSize") 1427 minusBtn = getattr(ui, f"{prefix}GroupMinus") 1428 plusBtn = getattr(ui, f"{prefix}GroupPlus") 1429 1430 current = int(self._state.get(key, 1)) 1431 1432 if sender is minusBtn: 1433 current = self._clampGroupSize(current - 1) 1434 elif sender is plusBtn: 1435 current = self._clampGroupSize(current + 1) 1436 else: 1437 # Called from the edit field itself — parse the text. 1438 try: 1439 current = self._clampGroupSize(int(editField.get())) 1440 except (ValueError, TypeError): 1441 pass # keep current value on invalid input 1442 1443 self._state[key] = current 1444 editField.set(str(current)) 1445 self._saveState() 1446 self._triggerChange(immediate=True) 1447 1448 def _onBeforeGroupJoinChanged(self, sender): 1449 idx = sender.get() 1450 self._state["beforeGroupJoin"] = self.GROUP_JOIN_OPTIONS[idx][0] 1451 self._saveState() 1452 self._triggerChange(immediate=True) 1453 1454 def _onAfterGroupJoinChanged(self, sender): 1455 idx = sender.get() 1456 self._state["afterGroupJoin"] = self.GROUP_JOIN_OPTIONS[idx][0] 1457 self._saveState() 1458 self._triggerChange(immediate=True) 1459 1460 def _resolveLineBreakSep(self) -> str: 1461 label = self._state.get("lineBreakSep", "Line Break") 1462 for name, value in self.LINE_BREAK_OPTIONS: 1463 if label == name: 1464 return value 1465 return label 1466 1467 def _resolvePageBreak(self) -> str: 1468 label = self._state.get("pageBreak", "Triple Line Break") 1469 for name, value in self.PAGE_BREAK_OPTIONS: 1470 if label == name: 1471 return value 1472 return label 1473 1474 def _resolveTokenJoiner(self) -> str: 1475 label = self._state.get("tokenJoiner", "Space") 1476 for name, value in self.TOKEN_JOINER_OPTIONS: 1477 if label == name: 1478 return value 1479 return label 1480 1481 def _resolveTokenSplit(self) -> str: 1482 """Return the internal split-mode key: 'space', 'chars', or 'line'.""" 1483 label = self._state.get("tokenSplit", "Spaces") 1484 for name, key in self.TOKEN_SPLIT_OPTIONS: 1485 if label == name: 1486 return key 1487 return "space" 1488 1489 def _splitTextToTokens(self, text: str) -> list[str]: 1490 """Split *text* into tokens according to the current tokenSplit mode.""" 1491 mode = self._resolveTokenSplit() 1492 if mode == "chars": 1493 return [c for c in text if not c.isspace()] 1494 if mode == "line": 1495 return [line.strip() for line in text.split("\n") if line.strip()] 1496 # default: "space" 1497 return [w for w in text.split() if w] 1498 1499 @staticmethod 1500 def _groupItems(items: list[str], groupSize: int, groupJoin: str) -> list[str]: 1501 """Combine every *groupSize* consecutive items into one string. 1502 1503 If ``groupSize <= 1`` the input is returned unchanged (identity). 1504 The last chunk may be shorter than *groupSize* (no padding, no truncation). 1505 Empty strings in the list participate in grouping like any other item. 1506 """ 1507 if groupSize <= 1: 1508 return items 1509 result: list[str] = [] 1510 for i in range(0, len(items), groupSize): 1511 chunk = items[i : i + groupSize] 1512 result.append(groupJoin.join(chunk)) 1513 return result 1514 1515 def _resolveGroupJoin(self, label: str) -> str: 1516 """Map a GROUP_JOIN_OPTIONS display name to its actual character.""" 1517 for name, value in self.GROUP_JOIN_OPTIONS: 1518 if name == label: 1519 return value 1520 return "" 1521 1522 def _onTextChanged(self, sender): 1523 self._state["text"] = sender.get() 1524 self._saveState() 1525 self._triggerChange() 1526 1527 def _loadState(self): 1528 try: 1529 if os.path.exists(self._statePath): 1530 with open(self._statePath, "r", encoding="utf-8") as f: 1531 saved = json.load(f) 1532 self._state.update(saved) 1533 except Exception: 1534 pass 1535 1536 # Strip computed keys that getState() adds but _state never stores. 1537 # These end up in the file when reloadState() is called with a 1538 # getState() snapshot (e.g. an imported export file). 1539 for _computed in ("pages", "pageItems"): 1540 self._state.pop(_computed, None) 1541 1542 # Coerce fields whose types getState() changes back to their internal 1543 # representation so that build() never receives the wrong type. 1544 if not isinstance(self._state.get("text", ""), str): 1545 self._state["text"] = "" 1546 if not isinstance(self._state.get("before", ""), str): 1547 self._state["before"] = "" 1548 if not isinstance(self._state.get("after", ""), str): 1549 self._state["after"] = "" 1550 omit = self._state.get("omitMissingMode", 0) 1551 if isinstance(omit, str): 1552 try: 1553 self._state["omitMissingMode"] = self._OMIT_MISSING_KEYS.index(omit) 1554 except (ValueError, AttributeError): 1555 self._state["omitMissingMode"] = 0 1556 elif not isinstance(omit, int): 1557 self._state["omitMissingMode"] = 0 1558 1559 # Backward-compat: ensure grouping keys exist with safe defaults. 1560 for _key in ("beforeGroupSize", "afterGroupSize"): 1561 val = self._state.get(_key) 1562 if not isinstance(val, int) or val < 1: 1563 self._state[_key] = 1 1564 for _key in ("beforeGroupJoin", "afterGroupJoin"): 1565 if _key not in self._state: 1566 self._state[_key] = "(none)"
TOKENS =
{'upper': 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'lower': 'abcdefghijklmnopqrstuvwxyz', 'mixed': ['Aa', 'Bb', 'Cc', 'Dd', 'Ee', 'Ff', 'Gg', 'Hh', 'Ii', 'Jj', 'Kk', 'Ll', 'Mm', 'Nn', 'Oo', 'Pp', 'Qq', 'Rr', 'Ss', 'Tt', 'Uu', 'Vv', 'Ww', 'Xx', 'Yy', 'Zz'], 'num': '0123456789', 'symbols': '*#/\\·•@&', 'paren': '()[]{}⟨⟩', 'punct': '.,:;…!?¡¿', 'quotes': '‚„“”‘’', 'currency': '¢¤$€£¥', 'math': '+−×÷=≠><≈~^∅%‰', 'arrows': '↑↗→↘↓↙←↖↲↳', 'upperCyr': 'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЧЦШЩЬЫЪЭЮЯ', 'lowerCyr': 'абвгдеёжзийклмнопрстуфхчцшщьыъэюя', 'mixedCyr': 'АаБбВвГгДдЕеЁёЖжЗзИиЙйКкЛлМмНнОоПпРрСсТтУуФфХхЧчЦцШшЩщЬьЫыЪъЭэЮюЯя', 'text': ['Angel', 'Blind', 'Clique', 'Dunce', 'Enact', 'Furlong', 'Gnome', 'Human', 'Inlet', 'Justin', 'Knoll', 'Linden', 'Milliner', 'Number', 'Onset', 'Pneumo', 'Quanta', 'Rhone', 'Snout', 'Tundra', 'Uncle', 'Vulcan', 'Whale', 'Xmas', 'Yunnan', 'Zloty', 'Adept', 'Bodice', 'Coast', 'Docile', 'Eosin', 'Focal', 'Gondola', 'Hoist', 'Iodine', 'Jocose', 'Koala', 'Loads', 'Modal', 'Nodule', 'Oddball', 'Poncho', 'Qophs', 'Roman', 'Sodium', 'Tocsin', 'Udder', 'Vocal', 'Woman', 'Xenon', 'Young', 'Zodiac']}
OT_FEATURE_GROUPS =
[('Ligatures & Context', [('liga', 'Standard Ligatures'), ('dlig', 'Discretionary Ligatures'), ('calt', 'Contextual Alternates'), ('clig', 'Contextual Ligatures'), ('hlig', 'Historical Ligatures')]), ('Stylistic Sets', [('ss01', 'Stylistic Set 01'), ('ss02', 'Stylistic Set 02'), ('ss03', 'Stylistic Set 03'), ('ss04', 'Stylistic Set 04'), ('ss05', 'Stylistic Set 05'), ('ss06', 'Stylistic Set 06'), ('ss07', 'Stylistic Set 07'), ('ss08', 'Stylistic Set 08'), ('ss09', 'Stylistic Set 09'), ('ss10', 'Stylistic Set 10'), ('ss11', 'Stylistic Set 11'), ('ss12', 'Stylistic Set 12'), ('ss13', 'Stylistic Set 13'), ('ss14', 'Stylistic Set 14'), ('ss15', 'Stylistic Set 15'), ('ss16', 'Stylistic Set 16'), ('ss17', 'Stylistic Set 17'), ('ss18', 'Stylistic Set 18'), ('ss19', 'Stylistic Set 19'), ('ss20', 'Stylistic Set 20')]), ('Case', [('smcp', 'Small Capitals'), ('c2sc', 'Capitals to Small Caps'), ('case', 'Case-Sensitive Forms'), ('cpsp', 'Capital Spacing'), ('pcap', 'Petite Capitals'), ('unic', 'Unicase')]), ('Numerals', [('lnum', 'Lining Figures'), ('onum', 'Oldstyle Figures'), ('tnum', 'Tabular Figures'), ('pnum', 'Proportional Figures'), ('zero', 'Slashed Zero'), ('frac', 'Fractions'), ('afrc', 'Alternative Fractions'), ('numr', 'Numerators'), ('dnom', 'Denominators'), ('sups', 'Superscript'), ('subs', 'Subscript'), ('sinf', 'Scientific Inferiors'), ('ordn', 'Ordinals')]), ('Spacing & Kerning', [('kern', 'Kerning'), ('dist', 'Distances')]), ('Character Variants', [('cv01', 'Character Variant 01'), ('cv02', 'Character Variant 02'), ('cv03', 'Character Variant 03'), ('cv04', 'Character Variant 04'), ('cv05', 'Character Variant 05')]), ('Alternates & Decorative', [('salt', 'Stylistic Alternates'), ('swsh', 'Swash'), ('cswh', 'Contextual Swash'), ('titl', 'Titling'), ('init', 'Initial Forms'), ('aalt', 'Access All Alternates'), ('nalt', 'Alternate Annotation Forms')]), ('Language & Historical', [('locl', 'Localized Forms'), ('hist', 'Historical Forms')])]
OT_DEFAULT_ON: frozenset[str] =
frozenset({'liga', 'calt', 'clig', 'kern', 'locl'})
138class RenderContentPanel(RenderPanelPlugin): 139 """Minimal RenderView side panel plugin with a text input field.""" 140 141 def __init__( 142 self, 143 defaultText: str = "Type your proof text here.", 144 panelWidth: int = 290, 145 statePath: str | None = None, 146 defaultDirectory: str = "/Users/christianjansky/Library/CloudStorage/Dropbox/KOMETA-Draw/01 Content", 147 ): 148 self._panelWidth = panelWidth 149 self._defaultText = defaultText 150 self._defaultDirectory = defaultDirectory 151 self._statePath = statePath or os.path.expanduser( 152 "~/Library/Application Support/KOMETA-Draw/render_view_content_panel.json" 153 ) 154 self._state = { 155 "text": defaultText, 156 "textCase": 0, 157 "openTypeFeatures": {}, 158 "openTypeFeaturesText": "", 159 "filterToFont": False, 160 "disableAllFeatures": False, 161 "lineBreakSep": "Line Break", 162 "pageBreak": "Triple Line Break", 163 "tokenJoiner": "Space", 164 "tokenSplit": "Spaces", 165 "tokenSurround": False, 166 "shuffle": False, 167 "before": "", 168 "after": "", 169 "combineMode": 1, 170 "omitMissingMode": 0, 171 "beforeGroupSize": 1, 172 "afterGroupSize": 1, 173 "beforeGroupJoin": "(none)", 174 "afterGroupJoin": "(none)", 175 } 176 self._onChange = None 177 self._persistenceEnabled = True 178 self._shuffleSeed: int = 0 179 self._refreshShuffledTokens() 180 self._loadState() 181 182 TEXT_CASES = ["Default", "Title", "UPPER", "lower"] 183 _CASE_KEYS = [None, "title", "upper", "lower"] 184 OMIT_MISSING_MODES = ["Glyphs", "Words"] 185 _OMIT_MISSING_KEYS = ["glyphs", "words"] 186 GROUP_JOIN_OPTIONS = [ 187 ("(none)", ""), 188 ("Space", " "), 189 ("Tab", "\t"), 190 ("Newline", "\n"), 191 ("Hyphen", "-"), 192 ("Slash", "/"), 193 ("Comma", ","), 194 ] 195 LINE_BREAK_OPTIONS = [("Line Break", "\n"), ("Space", " ")] 196 TOKEN_JOINER_OPTIONS = [("Space", " "), ("Line Break", "\n"), ("None", "")] 197 TOKEN_SPLIT_OPTIONS = [ 198 ("Spaces", "space"), 199 ("Characters", "chars"), 200 ("Line Breaks", "line"), 201 ] 202 PAGE_BREAK_OPTIONS = [ 203 ("Single Line Break", "\n"), 204 ("Double Line Break", "\n\n"), 205 ("Triple Line Break", "\n\n\n"), 206 ] 207 208 def getState(self) -> dict: 209 state = dict(self._state) 210 joiner = self._resolveTokenJoiner() 211 surround = bool(state.get("tokenSurround", False)) 212 caseIdx = int(state.get("textCase", 0)) 213 caseKey = ( 214 self._CASE_KEYS[caseIdx] if 0 <= caseIdx < len(self._CASE_KEYS) else None 215 ) 216 sep = self._resolveLineBreakSep() 217 raw_text = state.get("text", "") 218 # Split by the chosen page-break sequence 219 page_chunks = raw_text.split(self._resolvePageBreak()) 220 pages = [] 221 page_items = [] 222 for chunk in page_chunks: 223 expanded = self._compileTemplate(chunk, joiner=joiner, surround=surround) 224 text = changeCase(expanded, caseKey) if caseKey is not None else expanded 225 pages.append(text.replace("\n", sep)) 226 page_items.append( 227 self._compileTemplateItems(chunk, caseKey=caseKey, sep=sep) 228 ) 229 state["pages"] = pages 230 state["pageItems"] = page_items 231 state["text"] = pages[0] if pages else "" 232 state["tokenJoiner"] = joiner 233 state["tokenSurround"] = surround 234 state["before"] = self._expandPool( 235 self._state.get("before", ""), 236 joiner=joiner, 237 caseKey=caseKey, 238 sectionSeed=1, 239 groupSize=int(self._state.get("beforeGroupSize", 1)), 240 groupJoin=self._resolveGroupJoin( 241 self._state.get("beforeGroupJoin", "(none)") 242 ), 243 ) 244 state["after"] = self._expandPool( 245 self._state.get("after", ""), 246 joiner=joiner, 247 caseKey=caseKey, 248 sectionSeed=2, 249 groupSize=int(self._state.get("afterGroupSize", 1)), 250 groupJoin=self._resolveGroupJoin( 251 self._state.get("afterGroupJoin", "(none)") 252 ), 253 ) 254 state["beforeGroupSize"] = int(self._state.get("beforeGroupSize", 1)) 255 state["afterGroupSize"] = int(self._state.get("afterGroupSize", 1)) 256 state["combineMode"] = self._state.get("combineMode", 0) 257 # Derive OT feature dict from the text field string. 258 # Migration: if text is empty but legacy dict has truthy entries, populate text. 259 _text = self._state.get("openTypeFeaturesText", "") 260 if not _text.strip(): 261 _legacy_features = self._state.get("openTypeFeatures", {}) 262 _legacy_tags = [k for k, v in _legacy_features.items() if v] 263 if _legacy_tags: 264 _text = " ".join(_legacy_tags) 265 self._state["openTypeFeaturesText"] = _text 266 _enabled = set(self._parseFeatureTags(_text)) 267 if self._state.get("disableAllFeatures", False): 268 _ot_dict: dict[str, bool] = {tag: False for tag in OT_DEFAULT_ON} 269 else: 270 _ot_dict = {k: True for k in _enabled} 271 for _tag in OT_DEFAULT_ON: 272 if _tag not in _enabled: 273 _ot_dict[_tag] = False 274 state["openTypeFeatures"] = _ot_dict 275 state["activeFeatureDesc"] = self._describeActiveFeatures() 276 _modeIdx = int(state.get("omitMissingMode", 0)) 277 state["omitMissingMode"] = ( 278 self._OMIT_MISSING_KEYS[_modeIdx] 279 if 0 <= _modeIdx < len(self._OMIT_MISSING_KEYS) 280 else "glyphs" 281 ) 282 return state 283 284 def build(self, parent, onChange: Callable[..., None]): 285 self._onChange = onChange 286 287 ui = vanilla.Group((0, 0, -0, -0)) 288 margin = 10 289 sectionMargin = PANEL_SECTION_MARGIN 290 self._panel_ui = ui 291 292 ui.textLabel = vanilla.TextBox( 293 (margin, margin, -margin, 12), 294 "Text", 295 alignment="center", 296 sizeStyle="mini", 297 ) 298 299 ui.textHint = vanilla.Button( 300 (-margin - 14, margin - 2, 14, 14), 301 "?", 302 sizeStyle="mini", 303 callback=self._onTextHint, 304 ) 305 306 ui.textField = vanilla.TextEditor( 307 (margin, margin + 12 + margin, -margin, 100), 308 self._state.get("text", ""), 309 callback=self._onTextChanged, 310 ) 311 ui.textField.getNSTextView().setRichText_(False) 312 313 splitRowTop = margin + 12 + margin + 100 + 4 314 _splitLabelW = 44 315 ui.tokenSplitLabel = vanilla.TextBox( 316 (margin, splitRowTop + 1, _splitLabelW, 14), 317 "Split by", 318 sizeStyle="mini", 319 ) 320 ui.tokenSplitLabel.getNSTextField().setTextColor_(NSColor.secondaryLabelColor()) 321 _splitNames = [name for name, _ in self.TOKEN_SPLIT_OPTIONS] 322 _splitIdx = next( 323 ( 324 i 325 for i, (n, _) in enumerate(self.TOKEN_SPLIT_OPTIONS) 326 if n == self._state.get("tokenSplit", "Spaces") 327 ), 328 0, 329 ) 330 ui.tokenSplitPopup = vanilla.PopUpButton( 331 (margin + _splitLabelW + 2, splitRowTop, -margin, 16), 332 _splitNames, 333 sizeStyle="mini", 334 callback=self._onTokenSplitChanged, 335 ) 336 ui.tokenSplitPopup.set(_splitIdx) 337 _nsPopup = ui.tokenSplitPopup.getNSPopUpButton() 338 _nsPopup.setBordered_(False) 339 _nsPopup.setFont_(NSFont.systemFontOfSize_(10)) 340 341 joinRowTop = splitRowTop + 16 + 2 342 _joinLabelW = 44 343 ui.tokenJoinInlineLabel = vanilla.TextBox( 344 (margin, joinRowTop + 5, _joinLabelW, 14), 345 "Join by", 346 sizeStyle="mini", 347 ) 348 ui.tokenJoinInlineLabel.getNSTextField().setTextColor_( 349 NSColor.secondaryLabelColor() 350 ) 351 _delimX = margin + _joinLabelW + 2 352 _delimW = 140 353 ui.tokenJoiner = vanilla.ComboBox( 354 (_delimX, joinRowTop, _delimW, 19), 355 [name for name, _ in self.TOKEN_JOINER_OPTIONS], 356 continuous=True, 357 sizeStyle="mini", 358 callback=self._onTokenJoinerChanged, 359 ) 360 ui.tokenJoiner.set(self._state.get("tokenJoiner", "Space")) 361 ui.tokenSurroundCheckbox = vanilla.CheckBox( 362 (_delimX + _delimW + 10, joinRowTop + 3, -margin, 17), 363 "Surround", 364 value=self._state.get("tokenSurround", False), 365 sizeStyle="mini", 366 callback=self._onTokenSurroundChanged, 367 ) 368 369 buttonsTop = joinRowTop + 19 + 8 370 btnW = (self._panelWidth - 2 * margin - 4) // 2 371 ui.tokensButton = vanilla.Button( 372 (margin, buttonsTop, btnW, 18), 373 "Tokens ▾", 374 sizeStyle="small", 375 callback=self._onTokensButton, 376 ) 377 ui.openButton = vanilla.Button( 378 (margin + btnW + 4, buttonsTop, -margin, 18), 379 "Open File", 380 sizeStyle="small", 381 callback=self._onOpenFile, 382 ) 383 384 subsTop = buttonsTop + 18 + sectionMargin 385 ui.substitutionsLabel = vanilla.TextBox( 386 (margin, subsTop, -margin, 12), 387 "Substitutions", 388 alignment="center", 389 sizeStyle="mini", 390 ) 391 row0Top = subsTop + 12 + margin 392 ui.pageBreakLabel = vanilla.TextBox( 393 (margin, row0Top, 100, 17), 394 "Page Break", 395 sizeStyle="small", 396 ) 397 ui.pageBreak = vanilla.ComboBox( 398 (116, row0Top, -margin, 17), 399 [name for name, _ in self.PAGE_BREAK_OPTIONS], 400 continuous=True, 401 sizeStyle="small", 402 callback=self._onPageBreakChanged, 403 ) 404 ui.pageBreak.set(self._state.get("pageBreak", "Triple Line Break")) 405 406 row1Top = row0Top + 17 + 6 407 ui.contentNewlineLabel = vanilla.TextBox( 408 (margin, row1Top, 100, 17), 409 "Content Newline", 410 sizeStyle="small", 411 ) 412 ui.lineBreakSep = vanilla.ComboBox( 413 (116, row1Top, -margin, 17), 414 [name for name, _ in self.LINE_BREAK_OPTIONS], 415 continuous=True, 416 sizeStyle="small", 417 callback=self._onLineBreakSepChanged, 418 ) 419 ui.lineBreakSep.set(self._state.get("lineBreakSep", "Line Break")) 420 421 row3Top = row1Top + 17 + 6 422 ui.shuffleLabel = vanilla.TextBox( 423 (margin, row3Top, 100, 17), 424 "Shuffle", 425 sizeStyle="small", 426 ) 427 ui.shuffleCheckbox = vanilla.CheckBox( 428 (116, row3Top, 20, 17), 429 "", 430 value=self._state.get("shuffle", False), 431 sizeStyle="small", 432 callback=self._onShuffleChanged, 433 ) 434 ui.reshuffleButton = vanilla.Button( 435 (136, row3Top, -margin, 17), 436 "Reshuffle", 437 sizeStyle="small", 438 callback=self._onReshuffleTokens, 439 ) 440 441 baTop = row3Top + 17 + sectionMargin 442 ui.beforeAfterLabel = vanilla.TextBox( 443 (margin, baTop, -margin, 12), 444 "Before / After", 445 alignment="center", 446 sizeStyle="mini", 447 ) 448 449 # ── Grouping controls (two rows: Before row, After row) ────────── 450 _grpLabelW = 36 451 _grpStepperW = 24 452 _grpBtnW = 14 453 _grpJoinLabelW = 28 454 _grpJoinPopupW = 56 455 _grpRowH = 16 456 _grpGap = 3 457 458 # Before row 459 _baGrpY0 = baTop + 12 + 2 460 ui.beforeGroupLabel = vanilla.TextBox( 461 (margin, _baGrpY0 + 1, _grpLabelW, 14), 462 "Before", 463 sizeStyle="mini", 464 ) 465 ui.beforeGroupLabel.getNSTextField().setTextColor_( 466 NSColor.secondaryLabelColor() 467 ) 468 _x = margin + _grpLabelW + _grpGap 469 ui.beforeGroupMinus = vanilla.Button( 470 (_x, _baGrpY0, _grpBtnW, _grpRowH), 471 "−", 472 sizeStyle="mini", 473 callback=self._onBeforeGroupSizeChanged, 474 ) 475 _nsBtn = ui.beforeGroupMinus.getNSButton() 476 _nsBtn.setBordered_(False) 477 _nsBtn.setFont_(NSFont.systemFontOfSize_(10)) 478 _x += _grpBtnW 479 ui.beforeGroupSize = vanilla.EditText( 480 (_x, _baGrpY0 - 1, _grpStepperW, _grpRowH + 2), 481 str(self._state.get("beforeGroupSize", 1)), 482 sizeStyle="mini", 483 callback=self._onBeforeGroupSizeChanged, 484 ) 485 ui.beforeGroupSize.getNSTextField().setAlignment_(2) # center 486 _x += _grpStepperW 487 ui.beforeGroupPlus = vanilla.Button( 488 (_x, _baGrpY0, _grpBtnW, _grpRowH), 489 "+", 490 sizeStyle="mini", 491 callback=self._onBeforeGroupSizeChanged, 492 ) 493 _nsBtn = ui.beforeGroupPlus.getNSButton() 494 _nsBtn.setBordered_(False) 495 _nsBtn.setFont_(NSFont.systemFontOfSize_(10)) 496 _x += _grpBtnW + _grpGap 497 ui.beforeGroupJoinLabel = vanilla.TextBox( 498 (_x, _baGrpY0 + 1, _grpJoinLabelW, 14), 499 "Join", 500 sizeStyle="mini", 501 ) 502 ui.beforeGroupJoinLabel.getNSTextField().setTextColor_( 503 NSColor.secondaryLabelColor() 504 ) 505 _x += _grpJoinLabelW + _grpGap 506 _baJoinNames = [name for name, _ in self.GROUP_JOIN_OPTIONS] 507 _baJoinIdx = next( 508 ( 509 i 510 for i, (n, _) in enumerate(self.GROUP_JOIN_OPTIONS) 511 if n == self._state.get("beforeGroupJoin", "(none)") 512 ), 513 0, 514 ) 515 ui.beforeGroupJoin = vanilla.PopUpButton( 516 (_x, _baGrpY0 - 1, _grpJoinPopupW, _grpRowH + 2), 517 _baJoinNames, 518 sizeStyle="mini", 519 callback=self._onBeforeGroupJoinChanged, 520 ) 521 ui.beforeGroupJoin.set(_baJoinIdx) 522 _nsPopup = ui.beforeGroupJoin.getNSPopUpButton() 523 _nsPopup.setBordered_(False) 524 _nsPopup.setFont_(NSFont.systemFontOfSize_(10)) 525 526 # After row 527 _baGrpY1 = _baGrpY0 + _grpRowH + 2 528 ui.afterGroupLabel = vanilla.TextBox( 529 (margin, _baGrpY1 + 1, _grpLabelW, 14), 530 "After", 531 sizeStyle="mini", 532 ) 533 ui.afterGroupLabel.getNSTextField().setTextColor_(NSColor.secondaryLabelColor()) 534 _x = margin + _grpLabelW + _grpGap 535 ui.afterGroupMinus = vanilla.Button( 536 (_x, _baGrpY1, _grpBtnW, _grpRowH), 537 "−", 538 sizeStyle="mini", 539 callback=self._onAfterGroupSizeChanged, 540 ) 541 _nsBtn = ui.afterGroupMinus.getNSButton() 542 _nsBtn.setBordered_(False) 543 _nsBtn.setFont_(NSFont.systemFontOfSize_(10)) 544 _x += _grpBtnW 545 ui.afterGroupSize = vanilla.EditText( 546 (_x, _baGrpY1 - 1, _grpStepperW, _grpRowH + 2), 547 str(self._state.get("afterGroupSize", 1)), 548 sizeStyle="mini", 549 callback=self._onAfterGroupSizeChanged, 550 ) 551 ui.afterGroupSize.getNSTextField().setAlignment_(2) # center 552 _x += _grpStepperW 553 ui.afterGroupPlus = vanilla.Button( 554 (_x, _baGrpY1, _grpBtnW, _grpRowH), 555 "+", 556 sizeStyle="mini", 557 callback=self._onAfterGroupSizeChanged, 558 ) 559 _nsBtn = ui.afterGroupPlus.getNSButton() 560 _nsBtn.setBordered_(False) 561 _nsBtn.setFont_(NSFont.systemFontOfSize_(10)) 562 _x += _grpBtnW + _grpGap 563 ui.afterGroupJoinLabel = vanilla.TextBox( 564 (_x, _baGrpY1 + 1, _grpJoinLabelW, 14), 565 "Join", 566 sizeStyle="mini", 567 ) 568 ui.afterGroupJoinLabel.getNSTextField().setTextColor_( 569 NSColor.secondaryLabelColor() 570 ) 571 _x += _grpJoinLabelW + _grpGap 572 _aaJoinIdx = next( 573 ( 574 i 575 for i, (n, _) in enumerate(self.GROUP_JOIN_OPTIONS) 576 if n == self._state.get("afterGroupJoin", "(none)") 577 ), 578 0, 579 ) 580 ui.afterGroupJoin = vanilla.PopUpButton( 581 (_x, _baGrpY1 - 1, _grpJoinPopupW, _grpRowH + 2), 582 _baJoinNames, 583 sizeStyle="mini", 584 callback=self._onAfterGroupJoinChanged, 585 ) 586 ui.afterGroupJoin.set(_aaJoinIdx) 587 _nsPopup = ui.afterGroupJoin.getNSPopUpButton() 588 _nsPopup.setBordered_(False) 589 _nsPopup.setFont_(NSFont.systemFontOfSize_(10)) 590 591 baEditTop = _baGrpY1 + _grpRowH + 6 592 baEditH = 60 593 ui._beforeEditor = vanilla.TextEditor( 594 (0, 0, -0, -0), 595 self._state.get("before", ""), 596 callback=self._onBeforeChanged, 597 ) 598 ui._beforeEditor.getNSTextView().setRichText_(False) 599 ui._afterEditor = vanilla.TextEditor( 600 (0, 0, -0, -0), 601 self._state.get("after", ""), 602 callback=self._onAfterChanged, 603 ) 604 ui._afterEditor.getNSTextView().setRichText_(False) 605 ui.beforeAfterSplit = vanilla.SplitView( 606 (margin, baEditTop, -margin, baEditH), 607 [ 608 dict(view=ui._beforeEditor, identifier="before"), 609 dict(view=ui._afterEditor, identifier="after"), 610 ], 611 isVertical=True, 612 ) 613 self._before_ui = ui._beforeEditor 614 self._after_ui = ui._afterEditor 615 616 combineModeTop = baEditTop + baEditH + margin 617 ui.combineMode = vanilla.SegmentedButton( 618 (margin, combineModeTop, -margin, 18), 619 [dict(title="Off"), dict(title="Zip"), dict(title="Expand")], 620 sizeStyle="small", 621 callback=self._onCombineModeChanged, 622 ) 623 ui.combineMode.set(self._state.get("combineMode", 1)) 624 self._setCombineModePanelEnabled(self._state.get("combineMode", 1) != 0) 625 626 caseTop = combineModeTop + 18 + sectionMargin 627 628 ui.textCaseLabel = vanilla.TextBox( 629 (margin, caseTop, -margin, 12), 630 "Text Case", 631 alignment="center", 632 sizeStyle="mini", 633 ) 634 ui.textCase = vanilla.SegmentedButton( 635 (margin, caseTop + 12 + margin, -margin, 21), 636 [dict(title=case) for case in self.TEXT_CASES], 637 sizeStyle="small", 638 callback=self._onTextCaseChanged, 639 ) 640 ui.textCase.set(self._state.get("textCase", 0)) 641 642 filterModeTop = caseTop + 12 + margin + 21 + sectionMargin 643 ui.filterModeLabel = vanilla.TextBox( 644 (margin, filterModeTop, -margin, 12), 645 "Filter Missing", 646 alignment="center", 647 sizeStyle="mini", 648 ) 649 ui.omitMissingMode = vanilla.SegmentedButton( 650 (margin, filterModeTop + 12 + margin, -margin, 21), 651 [dict(title=m) for m in self.OMIT_MISSING_MODES], 652 sizeStyle="small", 653 callback=self._onOmitMissingModeChanged, 654 ) 655 ui.omitMissingMode.set(self._state.get("omitMissingMode", 0)) 656 657 otTop = filterModeTop + 12 + margin + 21 + sectionMargin 658 ui.openTypeLabel = vanilla.TextBox( 659 (margin, otTop, -margin, 12), 660 "OpenType Features", 661 alignment="center", 662 sizeStyle="mini", 663 ) 664 # Editable text field for feature tags (above the button) 665 _otTextY = otTop + 12 + margin 666 ui.openTypeTextField = vanilla.EditText( 667 (margin, _otTextY, -margin, 22), 668 self._state.get("openTypeFeaturesText", ""), 669 placeholder="liga ss01 kern", 670 sizeStyle="small", 671 callback=self._onOpenTypeTextChanged, 672 ) 673 # Button below the text field 674 _otBtnY = _otTextY + 22 + 4 675 ui.openTypeButton = vanilla.Button( 676 (margin, _otBtnY, -margin, 22), 677 "Features ▾", 678 sizeStyle="small", 679 callback=self._onOpenTypeButton, 680 ) 681 # Checkboxes below the button 682 _otCbY = _otBtnY + 22 + 4 683 _halfW = (self._panelWidth - 2 * margin) // 2 684 ui.openTypeFilter = vanilla.CheckBox( 685 (margin, _otCbY, _halfW, 15), 686 "Filter available", 687 value=self._state.get("filterToFont", False), 688 sizeStyle="mini", 689 callback=self._onFilterToFontChanged, 690 ) 691 ui.disableAllFeatures = vanilla.CheckBox( 692 (margin + _halfW, _otCbY, -margin, 15), 693 "Disable all", 694 value=self._state.get("disableAllFeatures", False), 695 sizeStyle="mini", 696 callback=self._onDisableAllFeaturesChanged, 697 ) 698 699 self._refreshShuffleUI() 700 self._refreshOTControlsEnabled() 701 parent.body = ui 702 703 def _onTextHint(self, sender): 704 pageBreakSymbol = self._resolvePageBreak().replace("\n", "↵") 705 rows = [ 706 ("↵", "Line break (within block)"), 707 (pageBreakSymbol, "Page break (new block)"), 708 ("{{…}}", "Insert full token"), 709 ("{{…(n)}}", "Insert first n items of token"), 710 ("{{…(~)}}", "Force shuffle this token"), 711 ("{{…(~n)}}", "Force shuffle, take n"), 712 ("{{…(!~)}}", "Opt out of shuffle"), 713 ("{{…(!~n)}}", "Opt out of shuffle, take n"), 714 ("{{X*n}}", "Repeat char/word n times"), 715 ('{{"…"*n}}', "Repeat string n times"), 716 ('{{…+"…"}}', "Suffix each token item"), 717 ('{{"…"+…}}', "Prefix each token item"), 718 ("Group N", "Combine N before/after items"), 719 ] 720 col1W, col2W, rowH = 56, 194, 16 721 marginX, marginY = 10, 10 722 popH = marginY + len(rows) * rowH + (len(rows) - 1) * 4 + marginY 723 self._hintPopover = vanilla.Popover((col1W + col2W + marginX * 2, popH)) 724 g = self._hintPopover 725 y = marginY 726 for i, (key, desc) in enumerate(rows): 727 setattr( 728 g, 729 f"key{i}", 730 vanilla.TextBox( 731 (marginX, y, col1W, rowH), 732 key, 733 sizeStyle="small", 734 ), 735 ) 736 setattr( 737 g, 738 f"desc{i}", 739 vanilla.TextBox( 740 (marginX + col1W, y, col2W, rowH), 741 desc, 742 sizeStyle="small", 743 ), 744 ) 745 y += rowH + 4 746 self._hintPopover.open(parentView=sender) 747 748 def _onOpenFile(self, sender): 749 panel = NSOpenPanel.openPanel() 750 panel.setAllowedFileTypes_(["txt"]) 751 panel.setAllowsMultipleSelection_(False) 752 panel.setCanChooseDirectories_(False) 753 if self._defaultDirectory: 754 url = NSURL.fileURLWithPath_(self._defaultDirectory) 755 panel.setDirectoryURL_(url) 756 if panel.runModal() == NSFileHandlingPanelOKButton: 757 url = panel.URLs()[0] 758 text = open(url.path(), "r", encoding="utf-8").read() 759 self._state["text"] = text 760 self._panel_ui.textField.set(text) 761 self._saveState() 762 self._triggerChange(immediate=True) 763 764 def _expandPool( 765 self, 766 text: str, 767 joiner: str | None = None, 768 caseKey: str | None = None, 769 sectionSeed: int = 0, 770 groupSize: int = 1, 771 groupJoin: str = "", 772 ) -> list[str]: 773 """Expand tokens in a before/after field and return as a pool of items. 774 775 Always uses a space as the internal pool joiner for expansion and 776 splitting, so that e.g. ``{{upper}}`` always yields 26 separate items 777 regardless of the main ``tokenJoiner`` setting (which may be empty). 778 Returns ``[""]`` for blank input so that zip/expand math stays 779 consistent. 780 781 Empty lines act as explicit blank items (``""``), allowing unpaired 782 before/after entries. For example, typing ``"\\n("`` in the Before 783 field produces ``["", "("]`` so the first main-text item gets no 784 prefix while the second gets ``(``. Trailing blank entries are 785 stripped automatically to absorb stray editor newlines, but leading 786 and interior blanks are preserved. 787 788 If *groupSize* > 1, consecutive items are combined into groups of N 789 joined by *groupJoin*, so that e.g. ``{{mixed}}`` with groupSize=2 790 yields ``["Ab", "Cd", ...]`` instead of 52 single characters. 791 """ 792 # Pool items are always space-separated, independent of the main 793 # tokenJoiner. This prevents {{upper}} from collapsing into a single 794 # 26-char string when the main joiner is set to "None". 795 # Newlines separate pool items; an empty line produces a blank item. 796 POOL_JOINER = " " 797 if not text.strip(): 798 return [""] 799 items: list[str] = [] 800 for line in text.split("\n"): 801 if line.strip(): 802 expanded = self._compileTemplate( 803 line.strip(), 804 joiner=POOL_JOINER, 805 surround=False, 806 sectionSeed=sectionSeed, 807 ) 808 items.extend(w for w in expanded.split() if w) 809 else: 810 items.append("") 811 # Strip trailing blank entries (stray editor newlines). 812 while items and items[-1] == "": 813 items.pop() 814 # Apply grouping before case transformation so grouped items are 815 # treated as single units when case is applied. 816 if groupSize > 1: 817 items = self._groupItems(items, groupSize, groupJoin) 818 if caseKey is not None: 819 items = [changeCase(item, caseKey) if item else item for item in items] 820 return items if items else [""] 821 822 def _refreshShuffledTokens(self): 823 self._shuffleSeed = random.randint(0, 2**31) 824 825 def _getShuffledItems( 826 self, key: str, items: list, occurrence: int = 0, sectionSeed: int = 0 827 ) -> list: 828 shuffled = list(items) 829 random.Random(self._shuffleSeed ^ hash(key) ^ occurrence ^ sectionSeed).shuffle( 830 shuffled 831 ) 832 return shuffled 833 834 def _shufflePlainSegment(self, text: str, sectionSeed: int = 0) -> str: 835 rng = random.Random(self._shuffleSeed ^ sectionSeed) 836 mode = self._resolveTokenSplit() 837 if mode == "chars": 838 chars = [c for c in text if not c.isspace()] 839 rng.shuffle(chars) 840 it = iter(chars) 841 return "".join(next(it) if not c.isspace() else c for c in text) 842 if mode == "line": 843 lines = text.split("\n") 844 non_empty = [l for l in lines if l.strip()] 845 rng.shuffle(non_empty) 846 it = iter(non_empty) 847 return "\n".join(next(it) if l.strip() else l for l in lines) 848 # default: "space" — shuffle words within each line 849 lines = text.split("\n") 850 result = [] 851 for line in lines: 852 words = [w for w in line.split(" ") if w] 853 if words: 854 rng.shuffle(words) 855 result.append(" ".join(words)) 856 else: 857 result.append(line) 858 return "\n".join(result) 859 860 def _compileTemplate( 861 self, 862 text: str, 863 joiner: str = " ", 864 surround: bool = False, 865 sectionSeed: int = 0, 866 ) -> str: 867 text = text.replace("“", '"').replace("”", '"') 868 shuffle_on = self._state.get("shuffle", False) 869 _token_names = "|".join(re.escape(k) for k in TOKENS) 870 combined_pattern = re.compile( 871 r"\{\{(?:" 872 r"(?P<tok>" + _token_names + r")(?P<mod>\((?:!?~\d*|\d+~?|)\))?" 873 r"|(?P<addl>" 874 + _token_names 875 + r')(?P<addlmod>\((?:!?~\d*|\d+~?|)\))?\+"(?P<addrsuf>[^"]*)"' 876 r'|"(?P<addlpre>[^"]*)"+\+(?P<addr>' 877 + _token_names 878 + r")(?P<addrmod>\((?:!?~\d*|\d+~?|)\))?" 879 r'|"(?P<qlit>[^"]*?)"\*(?P<qn>[1-9]\d*)' 880 r"|(?P<ulit>\w+)\*(?P<un>[1-9]\d*)" 881 r")\}\}" 882 ) 883 result = [] 884 last_end = 0 885 key_counts: dict[str, int] = {} 886 887 for m in combined_pattern.finditer(text): 888 plain = text[last_end : m.start()] 889 if plain: 890 if shuffle_on: 891 plain = self._shufflePlainSegment(plain, sectionSeed=sectionSeed) 892 result.append(plain) 893 894 if m.group("tok"): 895 key = m.group("tok") 896 param = m.group("mod") 897 base_items = ( 898 list(TOKENS[key]) 899 if isinstance(TOKENS[key], str) 900 else list(TOKENS[key]) 901 ) 902 if param: 903 inner = param.strip("()") 904 if inner.startswith("!~"): 905 opt_out, force_shuffle, count_str = True, False, inner[2:] 906 elif inner.startswith("~"): 907 opt_out, force_shuffle, count_str = False, True, inner[1:] 908 elif inner.endswith("~"): 909 opt_out, force_shuffle, count_str = False, True, inner[:-1] 910 else: 911 opt_out, force_shuffle, count_str = False, False, inner 912 else: 913 opt_out, force_shuffle, count_str = False, False, "" 914 915 use_shuffle = (shuffle_on or force_shuffle) and not opt_out 916 occurrence = key_counts.get(key, 0) 917 key_counts[key] = occurrence + 1 918 effective_items = ( 919 self._getShuffledItems(key, base_items, occurrence, sectionSeed) 920 if use_shuffle 921 else base_items 922 ) 923 sliced = ( 924 effective_items[: int(count_str)] if count_str else effective_items 925 ) 926 elif m.group("addl") or m.group("addr"): 927 # Addition: token+"suffix" or "prefix"+token 928 if m.group("addl"): 929 key = m.group("addl") 930 param = m.group("addlmod") 931 concat_pre, concat_suf = "", m.group("addrsuf") 932 else: 933 key = m.group("addr") 934 param = m.group("addrmod") 935 concat_pre, concat_suf = m.group("addlpre"), "" 936 base_items = ( 937 list(TOKENS[key]) 938 if isinstance(TOKENS[key], str) 939 else list(TOKENS[key]) 940 ) 941 if param: 942 inner = param.strip("()") 943 if inner.startswith("!~"): 944 opt_out, force_shuffle, count_str = True, False, inner[2:] 945 elif inner.startswith("~"): 946 opt_out, force_shuffle, count_str = False, True, inner[1:] 947 elif inner.endswith("~"): 948 opt_out, force_shuffle, count_str = False, True, inner[:-1] 949 else: 950 opt_out, force_shuffle, count_str = False, False, inner 951 else: 952 opt_out, force_shuffle, count_str = False, False, "" 953 use_shuffle = (shuffle_on or force_shuffle) and not opt_out 954 occurrence = key_counts.get(key, 0) 955 key_counts[key] = occurrence + 1 956 effective_items = ( 957 self._getShuffledItems(key, base_items, occurrence, sectionSeed) 958 if use_shuffle 959 else base_items 960 ) 961 base_sliced = ( 962 effective_items[: int(count_str)] if count_str else effective_items 963 ) 964 sliced = [concat_pre + item + concat_suf for item in base_sliced] 965 else: 966 # Repeat literal shorthand: {{X*N}} or {{"string"*N}} 967 literal = ( 968 m.group("qlit") if m.group("qlit") is not None else m.group("ulit") 969 ) 970 count = int( 971 m.group("qn") if m.group("qlit") is not None else m.group("un") 972 ) 973 sliced = [literal] * count 974 975 joined = joiner.join(sliced) 976 if surround and joiner and sliced: 977 joined = joiner + joined + joiner 978 result.append(joined) 979 last_end = m.end() 980 981 plain = text[last_end:] 982 if plain and shuffle_on: 983 plain = self._shufflePlainSegment(plain, sectionSeed=sectionSeed) 984 result.append(plain) 985 986 return "".join(result) 987 988 def _compileTemplateItems( 989 self, 990 text: str, 991 caseKey: str | None = None, 992 sep: str = " ", 993 ) -> list[str]: 994 text = text.replace("“", '"').replace("”", '"') 995 shuffle_on = self._state.get("shuffle", False) 996 _token_names = "|".join(re.escape(key) for key in TOKENS) 997 combined_pattern = re.compile( 998 r"\{\{(?:" 999 r"(?P<tok>" + _token_names + r")(?P<mod>\((?:!?~\d*|\d+~?|)\))?" 1000 r"|(?P<addl>" 1001 + _token_names 1002 + r')(?P<addlmod>\((?:!?~\d*|\d+~?|)\))?\+"(?P<addrsuf>[^"]*)"' 1003 r'|"(?P<addlpre>[^"]*)"+\+(?P<addr>' 1004 + _token_names 1005 + r")(?P<addrmod>\((?:!?~\d*|\d+~?|)\))?" 1006 r'|"(?P<qlit>[^"]*?)"\*(?P<qn>[1-9]\d*)' 1007 r"|(?P<ulit>\w+)\*(?P<un>[1-9]\d*)" 1008 r")\}\}" 1009 ) 1010 items: list[str] = [] 1011 key_counts: dict[str, int] = {} 1012 last_end = 0 1013 1014 for match in combined_pattern.finditer(text): 1015 plain = text[last_end : match.start()] 1016 if plain.strip(): 1017 plain_words = [ 1018 changeCase(w, caseKey) if caseKey is not None else w 1019 for w in self._splitTextToTokens(plain) 1020 ] 1021 items.extend(plain_words) 1022 1023 if match.group("tok"): 1024 token_key = match.group("tok") 1025 base_items = ( 1026 list(TOKENS[token_key]) 1027 if isinstance(TOKENS[token_key], str) 1028 else list(TOKENS[token_key]) 1029 ) 1030 param = match.group("mod") 1031 if param: 1032 inner = param.strip("()") 1033 if inner.startswith("!~"): 1034 opt_out, force_shuffle, count_str = True, False, inner[2:] 1035 elif inner.startswith("~"): 1036 opt_out, force_shuffle, count_str = False, True, inner[1:] 1037 elif inner.endswith("~"): 1038 opt_out, force_shuffle, count_str = False, True, inner[:-1] 1039 else: 1040 opt_out, force_shuffle, count_str = False, False, inner 1041 else: 1042 opt_out, force_shuffle, count_str = False, False, "" 1043 1044 use_shuffle = (shuffle_on or force_shuffle) and not opt_out 1045 occurrence = key_counts.get(token_key, 0) 1046 key_counts[token_key] = occurrence + 1 1047 token_items = ( 1048 self._getShuffledItems(token_key, base_items, occurrence) 1049 if use_shuffle 1050 else list(base_items) 1051 ) 1052 if count_str: 1053 token_items = token_items[: int(count_str)] 1054 elif match.group("addl") or match.group("addr"): 1055 # Addition: token+"suffix" or "prefix"+token 1056 if match.group("addl"): 1057 token_key = match.group("addl") 1058 param = match.group("addlmod") 1059 concat_pre, concat_suf = "", match.group("addrsuf") 1060 else: 1061 token_key = match.group("addr") 1062 param = match.group("addrmod") 1063 concat_pre, concat_suf = match.group("addlpre"), "" 1064 base_items = ( 1065 list(TOKENS[token_key]) 1066 if isinstance(TOKENS[token_key], str) 1067 else list(TOKENS[token_key]) 1068 ) 1069 if param: 1070 inner = param.strip("()") 1071 if inner.startswith("!~"): 1072 opt_out, force_shuffle, count_str = True, False, inner[2:] 1073 elif inner.startswith("~"): 1074 opt_out, force_shuffle, count_str = False, True, inner[1:] 1075 elif inner.endswith("~"): 1076 opt_out, force_shuffle, count_str = False, True, inner[:-1] 1077 else: 1078 opt_out, force_shuffle, count_str = False, False, inner 1079 else: 1080 opt_out, force_shuffle, count_str = False, False, "" 1081 use_shuffle = (shuffle_on or force_shuffle) and not opt_out 1082 occurrence = key_counts.get(token_key, 0) 1083 key_counts[token_key] = occurrence + 1 1084 token_items = ( 1085 self._getShuffledItems(token_key, base_items, occurrence) 1086 if use_shuffle 1087 else list(base_items) 1088 ) 1089 if count_str: 1090 token_items = token_items[: int(count_str)] 1091 token_items = [concat_pre + item + concat_suf for item in token_items] 1092 else: 1093 # Repeat literal shorthand: {{X*N}} or {{"string"*N}} 1094 literal = ( 1095 match.group("qlit") 1096 if match.group("qlit") is not None 1097 else match.group("ulit") 1098 ) 1099 count = int( 1100 match.group("qn") 1101 if match.group("qlit") is not None 1102 else match.group("un") 1103 ) 1104 token_items = [literal] * count 1105 1106 if caseKey is not None: 1107 token_items = [changeCase(item, caseKey) for item in token_items] 1108 items.extend(token_items) 1109 last_end = match.end() 1110 1111 plain = text[last_end:] 1112 if plain.strip(): 1113 plain_words = [ 1114 changeCase(w, caseKey) if caseKey is not None else w 1115 for w in self._splitTextToTokens(plain) 1116 ] 1117 items.extend(plain_words) 1118 1119 return items 1120 1121 def _onTokensButton(self, sender): 1122 list_items = [] 1123 for key, value in TOKENS.items(): 1124 elems = list(value) 1125 preview = " ".join(elems) 1126 list_items.append({"token": "{{" + key + "}}", "preview": preview}) 1127 1128 self._tokenPopover = vanilla.Popover((280, 230)) 1129 self._tokenPopover.tokenList = vanilla.List( 1130 (0, 0, -0, -0), 1131 list_items, 1132 columnDescriptions=[ 1133 {"title": "Token", "key": "token", "width": 110}, 1134 {"title": "Preview", "key": "preview"}, 1135 ], 1136 selectionCallback=self._onTokenSelected, 1137 allowsMultipleSelection=False, 1138 allowsEmptySelection=True, 1139 showColumnTitles=True, 1140 ) 1141 self._tokenPopover.tokenList.setSelection([]) 1142 self._tokenPopover.open(parentView=sender) 1143 1144 def _onTokenSelected(self, sender): 1145 selection = sender.getSelection() 1146 if not selection: 1147 return 1148 idx = selection[0] 1149 token = sender.get()[idx]["token"] 1150 current = self._panel_ui.textField.get() 1151 new_text = current + token 1152 self._panel_ui.textField.set(new_text) 1153 self._state["text"] = new_text 1154 self._saveState() 1155 self._triggerChange(immediate=True) 1156 self._tokenPopover.close() 1157 1158 def _onTextCaseChanged(self, sender): 1159 self._state["textCase"] = sender.get() 1160 self._saveState() 1161 self._triggerChange(immediate=True) 1162 1163 def _onOmitMissingModeChanged(self, sender): 1164 self._state["omitMissingMode"] = sender.get() 1165 self._saveState() 1166 self._triggerChange(immediate=True) 1167 1168 @staticmethod 1169 def _parseFeatureTags(text: str) -> list[str]: 1170 """Parse a space-separated feature tag string into a deduplicated list. 1171 1172 Preserves insertion order, keeping the last occurrence of duplicates. 1173 Lowercases all tags and filters empty strings. 1174 """ 1175 if not text or not text.strip(): 1176 return [] 1177 seen: dict[str, bool] = {} 1178 for tag in text.split(): 1179 normalized = tag.strip().lower() 1180 if normalized: 1181 seen[normalized] = True 1182 return list(seen.keys()) 1183 1184 @staticmethod 1185 def _featureTagsToString(tags: list[str]) -> str: 1186 """Join a list of feature tags into a space-separated string.""" 1187 return " ".join(tags) 1188 1189 def _activeFeatureTags(self) -> list[str]: 1190 text = self._state.get("openTypeFeaturesText", "") 1191 return self._parseFeatureTags(text) 1192 1193 def _describeActiveFeatures(self) -> str: 1194 if self._state.get("disableAllFeatures", False): 1195 return "" 1196 active = self._activeFeatureTags() 1197 return " ".join(active).upper() if active else "" 1198 1199 def _updateOpenTypeTextField(self): 1200 """Sync the text field value from the current feature state.""" 1201 if hasattr(self, "_panel_ui"): 1202 tags = self._activeFeatureTags() 1203 text = self._featureTagsToString(tags) 1204 self._panel_ui.openTypeTextField.set(text) 1205 1206 def setActiveFontPath(self, path: str | None) -> None: 1207 """Notify the panel of the active font path used for feature filtering. 1208 1209 Called automatically by RenderPanelManager whenever the panel state is 1210 fetched (i.e. before every render). Eagerly caches the font's OT 1211 features using the explicit-path variant of listOpenTypeFeatures, which 1212 works without an active DrawBot drawing context. 1213 """ 1214 if path == getattr(self, "_activeFontPath", None): 1215 return 1216 self._activeFontPath = path 1217 self._cachedFontFeatures: set[str] | None = None 1218 if path: 1219 try: 1220 import drawBot 1221 1222 result = drawBot.listOpenTypeFeatures(path) 1223 if isinstance(result, dict): 1224 self._cachedFontFeatures = set(result.keys()) 1225 elif result: 1226 self._cachedFontFeatures = set(result) 1227 except Exception: 1228 pass 1229 1230 def _getFontFeatures(self) -> set[str] | None: 1231 """Return the cached OT feature set for the active font, or None.""" 1232 return getattr(self, "_cachedFontFeatures", None) 1233 1234 def _buildFeatureListItems(self) -> list[dict]: 1235 # Parse active tags from the text field (source of truth). 1236 active_tags = set(self._activeFeatureTags()) 1237 filter_to_font = self._state.get("filterToFont", False) 1238 # Use cached features; None means "no font known yet → show all". 1239 font_features = self._getFontFeatures() if filter_to_font else None 1240 items = [] 1241 for group_name, group_features in OT_FEATURE_GROUPS: 1242 group_rows = [] 1243 for tag, name in group_features: 1244 if font_features is not None and tag not in font_features: 1245 continue 1246 group_rows.append( 1247 { 1248 "check": "✓" if tag in active_tags else "", 1249 "tag": tag, 1250 "name": name, 1251 } 1252 ) 1253 if group_rows: 1254 items.append({"check": "", "tag": "", "name": f" {group_name}"}) 1255 items.extend(group_rows) 1256 return items 1257 1258 def _onOpenTypeButton(self, sender): 1259 self._updatingFeatureList = False 1260 items = self._buildFeatureListItems() 1261 self._featurePopover = vanilla.Popover((280, 380)) 1262 self._featurePopover.featureList = vanilla.List( 1263 (0, 0, -0, -28), 1264 items, 1265 columnDescriptions=[ 1266 {"title": "", "key": "check", "width": 16}, 1267 {"title": "Tag", "key": "tag", "width": 36}, 1268 {"title": "Feature", "key": "name"}, 1269 ], 1270 selectionCallback=self._onFeatureSelected, 1271 showColumnTitles=False, 1272 allowsMultipleSelection=False, 1273 allowsEmptySelection=True, 1274 ) 1275 self._featurePopover.clearButton = vanilla.Button( 1276 (8, -24, 74, 18), 1277 "Clear All", 1278 sizeStyle="mini", 1279 callback=self._onClearAllFeatures, 1280 ) 1281 self._featurePopover.open(parentView=sender) 1282 1283 def _onFeatureSelected(self, sender): 1284 if getattr(self, "_updatingFeatureList", False): 1285 return 1286 selection = sender.getSelection() 1287 if not selection: 1288 return 1289 idx = selection[0] 1290 items = sender.get() 1291 tag = items[idx].get("tag", "") 1292 if not tag: 1293 self._updatingFeatureList = True 1294 sender.setSelection([]) 1295 self._updatingFeatureList = False 1296 return 1297 # Toggle the tag in the text field's active list. 1298 current_tags = self._activeFeatureTags() 1299 if tag in current_tags: 1300 new_tags = [t for t in current_tags if t != tag] 1301 else: 1302 new_tags = current_tags + [tag] 1303 self._state["openTypeFeaturesText"] = self._featureTagsToString(new_tags) 1304 # Update popover list to reflect the toggle. 1305 new_items = self._buildFeatureListItems() 1306 self._updatingFeatureList = True 1307 sender.set(new_items) 1308 sender.setSelection([]) 1309 self._updatingFeatureList = False 1310 self._updateOpenTypeTextField() 1311 self._saveState() 1312 self._triggerChange(immediate=True) 1313 1314 def _onClearAllFeatures(self, sender): 1315 self._state["openTypeFeaturesText"] = "" 1316 new_items = self._buildFeatureListItems() 1317 self._updatingFeatureList = True 1318 self._featurePopover.featureList.set(new_items) 1319 self._updatingFeatureList = False 1320 self._updateOpenTypeTextField() 1321 self._saveState() 1322 self._triggerChange(immediate=True) 1323 1324 def _refreshOTControlsEnabled(self): 1325 disabled = bool(self._state.get("disableAllFeatures", False)) 1326 if hasattr(self, "_panel_ui"): 1327 self._panel_ui.openTypeTextField.enable(not disabled) 1328 self._panel_ui.openTypeButton.enable(not disabled) 1329 self._panel_ui.openTypeFilter.enable(not disabled) 1330 1331 def _onOpenTypeTextChanged(self, sender): 1332 self._state["openTypeFeaturesText"] = sender.get() 1333 self._saveState() 1334 self._triggerChange() 1335 1336 def _onDisableAllFeaturesChanged(self, sender): 1337 self._state["disableAllFeatures"] = bool(sender.get()) 1338 self._refreshOTControlsEnabled() 1339 self._saveState() 1340 self._triggerChange(immediate=True) 1341 1342 def _onFilterToFontChanged(self, sender): 1343 self._state["filterToFont"] = bool(sender.get()) 1344 self._saveState() 1345 if hasattr(self, "_featurePopover"): 1346 new_items = self._buildFeatureListItems() 1347 self._updatingFeatureList = True 1348 self._featurePopover.featureList.set(new_items) 1349 self._updatingFeatureList = False 1350 1351 def _onLineBreakSepChanged(self, sender): 1352 self._state["lineBreakSep"] = sender.get() 1353 self._saveState() 1354 self._triggerChange() 1355 1356 def _onPageBreakChanged(self, sender): 1357 self._state["pageBreak"] = sender.get() 1358 self._saveState() 1359 self._triggerChange(immediate=True) 1360 1361 def _onTokenJoinerChanged(self, sender): 1362 self._state["tokenJoiner"] = sender.get() 1363 self._saveState() 1364 self._triggerChange(immediate=True) 1365 1366 def _onTokenSplitChanged(self, sender): 1367 idx = sender.get() 1368 self._state["tokenSplit"] = self.TOKEN_SPLIT_OPTIONS[idx][0] 1369 self._saveState() 1370 self._triggerChange(immediate=True) 1371 1372 def _onTokenSurroundChanged(self, sender): 1373 self._state["tokenSurround"] = bool(sender.get()) 1374 self._saveState() 1375 self._triggerChange(immediate=True) 1376 1377 def _refreshShuffleUI(self): 1378 enabled = bool(self._state.get("shuffle", False)) 1379 self._panel_ui.reshuffleButton.show(enabled) 1380 1381 def _onShuffleChanged(self, sender): 1382 self._state["shuffle"] = bool(sender.get()) 1383 self._refreshShuffleUI() 1384 self._saveState() 1385 self._triggerChange(immediate=True) 1386 1387 def _onReshuffleTokens(self, sender): 1388 self._refreshShuffledTokens() 1389 self._triggerChange(immediate=True) 1390 1391 def _onBeforeChanged(self, sender): 1392 self._state["before"] = sender.get() 1393 self._saveState() 1394 self._triggerChange() 1395 1396 def _onAfterChanged(self, sender): 1397 self._state["after"] = sender.get() 1398 self._saveState() 1399 self._triggerChange() 1400 1401 def _setCombineModePanelEnabled(self, enabled: bool): 1402 self._before_ui.getNSTextView().setEditable_(enabled) 1403 self._after_ui.getNSTextView().setEditable_(enabled) 1404 1405 def _onCombineModeChanged(self, sender): 1406 mode = sender.get() 1407 self._state["combineMode"] = mode 1408 self._setCombineModePanelEnabled(mode != 0) 1409 self._saveState() 1410 self._triggerChange(immediate=True) 1411 1412 # ── Grouping callbacks ──────────────────────────────────────────────── 1413 1414 def _clampGroupSize(self, value: int) -> int: 1415 """Clamp a group size to the valid range [1, 50].""" 1416 return max(1, min(50, value)) 1417 1418 def _onBeforeGroupSizeChanged(self, sender): 1419 self._updateGroupSize("beforeGroupSize", sender) 1420 1421 def _onAfterGroupSizeChanged(self, sender): 1422 self._updateGroupSize("afterGroupSize", sender) 1423 1424 def _updateGroupSize(self, key: str, sender): 1425 ui = self._panel_ui 1426 prefix = "before" if key == "beforeGroupSize" else "after" 1427 editField = getattr(ui, f"{prefix}GroupSize") 1428 minusBtn = getattr(ui, f"{prefix}GroupMinus") 1429 plusBtn = getattr(ui, f"{prefix}GroupPlus") 1430 1431 current = int(self._state.get(key, 1)) 1432 1433 if sender is minusBtn: 1434 current = self._clampGroupSize(current - 1) 1435 elif sender is plusBtn: 1436 current = self._clampGroupSize(current + 1) 1437 else: 1438 # Called from the edit field itself — parse the text. 1439 try: 1440 current = self._clampGroupSize(int(editField.get())) 1441 except (ValueError, TypeError): 1442 pass # keep current value on invalid input 1443 1444 self._state[key] = current 1445 editField.set(str(current)) 1446 self._saveState() 1447 self._triggerChange(immediate=True) 1448 1449 def _onBeforeGroupJoinChanged(self, sender): 1450 idx = sender.get() 1451 self._state["beforeGroupJoin"] = self.GROUP_JOIN_OPTIONS[idx][0] 1452 self._saveState() 1453 self._triggerChange(immediate=True) 1454 1455 def _onAfterGroupJoinChanged(self, sender): 1456 idx = sender.get() 1457 self._state["afterGroupJoin"] = self.GROUP_JOIN_OPTIONS[idx][0] 1458 self._saveState() 1459 self._triggerChange(immediate=True) 1460 1461 def _resolveLineBreakSep(self) -> str: 1462 label = self._state.get("lineBreakSep", "Line Break") 1463 for name, value in self.LINE_BREAK_OPTIONS: 1464 if label == name: 1465 return value 1466 return label 1467 1468 def _resolvePageBreak(self) -> str: 1469 label = self._state.get("pageBreak", "Triple Line Break") 1470 for name, value in self.PAGE_BREAK_OPTIONS: 1471 if label == name: 1472 return value 1473 return label 1474 1475 def _resolveTokenJoiner(self) -> str: 1476 label = self._state.get("tokenJoiner", "Space") 1477 for name, value in self.TOKEN_JOINER_OPTIONS: 1478 if label == name: 1479 return value 1480 return label 1481 1482 def _resolveTokenSplit(self) -> str: 1483 """Return the internal split-mode key: 'space', 'chars', or 'line'.""" 1484 label = self._state.get("tokenSplit", "Spaces") 1485 for name, key in self.TOKEN_SPLIT_OPTIONS: 1486 if label == name: 1487 return key 1488 return "space" 1489 1490 def _splitTextToTokens(self, text: str) -> list[str]: 1491 """Split *text* into tokens according to the current tokenSplit mode.""" 1492 mode = self._resolveTokenSplit() 1493 if mode == "chars": 1494 return [c for c in text if not c.isspace()] 1495 if mode == "line": 1496 return [line.strip() for line in text.split("\n") if line.strip()] 1497 # default: "space" 1498 return [w for w in text.split() if w] 1499 1500 @staticmethod 1501 def _groupItems(items: list[str], groupSize: int, groupJoin: str) -> list[str]: 1502 """Combine every *groupSize* consecutive items into one string. 1503 1504 If ``groupSize <= 1`` the input is returned unchanged (identity). 1505 The last chunk may be shorter than *groupSize* (no padding, no truncation). 1506 Empty strings in the list participate in grouping like any other item. 1507 """ 1508 if groupSize <= 1: 1509 return items 1510 result: list[str] = [] 1511 for i in range(0, len(items), groupSize): 1512 chunk = items[i : i + groupSize] 1513 result.append(groupJoin.join(chunk)) 1514 return result 1515 1516 def _resolveGroupJoin(self, label: str) -> str: 1517 """Map a GROUP_JOIN_OPTIONS display name to its actual character.""" 1518 for name, value in self.GROUP_JOIN_OPTIONS: 1519 if name == label: 1520 return value 1521 return "" 1522 1523 def _onTextChanged(self, sender): 1524 self._state["text"] = sender.get() 1525 self._saveState() 1526 self._triggerChange() 1527 1528 def _loadState(self): 1529 try: 1530 if os.path.exists(self._statePath): 1531 with open(self._statePath, "r", encoding="utf-8") as f: 1532 saved = json.load(f) 1533 self._state.update(saved) 1534 except Exception: 1535 pass 1536 1537 # Strip computed keys that getState() adds but _state never stores. 1538 # These end up in the file when reloadState() is called with a 1539 # getState() snapshot (e.g. an imported export file). 1540 for _computed in ("pages", "pageItems"): 1541 self._state.pop(_computed, None) 1542 1543 # Coerce fields whose types getState() changes back to their internal 1544 # representation so that build() never receives the wrong type. 1545 if not isinstance(self._state.get("text", ""), str): 1546 self._state["text"] = "" 1547 if not isinstance(self._state.get("before", ""), str): 1548 self._state["before"] = "" 1549 if not isinstance(self._state.get("after", ""), str): 1550 self._state["after"] = "" 1551 omit = self._state.get("omitMissingMode", 0) 1552 if isinstance(omit, str): 1553 try: 1554 self._state["omitMissingMode"] = self._OMIT_MISSING_KEYS.index(omit) 1555 except (ValueError, AttributeError): 1556 self._state["omitMissingMode"] = 0 1557 elif not isinstance(omit, int): 1558 self._state["omitMissingMode"] = 0 1559 1560 # Backward-compat: ensure grouping keys exist with safe defaults. 1561 for _key in ("beforeGroupSize", "afterGroupSize"): 1562 val = self._state.get(_key) 1563 if not isinstance(val, int) or val < 1: 1564 self._state[_key] = 1 1565 for _key in ("beforeGroupJoin", "afterGroupJoin"): 1566 if _key not in self._state: 1567 self._state[_key] = "(none)"
Minimal RenderView side panel plugin with a text input field.
RenderContentPanel( defaultText: str = 'Type your proof text here.', panelWidth: int = 290, statePath: str | None = None, defaultDirectory: str = '/Users/christianjansky/Library/CloudStorage/Dropbox/KOMETA-Draw/01 Content')
141 def __init__( 142 self, 143 defaultText: str = "Type your proof text here.", 144 panelWidth: int = 290, 145 statePath: str | None = None, 146 defaultDirectory: str = "/Users/christianjansky/Library/CloudStorage/Dropbox/KOMETA-Draw/01 Content", 147 ): 148 self._panelWidth = panelWidth 149 self._defaultText = defaultText 150 self._defaultDirectory = defaultDirectory 151 self._statePath = statePath or os.path.expanduser( 152 "~/Library/Application Support/KOMETA-Draw/render_view_content_panel.json" 153 ) 154 self._state = { 155 "text": defaultText, 156 "textCase": 0, 157 "openTypeFeatures": {}, 158 "openTypeFeaturesText": "", 159 "filterToFont": False, 160 "disableAllFeatures": False, 161 "lineBreakSep": "Line Break", 162 "pageBreak": "Triple Line Break", 163 "tokenJoiner": "Space", 164 "tokenSplit": "Spaces", 165 "tokenSurround": False, 166 "shuffle": False, 167 "before": "", 168 "after": "", 169 "combineMode": 1, 170 "omitMissingMode": 0, 171 "beforeGroupSize": 1, 172 "afterGroupSize": 1, 173 "beforeGroupJoin": "(none)", 174 "afterGroupJoin": "(none)", 175 } 176 self._onChange = None 177 self._persistenceEnabled = True 178 self._shuffleSeed: int = 0 179 self._refreshShuffledTokens() 180 self._loadState()
GROUP_JOIN_OPTIONS =
[('(none)', ''), ('Space', ' '), ('Tab', '\t'), ('Newline', '\n'), ('Hyphen', '-'), ('Slash', '/'), ('Comma', ',')]
PAGE_BREAK_OPTIONS =
[('Single Line Break', '\n'), ('Double Line Break', '\n\n'), ('Triple Line Break', '\n\n\n')]
def
getState(self) -> dict:
208 def getState(self) -> dict: 209 state = dict(self._state) 210 joiner = self._resolveTokenJoiner() 211 surround = bool(state.get("tokenSurround", False)) 212 caseIdx = int(state.get("textCase", 0)) 213 caseKey = ( 214 self._CASE_KEYS[caseIdx] if 0 <= caseIdx < len(self._CASE_KEYS) else None 215 ) 216 sep = self._resolveLineBreakSep() 217 raw_text = state.get("text", "") 218 # Split by the chosen page-break sequence 219 page_chunks = raw_text.split(self._resolvePageBreak()) 220 pages = [] 221 page_items = [] 222 for chunk in page_chunks: 223 expanded = self._compileTemplate(chunk, joiner=joiner, surround=surround) 224 text = changeCase(expanded, caseKey) if caseKey is not None else expanded 225 pages.append(text.replace("\n", sep)) 226 page_items.append( 227 self._compileTemplateItems(chunk, caseKey=caseKey, sep=sep) 228 ) 229 state["pages"] = pages 230 state["pageItems"] = page_items 231 state["text"] = pages[0] if pages else "" 232 state["tokenJoiner"] = joiner 233 state["tokenSurround"] = surround 234 state["before"] = self._expandPool( 235 self._state.get("before", ""), 236 joiner=joiner, 237 caseKey=caseKey, 238 sectionSeed=1, 239 groupSize=int(self._state.get("beforeGroupSize", 1)), 240 groupJoin=self._resolveGroupJoin( 241 self._state.get("beforeGroupJoin", "(none)") 242 ), 243 ) 244 state["after"] = self._expandPool( 245 self._state.get("after", ""), 246 joiner=joiner, 247 caseKey=caseKey, 248 sectionSeed=2, 249 groupSize=int(self._state.get("afterGroupSize", 1)), 250 groupJoin=self._resolveGroupJoin( 251 self._state.get("afterGroupJoin", "(none)") 252 ), 253 ) 254 state["beforeGroupSize"] = int(self._state.get("beforeGroupSize", 1)) 255 state["afterGroupSize"] = int(self._state.get("afterGroupSize", 1)) 256 state["combineMode"] = self._state.get("combineMode", 0) 257 # Derive OT feature dict from the text field string. 258 # Migration: if text is empty but legacy dict has truthy entries, populate text. 259 _text = self._state.get("openTypeFeaturesText", "") 260 if not _text.strip(): 261 _legacy_features = self._state.get("openTypeFeatures", {}) 262 _legacy_tags = [k for k, v in _legacy_features.items() if v] 263 if _legacy_tags: 264 _text = " ".join(_legacy_tags) 265 self._state["openTypeFeaturesText"] = _text 266 _enabled = set(self._parseFeatureTags(_text)) 267 if self._state.get("disableAllFeatures", False): 268 _ot_dict: dict[str, bool] = {tag: False for tag in OT_DEFAULT_ON} 269 else: 270 _ot_dict = {k: True for k in _enabled} 271 for _tag in OT_DEFAULT_ON: 272 if _tag not in _enabled: 273 _ot_dict[_tag] = False 274 state["openTypeFeatures"] = _ot_dict 275 state["activeFeatureDesc"] = self._describeActiveFeatures() 276 _modeIdx = int(state.get("omitMissingMode", 0)) 277 state["omitMissingMode"] = ( 278 self._OMIT_MISSING_KEYS[_modeIdx] 279 if 0 <= _modeIdx < len(self._OMIT_MISSING_KEYS) 280 else "glyphs" 281 ) 282 return state
def
build(self, parent, onChange: Callable[..., NoneType]):
284 def build(self, parent, onChange: Callable[..., None]): 285 self._onChange = onChange 286 287 ui = vanilla.Group((0, 0, -0, -0)) 288 margin = 10 289 sectionMargin = PANEL_SECTION_MARGIN 290 self._panel_ui = ui 291 292 ui.textLabel = vanilla.TextBox( 293 (margin, margin, -margin, 12), 294 "Text", 295 alignment="center", 296 sizeStyle="mini", 297 ) 298 299 ui.textHint = vanilla.Button( 300 (-margin - 14, margin - 2, 14, 14), 301 "?", 302 sizeStyle="mini", 303 callback=self._onTextHint, 304 ) 305 306 ui.textField = vanilla.TextEditor( 307 (margin, margin + 12 + margin, -margin, 100), 308 self._state.get("text", ""), 309 callback=self._onTextChanged, 310 ) 311 ui.textField.getNSTextView().setRichText_(False) 312 313 splitRowTop = margin + 12 + margin + 100 + 4 314 _splitLabelW = 44 315 ui.tokenSplitLabel = vanilla.TextBox( 316 (margin, splitRowTop + 1, _splitLabelW, 14), 317 "Split by", 318 sizeStyle="mini", 319 ) 320 ui.tokenSplitLabel.getNSTextField().setTextColor_(NSColor.secondaryLabelColor()) 321 _splitNames = [name for name, _ in self.TOKEN_SPLIT_OPTIONS] 322 _splitIdx = next( 323 ( 324 i 325 for i, (n, _) in enumerate(self.TOKEN_SPLIT_OPTIONS) 326 if n == self._state.get("tokenSplit", "Spaces") 327 ), 328 0, 329 ) 330 ui.tokenSplitPopup = vanilla.PopUpButton( 331 (margin + _splitLabelW + 2, splitRowTop, -margin, 16), 332 _splitNames, 333 sizeStyle="mini", 334 callback=self._onTokenSplitChanged, 335 ) 336 ui.tokenSplitPopup.set(_splitIdx) 337 _nsPopup = ui.tokenSplitPopup.getNSPopUpButton() 338 _nsPopup.setBordered_(False) 339 _nsPopup.setFont_(NSFont.systemFontOfSize_(10)) 340 341 joinRowTop = splitRowTop + 16 + 2 342 _joinLabelW = 44 343 ui.tokenJoinInlineLabel = vanilla.TextBox( 344 (margin, joinRowTop + 5, _joinLabelW, 14), 345 "Join by", 346 sizeStyle="mini", 347 ) 348 ui.tokenJoinInlineLabel.getNSTextField().setTextColor_( 349 NSColor.secondaryLabelColor() 350 ) 351 _delimX = margin + _joinLabelW + 2 352 _delimW = 140 353 ui.tokenJoiner = vanilla.ComboBox( 354 (_delimX, joinRowTop, _delimW, 19), 355 [name for name, _ in self.TOKEN_JOINER_OPTIONS], 356 continuous=True, 357 sizeStyle="mini", 358 callback=self._onTokenJoinerChanged, 359 ) 360 ui.tokenJoiner.set(self._state.get("tokenJoiner", "Space")) 361 ui.tokenSurroundCheckbox = vanilla.CheckBox( 362 (_delimX + _delimW + 10, joinRowTop + 3, -margin, 17), 363 "Surround", 364 value=self._state.get("tokenSurround", False), 365 sizeStyle="mini", 366 callback=self._onTokenSurroundChanged, 367 ) 368 369 buttonsTop = joinRowTop + 19 + 8 370 btnW = (self._panelWidth - 2 * margin - 4) // 2 371 ui.tokensButton = vanilla.Button( 372 (margin, buttonsTop, btnW, 18), 373 "Tokens ▾", 374 sizeStyle="small", 375 callback=self._onTokensButton, 376 ) 377 ui.openButton = vanilla.Button( 378 (margin + btnW + 4, buttonsTop, -margin, 18), 379 "Open File", 380 sizeStyle="small", 381 callback=self._onOpenFile, 382 ) 383 384 subsTop = buttonsTop + 18 + sectionMargin 385 ui.substitutionsLabel = vanilla.TextBox( 386 (margin, subsTop, -margin, 12), 387 "Substitutions", 388 alignment="center", 389 sizeStyle="mini", 390 ) 391 row0Top = subsTop + 12 + margin 392 ui.pageBreakLabel = vanilla.TextBox( 393 (margin, row0Top, 100, 17), 394 "Page Break", 395 sizeStyle="small", 396 ) 397 ui.pageBreak = vanilla.ComboBox( 398 (116, row0Top, -margin, 17), 399 [name for name, _ in self.PAGE_BREAK_OPTIONS], 400 continuous=True, 401 sizeStyle="small", 402 callback=self._onPageBreakChanged, 403 ) 404 ui.pageBreak.set(self._state.get("pageBreak", "Triple Line Break")) 405 406 row1Top = row0Top + 17 + 6 407 ui.contentNewlineLabel = vanilla.TextBox( 408 (margin, row1Top, 100, 17), 409 "Content Newline", 410 sizeStyle="small", 411 ) 412 ui.lineBreakSep = vanilla.ComboBox( 413 (116, row1Top, -margin, 17), 414 [name for name, _ in self.LINE_BREAK_OPTIONS], 415 continuous=True, 416 sizeStyle="small", 417 callback=self._onLineBreakSepChanged, 418 ) 419 ui.lineBreakSep.set(self._state.get("lineBreakSep", "Line Break")) 420 421 row3Top = row1Top + 17 + 6 422 ui.shuffleLabel = vanilla.TextBox( 423 (margin, row3Top, 100, 17), 424 "Shuffle", 425 sizeStyle="small", 426 ) 427 ui.shuffleCheckbox = vanilla.CheckBox( 428 (116, row3Top, 20, 17), 429 "", 430 value=self._state.get("shuffle", False), 431 sizeStyle="small", 432 callback=self._onShuffleChanged, 433 ) 434 ui.reshuffleButton = vanilla.Button( 435 (136, row3Top, -margin, 17), 436 "Reshuffle", 437 sizeStyle="small", 438 callback=self._onReshuffleTokens, 439 ) 440 441 baTop = row3Top + 17 + sectionMargin 442 ui.beforeAfterLabel = vanilla.TextBox( 443 (margin, baTop, -margin, 12), 444 "Before / After", 445 alignment="center", 446 sizeStyle="mini", 447 ) 448 449 # ── Grouping controls (two rows: Before row, After row) ────────── 450 _grpLabelW = 36 451 _grpStepperW = 24 452 _grpBtnW = 14 453 _grpJoinLabelW = 28 454 _grpJoinPopupW = 56 455 _grpRowH = 16 456 _grpGap = 3 457 458 # Before row 459 _baGrpY0 = baTop + 12 + 2 460 ui.beforeGroupLabel = vanilla.TextBox( 461 (margin, _baGrpY0 + 1, _grpLabelW, 14), 462 "Before", 463 sizeStyle="mini", 464 ) 465 ui.beforeGroupLabel.getNSTextField().setTextColor_( 466 NSColor.secondaryLabelColor() 467 ) 468 _x = margin + _grpLabelW + _grpGap 469 ui.beforeGroupMinus = vanilla.Button( 470 (_x, _baGrpY0, _grpBtnW, _grpRowH), 471 "−", 472 sizeStyle="mini", 473 callback=self._onBeforeGroupSizeChanged, 474 ) 475 _nsBtn = ui.beforeGroupMinus.getNSButton() 476 _nsBtn.setBordered_(False) 477 _nsBtn.setFont_(NSFont.systemFontOfSize_(10)) 478 _x += _grpBtnW 479 ui.beforeGroupSize = vanilla.EditText( 480 (_x, _baGrpY0 - 1, _grpStepperW, _grpRowH + 2), 481 str(self._state.get("beforeGroupSize", 1)), 482 sizeStyle="mini", 483 callback=self._onBeforeGroupSizeChanged, 484 ) 485 ui.beforeGroupSize.getNSTextField().setAlignment_(2) # center 486 _x += _grpStepperW 487 ui.beforeGroupPlus = vanilla.Button( 488 (_x, _baGrpY0, _grpBtnW, _grpRowH), 489 "+", 490 sizeStyle="mini", 491 callback=self._onBeforeGroupSizeChanged, 492 ) 493 _nsBtn = ui.beforeGroupPlus.getNSButton() 494 _nsBtn.setBordered_(False) 495 _nsBtn.setFont_(NSFont.systemFontOfSize_(10)) 496 _x += _grpBtnW + _grpGap 497 ui.beforeGroupJoinLabel = vanilla.TextBox( 498 (_x, _baGrpY0 + 1, _grpJoinLabelW, 14), 499 "Join", 500 sizeStyle="mini", 501 ) 502 ui.beforeGroupJoinLabel.getNSTextField().setTextColor_( 503 NSColor.secondaryLabelColor() 504 ) 505 _x += _grpJoinLabelW + _grpGap 506 _baJoinNames = [name for name, _ in self.GROUP_JOIN_OPTIONS] 507 _baJoinIdx = next( 508 ( 509 i 510 for i, (n, _) in enumerate(self.GROUP_JOIN_OPTIONS) 511 if n == self._state.get("beforeGroupJoin", "(none)") 512 ), 513 0, 514 ) 515 ui.beforeGroupJoin = vanilla.PopUpButton( 516 (_x, _baGrpY0 - 1, _grpJoinPopupW, _grpRowH + 2), 517 _baJoinNames, 518 sizeStyle="mini", 519 callback=self._onBeforeGroupJoinChanged, 520 ) 521 ui.beforeGroupJoin.set(_baJoinIdx) 522 _nsPopup = ui.beforeGroupJoin.getNSPopUpButton() 523 _nsPopup.setBordered_(False) 524 _nsPopup.setFont_(NSFont.systemFontOfSize_(10)) 525 526 # After row 527 _baGrpY1 = _baGrpY0 + _grpRowH + 2 528 ui.afterGroupLabel = vanilla.TextBox( 529 (margin, _baGrpY1 + 1, _grpLabelW, 14), 530 "After", 531 sizeStyle="mini", 532 ) 533 ui.afterGroupLabel.getNSTextField().setTextColor_(NSColor.secondaryLabelColor()) 534 _x = margin + _grpLabelW + _grpGap 535 ui.afterGroupMinus = vanilla.Button( 536 (_x, _baGrpY1, _grpBtnW, _grpRowH), 537 "−", 538 sizeStyle="mini", 539 callback=self._onAfterGroupSizeChanged, 540 ) 541 _nsBtn = ui.afterGroupMinus.getNSButton() 542 _nsBtn.setBordered_(False) 543 _nsBtn.setFont_(NSFont.systemFontOfSize_(10)) 544 _x += _grpBtnW 545 ui.afterGroupSize = vanilla.EditText( 546 (_x, _baGrpY1 - 1, _grpStepperW, _grpRowH + 2), 547 str(self._state.get("afterGroupSize", 1)), 548 sizeStyle="mini", 549 callback=self._onAfterGroupSizeChanged, 550 ) 551 ui.afterGroupSize.getNSTextField().setAlignment_(2) # center 552 _x += _grpStepperW 553 ui.afterGroupPlus = vanilla.Button( 554 (_x, _baGrpY1, _grpBtnW, _grpRowH), 555 "+", 556 sizeStyle="mini", 557 callback=self._onAfterGroupSizeChanged, 558 ) 559 _nsBtn = ui.afterGroupPlus.getNSButton() 560 _nsBtn.setBordered_(False) 561 _nsBtn.setFont_(NSFont.systemFontOfSize_(10)) 562 _x += _grpBtnW + _grpGap 563 ui.afterGroupJoinLabel = vanilla.TextBox( 564 (_x, _baGrpY1 + 1, _grpJoinLabelW, 14), 565 "Join", 566 sizeStyle="mini", 567 ) 568 ui.afterGroupJoinLabel.getNSTextField().setTextColor_( 569 NSColor.secondaryLabelColor() 570 ) 571 _x += _grpJoinLabelW + _grpGap 572 _aaJoinIdx = next( 573 ( 574 i 575 for i, (n, _) in enumerate(self.GROUP_JOIN_OPTIONS) 576 if n == self._state.get("afterGroupJoin", "(none)") 577 ), 578 0, 579 ) 580 ui.afterGroupJoin = vanilla.PopUpButton( 581 (_x, _baGrpY1 - 1, _grpJoinPopupW, _grpRowH + 2), 582 _baJoinNames, 583 sizeStyle="mini", 584 callback=self._onAfterGroupJoinChanged, 585 ) 586 ui.afterGroupJoin.set(_aaJoinIdx) 587 _nsPopup = ui.afterGroupJoin.getNSPopUpButton() 588 _nsPopup.setBordered_(False) 589 _nsPopup.setFont_(NSFont.systemFontOfSize_(10)) 590 591 baEditTop = _baGrpY1 + _grpRowH + 6 592 baEditH = 60 593 ui._beforeEditor = vanilla.TextEditor( 594 (0, 0, -0, -0), 595 self._state.get("before", ""), 596 callback=self._onBeforeChanged, 597 ) 598 ui._beforeEditor.getNSTextView().setRichText_(False) 599 ui._afterEditor = vanilla.TextEditor( 600 (0, 0, -0, -0), 601 self._state.get("after", ""), 602 callback=self._onAfterChanged, 603 ) 604 ui._afterEditor.getNSTextView().setRichText_(False) 605 ui.beforeAfterSplit = vanilla.SplitView( 606 (margin, baEditTop, -margin, baEditH), 607 [ 608 dict(view=ui._beforeEditor, identifier="before"), 609 dict(view=ui._afterEditor, identifier="after"), 610 ], 611 isVertical=True, 612 ) 613 self._before_ui = ui._beforeEditor 614 self._after_ui = ui._afterEditor 615 616 combineModeTop = baEditTop + baEditH + margin 617 ui.combineMode = vanilla.SegmentedButton( 618 (margin, combineModeTop, -margin, 18), 619 [dict(title="Off"), dict(title="Zip"), dict(title="Expand")], 620 sizeStyle="small", 621 callback=self._onCombineModeChanged, 622 ) 623 ui.combineMode.set(self._state.get("combineMode", 1)) 624 self._setCombineModePanelEnabled(self._state.get("combineMode", 1) != 0) 625 626 caseTop = combineModeTop + 18 + sectionMargin 627 628 ui.textCaseLabel = vanilla.TextBox( 629 (margin, caseTop, -margin, 12), 630 "Text Case", 631 alignment="center", 632 sizeStyle="mini", 633 ) 634 ui.textCase = vanilla.SegmentedButton( 635 (margin, caseTop + 12 + margin, -margin, 21), 636 [dict(title=case) for case in self.TEXT_CASES], 637 sizeStyle="small", 638 callback=self._onTextCaseChanged, 639 ) 640 ui.textCase.set(self._state.get("textCase", 0)) 641 642 filterModeTop = caseTop + 12 + margin + 21 + sectionMargin 643 ui.filterModeLabel = vanilla.TextBox( 644 (margin, filterModeTop, -margin, 12), 645 "Filter Missing", 646 alignment="center", 647 sizeStyle="mini", 648 ) 649 ui.omitMissingMode = vanilla.SegmentedButton( 650 (margin, filterModeTop + 12 + margin, -margin, 21), 651 [dict(title=m) for m in self.OMIT_MISSING_MODES], 652 sizeStyle="small", 653 callback=self._onOmitMissingModeChanged, 654 ) 655 ui.omitMissingMode.set(self._state.get("omitMissingMode", 0)) 656 657 otTop = filterModeTop + 12 + margin + 21 + sectionMargin 658 ui.openTypeLabel = vanilla.TextBox( 659 (margin, otTop, -margin, 12), 660 "OpenType Features", 661 alignment="center", 662 sizeStyle="mini", 663 ) 664 # Editable text field for feature tags (above the button) 665 _otTextY = otTop + 12 + margin 666 ui.openTypeTextField = vanilla.EditText( 667 (margin, _otTextY, -margin, 22), 668 self._state.get("openTypeFeaturesText", ""), 669 placeholder="liga ss01 kern", 670 sizeStyle="small", 671 callback=self._onOpenTypeTextChanged, 672 ) 673 # Button below the text field 674 _otBtnY = _otTextY + 22 + 4 675 ui.openTypeButton = vanilla.Button( 676 (margin, _otBtnY, -margin, 22), 677 "Features ▾", 678 sizeStyle="small", 679 callback=self._onOpenTypeButton, 680 ) 681 # Checkboxes below the button 682 _otCbY = _otBtnY + 22 + 4 683 _halfW = (self._panelWidth - 2 * margin) // 2 684 ui.openTypeFilter = vanilla.CheckBox( 685 (margin, _otCbY, _halfW, 15), 686 "Filter available", 687 value=self._state.get("filterToFont", False), 688 sizeStyle="mini", 689 callback=self._onFilterToFontChanged, 690 ) 691 ui.disableAllFeatures = vanilla.CheckBox( 692 (margin + _halfW, _otCbY, -margin, 15), 693 "Disable all", 694 value=self._state.get("disableAllFeatures", False), 695 sizeStyle="mini", 696 callback=self._onDisableAllFeaturesChanged, 697 ) 698 699 self._refreshShuffleUI() 700 self._refreshOTControlsEnabled() 701 parent.body = ui
def
setActiveFontPath(self, path: str | None) -> None:
1206 def setActiveFontPath(self, path: str | None) -> None: 1207 """Notify the panel of the active font path used for feature filtering. 1208 1209 Called automatically by RenderPanelManager whenever the panel state is 1210 fetched (i.e. before every render). Eagerly caches the font's OT 1211 features using the explicit-path variant of listOpenTypeFeatures, which 1212 works without an active DrawBot drawing context. 1213 """ 1214 if path == getattr(self, "_activeFontPath", None): 1215 return 1216 self._activeFontPath = path 1217 self._cachedFontFeatures: set[str] | None = None 1218 if path: 1219 try: 1220 import drawBot 1221 1222 result = drawBot.listOpenTypeFeatures(path) 1223 if isinstance(result, dict): 1224 self._cachedFontFeatures = set(result.keys()) 1225 elif result: 1226 self._cachedFontFeatures = set(result) 1227 except Exception: 1228 pass
Notify the panel of the active font path used for feature filtering.
Called automatically by RenderPanelManager whenever the panel state is fetched (i.e. before every render). Eagerly caches the font's OT features using the explicit-path variant of listOpenTypeFeatures, which works without an active DrawBot drawing context.