classes.c93_render_layout_panel

  1import json
  2import os
  3import vanilla
  4from AppKit import NSOpenPanel, NSFileHandlingPanelOKButton, NSURL
  5from typing import Callable
  6
  7from lib import fonts as fontlib, layout
  8from theme import PANEL_SECTION_MARGIN
  9from .c92_render_panel_plugin import RenderPanelPlugin
 10
 11
 12class RenderLayoutPanel(RenderPanelPlugin):
 13    """RenderView side panel plugin for page size and margin controls."""
 14
 15    DEFAULT_PAGE_MARGIN = "5mm"
 16
 17    def __init__(
 18        self,
 19        panelWidth: int = 290,
 20        pageSizes: list[str] | None = None,
 21        statePath: str | None = None,
 22        enableFontPicker: bool = False,
 23        fontDirectory: str = "/Users/christianjansky/Library/CloudStorage/Dropbox/KOMETA-Typefaces",
 24    ):
 25        self._panelWidth = panelWidth
 26        self._enableFontPicker = enableFontPicker
 27        self._fontDirectory = fontDirectory
 28        self._pageSizes = pageSizes or [
 29            "A4Landscape",
 30            "A4",
 31            "A3Landscape",
 32            "A3",
 33            "screen",
 34        ]
 35        self._statePath = statePath or os.path.expanduser(
 36            "~/Library/Application Support/KOMETA-Draw/render_view_layout_panel.json"
 37        )
 38        self._state = {
 39            "pageSize": self._pageSizes[0],
 40            "pageMargin": self.DEFAULT_PAGE_MARGIN,
 41            "bodyPadding": "",
 42            "leading": 1.0,
 43            "tracking": 0,
 44            "fontSizeMode": 0,
 45            "fontSize": 48,
 46            "fontFitMode": 0,
 47            "alignH": "center",
 48            "alignV": "center",
 49            "debug": False,
 50            "invert": False,
 51            "flipX": False,
 52            "flipY": False,
 53            "perforation": False,
 54            "perforationRadius": 2.0,
 55            "perforationSpacing": 80.0,
 56            "perforationCount": 2,
 57            "perforationAlignX": "left",
 58            "perforationAlignY": "center",
 59            "perforationOffsetX": 0.0,
 60            "perforationOffsetY": 0.0,
 61            "fontFolder": "",
 62            "fontPaths": [],
 63            "selectedFontPaths": [],
 64            "viewMode": 0,
 65            "viewDirection": 0,
 66        }
 67        self._fontRows = []
 68        self._ui = None
 69        self._onChange = None
 70        self._persistenceEnabled = True
 71        self._loadState()
 72
 73    def getState(self) -> dict:
 74        return dict(self._state)
 75
 76    def build(self, parent, onChange: Callable[..., None]):
 77        """Build panel UI in the provided parent group."""
 78        self._onChange = onChange
 79        self._ui = vanilla.Group((0, 0, -0, -0))
 80
 81        margin = 10
 82        labelW = 90
 83        sectionMargin = PANEL_SECTION_MARGIN
 84        y = margin
 85        self._ui.pageSizeLabel = vanilla.TextBox(
 86            (margin, y + 3, labelW, 14),
 87            "Page Size",
 88            sizeStyle="small",
 89        )
 90        self._ui.pageSize = vanilla.ComboBox(
 91            (margin + labelW, y, -margin, 20),
 92            items=self._pageSizes,
 93            callback=self._onPageSizeChanged,
 94            sizeStyle="small",
 95        )
 96        self._ui.pageSize.set(self._state.get("pageSize", self._pageSizes[0]))
 97
 98        y += 28
 99        self._ui.pageMarginLabel = vanilla.TextBox(
100            (margin, y + 3, labelW, 14),
101            "Page Margin",
102            sizeStyle="small",
103        )
104        self._ui.pageMargin = vanilla.EditText(
105            (margin + labelW, y, -margin, 19),
106            text=str(self._state.get("pageMargin", self.DEFAULT_PAGE_MARGIN)),
107            callback=self._onPageMarginChanged,
108            sizeStyle="small",
109        )
110
111        y += sectionMargin
112        self._ui.fontPropsSectionLabel = vanilla.TextBox(
113            (margin, y, -margin, 12), "Font Props", alignment="center", sizeStyle="mini"
114        )
115
116        y += 18
117        self._ui.fontSizeModeLabel = vanilla.TextBox(
118            (margin, y + 3, labelW, 14),
119            "Font Size",
120            sizeStyle="small",
121        )
122        self._ui.fontSizeMode = vanilla.SegmentedButton(
123            (margin + labelW, y, -margin, 18),
124            [dict(title="Auto"), dict(title="Manual")],
125            callback=self._onFontSizeModeChanged,
126        )
127        self._ui.fontSizeMode.set(self._state.get("fontSizeMode", 0))
128
129        y += 28
130        self._ui.fontFitModeLabel = vanilla.TextBox(
131            (margin, y + 3, labelW, 14),
132            "Fit Mode",
133            sizeStyle="small",
134        )
135        self._ui.fontFitMode = vanilla.PopUpButton(
136            (margin + labelW, y, -margin, 20),
137            items=["Auto", "Preserve Whitespace", "Running Text"],
138            callback=self._onFontFitModeChanged,
139            sizeStyle="small",
140        )
141        self._ui.fontFitMode.set(self._state.get("fontFitMode", 0))
142        self._ui.fontSizeManual = vanilla.Slider(
143            (margin + labelW, y, -42, 19),
144            minValue=8,
145            maxValue=256,
146            value=float(self._state.get("fontSize", 48)),
147            callback=self._onFontSizeChanged,
148            continuous=True,
149            sizeStyle="small",
150        )
151        self._ui.fontSizeValue = vanilla.TextBox(
152            (-38, y + 3, 28, 14),
153            self._formatFontSizeValue(self._state.get("fontSize", 48)),
154            alignment="right",
155            sizeStyle="small",
156        )
157        self._refreshSizingControls()
158
159        y += 28
160        self._ui.leadingLabel = vanilla.TextBox(
161            (margin, y + 3, labelW, 14),
162            "Leading",
163            sizeStyle="small",
164        )
165        self._ui.leading = vanilla.Slider(
166            (margin + labelW, y, -42, 19),
167            minValue=0.5,
168            maxValue=1.5,
169            value=float(self._state.get("leading", 1.0)),
170            callback=self._onLeadingChanged,
171            continuous=True,
172            sizeStyle="small",
173        )
174        self._ui.leadingValue = vanilla.TextBox(
175            (-38, y + 3, 28, 14),
176            self._formatLeadingValue(self._state.get("leading", 1.0)),
177            alignment="right",
178            sizeStyle="small",
179        )
180
181        y += 28
182        self._ui.trackingLabel = vanilla.TextBox(
183            (margin, y + 3, labelW, 14),
184            "Tracking",
185            sizeStyle="small",
186        )
187        self._ui.tracking = vanilla.Slider(
188            (margin + labelW, y, -42, 19),
189            minValue=-50,
190            maxValue=50,
191            value=float(self._state.get("tracking", 0)),
192            callback=self._onTrackingChanged,
193            continuous=True,
194            sizeStyle="small",
195        )
196        self._ui.trackingValue = vanilla.TextBox(
197            (-38, y + 3, 28, 14),
198            self._formatTrackingValue(self._state.get("tracking", 0)),
199            alignment="right",
200            sizeStyle="small",
201        )
202
203        y += sectionMargin
204        self._ui.alignSectionLabel = vanilla.TextBox(
205            (margin, y, -margin, 12), "Align", alignment="center", sizeStyle="mini"
206        )
207        y += 18
208        segW = (self._panelWidth - labelW - margin * 2) // 3
209        self._ui.textAlignLabel = vanilla.TextBox(
210            (margin, y + 4, labelW, 14), "Horizontal", sizeStyle="small"
211        )
212        self._ui.textAlign = vanilla.SegmentedButton(
213            (margin + labelW, y, -margin, 18),
214            [
215                dict(title="Left", width=segW),
216                dict(title="Center", width=segW),
217                dict(title="Right", width=segW),
218            ],
219            callback=self._onAlignHChanged,
220        )
221        self._ui.textAlign.set(
222            ["left", "center", "right"].index(self._state.get("alignH", "center"))
223        )
224
225        y += 24
226        self._ui.yAlignLabel = vanilla.TextBox(
227            (margin, y + 4, labelW, 14), "Vertical", sizeStyle="small"
228        )
229        self._ui.yAlign = vanilla.SegmentedButton(
230            (margin + labelW, y, -margin, 18),
231            [
232                dict(title="Top", width=segW),
233                dict(title="Center", width=segW),
234                dict(title="Bottom", width=segW),
235            ],
236            callback=self._onAlignVChanged,
237        )
238        self._ui.yAlign.set(
239            ["top", "center", "bottom"].index(self._state.get("alignV", "center"))
240        )
241
242        y += 28
243        self._ui.debugLabel = vanilla.TextBox(
244            (margin, y + 2, labelW, 16), "Debug", sizeStyle="small"
245        )
246        self._ui.debug = vanilla.CheckBox(
247            (margin + labelW, y, -margin, 16),
248            title="",
249            value=bool(self._state.get("debug", False)),
250            callback=self._onDebugChanged,
251            sizeStyle="small",
252        )
253
254        y += 24
255        self._ui.invertLabel = vanilla.TextBox(
256            (margin, y + 2, labelW, 16), "Invert", sizeStyle="small"
257        )
258        self._ui.invert = vanilla.CheckBox(
259            (margin + labelW, y, -margin, 16),
260            title="",
261            value=bool(self._state.get("invert", False)),
262            callback=self._onInvertChanged,
263            sizeStyle="small",
264        )
265
266        y += 24
267        self._ui.flipLabel = vanilla.TextBox(
268            (margin, y + 2, labelW, 16), "Flip", sizeStyle="small"
269        )
270        flipSelected = []
271        if self._state.get("flipX", False):
272            flipSelected.append(0)
273        if self._state.get("flipY", False):
274            flipSelected.append(1)
275        self._ui.flip = vanilla.SegmentedButton(
276            (margin + labelW, y, -margin, 20),
277            segmentDescriptions=[{"title": "X"}, {"title": "Y"}],
278            selectionStyle="any",
279            callback=self._onFlipChanged,
280            sizeStyle="small",
281        )
282        self._ui.flip.set(flipSelected)
283
284        y += sectionMargin
285        self._ui.perfSectionLabel = vanilla.TextBox(
286            (margin, y, -margin, 12),
287            "Perforation",
288            alignment="center",
289            sizeStyle="mini",
290        )
291        y += 18
292        self._ui.perfEnableLabel = vanilla.TextBox(
293            (margin, y + 2, labelW, 16), "Enable", sizeStyle="small"
294        )
295        self._ui.perfEnable = vanilla.CheckBox(
296            (margin + labelW, y, -(margin + 32), 16),
297            title="",
298            value=bool(self._state.get("perforation", False)),
299            callback=self._onPerforationEnableChanged,
300            sizeStyle="small",
301        )
302        self._ui.perfSettingsButton = vanilla.Button(
303            (-(margin + 28), y - 1, -margin, 18),
304            "⚙",
305            callback=self._onOpenPerforationPopover,
306            sizeStyle="small",
307        )
308
309        if self._enableFontPicker:
310            y += 32
311            self._ui.fontStatus = vanilla.TextBox(
312                (margin, y, -margin, 12),
313                "No fonts loaded",
314                alignment="center",
315                sizeStyle="mini",
316            )
317
318            y += 16
319            actionBtnW = 34
320            self._ui.fontSelectionToggle = vanilla.SegmentedButton(
321                (margin, y, -(margin + actionBtnW + 4), 18),
322                [
323                    dict(title="All"),
324                    dict(title="None"),
325                    dict(title="Replace"),
326                ],
327                sizeStyle="small",
328                selectionStyle="momentary",
329                callback=self._onFontSelectionToggled,
330            )
331            self._ui.selectFontTypeButton = vanilla.ActionButton(
332                (-margin - actionBtnW, y + 1, -margin, 17),
333                [
334                    dict(title="Upright", callback=self._onSelectFontsByType),
335                    dict(title="Italic", callback=self._onSelectFontsByType),
336                    dict(title="Text", callback=self._onSelectFontsByType),
337                    dict(title="Display", callback=self._onSelectFontsByType),
338                    dict(title="Thin", callback=self._onSelectFontsByType),
339                    dict(title="Thick", callback=self._onSelectFontsByType),
340                ],
341                sizeStyle="small",
342            )
343
344            y += 24
345            self._ui.viewModeLabel = vanilla.TextBox(
346                (margin, y + 4, labelW, 14), "View Fonts", sizeStyle="small"
347            )
348            self._ui.viewMode = vanilla.SegmentedButton(
349                (margin + labelW, y, -margin, 18),
350                [dict(title="Separate"), dict(title="Together")],
351                callback=self._onViewModeChanged,
352                sizeStyle="small",
353            )
354            self._ui.viewMode.set(self._state.get("viewMode", 0))
355            y += 26
356            self._ui.viewDirectionLabel = vanilla.TextBox(
357                (margin, y + 4, labelW, 14), "Direction", sizeStyle="small"
358            )
359            self._ui.viewDirection = vanilla.SegmentedButton(
360                (margin + labelW, y, -margin, 18),
361                [dict(title="Rows"), dict(title="Columns")],
362                callback=self._onViewDirectionChanged,
363                sizeStyle="small",
364            )
365            self._ui.viewDirection.set(self._state.get("viewDirection", 0))
366            self._refreshViewModeControls()
367            y += 30
368            self._ui.fontList = vanilla.List(
369                (margin, y, -margin, -margin),
370                [],
371                columnDescriptions=[
372                    {
373                        "title": "Selected",
374                        "cell": vanilla.CheckBoxListCell(),
375                        "key": "Selected",
376                        "editable": True,
377                        "width": 56,
378                    },
379                    {"title": "Font Name", "key": "Font Name", "editable": False},
380                ],
381                showColumnTitles=True,
382                allowsMultipleSelection=True,
383                editCallback=self._onFontListEdited,
384            )
385
386            # Prompt on startup if no valid folder is configured.
387            if not os.path.isdir(self._state.get("fontFolder", "")):
388                self._promptForFontFolder(triggerChange=False)
389            self._refreshFontRows()
390
391        parent.body = self._ui
392
393    def _isValidPageSize(self, value: str) -> bool:
394        try:
395            layout.parsePageSize(value)
396            return True
397        except (ValueError, Exception):
398            return False
399
400    def _onPageSizeChanged(self, sender):
401        value = sender.get().strip()
402        self._state["pageSize"] = value
403        self._saveState()
404        if self._isValidPageSize(value):
405            self._triggerChange(immediate=True)
406
407    def _onPageMarginChanged(self, sender):
408        value = sender.get().strip() or "5mm"
409        self._state["pageMargin"] = value
410        self._saveState()
411        self._triggerChange()
412
413    def _onBodyPaddingChanged(self, sender):
414        self._state["bodyPadding"] = sender.get().strip()
415        self._saveState()
416        self._triggerChange()
417
418    def _onTrackingChanged(self, sender):
419        value = int(round(sender.get() / 5) * 5)
420        self._state["tracking"] = value
421        if self._ui is not None and hasattr(self._ui, "trackingValue"):
422            self._ui.trackingValue.set(self._formatTrackingValue(value))
423        self._saveState()
424        self._triggerChange()
425
426    def _onLeadingChanged(self, sender):
427        value = round(float(sender.get()) / 0.05) * 0.05
428        self._state["leading"] = value
429        if self._ui is not None and hasattr(self._ui, "leadingValue"):
430            self._ui.leadingValue.set(self._formatLeadingValue(value))
431        self._saveState()
432        self._triggerChange()
433
434    def _onFontFitModeChanged(self, sender):
435        value = sender.get()
436        self._state["fontFitMode"] = value
437        self._saveState()
438        self._triggerChange(immediate=True)
439
440    def _onFontSizeModeChanged(self, sender):
441        value = sender.get()
442        self._state["fontSizeMode"] = value
443        self._refreshSizingControls()
444        self._saveState()
445        self._triggerChange(immediate=True)
446
447    def _onFontSizeChanged(self, sender):
448        value = int(round(sender.get()))
449        self._state["fontSize"] = value
450        if self._ui is not None and hasattr(self._ui, "fontSizeValue"):
451            self._ui.fontSizeValue.set(self._formatFontSizeValue(value))
452        self._saveState()
453        self._triggerChange()
454
455    def _refreshSizingControls(self):
456        if self._ui is None:
457            return
458        isManual = self._state.get("fontSizeMode", 0) == 1
459        for attr in ("fontFitModeLabel", "fontFitMode"):
460            if hasattr(self._ui, attr):
461                getattr(self._ui, attr).show(not isManual)
462        for attr in ("fontSizeManual", "fontSizeValue"):
463            if hasattr(self._ui, attr):
464                getattr(self._ui, attr).show(isManual)
465
466    def _onAlignHChanged(self, sender):
467        options = ["left", "center", "right"]
468        self._state["alignH"] = options[sender.get()]
469        self._saveState()
470        self._triggerChange(immediate=True)
471
472    def _onAlignVChanged(self, sender):
473        options = ["top", "center", "bottom"]
474        self._state["alignV"] = options[sender.get()]
475        self._saveState()
476        self._triggerChange(immediate=True)
477
478    def _onDebugChanged(self, sender):
479        self._state["debug"] = bool(sender.get())
480        self._saveState()
481        self._triggerChange(immediate=True)
482
483    def _onInvertChanged(self, sender):
484        self._state["invert"] = bool(sender.get())
485        self._saveState()
486        self._triggerChange(immediate=True)
487
488    def _onFlipChanged(self, sender):
489        selected = sender.get()
490        self._state["flipX"] = 0 in selected
491        self._state["flipY"] = 1 in selected
492        self._saveState()
493        self._triggerChange(immediate=True)
494
495    def _onPerforationEnableChanged(self, sender):
496        self._state["perforation"] = bool(sender.get())
497        self._saveState()
498        self._triggerChange(immediate=True)
499
500    def _onOpenPerforationPopover(self, sender):
501        popMargin = 10
502        popLabelW = 60
503        popW = 220
504        rowH = 19
505        rowGap = 8
506        segH = 18
507        segGap = 8
508        py = popMargin
509
510        self._perfPopover = vanilla.Popover((popW, 231))
511
512        self._perfPopover.radiusLabel = vanilla.TextBox(
513            (popMargin, py + 3, popLabelW, 14), "Radius", sizeStyle="small"
514        )
515        self._perfPopover.radius = vanilla.EditText(
516            (popMargin + popLabelW, py, -popMargin, rowH),
517            text=str(self._state.get("perforationRadius", 2.0)),
518            callback=self._onPerforationRadiusChanged,
519            sizeStyle="small",
520        )
521        py += rowH + rowGap
522
523        self._perfPopover.spacingLabel = vanilla.TextBox(
524            (popMargin, py + 3, popLabelW, 14), "Spacing", sizeStyle="small"
525        )
526        self._perfPopover.spacing = vanilla.EditText(
527            (popMargin + popLabelW, py, -popMargin, rowH),
528            text=str(self._state.get("perforationSpacing", 80.0)),
529            callback=self._onPerforationSpacingChanged,
530            sizeStyle="small",
531        )
532        py += rowH + rowGap
533
534        self._perfPopover.countLabel = vanilla.TextBox(
535            (popMargin, py + 3, popLabelW, 14), "Count", sizeStyle="small"
536        )
537        self._perfPopover.count = vanilla.EditText(
538            (popMargin + popLabelW, py, -popMargin, rowH),
539            text=str(self._state.get("perforationCount", 2)),
540            callback=self._onPerforationCountChanged,
541            sizeStyle="small",
542        )
543        py += rowH + rowGap
544
545        self._perfPopover.offsetXLabel = vanilla.TextBox(
546            (popMargin, py + 3, popLabelW, 14), "Offset X", sizeStyle="small"
547        )
548        self._perfPopover.offsetX = vanilla.EditText(
549            (popMargin + popLabelW, py, -popMargin, rowH),
550            text=str(self._state.get("perforationOffsetX", 0.0)),
551            callback=self._onPerforationOffsetXChanged,
552            sizeStyle="small",
553        )
554        py += rowH + rowGap
555
556        self._perfPopover.offsetYLabel = vanilla.TextBox(
557            (popMargin, py + 3, popLabelW, 14), "Offset Y", sizeStyle="small"
558        )
559        self._perfPopover.offsetY = vanilla.EditText(
560            (popMargin + popLabelW, py, -popMargin, rowH),
561            text=str(self._state.get("perforationOffsetY", 0.0)),
562            callback=self._onPerforationOffsetYChanged,
563            sizeStyle="small",
564        )
565        py += rowH + rowGap
566
567        segW = (popW - popLabelW - popMargin * 2) // 3
568        self._perfPopover.alignXLabel = vanilla.TextBox(
569            (popMargin, py + 3, popLabelW, 14), "Align H", sizeStyle="small"
570        )
571        self._perfPopover.alignX = vanilla.SegmentedButton(
572            (popMargin + popLabelW, py, -popMargin, segH),
573            [
574                dict(title="Left", width=segW),
575                dict(title="Ctr", width=segW),
576                dict(title="Right", width=segW),
577            ],
578            callback=self._onPerforationAlignXChanged,
579        )
580        self._perfPopover.alignX.set(
581            ["left", "center", "right"].index(
582                self._state.get("perforationAlignX", "left")
583            )
584        )
585        py += segH + segGap
586
587        self._perfPopover.alignYLabel = vanilla.TextBox(
588            (popMargin, py + 3, popLabelW, 14), "Align V", sizeStyle="small"
589        )
590        self._perfPopover.alignY = vanilla.SegmentedButton(
591            (popMargin + popLabelW, py, -popMargin, segH),
592            [
593                dict(title="Top", width=segW),
594                dict(title="Ctr", width=segW),
595                dict(title="Bot", width=segW),
596            ],
597            callback=self._onPerforationAlignYChanged,
598        )
599        self._perfPopover.alignY.set(
600            ["top", "center", "bottom"].index(
601                self._state.get("perforationAlignY", "center")
602            )
603        )
604        py += segH + segGap
605
606        self._perfPopover.bodyPaddingLabel = vanilla.TextBox(
607            (popMargin, py + 3, popLabelW, 14), "Body Pad", sizeStyle="small"
608        )
609        self._perfPopover.bodyPadding = vanilla.EditText(
610            (popMargin + popLabelW, py, -popMargin, rowH),
611            text=str(self._state.get("bodyPadding", "")),
612            callback=self._onBodyPaddingChanged,
613            sizeStyle="small",
614        )
615
616        self._perfPopover.open(parentView=sender)
617
618    def _onPerforationRadiusChanged(self, sender):
619        try:
620            value = max(0.5, float(sender.get()))
621        except ValueError:
622            value = 2.0
623        self._state["perforationRadius"] = value
624        self._saveState()
625        self._triggerChange(immediate=True)
626
627    def _onPerforationSpacingChanged(self, sender):
628        try:
629            value = max(10.0, float(sender.get()))
630        except ValueError:
631            value = 80.0
632        self._state["perforationSpacing"] = value
633        self._saveState()
634        self._triggerChange(immediate=True)
635
636    def _onPerforationCountChanged(self, sender):
637        try:
638            value = max(1, int(sender.get()))
639        except ValueError:
640            value = 2
641        self._state["perforationCount"] = value
642        self._saveState()
643        self._triggerChange(immediate=True)
644
645    def _onPerforationAlignXChanged(self, sender):
646        self._state["perforationAlignX"] = ["left", "center", "right"][sender.get()]
647        self._saveState()
648        self._triggerChange(immediate=True)
649
650    def _onPerforationAlignYChanged(self, sender):
651        self._state["perforationAlignY"] = ["top", "center", "bottom"][sender.get()]
652        self._saveState()
653        self._triggerChange(immediate=True)
654
655    def _onPerforationOffsetXChanged(self, sender):
656        try:
657            value = float(sender.get())
658        except ValueError:
659            value = 0.0
660        self._state["perforationOffsetX"] = value
661        self._saveState()
662        self._triggerChange(immediate=True)
663
664    def _onPerforationOffsetYChanged(self, sender):
665        try:
666            value = float(sender.get())
667        except ValueError:
668            value = 0.0
669        self._state["perforationOffsetY"] = value
670        self._saveState()
671        self._triggerChange(immediate=True)
672
673    def _onChooseFontFolder(self, sender):
674        if self._promptForFontFolder(triggerChange=False):
675            self._refreshFontRows()
676            self._saveState()
677            self._triggerChange(immediate=True)
678
679    def _onFontSelectionToggled(self, sender):
680        idx = sender.get()
681        if idx == 0:
682            self._onSelectAllFonts(sender)
683        elif idx == 1:
684            self._onSelectNoFonts(sender)
685        elif idx == 2:
686            self._onChooseFontFolder(sender)
687
688    def _onSelectAllFonts(self, sender):
689        if not self._fontRows:
690            return
691        for row in self._fontRows:
692            row["Selected"] = True
693        self._syncSelectedFromRows()
694        self._writeRowsToUI()
695        self._saveState()
696        self._triggerChange(immediate=True)
697
698    def _onSelectNoFonts(self, sender):
699        if not self._fontRows:
700            return
701        for row in self._fontRows:
702            row["Selected"] = False
703        self._syncSelectedFromRows()
704        self._writeRowsToUI()
705        self._saveState()
706        self._triggerChange(immediate=True)
707
708    def _onSelectFontsByType(self, sender):
709        if not self._fontRows:
710            return
711        fontType = sender.title().lower()
712        for row in self._fontRows:
713            row["Selected"] = fontlib.isFontType(row["Full Name"], fontType)
714        self._syncSelectedFromRows()
715        self._writeRowsToUI()
716        self._saveState()
717        self._triggerChange(immediate=True)
718
719    def _onFontListEdited(self, sender):
720        rows = sender.get() or []
721        self._fontRows = [dict(row) for row in rows]
722        self._syncSelectedFromRows()
723        self._saveState()
724        self._triggerChange(immediate=True)
725
726    def _onViewModeChanged(self, sender):
727        self._state["viewMode"] = sender.get()
728        self._refreshViewModeControls()
729        self._saveState()
730        self._triggerChange(immediate=True)
731
732    def _onViewDirectionChanged(self, sender):
733        self._state["viewDirection"] = sender.get()
734        self._saveState()
735        self._triggerChange(immediate=True)
736
737    def _refreshViewModeControls(self):
738        if self._ui is None:
739            return
740        is_together = self._state.get("viewMode", 0) == 1
741        for attr in ("viewDirectionLabel", "viewDirection"):
742            if hasattr(self._ui, attr):
743                getattr(self._ui, attr).show(is_together)
744
745    def _promptForFontFolder(self, triggerChange: bool = True) -> bool:
746        panel = NSOpenPanel.openPanel()
747        panel.setCanChooseFiles_(False)
748        panel.setCanChooseDirectories_(True)
749        panel.setAllowsMultipleSelection_(False)
750        if self._fontDirectory:
751            panel.setDirectoryURL_(NSURL.fileURLWithPath_(self._fontDirectory))
752        if panel.runModal() != NSFileHandlingPanelOKButton:
753            return False
754        folder = panel.URLs()[0].path()
755        if not folder or not os.path.isdir(folder):
756            return False
757
758        self._state["fontFolder"] = folder
759        self._refreshFontRows()
760        self._saveState()
761        if triggerChange:
762            self._triggerChange()
763        return True
764
765    def _refreshFontRows(self):
766        folder = self._state.get("fontFolder", "")
767        if not folder or not os.path.isdir(folder):
768            self._state["fontFolder"] = ""
769            self._state["fontPaths"] = []
770            self._state["selectedFontPaths"] = []
771            self._fontRows = []
772            self._updateFontStatusText()
773            self._writeRowsToUI()
774            return
775
776        fontPaths = fontlib.getFonts(folder) or []
777        selected = [
778            path
779            for path in self._state.get("selectedFontPaths", [])
780            if path in fontPaths
781        ]
782        if not selected:
783            defaultFont = fontlib.findFontByNumber(fontPaths, 50)
784            selected = [defaultFont] if defaultFont else fontPaths[:1]
785
786        self._state["fontPaths"] = fontPaths
787        self._state["selectedFontPaths"] = selected
788        self._fontRows = [
789            {
790                "Selected": path in selected,
791                "Font Name": fontlib.parseNameObject(path),
792                "Full Name": path,
793            }
794            for path in fontPaths
795        ]
796        self._updateFontStatusText()
797        self._writeRowsToUI()
798
799    def _syncSelectedFromRows(self):
800        self._state["fontPaths"] = [row["Full Name"] for row in self._fontRows]
801        self._state["selectedFontPaths"] = [
802            row["Full Name"] for row in self._fontRows if row.get("Selected")
803        ]
804        self._updateFontStatusText()
805
806    def _updateFontStatusText(self):
807        total = len(self._state.get("fontPaths", []))
808        selected = len(self._state.get("selectedFontPaths", []))
809
810        if total == 0:
811            text = "No fonts loaded"
812        else:
813            text = f"{selected}/{total} fonts"
814
815        if self._ui is not None and hasattr(self._ui, "fontStatus"):
816            self._ui.fontStatus.set(text)
817
818    def _writeRowsToUI(self):
819        if self._ui is not None and hasattr(self._ui, "fontList"):
820            self._ui.fontList.set(self._fontRows)
821
822    def _formatTrackingValue(self, value) -> str:
823        return str(int(round(float(value))))
824
825    def _formatLeadingValue(self, value) -> str:
826        return f"{float(value):.2f}".rstrip("0").rstrip(".")
827
828    def _formatFontSizeValue(self, value) -> str:
829        return str(int(round(float(value))))
830
831    def _loadState(self):
832        if not os.path.exists(self._statePath):
833            return
834        try:
835            with open(self._statePath, "r", encoding="utf-8") as f:
836                raw = json.load(f)
837            if isinstance(raw, dict):
838                self._state.update(raw)
839
840            try:
841                self._state["tracking"] = int(
842                    round(float(self._state.get("tracking", 0)))
843                )
844            except Exception:
845                self._state["tracking"] = 0
846
847            try:
848                leading = float(self._state.get("leading", 1.0))
849                self._state["leading"] = min(1.5, max(0.5, leading))
850            except Exception:
851                self._state["leading"] = 1.0
852
853            try:
854                fontFitMode = int(self._state.get("fontFitMode", 0))
855                self._state["fontFitMode"] = min(2, max(0, fontFitMode))
856            except Exception:
857                self._state["fontFitMode"] = 0
858
859            try:
860                fontSizeMode = int(self._state.get("fontSizeMode", 0))
861                self._state["fontSizeMode"] = min(1, max(0, fontSizeMode))
862            except Exception:
863                self._state["fontSizeMode"] = 0
864
865            try:
866                fontSize = int(round(float(self._state.get("fontSize", 48))))
867                self._state["fontSize"] = min(256, max(8, fontSize))
868            except Exception:
869                self._state["fontSize"] = 48
870
871            try:
872                alignH = self._state.get("alignH", "center")
873                if alignH not in ("left", "center", "right"):
874                    alignH = "center"
875                self._state["alignH"] = alignH
876            except Exception:
877                self._state["alignH"] = "center"
878
879            try:
880                alignV = self._state.get("alignV", "center")
881                if alignV not in ("top", "center", "bottom"):
882                    alignV = "center"
883                self._state["alignV"] = alignV
884            except Exception:
885                self._state["alignV"] = "center"
886
887            try:
888                self._state["debug"] = bool(self._state.get("debug", False))
889            except Exception:
890                self._state["debug"] = False
891
892            try:
893                self._state["perforation"] = bool(self._state.get("perforation", False))
894            except Exception:
895                self._state["perforation"] = False
896
897            try:
898                self._state["perforationRadius"] = max(
899                    0.5, float(self._state.get("perforationRadius", 2.0))
900                )
901            except Exception:
902                self._state["perforationRadius"] = 2.0
903
904            try:
905                self._state["perforationSpacing"] = max(
906                    10.0, float(self._state.get("perforationSpacing", 80.0))
907                )
908            except Exception:
909                self._state["perforationSpacing"] = 80.0
910
911            try:
912                self._state["perforationCount"] = max(
913                    1, int(self._state.get("perforationCount", 2))
914                )
915            except Exception:
916                self._state["perforationCount"] = 2
917
918            try:
919                alignX = self._state.get("perforationAlignX", "left")
920                if alignX not in ("left", "center", "right"):
921                    alignX = "left"
922                self._state["perforationAlignX"] = alignX
923            except Exception:
924                self._state["perforationAlignX"] = "left"
925
926            try:
927                alignY = self._state.get("perforationAlignY", "center")
928                if alignY not in ("top", "center", "bottom"):
929                    alignY = "center"
930                self._state["perforationAlignY"] = alignY
931            except Exception:
932                self._state["perforationAlignY"] = "center"
933
934            try:
935                self._state["perforationOffsetX"] = float(
936                    self._state.get("perforationOffsetX", 0.0)
937                )
938            except Exception:
939                self._state["perforationOffsetX"] = 0.0
940
941            try:
942                self._state["perforationOffsetY"] = float(
943                    self._state.get("perforationOffsetY", 0.0)
944                )
945            except Exception:
946                self._state["perforationOffsetY"] = 0.0
947
948            folder = self._state.get("fontFolder", "")
949            if not folder or not os.path.isdir(folder):
950                self._state["fontFolder"] = ""
951                self._state["fontPaths"] = []
952                self._state["selectedFontPaths"] = []
953            else:
954                self._state["fontPaths"] = [
955                    p
956                    for p in self._state.get("fontPaths", [])
957                    if isinstance(p, str) and os.path.isfile(p)
958                ]
959                self._state["selectedFontPaths"] = [
960                    p
961                    for p in self._state.get("selectedFontPaths", [])
962                    if isinstance(p, str) and os.path.isfile(p)
963                ]
964        except Exception:
965            pass
966
967    def getPerforationParams(self, pageIndex: int) -> dict | None:
968        """Return perforation params for a 0-based page index, or None if disabled/skipped.
969
970        Perforations are drawn only on odd pages (1, 3, 5 … in 1-based terms,
971        i.e. page indices 0, 2, 4 … in 0-based terms).  The returned dict can
972        be unpacked directly into ``printing.drawPerforationCircles``:
973
974            params = panel.getPerforationParams(pageIndex)
975            if params:
976                printing.drawPerforationCircles(container, **params)
977        """
978        if not self._state.get("perforation", False):
979            return None
980        if pageIndex % 2 != 0:
981            return None
982        return {
983            "radius": self._state.get("perforationRadius", 2.0),
984            "spacing": self._state.get("perforationSpacing", 80.0),
985            "count": self._state.get("perforationCount", 2),
986            "align": (
987                self._state.get("perforationAlignX", "left"),
988                self._state.get("perforationAlignY", "center"),
989            ),
990        }
class RenderLayoutPanel(classes.c92_render_panel_plugin.RenderPanelPlugin):
 13class RenderLayoutPanel(RenderPanelPlugin):
 14    """RenderView side panel plugin for page size and margin controls."""
 15
 16    DEFAULT_PAGE_MARGIN = "5mm"
 17
 18    def __init__(
 19        self,
 20        panelWidth: int = 290,
 21        pageSizes: list[str] | None = None,
 22        statePath: str | None = None,
 23        enableFontPicker: bool = False,
 24        fontDirectory: str = "/Users/christianjansky/Library/CloudStorage/Dropbox/KOMETA-Typefaces",
 25    ):
 26        self._panelWidth = panelWidth
 27        self._enableFontPicker = enableFontPicker
 28        self._fontDirectory = fontDirectory
 29        self._pageSizes = pageSizes or [
 30            "A4Landscape",
 31            "A4",
 32            "A3Landscape",
 33            "A3",
 34            "screen",
 35        ]
 36        self._statePath = statePath or os.path.expanduser(
 37            "~/Library/Application Support/KOMETA-Draw/render_view_layout_panel.json"
 38        )
 39        self._state = {
 40            "pageSize": self._pageSizes[0],
 41            "pageMargin": self.DEFAULT_PAGE_MARGIN,
 42            "bodyPadding": "",
 43            "leading": 1.0,
 44            "tracking": 0,
 45            "fontSizeMode": 0,
 46            "fontSize": 48,
 47            "fontFitMode": 0,
 48            "alignH": "center",
 49            "alignV": "center",
 50            "debug": False,
 51            "invert": False,
 52            "flipX": False,
 53            "flipY": False,
 54            "perforation": False,
 55            "perforationRadius": 2.0,
 56            "perforationSpacing": 80.0,
 57            "perforationCount": 2,
 58            "perforationAlignX": "left",
 59            "perforationAlignY": "center",
 60            "perforationOffsetX": 0.0,
 61            "perforationOffsetY": 0.0,
 62            "fontFolder": "",
 63            "fontPaths": [],
 64            "selectedFontPaths": [],
 65            "viewMode": 0,
 66            "viewDirection": 0,
 67        }
 68        self._fontRows = []
 69        self._ui = None
 70        self._onChange = None
 71        self._persistenceEnabled = True
 72        self._loadState()
 73
 74    def getState(self) -> dict:
 75        return dict(self._state)
 76
 77    def build(self, parent, onChange: Callable[..., None]):
 78        """Build panel UI in the provided parent group."""
 79        self._onChange = onChange
 80        self._ui = vanilla.Group((0, 0, -0, -0))
 81
 82        margin = 10
 83        labelW = 90
 84        sectionMargin = PANEL_SECTION_MARGIN
 85        y = margin
 86        self._ui.pageSizeLabel = vanilla.TextBox(
 87            (margin, y + 3, labelW, 14),
 88            "Page Size",
 89            sizeStyle="small",
 90        )
 91        self._ui.pageSize = vanilla.ComboBox(
 92            (margin + labelW, y, -margin, 20),
 93            items=self._pageSizes,
 94            callback=self._onPageSizeChanged,
 95            sizeStyle="small",
 96        )
 97        self._ui.pageSize.set(self._state.get("pageSize", self._pageSizes[0]))
 98
 99        y += 28
