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'})
class RenderContentPanel(classes.c92_render_panel_plugin.RenderPanelPlugin):
 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()
TEXT_CASES = ['Default', 'Title', 'UPPER', 'lower']
OMIT_MISSING_MODES = ['Glyphs', 'Words']
GROUP_JOIN_OPTIONS = [('(none)', ''), ('Space', ' '), ('Tab', '\t'), ('Newline', '\n'), ('Hyphen', '-'), ('Slash', '/'), ('Comma', ',')]
LINE_BREAK_OPTIONS = [('Line Break', '\n'), ('Space', ' ')]
TOKEN_JOINER_OPTIONS = [('Space', ' '), ('Line Break', '\n'), ('None', '')]
TOKEN_SPLIT_OPTIONS = [('Spaces', 'space'), ('Characters', 'chars'), ('Line Breaks', 'line')]
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.