100        self._ui.pageMarginLabel = vanilla.TextBox(
101            (margin, y + 3, labelW, 14),
102            "Page Margin",
103            sizeStyle="small",
104        )
105        self._ui.pageMargin = vanilla.EditText(
106            (margin + labelW, y, -margin, 19),
107            text=str(self._state.get("pageMargin", self.DEFAULT_PAGE_MARGIN)),
108            callback=self._onPageMarginChanged,
109            sizeStyle="small",
110        )
111
112        y += sectionMargin
113        self._ui.fontPropsSectionLabel = vanilla.TextBox(
114            (margin, y, -margin, 12), "Font Props", alignment="center", sizeStyle="mini"
115        )
116
117        y += 18
118        self._ui.fontSizeModeLabel = vanilla.TextBox(
119            (margin, y + 3, labelW, 14),
120            "Font Size",
121            sizeStyle="small",
122        )
123        self._ui.fontSizeMode = vanilla.SegmentedButton(
124            (margin + labelW, y, -margin, 18),
125            [dict(title="Auto"), dict(title="Manual")],
126            callback=self._onFontSizeModeChanged,
127        )
128        self._ui.fontSizeMode.set(self._state.get("fontSizeMode", 0))
129
130        y += 28
131        self._ui.fontFitModeLabel = vanilla.TextBox(
132            (margin, y + 3, labelW, 14),
133            "Fit Mode",
134            sizeStyle="small",
135        )
136        self._ui.fontFitMode = vanilla.PopUpButton(
137            (margin + labelW, y, -margin, 20),
138            items=["Auto", "Preserve Whitespace", "Running Text"],
139            callback=self._onFontFitModeChanged,
140            sizeStyle="small",
141        )
142        self._ui.fontFitMode.set(self._state.get("fontFitMode", 0))
143        self._ui.fontSizeManual = vanilla.Slider(
144            (margin + labelW, y, -42, 19),
145            minValue=8,
146            maxValue=256,
147            value=float(self._state.get("fontSize", 48)),
148            callback=self._onFontSizeChanged,
149            continuous=True,
150            sizeStyle="small",
151        )
152        self._ui.fontSizeValue = vanilla.TextBox(
153            (-38, y + 3, 28, 14),
154            self._formatFontSizeValue(self._state.get("fontSize", 48)),
155            alignment="right",
156            sizeStyle="small",
157        )
158        self._refreshSizingControls()
159
160        y += 28
161        self._ui.leadingLabel = vanilla.TextBox(
162            (margin, y + 3, labelW, 14),
163            "Leading",
164            sizeStyle="small",
165        )
166        self._ui.leading = vanilla.Slider(
167            (margin + labelW, y, -42, 19),
168            minValue=0.5,
169            maxValue=1.5,
170            value=float(self._state.get("leading", 1.0)),
171            callback=self._onLeadingChanged,
172            continuous=True,
173            sizeStyle="small",
174        )
175        self._ui.leadingValue = vanilla.TextBox(
176            (-38, y + 3, 28, 14),
177            self._formatLeadingValue(self._state.get("leading", 1.0)),
178            alignment="right",
179            sizeStyle="small",
180        )
181
182        y += 28
183        self._ui.trackingLabel = vanilla.TextBox(
184            (margin, y + 3, labelW, 14),
185            "Tracking",
186            sizeStyle="small",
187        )
188        self._ui.tracking = vanilla.Slider(
189            (margin + labelW, y, -42, 19),
190            minValue=-50,
191            maxValue=50,
192            value=float(self._state.get("tracking", 0)),
193            callback=self._onTrackingChanged,
194            continuous=True,
195            sizeStyle="small",
196        )
197        self._ui.trackingValue = vanilla.TextBox(
198            (-38, y + 3, 28, 14),
199            self._formatTrackingValue(self._state.get("tracking", 0)),
200            alignment="right",
201            sizeStyle="small",
202        )
203
204        y += sectionMargin
205        self._ui.alignSectionLabel = vanilla.TextBox(
206            (margin, y, -margin, 12), "Align", alignment="center", sizeStyle="mini"
207        )
208        y += 18
209        segW = (self._panelWidth - labelW - margin * 2) // 3
210        self._ui.textAlignLabel = vanilla.TextBox(
211            (margin, y + 4, labelW, 14), "Horizontal", sizeStyle="small"
212        )
213        self._ui.textAlign = vanilla.SegmentedButton(
214            (margin + labelW, y, -margin, 18),
215            [
216                dict(title="Left", width=segW),
217                dict(title="Center", width=segW),
218                dict(title="Right", width=segW),
219            ],
220            callback=self._onAlignHChanged,
221        )
222        self._ui.textAlign.set(
223            ["left", "center", "right"].index(self._state.get("alignH", "center"))
224        )
225
226        y += 24
227        self._ui.yAlignLabel = vanilla.TextBox(
228            (margin, y + 4, labelW, 14), "Vertical", sizeStyle="small"
229        )
230        self._ui.yAlign = vanilla.SegmentedButton(
231            (margin + labelW, y, -margin, 18),
232            [
233                dict(title="Top", width=segW),
234                dict(title="Center", width=segW),
235                dict(title="Bottom", width=segW),
236            ],
237            callback=self._onAlignVChanged,
238        )
239        self._ui.yAlign.set(
240            ["top", "center", "bottom"].index(self._state.get("alignV", "center"))
241        )
242
243        y += 28
244        self._ui.debugLabel = vanilla.TextBox(
245            (margin, y + 2, labelW, 16), "Debug", sizeStyle="small"
246        )
247        self._ui.debug = vanilla.CheckBox(
248            (margin + labelW, y, -margin, 16),
249            title="",
250            value=bool(self._state.get("debug", False)),
251            callback=self._onDebugChanged,
252            sizeStyle="small",
253        )
254
255        y += 24
256        self._ui.invertLabel = vanilla.TextBox(
257            (margin, y + 2, labelW, 16), "Invert", sizeStyle="small"
258        )
259        self._ui.invert = vanilla.CheckBox(
260            (margin + labelW, y, -margin, 16),
261            title="",
262            value=bool(self._state.get("invert", False)),
263            callback=self._onInvertChanged,
264            sizeStyle="small",
265        )
266
267        y += 24
268        self._ui.flipLabel = vanilla.TextBox(
269            (margin, y + 2, labelW, 16), "Flip", sizeStyle="small"
270        )
271        flipSelected = []
272        if self._state.get("flipX", False):
273            flipSelected.append(0)
274        if self._state.get("flipY", False):
275            flipSelected.append(1)
276        self._ui.flip = vanilla.SegmentedButton(
277            (margin + labelW, y, -margin, 20),
278            segmentDescriptions=[{"title": "X"}, {"title": "Y"}],
279            selectionStyle="any",
280            callback=self._onFlipChanged,
281            sizeStyle="small",
282        )
283        self._ui.flip.set(flipSelected)
284
285        y += sectionMargin
286        self._ui.perfSectionLabel = vanilla.TextBox(
287            (margin, y, -margin, 12),
288            "Perforation",
289            alignment="center",
290            sizeStyle="mini",
291        )
292        y += 18
293        self._ui.perfEnableLabel = vanilla.TextBox(
294            (margin, y + 2, labelW, 16), "Enable", sizeStyle="small"
295        )
296        self._ui.perfEnable = vanilla.CheckBox(
297            (margin + labelW, y, -(margin + 32), 16),
298            title="",
299            value=bool(self._state.get("perforation", False)),
300            callback=self._onPerforationEnableChanged,
301            sizeStyle="small",
302        )
303        self._ui.perfSettingsButton = vanilla.Button(
304            (-(margin + 28), y - 1, -margin, 18),
305            "⚙",
306            callback=self._onOpenPerforationPopover,
307            sizeStyle="small",
308        )
309
310        if self._enableFontPicker:
311            y += 32
312            self._ui.fontStatus = vanilla.TextBox(
313                (margin, y, -margin, 12),
314                "No fonts loaded",
315                alignment="center",
316                sizeStyle="mini",
317            )
318
319            y += 16
320            actionBtnW = 34
321            self._ui.fontSelectionToggle = vanilla.SegmentedButton(
322                (margin, y, -(margin + actionBtnW + 4), 18),
323                [
324                    dict(title="All"),
325                    dict(title="None"),
326                    dict(title="Replace"),
327                ],
328                sizeStyle="small",
329                selectionStyle="momentary",
330                callback=self._onFontSelectionToggled,
331            )
332            self._ui.selectFontTypeButton = vanilla.ActionButton(
333                (-margin - actionBtnW, y + 1, -margin, 17),
334                [
335                    dict(title="Upright", callback=self._onSelectFontsByType),
336                    dict(title="Italic", callback=self._onSelectFontsByType),
337                    dict(title="Text", callback=self._onSelectFontsByType),
338                    dict(title="Display", callback=self._onSelectFontsByType),
339                    dict(title="Thin", callback=self._onSelectFontsByType),
340                    dict(title="Thick", callback=self._onSelectFontsByType),
341                ],
342                sizeStyle="small",
343            )
344
345            y += 24
346            self._ui.viewModeLabel = vanilla.TextBox(
347                (margin, y + 4, labelW, 14), "View Fonts", sizeStyle="small"
348            )
349            self._ui.viewMode = vanilla.SegmentedButton(
350                (margin + labelW, y, -margin, 18),
351                [dict(title="Separate"), dict(title="Together")],
352                callback=self._onViewModeChanged,
353                sizeStyle="small",
354            )
355            self._ui.viewMode.set(self._state.get("viewMode", 0))
356            y += 26
357            self._ui.viewDirectionLabel = vanilla.TextBox(
358                (margin, y + 4, labelW, 14), "Direction", sizeStyle="small"
359            )
360            self._ui.viewDirection = vanilla.SegmentedButton(
361                (margin + labelW, y, -margin, 18),
362                [dict(title="Rows"), dict(title="Columns")],
363                callback=self._onViewDirectionChanged,
364                sizeStyle="small",
365            )
366            self._ui.viewDirection.set(self._state.get("viewDirection", 0))
367            self._refreshViewModeControls()
368            y += 30
369            self._ui.fontList = vanilla.List(
370                (margin, y, -margin, -margin),
371                [],
372                columnDescriptions=[
373                    {
374                        "title": "Selected",
375                        "cell": vanilla.CheckBoxListCell(),
376                        "key": "Selected",
377                        "editable": True,
378                        "width": 56,
379                    },
380                    {"title": "Font Name", "key": "Font Name", "editable": False},
381                ],
382                showColumnTitles=True,
383                allowsMultipleSelection=True,
384                editCallback=self._onFontListEdited,
385            )
386
387            # Prompt on startup if no valid folder is configured.
388            if not os.path.isdir(self._state.get("fontFolder", "")):
389                self._promptForFontFolder(triggerChange=False)
390            self._refreshFontRows()
391
392        parent.body = self._ui
393
394    def _isValidPageSize(self, value: str) -> bool:
395        try:
396            layout.parsePageSize(value)
397            return True
398        except (ValueError, Exception):
399            return False
400
401    def _onPageSizeChanged(self, sender):
402        value = sender.get().strip()
403        self._state["pageSize"] = value
404        self._saveState()
405        if self._isValidPageSize(value):
406            self._triggerChange(immediate=True)
407
408    def _onPageMarginChanged(self, sender):
409        value = sender.get().strip() or "5mm"
410        self._state["pageMargin"] = value
411        self._saveState()
412        self._triggerChange()
413
414    def _onBodyPaddingChanged(self, sender):
415        self._state["bodyPadding"] = sender.get().strip()
416        self._saveState()
417        self._triggerChange()
418
419    def _onTrackingChanged(self, sender):
420        value = int(round(sender.get() / 5) * 5)
421        self._state["tracking"] = value
422        if self._ui is not None and hasattr(self._ui, "trackingValue"):
423            self._ui.trackingValue.set(self._formatTrackingValue(value))
424        self._saveState()
425        self._triggerChange()
426
427    def _onLeadingChanged(self, sender):
428        value = round(float(sender.get()) / 0.05) * 0.05
429        self._state["leading"] = value
430        if self._ui is not None and hasattr(self._ui, "leadingValue"):
431            self._ui.leadingValue.set(self._formatLeadingValue(value))
432        self._saveState()
433        self._triggerChange()
434
435    def _onFontFitModeChanged(self, sender):
436        value = sender.get()
437        self._state["fontFitMode"] = value
438        self._saveState()
439        self._triggerChange(immediate=True)
440
441    def _onFontSizeModeChanged(self, sender):
442        value = sender.get()
443        self._state["fontSizeMode"] = value
444        self._refreshSizingControls()
445        self._saveState()
446        self._triggerChange(immediate=True)
447
448    def _onFontSizeChanged(self, sender):
449        value = int(round(sender.get()))
450        self._state["fontSize"] = value
451        if self._ui is not None and hasattr(self._ui, "fontSizeValue"):
452            self._ui.fontSizeValue.set(self._formatFontSizeValue(value))
453        self._saveState()
454        self._triggerChange()
455
456    def _refreshSizingControls(self):
457        if self._ui is None:
458            return
459        isManual = self._state.get("fontSizeMode", 0) == 1
460        for attr in ("fontFitModeLabel", "fontFitMode"):
461            if hasattr(self._ui, attr):
462                getattr(self._ui, attr).show(not isManual)
463        for attr in ("fontSizeManual", "fontSizeValue"):
464            if hasattr(self._ui, attr):
465                getattr(self._ui, attr).show(isManual)
466
467    def _onAlignHChanged(self, sender):
468        options = ["left", "center", "right"]
469        self._state["alignH"] = options[sender.get()]
470        self._saveState()
471        self._triggerChange(immediate=True)
472
473    def _onAlignVChanged(self, sender):
474        options = ["top", "center", "bottom"]
475        self._state["alignV"] = options[sender.get()]
476        self._saveState()
477        self._triggerChange(immediate=True)
478
479    def _onDebugChanged(self, sender):
480        self._state["debug"] = bool(sender.get())
481        self._saveState()
482        self._triggerChange(immediate=True)
483
484    def _onInvertChanged(self, sender):
485        self._state["invert"] = bool(sender.get())
486        self._saveState()
487        self._triggerChange(immediate=True)
488
489    def _onFlipChanged(self, sender):
490        selected = sender.get()
491        self._state["flipX"] = 0 in selected
492        self._state["flipY"] = 1 in selected
493        self._saveState()
494        self._triggerChange(immediate=True)
495
496    def _onPerforationEnableChanged(self, sender):
497        self._state["perforation"] = bool(sender.get())
498        self._saveState()
499        self._triggerChange(immediate=True)
500
501    def _onOpenPerforationPopover(self, sender):
502        popMargin = 10
503        popLabelW = 60
504        popW = 220
505        rowH = 19
506        rowGap = 8
507        segH = 18
508        segGap = 8
509        py = popMargin
510
511        self._perfPopover = vanilla.Popover((popW, 231))
512
513        self._perfPopover.radiusLabel = vanilla.TextBox(
514            (popMargin, py + 3, popLabelW, 14), "Radius", sizeStyle="small"
515        )
516        self._perfPopover.radius = vanilla.EditText(
517            (popMargin + popLabelW, py, -popMargin, rowH),
518            text=str(self._state.get("perforationRadius", 2.0)),
519            callback=self._onPerforationRadiusChanged,
520            sizeStyle="small",
521        )
522        py += rowH + rowGap
523
524        self._perfPopover.spacingLabel = vanilla.TextBox(
525            (popMargin, py + 3, popLabelW, 14), "Spacing", sizeStyle="small"
526        )
527        self._perfPopover.spacing = vanilla.EditText(
528            (popMargin + popLabelW, py, -popMargin, rowH),
529            text=str(self._state.get("perforationSpacing", 80.0)),
530            callback=self._onPerforationSpacingChanged,
531            sizeStyle="small",
532        )
533        py += rowH + rowGap
534
535        self._perfPopover.countLabel = vanilla.TextBox(
536            (popMargin, py + 3, popLabelW, 14), "Count", sizeStyle="small"
537        )
538        self._perfPopover.count = vanilla.EditText(
539            (popMargin + popLabelW, py, -popMargin, rowH),
540            text=str(self._state.get("perforationCount", 2)),
541            callback=self._onPerforationCountChanged,
542            sizeStyle="small",
543        )
544        py += rowH + rowGap
545
546        self._perfPopover.offsetXLabel = vanilla.TextBox(
547            (popMargin, py + 3, popLabelW, 14), "Offset X", sizeStyle="small"
548        )
549        self._perfPopover.offsetX = vanilla.EditText(
550            (popMargin + popLabelW, py, -popMargin, rowH),
551            text=str(self._state.get("perforationOffsetX", 0.0)),
552            callback=self._onPerforationOffsetXChanged,
553            sizeStyle="small",
554        )
555        py += rowH + rowGap
556
557        self._perfPopover.offsetYLabel = vanilla.TextBox(
558            (popMargin, py + 3, popLabelW, 14), "Offset Y", sizeStyle="small"
559        )
560        self._perfPopover.offsetY = vanilla.EditText(
561            (popMargin + popLabelW, py, -popMargin, rowH),
562            text=str(self._state.get("perforationOffsetY", 0.0)),
563            callback=self._onPerforationOffsetYChanged,
564            sizeStyle="small",
565        )
566        py += rowH + rowGap
567
568        segW = (popW - popLabelW - popMargin * 2) // 3
569        self._perfPopover.alignXLabel = vanilla.TextBox(
570            (popMargin, py + 3, popLabelW, 14), "Align H", sizeStyle="small"
571        )
572        self._perfPopover.alignX = vanilla.SegmentedButton(
573            (popMargin + popLabelW, py, -popMargin, segH),
574            [
575                dict(title="Left", width=segW),
576                dict(title="Ctr", width=segW),
577                dict(title="Right", width=segW),
578            ],
579            callback=self._onPerforationAlignXChanged,
580        )
581        self._perfPopover.alignX.set(
582            ["left", "center", "right"].index(
583                self._state.get("perforationAlignX", "left")
584            )
585        )
586        py += segH + segGap
587
588        self._perfPopover.alignYLabel = vanilla.TextBox(
589            (popMargin, py + 3, popLabelW, 14), "Align V", sizeStyle="small"
590        )
591        self._perfPopover.alignY = vanilla.SegmentedButton(
592            (popMargin + popLabelW, py, -popMargin, segH),
593            [
594                dict(title="Top", width=segW),
595                dict(title="Ctr", width=segW),
596                dict(title="Bot", width=segW),
597            ],
598            callback=self._onPerforationAlignYChanged,
599        )
600        self._perfPopover.alignY.set(
601            ["top", "center", "bottom"].index(
602                self._state.get("perforationAlignY", "center")
603            )
604        )
605        py += segH + segGap
606
607        self._perfPopover.bodyPaddingLabel = vanilla.TextBox(
608            (popMargin, py + 3, popLabelW, 14), "Body Pad", sizeStyle="small"
609        )
610        self._perfPopover.bodyPadding = vanilla.EditText(
611            (popMargin + popLabelW, py, -popMargin, rowH),
612            text=str(self._state.get("bodyPadding", "")),
613            callback=self._onBodyPaddingChanged,
614            sizeStyle="small",
615        )
616
617        self._perfPopover.open(parentView=sender)
618
619    def _onPerforationRadiusChanged(self, sender):
620        try:
621            value = max(0.5, float(sender.get()))
622        except ValueError:
623            value = 2.0
624        self._state["perforationRadius"] = value
625        self._saveState()
626        self._triggerChange(immediate=True)
627
628    def _onPerforationSpacingChanged(self, sender):
629        try:
630            value = max(10.0, float(sender.get()))
631        except ValueError:
632            value = 80.0
633        self._state["perforationSpacing"] = value
634        self._saveState()
635        self._triggerChange(immediate=True)
636
637    def _onPerforationCountChanged(self, sender):
638        try:
639            value = max(1, int(sender.get()))
640        except ValueError:
641            value = 2
642        self._state["perforationCount"] = value
643        self._saveState()
644        self._triggerChange(immediate=True)
645
646    def _onPerforationAlignXChanged(self, sender):
647        self._state["perforationAlignX"] = ["left", "center", "right"][sender.get()]
648        self._saveState()
649        self._triggerChange(immediate=True)
650
651    def _onPerforationAlignYChanged(self, sender):
652        self._state["perforationAlignY"] = ["top", "center", "bottom"][sender.get()]
653        self._saveState()
654        self._triggerChange(immediate=True)
655
656    def _onPerforationOffsetXChanged(self, sender):
657        try:
658            value = float(sender.get())
659        except ValueError:
660            value = 0.0
661        self._state["perforationOffsetX"] = value
662        self._saveState()
663        self._triggerChange(immediate=True)
664
665    def _onPerforationOffsetYChanged(self, sender):
666        try:
667            value = float(sender.get())
668        except ValueError:
669            value = 0.0
670        self._state["perforationOffsetY"] = value
671        self._saveState()
672        self._triggerChange(immediate=True)
673
674    def _onChooseFontFolder(self, sender):
675        if self._promptForFontFolder(triggerChange=False):
676            self._refreshFontRows()
677            self._saveState()
678            self._triggerChange(immediate=True)
679
680    def _onFontSelectionToggled(self, sender):
681        idx = sender.get()
682        if idx == 0:
683            self._onSelectAllFonts(sender)
684        elif idx == 1:
685            self._onSelectNoFonts(sender)
686        elif idx == 2:
687            self._onChooseFontFolder(sender)
688
689    def _onSelectAllFonts(self, sender):
690        if not self._fontRows:
691            return
692        for row in self._fontRows:
693            row["Selected"] = True
694        self._syncSelectedFromRows()
695        self._writeRowsToUI()
696        self._saveState()
697        self._triggerChange(immediate=True)
698
699    def _onSelectNoFonts(self, sender):
700        if not self._fontRows:
701            return
702        for row in self._fontRows:
703            row["Selected"] = False
704        self._syncSelectedFromRows()
705        self._writeRowsToUI()
706        self._saveState()
707        self._triggerChange(immediate=True)
708
709    def _onSelectFontsByType(self, sender):
710        if not self._fontRows:
711            return
712        fontType = sender.title().lower()
713        for row in self._fontRows:
714            row["Selected"] = fontlib.isFontType(row["Full Name"], fontType)
715        self._syncSelectedFromRows()
716        self._writeRowsToUI()
717        self._saveState()
718        self._triggerChange(immediate=True)
719
720    def _onFontListEdited(self, sender):
721        rows = sender.get() or []
722        self._fontRows = [dict(row) for row in rows]
723        self._syncSelectedFromRows()
724        self._saveState()
725        self._triggerChange(immediate=True)
726
727    def _onViewModeChanged(self, sender):
728        self._state["viewMode"] = sender.get()
729        self._refreshViewModeControls()
730        self._saveState()
731        self._triggerChange(immediate=True)
732
733    def _onViewDirectionChanged(self, sender):
734        self._state["viewDirection"] = sender.get()
735        self._saveState()
736        self._triggerChange(immediate=True)
737
738    def _refreshViewModeControls(self):
739        if self._ui is None:
740            return
741        is_together = self._state.get("viewMode", 0) == 1
742        for attr in ("viewDirectionLabel", "viewDirection"):
743            if hasattr(self._ui, attr):
744                getattr(self._ui, attr).show(is_together)
745
746    def _promptForFontFolder(self, triggerChange: bool = True) -> bool:
747        panel = NSOpenPanel.openPanel()
748        panel.setCanChooseFiles_(False)
749        panel.setCanChooseDirectories_(True)
750        panel.setAllowsMultipleSelection_(False)
751        if self._fontDirectory:
752            panel.setDirectoryURL_(NSURL.fileURLWithPath_(self._fontDirectory))
753        if panel.runModal() != NSFileHandlingPanelOKButton:
754            return False
755        folder = panel.URLs()[0].path()
756        if not folder or not os.path.isdir(folder):
757            return False
758
759        self._state["fontFolder"] = folder
760        self._refreshFontRows()
761        self._saveState()
762        if triggerChange:
763            self._triggerChange()
764        return True
765
766    def _refreshFontRows(self):
767        folder = self._state.get("fontFolder", "")
768        if not folder or not os.path.isdir(folder):
769            self._state["fontFolder"] = ""
770            self._state["fontPaths"] = []
771            self._state["selectedFontPaths"] = []
772            self._fontRows = []
773            self._updateFontStatusText()
774            self._writeRowsToUI()
775            return
776
777        fontPaths = fontlib.getFonts(folder) or []
778        selected = [
779            path
780            for path in self._state.get("selectedFontPaths", [])
781            if path in fontPaths
782        ]
783        if not selected:
784            defaultFont = fontlib.findFontByNumber(fontPaths, 50)
785            selected = [defaultFont] if defaultFont else fontPaths[:1]
786
787        self._state["fontPaths"] = fontPaths
788        self._state["selectedFontPaths"] = selected
789        self._fontRows = [
790            {
791                "Selected": path in selected,
792                "Font Name": fontlib.parseNameObject(path),
793                "Full Name": path,
794            }
795            for path in fontPaths
796        ]
797        self._updateFontStatusText()
798        self._writeRowsToUI()
799
800    def _syncSelectedFromRows(self):
801        self._state["fontPaths"] = [row["Full Name"] for row in self._fontRows]
802        self._state["selectedFontPaths"] = [
803            row["Full Name"] for row in self._fontRows if row.get("Selected")
804        ]
805        self._updateFontStatusText()
806
807    def _updateFontStatusText(self):
808        total = len(self._state.get("fontPaths", []))
809        selected = len(self._state.get("selectedFontPaths", []))
810
811        if total == 0:
812            text = "No fonts loaded"
813        else:
814            text = f"{selected}/{total} fonts"
815
816        if self._ui is not None and hasattr(self._ui, "fontStatus"):
817            self._ui.fontStatus.set(text)
818
819    def _writeRowsToUI(self):
820        if self._ui is not None and hasattr(self._ui, "fontList"):
821            self._ui.fontList.set(self._fontRows)
822
823    def _formatTrackingValue(self, value) -> str:
824        return str(int(round(float(value))))
825
826    def _formatLeadingValue(self, value) -> str:
827        return f"{float(value):.2f}".rstrip("0").rstrip(".")
828
829    def _formatFontSizeValue(self, value) -> str:
830        return str(int(round(float(value))))
831
832    def _loadState(self):
833        if not os.path.exists(self._statePath):
834            return
835        try:
836            with open(self._statePath, "r", encoding="utf-8") as f:
837                raw = json.load(f)
838            if isinstance(raw, dict):
839                self._state.update(raw)
840
841            try:
842                self._state["tracking"] = int(
843                    round(float(self._state.get("tracking", 0)))
844                )
845            except Exception:
846                self._state["tracking"] = 0
847
848            try:
849                leading = float(self._state.get("leading", 1.0))
850                self._state["leading"] = min(1.5, max(0.5, leading))
851            except Exception:
852                self._state["leading"] = 1.0
853
854            try:
855                fontFitMode = int(self._state.get("fontFitMode", 0))
856                self._state["fontFitMode"] = min(2, max(0, fontFitMode))
857            except Exception:
858                self._state["fontFitMode"] = 0
859
860            try:
861                fontSizeMode = int(self._state.get("fontSizeMode", 0))
862                self._state["fontSizeMode"] = min(1, max(0, fontSizeMode))
863            except Exception:
864                self._state["fontSizeMode"] = 0
865
866            try:
867                fontSize = int(round(float(self._state.get("fontSize", 48))))
868                self._state["fontSize"] = min(256, max(8, fontSize))
869            except Exception:
870                self._state["fontSize"] = 48
871
872            try:
873                alignH = self._state.get("alignH", "center")
874                if alignH not in ("left", "center", "right"):
875                    alignH = "center"
876                self._state["alignH"] = alignH
877            except Exception:
878                self._state["alignH"] = "center"
879
880            try:
881                alignV = self._state.get("alignV", "center")
882                if alignV not in ("top", "center", "bottom"):
883                    alignV = "center"
884                self._state["alignV"] = alignV
885            except Exception:
886                self._state["alignV"] = "center"
887
888            try:
889                self._state["debug"] = bool(self._state.get("debug", False))
890            except Exception:
891                self._state["debug"] = False
892
893            try:
894                self._state["perforation"] = bool(self._state.get("perforation", False))
895            except Exception:
896                self._state["perforation"] = False
897
898            try:
899                self._state["perforationRadius"] = max(
900                    0.5, float(self._state.get("perforationRadius", 2.0))
901                )
902            except Exception:
903                self._state["perforationRadius"] = 2.0
904
905            try:
906                self._state["perforationSpacing"] = max(
907                    10.0, float(self._state.get("perforationSpacing", 80.0))
908                )
909            except Exception:
910                self._state["perforationSpacing"] = 80.0
911
912            try:
913                self._state["perforationCount"] = max(
914                    1, int(self._state.get("perforationCount", 2))
915                )
916            except Exception:
917                self._state["perforationCount"] = 2
918
919            try:
920                alignX = self._state.get("perforationAlignX", "left")
921                if alignX not in ("left", "center", "right"):
922                    alignX = "left"
923                self._state["perforationAlignX"] = alignX
924            except Exception:
925                self._state["perforationAlignX"] = "left"
926
927            try:
928                alignY = self._state.get("perforationAlignY", "center")
929                if alignY not in ("top", "center", "bottom"):
930                    alignY = "center"
931                self._state["perforationAlignY"] = alignY
932            except Exception:
933                self._state["perforationAlignY"] = "center"
934
935            try:
936                self._state["perforationOffsetX"] = float(
937                    self._state.get("perforationOffsetX", 0.0)
938                )
939            except Exception:
940                self._state["perforationOffsetX"] = 0.0
941
942            try:
943                self._state["perforationOffsetY"] = float(
944                    self._state.get("perforationOffsetY", 0.0)
945                )
946            except Exception:
947                self._state["perforationOffsetY"] = 0.0
948
949            folder = self._state.get("fontFolder", "")
950            if not folder or not os.path.isdir(folder):
951                self._state["fontFolder"] = ""
952                self._state["fontPaths"] = []
953                self._state["selectedFontPaths"] = []
954            else:
955                self._state["fontPaths"] = [
956                    p
957                    for p in self._state.get("fontPaths", [])
958                    if isinstance(p, str) and os.path.isfile(p)
959                ]
960                self._state["selectedFontPaths"] = [
961                    p
962                    for p in self._state.get("selectedFontPaths", [])
963                    if isinstance(p, str) and os.path.isfile(p)
964                ]
965        except Exception:
966            pass
967
968    def getPerforationParams(self, pageIndex: int) -> dict | None:
969        """Return perforation params for a 0-based page index, or None if disabled/skipped.
970
971        Perforations are drawn only on odd pages (1, 3, 5 … in 1-based terms,
972        i.e. page indices 0, 2, 4 … in 0-based terms).  The returned dict can
973        be unpacked directly into ``printing.drawPerforationCircles``:
974
975            params = panel.getPerforationParams(pageIndex)
976            if params:
977                printing.drawPerforationCircles(container, **params)
978        """
979        if not self._state.get("perforation", False):
980            return None
981        if pageIndex % 2 != 0:
982            return None
983        return {
984            "radius": self._state.get("perforationRadius", 2.0),
985            "spacing": self._state.get("perforationSpacing", 80.0),
986            "count": self._state.get("perforationCount", 2),
987            "align": (
988                self._state.get("perforationAlignX", "left"),
989                self._state.get("perforationAlignY", "center"),
990            ),
991        }

RenderView side panel plugin for page size and margin controls.

RenderLayoutPanel( panelWidth: int = 290, pageSizes: list[str] | None = None, statePath: str | None = None, enableFontPicker: bool = False, fontDirectory: str = '/Users/christianjansky/Library/CloudStorage/Dropbox/KOMETA-Typefaces')
18    def __init__(
19        self,
20        panelWidth: int = 290,
21        pageSizes: list[str] | None = None,
22        statePath: str | None = None,
23        enableFontPicker: bool = False,
24        fontDirectory: str = "/Users/christianjansky/Library/CloudStorage/Dropbox/KOMETA-Typefaces",
25    ):
26        self._panelWidth = panelWidth
27        self._enableFontPicker = enableFontPicker
28        self._fontDirectory = fontDirectory
29        self._pageSizes = pageSizes or [
30            "A4Landscape",
31            "A4",
32            "A3Landscape",
33            "A3",
34            "screen",
35        ]
36        self._statePath = statePath or os.path.expanduser(
37            "~/Library/Application Support/KOMETA-Draw/render_view_layout_panel.json"
38        )
39        self._state = {
40            "pageSize": self._pageSizes[0],
41            "pageMargin": self.DEFAULT_PAGE_MARGIN,
42            "bodyPadding": "",
43            "leading": 1.0,
44            "tracking": 0,
45            "fontSizeMode": 0,
46            "fontSize": 48,
47            "fontFitMode": 0,
48            "alignH": "center",
49            "alignV": "center",
50            "debug": False,
51            "invert": False,
52            "flipX": False,
53            "flipY": False,
54            "perforation": False,
55            "perforationRadius": 2.0,
56            "perforationSpacing": 80.0,
57            "perforationCount": 2,
58            "perforationAlignX": "left",
59            "perforationAlignY": "center",
60            "perforationOffsetX": 0.0,
61            "perforationOffsetY": 0.0,
62            "fontFolder": "",
63            "fontPaths": [],
64            "selectedFontPaths": [],
65            "viewMode": 0,
66            "viewDirection": 0,
67        }
68        self._fontRows = []
69        self._ui = None
70        self._onChange = None
71        self._persistenceEnabled = True
72        self._loadState()
DEFAULT_PAGE_MARGIN = '5mm'
def getState(self) -> dict:
74    def getState(self) -> dict:
75        return dict(self._state)
def build(self, parent, onChange: Callable[..., NoneType]):
 77    def build(self, parent, onChange: Callable[..., None]):
 78        """Build panel UI in the provided parent group."""
 79        self._onChange = onChange
 80        self._ui = vanilla.Group((0, 0, -0, -0))
 81
 82        margin = 10
 83        labelW = 90
 84        sectionMargin = PANEL_SECTION_MARGIN
 85        y = margin
 86        self._ui.pageSizeLabel = vanilla.TextBox(
 87            (margin, y + 3, labelW, 14),
 88            "Page Size",
 89            sizeStyle="small",
 90        )
 91        self._ui.pageSize = vanilla.ComboBox(
 92            (margin + labelW, y, -margin, 20),
 93            items=self._pageSizes,
 94            callback=self._onPageSizeChanged,
 95            sizeStyle="small",
 96        )
 97        self._ui.pageSize.set(self._state.get("pageSize", self._pageSizes[0]))
 98
 99        y += 28
100        self._ui.pageMarginLabel = vanilla.TextBox(
101            (margin, y + 3, labelW, 14),
102            "Page Margin",
103            sizeStyle="small",
104        )
105        self._ui.pageMargin = vanilla.EditText(
106            (margin + labelW, y, -margin, 19),
107            text=str(self._state.get("pageMargin", self.DEFAULT_PAGE_MARGIN)),
108            callback=self._onPageMarginChanged,
109            sizeStyle="small",
110        )
111
112        y += sectionMargin
113        self._ui.fontPropsSectionLabel = vanilla.TextBox(
114            (margin, y, -margin, 12), "Font Props", alignment="center", sizeStyle="mini"
115        )
116
117        y += 18
118        self._ui.fontSizeModeLabel = vanilla.TextBox(
119            (margin, y + 3, labelW, 14),
120            "Font Size",
121            sizeStyle="small",
122        )
123        self._ui.fontSizeMode = vanilla.SegmentedButton(
124            (margin + labelW, y, -margin, 18),
125            [dict(title="Auto"), dict(title="Manual")],
126            callback=self._onFontSizeModeChanged,
127        )
128        self._ui.fontSizeMode.set(self._state.get("fontSizeMode", 0))
129
130        y += 28
131        self._ui.fontFitModeLabel = vanilla.TextBox(
132            (margin, y + 3, labelW, 14),
133            "Fit Mode",
134            sizeStyle="small",
135        )
136        self._ui.fontFitMode = vanilla.PopUpButton(
137            (margin + labelW, y, -margin, 20),
138            items=["Auto", "Preserve Whitespace", "Running Text"],
139            callback=self._onFontFitModeChanged,
140            sizeStyle="small",
141        )
142        self._ui.fontFitMode.set(self._state.get("fontFitMode", 0))
143        self._ui.fontSizeManual = vanilla.Slider(
144            (margin + labelW, y, -42, 19),
145            minValue=8,
146            maxValue=256,
147            value=float(self._state.get("fontSize", 48)),
148            callback=self._onFontSizeChanged,
149            continuous=True,
150            sizeStyle="small",
151        )
152        self._ui.fontSizeValue = vanilla.TextBox(
153            (-38, y + 3, 28, 14),
154            self._formatFontSizeValue(self._state.get("fontSize", 48)),
155            alignment="right",
156            sizeStyle="small",
157        )
158        self._refreshSizingControls()
159
160        y += 28
161        self._ui.leadingLabel = vanilla.TextBox(
162            (margin, y + 3, labelW, 14),
163            "Leading",
164            sizeStyle="small",
165        )
166        self._ui.leading = vanilla.Slider(
167            (margin + labelW, y, -42, 19),
168            minValue=0.5,
169            maxValue=1.5,
170            value=float(self._state.get("leading", 1.0)),
171            callback=self._onLeadingChanged,
172            continuous=True,
173            sizeStyle="small",
174        )
175        self._ui.leadingValue = vanilla.TextBox(
176            (-38, y + 3, 28, 14),
177            self._formatLeadingValue(self._state.get("leading", 1.0)),
178            alignment="right",
179            sizeStyle="small",
180        )
181
182        y += 28
183        self._ui.trackingLabel = vanilla.TextBox(
184            (margin, y + 3, labelW, 14),
185            "Tracking",
186            sizeStyle="small",
187        )
188        self._ui.tracking = vanilla.Slider(
189            (margin + labelW, y, -42, 19),
190            minValue=-50,
191            maxValue=50,
192            value=float(self._state.get("tracking", 0)),
193            callback=self._onTrackingChanged,
194            continuous=True,
195            sizeStyle="small",
196        )
197        self._ui.trackingValue = vanilla.TextBox(
198            (-38, y + 3, 28, 14),
199            self._formatTrackingValue(self._state.get("tracking", 0)),
200            alignment="right",
201            sizeStyle="small",
202        )
203
204        y += sectionMargin
205        self._ui.alignSectionLabel = vanilla.TextBox(
206            (margin, y, -margin, 12), "Align", alignment="center", sizeStyle="mini"
207        )
208        y += 18
209        segW = (self._panelWidth - labelW - margin * 2) // 3
210        self._ui.textAlignLabel = vanilla.TextBox(
211            (margin, y + 4, labelW, 14), "Horizontal", sizeStyle="small"
212        )
213        self._ui.textAlign = vanilla.SegmentedButton(
214            (margin + labelW, y, -margin, 18),
215            [
216                dict(title="Left", width=segW),
217                dict(title="Center", width=segW),
218                dict(title="Right", width=segW),
219            ],
220            callback=self._onAlignHChanged,
221        )
222        self._ui.textAlign.set(
223            ["left", "center", "right"].index(self._state.get("alignH", "center"))
224        )
225
226        y += 24
227        self._ui.yAlignLabel = vanilla.TextBox(
228            (margin, y + 4, labelW, 14), "Vertical", sizeStyle="small"
229        )
230        self._ui.yAlign = vanilla.SegmentedButton(
231            (margin + labelW, y, -margin, 18),
232            [
233                dict(title="Top", width=segW),
234                dict(title="Center", width=segW),
235                dict(title="Bottom", width=segW),
236            ],
237            callback=self._onAlignVChanged,
238        )
239        self._ui.yAlign.set(
240            ["top", "center", "bottom"].index(self._state.get("alignV", "center"))
241        )
242
243        y += 28
244        self._ui.debugLabel = vanilla.TextBox(
245            (margin, y + 2, labelW, 16), "Debug", sizeStyle="small"
246        )
247        self._ui.debug = vanilla.CheckBox(
248            (margin + labelW, y, -margin, 16),
249            title="",
250            value=bool(self._state.get("debug", False)),
251            callback=self._onDebugChanged,
252            sizeStyle="small",
253        )
254
255        y += 24
256        self._ui.invertLabel = vanilla.TextBox(
257            (margin, y + 2, labelW, 16), "Invert", sizeStyle="small"
258        )
259        self._ui.invert = vanilla.CheckBox(
260            (margin + labelW, y, -margin, 16),
261            title="",
262            value=bool(self._state.get("invert", False)),
263            callback=self._onInvertChanged,
264            sizeStyle="small",
265        )
266
267        y += 24
268        self._ui.flipLabel = vanilla.TextBox(
269            (margin, y + 2, labelW, 16), "Flip", sizeStyle="small"
270        )
271        flipSelected = []
272        if self._state.get("flipX", False):
273            flipSelected.append(0)
274        if self._state.get("flipY", False):
275            flipSelected.append(1)
276        self._ui.flip = vanilla.SegmentedButton(
277            (margin + labelW, y, -margin, 20),
278            segmentDescriptions=[{"title": "X"}, {"title": "Y"}],
279            selectionStyle="any",
280            callback=self._onFlipChanged,
281            sizeStyle="small",
282        )
283        self._ui.flip.set(flipSelected)
284
285        y += sectionMargin
286        self._ui.perfSectionLabel = vanilla.TextBox(
287            (margin, y, -margin, 12),
288            "Perforation",
289            alignment="center",
290            sizeStyle="mini",
291        )
292        y += 18
293        self._ui.perfEnableLabel = vanilla.TextBox(
294            (margin, y + 2, labelW, 16), "Enable", sizeStyle="small"
295        )
296        self._ui.perfEnable = vanilla.CheckBox(
297            (margin + labelW, y, -(margin + 32), 16),
298            title="",
299            value=bool(self._state.get("perforation", False)),
300            callback=self._onPerforationEnableChanged,
301            sizeStyle="small",
302        )
303        self._ui.perfSettingsButton = vanilla.Button(
304            (-(margin + 28), y - 1, -margin, 18),
305            "⚙",
306            callback=self._onOpenPerforationPopover,
307            sizeStyle="small",
308        )
309
310        if self._enableFontPicker:
311            y += 32
312            self._ui.fontStatus = vanilla.TextBox(
313                (margin, y, -margin, 12),
314                "No fonts loaded",
315                alignment="center",
316                sizeStyle="mini",
317            )
318
319            y += 16
320            actionBtnW = 34
321            self._ui.fontSelectionToggle = vanilla.SegmentedButton(
322                (margin, y, -(margin + actionBtnW + 4), 18),
323                [
324                    dict(title="All"),
325                    dict(title="None"),
326                    dict(title="Replace"),
327                ],
328                sizeStyle="small",
329                selectionStyle="momentary",
330                callback=self._onFontSelectionToggled,
331            )
332            self._ui.selectFontTypeButton = vanilla.ActionButton(
333                (-margin - actionBtnW, y + 1, -margin, 17),
334                [
335                    dict(title="Upright", callback=self._onSelectFontsByType),
336                    dict(title="Italic", callback=self._onSelectFontsByType),
337                    dict(title="Text", callback=self._onSelectFontsByType),
338                    dict(title="Display", callback=self._onSelectFontsByType),
339                    dict(title="Thin", callback=self._onSelectFontsByType),
340                    dict(title="Thick", callback=self._onSelectFontsByType),
341                ],
342                sizeStyle="small",
343            )
344
345            y += 24
346            self._ui.viewModeLabel = vanilla.TextBox(
347                (margin, y + 4, labelW, 14), "View Fonts", sizeStyle="small"
348            )
349            self._ui.viewMode = vanilla.SegmentedButton(
350                (margin + labelW, y, -margin, 18),
351                [dict(title="Separate"), dict(title="Together")],
352                callback=self._onViewModeChanged,
353                sizeStyle="small",
354            )
355            self._ui.viewMode.set(self._state.get("viewMode", 0))
356            y += 26
357            self._ui.viewDirectionLabel = vanilla.TextBox(
358                (margin, y + 4, labelW, 14), "Direction", sizeStyle="small"
359            )
360            self._ui.viewDirection = vanilla.SegmentedButton(
361                (margin + labelW, y, -margin, 18),
362                [dict(title="Rows"), dict(title="Columns")],
363                callback=self._onViewDirectionChanged,
364                sizeStyle="small",
365            )
366            self._ui.viewDirection.set(self._state.get("viewDirection", 0))
367            self._refreshViewModeControls()
368            y += 30
369            self._ui.fontList = vanilla.List(
370                (margin, y, -margin, -margin),
371                [],
372                columnDescriptions=[
373                    {
374                        "title": "Selected",
375                        "cell": vanilla.CheckBoxListCell(),
376                        "key": "Selected",
377                        "editable": True,
378                        "width": 56,
379                    },
380                    {"title": "Font Name", "key": "Font Name", "editable": False},
381                ],
382                showColumnTitles=True,
383                allowsMultipleSelection=True,
384                editCallback=self._onFontListEdited,
385            )
386
387            # Prompt on startup if no valid folder is configured.
388            if not os.path.isdir(self._state.get("fontFolder", "")):
389                self._promptForFontFolder(triggerChange=False)
390            self._refreshFontRows()
391
392        parent.body = self._ui

Build panel UI in the provided parent group.

def getPerforationParams(self, pageIndex: int) -> dict | None:
968    def getPerforationParams(self, pageIndex: int) -> dict | None:
969        """Return perforation params for a 0-based page index, or None if disabled/skipped.
970
971        Perforations are drawn only on odd pages (1, 3, 5 … in 1-based terms,
972        i.e. page indices 0, 2, 4 … in 0-based terms).  The returned dict can
973        be unpacked directly into ``printing.drawPerforationCircles``:
974
975            params = panel.getPerforationParams(pageIndex)
976            if params:
977                printing.drawPerforationCircles(container, **params)
978        """
979        if not self._state.get("perforation", False):
980            return None
981        if pageIndex % 2 != 0:
982            return None
983        return {
984            "radius": self._state.get("perforationRadius", 2.0),
985            "spacing": self._state.get("perforationSpacing", 80.0),
986            "count": self._state.get("perforationCount", 2),
987            "align": (
988                self._state.get("perforationAlignX", "left"),
989                self._state.get("perforationAlignY", "center"),
990            ),
991        }

Return perforation params for a 0-based page index, or None if disabled/skipped.

Perforations are drawn only on odd pages (1, 3, 5 … in 1-based terms, i.e. page indices 0, 2, 4 … in 0-based terms). The returned dict can be unpacked directly into printing.drawPerforationCircles:

params = panel.getPerforationParams(pageIndex)
if params:
    printing.drawPerforationCircles(container, **params)