classes.c91_render_panel_manager

  1import json
  2import os
  3import objc
  4import vanilla
  5from AppKit import (
  6    NSImageNameGoRightTemplate,
  7    NSImageNameGoLeftTemplate,
  8    NSImageNameRefreshTemplate,
  9    NSImageNameStatusAvailable,
 10    NSImageNameStatusUnavailable,
 11    NSImageNameActionTemplate,
 12    NSObject,
 13    NSMenu,
 14    NSMenuItem,
 15    NSApplication,
 16    NSSavePanel,
 17    NSOpenPanel,
 18    NSFileHandlingPanelOKButton,
 19)
 20from loguru import logger
 21
 22
 23class _MenuTarget(NSObject):
 24    """Minimal ObjC target that forwards NSMenuItem actions to Python callables."""
 25
 26    def dispatch_(self, sender):
 27        fn = sender.representedObject()
 28        if callable(fn):
 29            fn()
 30
 31
 32_menuTargetSingleton = None
 33
 34
 35def _getMenuTarget() -> _MenuTarget:
 36    global _menuTargetSingleton
 37    if _menuTargetSingleton is None:
 38        _menuTargetSingleton = _MenuTarget.alloc().init()
 39    return _menuTargetSingleton
 40
 41
 42PANEL_MARGIN_X = 8
 43PANEL_MARGIN_Y = 8
 44PANEL_TOGGLE_SIZE = 20
 45PANEL_COLLAPSED_WIDTH = PANEL_TOGGLE_SIZE + PANEL_MARGIN_X * 2
 46PANEL_EXPANDED_HEIGHT = PANEL_MARGIN_Y + PANEL_TOGGLE_SIZE
 47PANEL_COLLAPSED_HEIGHT = PANEL_TOGGLE_SIZE * 2 + PANEL_MARGIN_Y * 4
 48PANEL_MENU_TOP = 16
 49
 50
 51class RenderPanelManager:
 52    """Manage collapsed/expanded side panel UI with optional tab composition."""
 53
 54    def __init__(self, panelPlugin=None, tabs=None, panelWidth: int | None = None):
 55        if panelPlugin is not None and tabs is not None:
 56            raise ValueError("Provide either panelPlugin or tabs, not both.")
 57
 58        self._tabs = list(tabs or [])
 59        if self._tabs:
 60            self._panelPlugin = self
 61            childWidths = [int(plugin.getPanelWidth()) for _, plugin in self._tabs]
 62            self._panelWidth = (
 63                int(panelWidth) if panelWidth is not None else max(childWidths)
 64            )
 65        else:
 66            self._panelPlugin = panelPlugin
 67            self._panelWidth = (
 68                max(180, int(panelPlugin.getPanelWidth()))
 69                if panelPlugin is not None
 70                else 0
 71            )
 72
 73        self._panelVisible = panelPlugin is not None
 74        if self._tabs:
 75            self._panelVisible = True
 76        self._window = None
 77        self._canvasHeight = 0
 78        self._onPanelChanged = None
 79        self._onForceRender = None
 80        self._persistenceEnabled = True
 81
 82    def hasPanel(self) -> bool:
 83        return self._panelPlugin is not None
 84
 85    def isVisible(self) -> bool:
 86        return self._panelVisible
 87
 88    def getPanelWidth(self) -> int:
 89        return self._panelWidth
 90
 91    def getRightInset(self) -> int:
 92        if self._panelPlugin is None:
 93            return 0
 94        return self._panelWidth if self._panelVisible else PANEL_COLLAPSED_WIDTH
 95
 96    def toggleVisibility(self):
 97        if self._panelPlugin is None:
 98            return
 99        self._panelVisible = not self._panelVisible
100
101    def isPersistenceEnabled(self) -> bool:
102        return self._persistenceEnabled
103
104    def getState(self) -> dict:
105        if self._tabs:
106            return self._getTabbedState()
107
108        if self._panelPlugin is None:
109            return {}
110        try:
111            return self._panelPlugin.getState()
112        except Exception as e:
113            logger.warning(f"Failed to get panel state: {e}")
114            return {}
115
116    def build(self, window, canvasHeight: int, onPanelChanged, onForceRender=None):
117        if self._panelPlugin is None:
118            return
119
120        self._window = window
121        self._canvasHeight = canvasHeight
122        self._onPanelChanged = onPanelChanged
123        self._onForceRender = onForceRender
124
125        self._window.panelMenu = vanilla.Group(
126            (-self._panelWidth, 0, -0, PANEL_EXPANDED_HEIGHT)
127        )
128        self._window.panelMenu.panelToggleBtn = vanilla.GradientButton(
129            (PANEL_MARGIN_X, PANEL_MARGIN_Y, PANEL_TOGGLE_SIZE, PANEL_TOGGLE_SIZE),
130            imageNamed=NSImageNameGoRightTemplate,
131            sizeStyle="mini",
132            callback=self._togglePanel,
133        )
134        self._window.panelMenu.refreshBtn = vanilla.GradientButton(
135            (
136                -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE,
137                PANEL_MARGIN_Y,
138                PANEL_TOGGLE_SIZE,
139                PANEL_TOGGLE_SIZE,
140            ),
141            imageNamed=NSImageNameRefreshTemplate,
142            sizeStyle="mini",
143            callback=self._onRefresh,
144        )
145        self._window.panelMenu.persistenceBtn = vanilla.GradientButton(
146            (
147                -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE * 2,
148                PANEL_MARGIN_Y,
149                PANEL_TOGGLE_SIZE,
150                PANEL_TOGGLE_SIZE,
151            ),
152            imageNamed=NSImageNameStatusAvailable,
153            sizeStyle="mini",
154            callback=self._onPersistenceChanged,
155        )
156        self._window.panelMenu.stateBtn = vanilla.GradientButton(
157            (
158                -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE * 3,
159                PANEL_MARGIN_Y,
160                PANEL_TOGGLE_SIZE,
161                PANEL_TOGGLE_SIZE,
162            ),
163            imageNamed=NSImageNameActionTemplate,
164            sizeStyle="mini",
165            callback=self._onStateMenu,
166        )
167        self._window.panelBody = vanilla.Group(
168            (-self._panelWidth, PANEL_MENU_TOP, -0, self._canvasHeight)
169        )
170        if self._tabs:
171            self._buildTabbedBody(self._window.panelBody, self._onPanelChanged)
172        else:
173            self._panelPlugin.build(self._window.panelBody, self._onPanelChanged)
174        self._window.panelBody.show(True)
175
176        self.applyUILayout()
177
178    def applyUILayout(self):
179        if self._window is None:
180            return
181
182        rightInset = self.getRightInset()
183        self._window.canvas.setPosSize((0, 0, -rightInset, self._canvasHeight))
184
185        if self._panelPlugin is None:
186            return
187
188        if self._panelVisible:
189            self._window.panelMenu.setPosSize(
190                (-self._panelWidth, 0, -0, PANEL_EXPANDED_HEIGHT)
191            )
192            self._window.panelMenu.panelToggleBtn.setImage(
193                imageNamed=NSImageNameGoRightTemplate
194            )
195            # Expanded: toggle on left, refresh on right
196            self._window.panelMenu.panelToggleBtn.setPosSize(
197                (PANEL_MARGIN_X, PANEL_MARGIN_Y, PANEL_TOGGLE_SIZE, PANEL_TOGGLE_SIZE)
198            )
199            self._window.panelMenu.refreshBtn.setPosSize(
200                (
201                    -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE,
202                    PANEL_MARGIN_Y,
203                    PANEL_TOGGLE_SIZE,
204                    PANEL_TOGGLE_SIZE,
205                )
206            )
207            self._window.panelMenu.persistenceBtn.setPosSize(
208                (
209                    -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE * 2,
210                    PANEL_MARGIN_Y,
211                    PANEL_TOGGLE_SIZE,
212                    PANEL_TOGGLE_SIZE,
213                )
214            )
215            self._window.panelMenu.stateBtn.setPosSize(
216                (
217                    -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE * 3,
218                    PANEL_MARGIN_Y,
219                    PANEL_TOGGLE_SIZE,
220                    PANEL_TOGGLE_SIZE,
221                )
222            )
223            self._window.panelMenu.persistenceBtn.show(True)
224            self._window.panelMenu.stateBtn.show(True)
225            self._window.panelBody.setPosSize(
226                (-self._panelWidth, PANEL_MENU_TOP, -0, self._canvasHeight)
227            )
228            self._window.panelBody.show(True)
229        else:
230            self._window.panelMenu.setPosSize(
231                (-PANEL_COLLAPSED_WIDTH, 0, -0, PANEL_COLLAPSED_HEIGHT)
232            )
233            self._window.panelMenu.panelToggleBtn.setImage(
234                imageNamed=NSImageNameGoLeftTemplate
235            )
236            # Collapsed: toggle on top, refresh below
237            self._window.panelMenu.panelToggleBtn.setPosSize(
238                (PANEL_MARGIN_X, PANEL_MARGIN_Y, PANEL_TOGGLE_SIZE, PANEL_TOGGLE_SIZE)
239            )
240            self._window.panelMenu.refreshBtn.setPosSize(
241                (
242                    PANEL_MARGIN_X,
243                    PANEL_MARGIN_Y * 2 + PANEL_TOGGLE_SIZE,
244                    PANEL_TOGGLE_SIZE,
245                    PANEL_TOGGLE_SIZE,
246                )
247            )
248            self._window.panelMenu.persistenceBtn.show(False)
249            self._window.panelMenu.stateBtn.show(False)
250            self._window.panelBody.show(False)
251
252    def _togglePanel(self, sender=None):
253        if self._panelPlugin is None:
254            return
255        self.toggleVisibility()
256        self.applyUILayout()
257        callback = self._onForceRender or self._onPanelChanged
258        if callback is not None:
259            callback()
260
261    def _onRefresh(self, sender=None):
262        callback = self._onForceRender or self._onPanelChanged
263        if callback is not None:
264            callback()
265
266    def _onPersistenceChanged(self, sender=None):
267        self._persistenceEnabled = not self._persistenceEnabled
268        iconName = (
269            NSImageNameStatusAvailable
270            if self._persistenceEnabled
271            else NSImageNameStatusUnavailable
272        )
273        self._window.panelMenu.persistenceBtn.setImage(imageNamed=iconName)
274        plugins = (
275            [plugin for _, plugin in self._tabs] if self._tabs else [self._panelPlugin]
276        )
277        for plugin in plugins:
278            fn = getattr(plugin, "setPersistenceEnabled", None)
279            if fn is not None:
280                fn(self._persistenceEnabled)
281
282    def _getTabbedState(self) -> dict:
283        merged = {}
284        namespaced = {}
285
286        for title, plugin in self._tabs:
287            state = plugin.getState() or {}
288            # Use raw persisted state for the namespace so that computed
289            # transformations in getState() (e.g. compiled templates, expanded
290            # pools) are not written into the export file.
291            persisted_fn = getattr(plugin, "getPersistedState", None)
292            persisted = (persisted_fn() if persisted_fn is not None else state) or {}
293            if isinstance(state, dict):
294                merged.update(state)
295                namespaced[title.lower()] = dict(persisted)
296
297        merged["_panels"] = namespaced
298
299        # Propagate the first selected font path to any panel that supports
300        # setActiveFontPath() so that "Active font only" OT feature filtering
301        # uses the actual font file rather than the DrawBot drawing context.
302        font_paths = merged.get("selectedFontPaths") or []
303        active_path = font_paths[0] if font_paths else None
304        for _title, plugin in self._tabs:
305            fn = getattr(plugin, "setActiveFontPath", None)
306            if fn is not None:
307                fn(active_path)
308
309        return merged
310
311    def _buildTabbedBody(self, parent, onChange):
312        ui = vanilla.Group((0, 0, -0, -0))
313        titles = [title for title, _ in self._tabs]
314        ui.tabs = vanilla.Tabs((8, 8, -8, -8), titles)
315
316        for index, (_title, plugin) in enumerate(self._tabs):
317            plugin.build(ui.tabs[index], onChange)
318
319        parent.body = ui
320
321    # ── State export / import ─────────────────────────────────────────────────
322
323    def _onStateMenu(self, sender=None):
324        """Show a contextual menu with Save State / Load State actions."""
325        menu = NSMenu.alloc().initWithTitle_("Panel State")
326        menu.setAutoenablesItems_(False)
327        target = _getMenuTarget()
328
329        for title, fn in [
330            ("Load State", self._importState),
331            ("Save State", self._exportState),
332        ]:
333            item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
334                title, "dispatch:", ""
335            )
336            item.setTarget_(target)
337            item.setRepresentedObject_(fn)
338            item.setEnabled_(True)
339            menu.addItem_(item)
340
341        event = NSApplication.sharedApplication().currentEvent()
342        view = self._window.panelMenu.stateBtn.getNSButton()
343        NSMenu.popUpContextMenu_withEvent_forView_(menu, event, view)
344
345    def _exportState(self):
346        """Save the current panel state to a user-chosen JSON file.
347
348        In tabbed mode the full merged state from ``getState()`` contains all
349        panel keys at the root level (for the render callback) *plus* a
350        ``_panels`` sub-dict with each panel's state namespaced by tab title.
351        Exporting the whole dict would produce duplicated keys, so we export
352        only what import needs: ``{"_panels": {...}}`` in tabbed mode, or the
353        flat state dict in single-panel mode.
354        """
355        full_state = self.getState()
356        if self._tabs:
357            export_state = {"_panels": full_state.get("_panels", {})}
358        else:
359            export_state = full_state
360        panel = NSSavePanel.savePanel()
361        panel.setAllowedFileTypes_(["json"])
362        panel.setNameFieldStringValue_("panel_state.json")
363        if panel.runModal() != NSFileHandlingPanelOKButton:
364            return
365        path = panel.URL().path()
366        if not path:
367            return
368        try:
369            folder = os.path.dirname(path)
370            if folder:
371                os.makedirs(folder, exist_ok=True)
372            with open(path, "w", encoding="utf-8") as f:
373                json.dump(export_state, f, indent=2)
374        except Exception as e:
375            logger.warning(f"Failed to export panel state: {e}")
376
377    def _importState(self):
378        """Load panel state from a user-chosen JSON file and rebuild the UI."""
379        panel = NSOpenPanel.openPanel()
380        panel.setCanChooseFiles_(True)
381        panel.setCanChooseDirectories_(False)
382        panel.setAllowsMultipleSelection_(False)
383        panel.setAllowedFileTypes_(["json"])
384        if panel.runModal() != NSFileHandlingPanelOKButton:
385            return
386        path = panel.URLs()[0].path()
387        if not path or not os.path.isfile(path):
388            return
389        try:
390            with open(path, "r", encoding="utf-8") as f:
391                data = json.load(f)
392        except Exception as e:
393            logger.warning(f"Failed to read panel state file: {e}")
394            return
395        if not isinstance(data, dict):
396            logger.warning("Panel state file does not contain a JSON object.")
397            return
398
399        # In tabbed mode each panel gets only its own namespaced state from
400        # the "_panels" dict (keyed by lower-cased tab title).  In single-panel
401        # mode the full dict (minus the metadata key) is used.
402        panels_ns = data.get("_panels") if isinstance(data.get("_panels"), dict) else {}
403
404        if self._tabs:
405            for title, plugin in self._tabs:
406                panel_data = panels_ns.get(title.lower())
407                if panel_data is None:
408                    logger.warning(
409                        f"No state found for panel '{title}' in the exported file; skipping."
410                    )
411                    continue
412                fn = getattr(plugin, "reloadState", None)
413                if fn is not None:
414                    try:
415                        fn(panel_data)
416                    except Exception as e:
417                        logger.warning(
418                            f"Failed to reload state for panel '{title}': {e}"
419                        )
420        else:
421            flat = {k: v for k, v in data.items() if k != "_panels"}
422            fn = getattr(self._panelPlugin, "reloadState", None)
423            if fn is not None:
424                try:
425                    fn(flat)
426                except Exception as e:
427                    logger.warning(f"Failed to reload state for plugin: {e}")
428
429        self._rebuildPanelBody()
430        if self._onPanelChanged is not None:
431            self._onPanelChanged()
432
433    def _rebuildPanelBody(self):
434        """Clear the existing panel body content and rebuild plugin UI from current state.
435
436        Vanilla's ``_setAttr`` asserts that an attribute cannot be replaced while
437        it still exists (``"can't replace vanilla attribute"``).  We therefore
438        ``del panelBody.body`` via Vanilla's own ``__delattr__`` — which calls
439        ``removeFromSuperview()`` on the old NSView — before re-adding a fresh
440        one.  We operate one level *inside* ``panelBody`` (not on ``panelBody``
441        itself) so the outer group's frame stays intact.
442        """
443        if self._window is None:
444            return
445        if hasattr(self._window.panelBody, "body"):
446            del self._window.panelBody.body
447        if self._tabs:
448            self._buildTabbedBody(self._window.panelBody, self._onPanelChanged)
449        else:
450            self._panelPlugin.build(self._window.panelBody, self._onPanelChanged)
451        self._window.panelBody.show(self._panelVisible)
PANEL_MARGIN_X = 8
PANEL_MARGIN_Y = 8
PANEL_TOGGLE_SIZE = 20
PANEL_COLLAPSED_WIDTH = 36
PANEL_EXPANDED_HEIGHT = 28
PANEL_COLLAPSED_HEIGHT = 72
PANEL_MENU_TOP = 16
class RenderPanelManager:
 52class RenderPanelManager:
 53    """Manage collapsed/expanded side panel UI with optional tab composition."""
 54
 55    def __init__(self, panelPlugin=None, tabs=None, panelWidth: int | None = None):
 56        if panelPlugin is not None and tabs is not None:
 57            raise ValueError("Provide either panelPlugin or tabs, not both.")
 58
 59        self._tabs = list(tabs or [])
 60        if self._tabs:
 61            self._panelPlugin = self
 62            childWidths = [int(plugin.getPanelWidth()) for _, plugin in self._tabs]
 63            self._panelWidth = (
 64                int(panelWidth) if panelWidth is not None else max(childWidths)
 65            )
 66        else:
 67            self._panelPlugin = panelPlugin
 68            self._panelWidth = (
 69                max(180, int(panelPlugin.getPanelWidth()))
 70                if panelPlugin is not None
 71                else 0
 72            )
 73
 74        self._panelVisible = panelPlugin is not None
 75        if self._tabs:
 76            self._panelVisible = True
 77        self._window = None
 78        self._canvasHeight = 0
 79        self._onPanelChanged = None
 80        self._onForceRender = None
 81        self._persistenceEnabled = True
 82
 83    def hasPanel(self) -> bool:
 84        return self._panelPlugin is not None
 85
 86    def isVisible(self) -> bool:
 87        return self._panelVisible
 88
 89    def getPanelWidth(self) -> int:
 90        return self._panelWidth
 91
 92    def getRightInset(self) -> int:
 93        if self._panelPlugin is None:
 94            return 0
 95        return self._panelWidth if self._panelVisible else PANEL_COLLAPSED_WIDTH
 96
 97    def toggleVisibility(self):
 98        if self._panelPlugin is None:
 99            return
100        self._panelVisible = not self._panelVisible
101
102    def isPersistenceEnabled(self) -> bool:
103        return self._persistenceEnabled
104
105    def getState(self) -> dict:
106        if self._tabs:
107            return self._getTabbedState()
108
109        if self._panelPlugin is None:
110            return {}
111        try:
112            return self._panelPlugin.getState()
113        except Exception as e:
114            logger.warning(f"Failed to get panel state: {e}")
115            return {}
116
117    def build(self, window, canvasHeight: int, onPanelChanged, onForceRender=None):
118        if self._panelPlugin is None:
119            return
120
121        self._window = window
122        self._canvasHeight = canvasHeight
123        self._onPanelChanged = onPanelChanged
124        self._onForceRender = onForceRender
125
126        self._window.panelMenu = vanilla.Group(
127            (-self._panelWidth, 0, -0, PANEL_EXPANDED_HEIGHT)
128        )
129        self._window.panelMenu.panelToggleBtn = vanilla.GradientButton(
130            (PANEL_MARGIN_X, PANEL_MARGIN_Y, PANEL_TOGGLE_SIZE, PANEL_TOGGLE_SIZE),
131            imageNamed=NSImageNameGoRightTemplate,
132            sizeStyle="mini",
133            callback=self._togglePanel,
134        )
135        self._window.panelMenu.refreshBtn = vanilla.GradientButton(
136            (
137                -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE,
138                PANEL_MARGIN_Y,
139                PANEL_TOGGLE_SIZE,
140                PANEL_TOGGLE_SIZE,
141            ),
142            imageNamed=NSImageNameRefreshTemplate,
143            sizeStyle="mini",
144            callback=self._onRefresh,
145        )
146        self._window.panelMenu.persistenceBtn = vanilla.GradientButton(
147            (
148                -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE * 2,
149                PANEL_MARGIN_Y,
150                PANEL_TOGGLE_SIZE,
151                PANEL_TOGGLE_SIZE,
152            ),
153            imageNamed=NSImageNameStatusAvailable,
154            sizeStyle="mini",
155            callback=self._onPersistenceChanged,
156        )
157        self._window.panelMenu.stateBtn = vanilla.GradientButton(
158            (
159                -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE * 3,
160                PANEL_MARGIN_Y,
161                PANEL_TOGGLE_SIZE,
162                PANEL_TOGGLE_SIZE,
163            ),
164            imageNamed=NSImageNameActionTemplate,
165            sizeStyle="mini",
166            callback=self._onStateMenu,
167        )
168        self._window.panelBody = vanilla.Group(
169            (-self._panelWidth, PANEL_MENU_TOP, -0, self._canvasHeight)
170        )
171        if self._tabs:
172            self._buildTabbedBody(self._window.panelBody, self._onPanelChanged)
173        else:
174            self._panelPlugin.build(self._window.panelBody, self._onPanelChanged)
175        self._window.panelBody.show(True)
176
177        self.applyUILayout()
178
179    def applyUILayout(self):
180        if self._window is None:
181            return
182
183        rightInset = self.getRightInset()
184        self._window.canvas.setPosSize((0, 0, -rightInset, self._canvasHeight))
185
186        if self._panelPlugin is None:
187            return
188
189        if self._panelVisible:
190            self._window.panelMenu.setPosSize(
191                (-self._panelWidth, 0, -0, PANEL_EXPANDED_HEIGHT)
192            )
193            self._window.panelMenu.panelToggleBtn.setImage(
194                imageNamed=NSImageNameGoRightTemplate
195            )
196            # Expanded: toggle on left, refresh on right
197            self._window.panelMenu.panelToggleBtn.setPosSize(
198                (PANEL_MARGIN_X, PANEL_MARGIN_Y, PANEL_TOGGLE_SIZE, PANEL_TOGGLE_SIZE)
199            )
200            self._window.panelMenu.refreshBtn.setPosSize(
201                (
202                    -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE,
203                    PANEL_MARGIN_Y,
204                    PANEL_TOGGLE_SIZE,
205                    PANEL_TOGGLE_SIZE,
206                )
207            )
208            self._window.panelMenu.persistenceBtn.setPosSize(
209                (
210                    -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE * 2,
211                    PANEL_MARGIN_Y,
212                    PANEL_TOGGLE_SIZE,
213                    PANEL_TOGGLE_SIZE,
214                )
215            )
216            self._window.panelMenu.stateBtn.setPosSize(
217                (
218                    -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE * 3,
219                    PANEL_MARGIN_Y,
220                    PANEL_TOGGLE_SIZE,
221                    PANEL_TOGGLE_SIZE,
222                )
223            )
224            self._window.panelMenu.persistenceBtn.show(True)
225            self._window.panelMenu.stateBtn.show(True)
226            self._window.panelBody.setPosSize(
227                (-self._panelWidth, PANEL_MENU_TOP, -0, self._canvasHeight)
228            )
229            self._window.panelBody.show(True)
230        else:
231            self._window.panelMenu.setPosSize(
232                (-PANEL_COLLAPSED_WIDTH, 0, -0, PANEL_COLLAPSED_HEIGHT)
233            )
234            self._window.panelMenu.panelToggleBtn.setImage(
235                imageNamed=NSImageNameGoLeftTemplate
236            )
237            # Collapsed: toggle on top, refresh below
238            self._window.panelMenu.panelToggleBtn.setPosSize(
239                (PANEL_MARGIN_X, PANEL_MARGIN_Y, PANEL_TOGGLE_SIZE, PANEL_TOGGLE_SIZE)
240            )
241            self._window.panelMenu.refreshBtn.setPosSize(
242                (
243                    PANEL_MARGIN_X,
244                    PANEL_MARGIN_Y * 2 + PANEL_TOGGLE_SIZE,
245                    PANEL_TOGGLE_SIZE,
246                    PANEL_TOGGLE_SIZE,
247                )
248            )
249            self._window.panelMenu.persistenceBtn.show(False)
250            self._window.panelMenu.stateBtn.show(False)
251            self._window.panelBody.show(False)
252
253    def _togglePanel(self, sender=None):
254        if self._panelPlugin is None:
255            return
256        self.toggleVisibility()
257        self.applyUILayout()
258        callback = self._onForceRender or self._onPanelChanged
259        if callback is not None:
260            callback()
261
262    def _onRefresh(self, sender=None):
263        callback = self._onForceRender or self._onPanelChanged
264        if callback is not None:
265            callback()
266
267    def _onPersistenceChanged(self, sender=None):
268        self._persistenceEnabled = not self._persistenceEnabled
269        iconName = (
270            NSImageNameStatusAvailable
271            if self._persistenceEnabled
272            else NSImageNameStatusUnavailable
273        )
274        self._window.panelMenu.persistenceBtn.setImage(imageNamed=iconName)
275        plugins = (
276            [plugin for _, plugin in self._tabs] if self._tabs else [self._panelPlugin]
277        )
278        for plugin in plugins:
279            fn = getattr(plugin, "setPersistenceEnabled", None)
280            if fn is not None:
281                fn(self._persistenceEnabled)
282
283    def _getTabbedState(self) -> dict:
284        merged = {}
285        namespaced = {}
286
287        for title, plugin in self._tabs:
288            state = plugin.getState() or {}
289            # Use raw persisted state for the namespace so that computed
290            # transformations in getState() (e.g. compiled templates, expanded
291            # pools) are not written into the export file.
292            persisted_fn = getattr(plugin, "getPersistedState", None)
293            persisted = (persisted_fn() if persisted_fn is not None else state) or {}
294            if isinstance(state, dict):
295                merged.update(state)
296                namespaced[title.lower()] = dict(persisted)
297
298        merged["_panels"] = namespaced
299
300        # Propagate the first selected font path to any panel that supports
301        # setActiveFontPath() so that "Active font only" OT feature filtering
302        # uses the actual font file rather than the DrawBot drawing context.
303        font_paths = merged.get("selectedFontPaths") or []
304        active_path = font_paths[0] if font_paths else None
305        for _title, plugin in self._tabs:
306            fn = getattr(plugin, "setActiveFontPath", None)
307            if fn is not None:
308                fn(active_path)
309
310        return merged
311
312    def _buildTabbedBody(self, parent, onChange):
313        ui = vanilla.Group((0, 0, -0, -0))
314        titles = [title for title, _ in self._tabs]
315        ui.tabs = vanilla.Tabs((8, 8, -8, -8), titles)
316
317        for index, (_title, plugin) in enumerate(self._tabs):
318            plugin.build(ui.tabs[index], onChange)
319
320        parent.body = ui
321
322    # ── State export / import ─────────────────────────────────────────────────
323
324    def _onStateMenu(self, sender=None):
325        """Show a contextual menu with Save State / Load State actions."""
326        menu = NSMenu.alloc().initWithTitle_("Panel State")
327        menu.setAutoenablesItems_(False)
328        target = _getMenuTarget()
329
330        for title, fn in [
331            ("Load State", self._importState),
332            ("Save State", self._exportState),
333        ]:
334            item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
335                title, "dispatch:", ""
336            )
337            item.setTarget_(target)
338            item.setRepresentedObject_(fn)
339            item.setEnabled_(True)
340            menu.addItem_(item)
341
342        event = NSApplication.sharedApplication().currentEvent()
343        view = self._window.panelMenu.stateBtn.getNSButton()
344        NSMenu.popUpContextMenu_withEvent_forView_(menu, event, view)
345
346    def _exportState(self):
347        """Save the current panel state to a user-chosen JSON file.
348
349        In tabbed mode the full merged state from ``getState()`` contains all
350        panel keys at the root level (for the render callback) *plus* a
351        ``_panels`` sub-dict with each panel's state namespaced by tab title.
352        Exporting the whole dict would produce duplicated keys, so we export
353        only what import needs: ``{"_panels": {...}}`` in tabbed mode, or the
354        flat state dict in single-panel mode.
355        """
356        full_state = self.getState()
357        if self._tabs:
358            export_state = {"_panels": full_state.get("_panels", {})}
359        else:
360            export_state = full_state
361        panel = NSSavePanel.savePanel()
362        panel.setAllowedFileTypes_(["json"])
363        panel.setNameFieldStringValue_("panel_state.json")
364        if panel.runModal() != NSFileHandlingPanelOKButton:
365            return
366        path = panel.URL().path()
367        if not path:
368            return
369        try:
370            folder = os.path.dirname(path)
371            if folder:
372                os.makedirs(folder, exist_ok=True)
373            with open(path, "w", encoding="utf-8") as f:
374                json.dump(export_state, f, indent=2)
375        except Exception as e:
376            logger.warning(f"Failed to export panel state: {e}")
377
378    def _importState(self):
379        """Load panel state from a user-chosen JSON file and rebuild the UI."""
380        panel = NSOpenPanel.openPanel()
381        panel.setCanChooseFiles_(True)
382        panel.setCanChooseDirectories_(False)
383        panel.setAllowsMultipleSelection_(False)
384        panel.setAllowedFileTypes_(["json"])
385        if panel.runModal() != NSFileHandlingPanelOKButton:
386            return
387        path = panel.URLs()[0].path()
388        if not path or not os.path.isfile(path):
389            return
390        try:
391            with open(path, "r", encoding="utf-8") as f:
392                data = json.load(f)
393        except Exception as e:
394            logger.warning(f"Failed to read panel state file: {e}")
395            return
396        if not isinstance(data, dict):
397            logger.warning("Panel state file does not contain a JSON object.")
398            return
399
400        # In tabbed mode each panel gets only its own namespaced state from
401        # the "_panels" dict (keyed by lower-cased tab title).  In single-panel
402        # mode the full dict (minus the metadata key) is used.
403        panels_ns = data.get("_panels") if isinstance(data.get("_panels"), dict) else {}
404
405        if self._tabs:
406            for title, plugin in self._tabs:
407                panel_data = panels_ns.get(title.lower())
408                if panel_data is None:
409                    logger.warning(
410                        f"No state found for panel '{title}' in the exported file; skipping."
411                    )
412                    continue
413                fn = getattr(plugin, "reloadState", None)
414                if fn is not None:
415                    try:
416                        fn(panel_data)
417                    except Exception as e:
418                        logger.warning(
419                            f"Failed to reload state for panel '{title}': {e}"
420                        )
421        else:
422            flat = {k: v for k, v in data.items() if k != "_panels"}
423            fn = getattr(self._panelPlugin, "reloadState", None)
424            if fn is not None:
425                try:
426                    fn(flat)
427                except Exception as e:
428                    logger.warning(f"Failed to reload state for plugin: {e}")
429
430        self._rebuildPanelBody()
431        if self._onPanelChanged is not None:
432            self._onPanelChanged()
433
434    def _rebuildPanelBody(self):
435        """Clear the existing panel body content and rebuild plugin UI from current state.
436
437        Vanilla's ``_setAttr`` asserts that an attribute cannot be replaced while
438        it still exists (``"can't replace vanilla attribute"``).  We therefore
439        ``del panelBody.body`` via Vanilla's own ``__delattr__`` — which calls
440        ``removeFromSuperview()`` on the old NSView — before re-adding a fresh
441        one.  We operate one level *inside* ``panelBody`` (not on ``panelBody``
442        itself) so the outer group's frame stays intact.
443        """
444        if self._window is None:
445            return
446        if hasattr(self._window.panelBody, "body"):
447            del self._window.panelBody.body
448        if self._tabs:
449            self._buildTabbedBody(self._window.panelBody, self._onPanelChanged)
450        else:
451            self._panelPlugin.build(self._window.panelBody, self._onPanelChanged)
452        self._window.panelBody.show(self._panelVisible)

Manage collapsed/expanded side panel UI with optional tab composition.

RenderPanelManager(panelPlugin=None, tabs=None, panelWidth: int | None = None)
55    def __init__(self, panelPlugin=None, tabs=None, panelWidth: int | None = None):
56        if panelPlugin is not None and tabs is not None:
57            raise ValueError("Provide either panelPlugin or tabs, not both.")
58
59        self._tabs = list(tabs or [])
60        if self._tabs:
61            self._panelPlugin = self
62            childWidths = [int(plugin.getPanelWidth()) for _, plugin in self._tabs]
63            self._panelWidth = (
64                int(panelWidth) if panelWidth is not None else max(childWidths)
65            )
66        else:
67            self._panelPlugin = panelPlugin
68            self._panelWidth = (
69                max(180, int(panelPlugin.getPanelWidth()))
70                if panelPlugin is not None
71                else 0
72            )
73
74        self._panelVisible = panelPlugin is not None
75        if self._tabs:
76            self._panelVisible = True
77        self._window = None
78        self._canvasHeight = 0
79        self._onPanelChanged = None
80        self._onForceRender = None
81        self._persistenceEnabled = True
def hasPanel(self) -> bool:
83    def hasPanel(self) -> bool:
84        return self._panelPlugin is not None
def isVisible(self) -> bool:
86    def isVisible(self) -> bool:
87        return self._panelVisible
def getPanelWidth(self) -> int:
89    def getPanelWidth(self) -> int:
90        return self._panelWidth
def getRightInset(self) -> int:
92    def getRightInset(self) -> int:
93        if self._panelPlugin is None:
94            return 0
95        return self._panelWidth if self._panelVisible else PANEL_COLLAPSED_WIDTH
def toggleVisibility(self):
 97    def toggleVisibility(self):
 98        if self._panelPlugin is None:
 99            return
100        self._panelVisible = not self._panelVisible
def isPersistenceEnabled(self) -> bool:
102    def isPersistenceEnabled(self) -> bool:
103        return self._persistenceEnabled
def getState(self) -> dict:
105    def getState(self) -> dict:
106        if self._tabs:
107            return self._getTabbedState()
108
109        if self._panelPlugin is None:
110            return {}
111        try:
112            return self._panelPlugin.getState()
113        except Exception as e:
114            logger.warning(f"Failed to get panel state: {e}")
115            return {}
def build(self, window, canvasHeight: int, onPanelChanged, onForceRender=None):
117    def build(self, window, canvasHeight: int, onPanelChanged, onForceRender=None):
118        if self._panelPlugin is None:
119            return
120
121        self._window = window
122        self._canvasHeight = canvasHeight
123        self._onPanelChanged = onPanelChanged
124        self._onForceRender = onForceRender
125
126        self._window.panelMenu = vanilla.Group(
127            (-self._panelWidth, 0, -0, PANEL_EXPANDED_HEIGHT)
128        )
129        self._window.panelMenu.panelToggleBtn = vanilla.GradientButton(
130            (PANEL_MARGIN_X, PANEL_MARGIN_Y, PANEL_TOGGLE_SIZE, PANEL_TOGGLE_SIZE),
131            imageNamed=NSImageNameGoRightTemplate,
132            sizeStyle="mini",
133            callback=self._togglePanel,
134        )
135        self._window.panelMenu.refreshBtn = vanilla.GradientButton(
136            (
137                -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE,
138                PANEL_MARGIN_Y,
139                PANEL_TOGGLE_SIZE,
140                PANEL_TOGGLE_SIZE,
141            ),
142            imageNamed=NSImageNameRefreshTemplate,
143            sizeStyle="mini",
144            callback=self._onRefresh,
145        )
146        self._window.panelMenu.persistenceBtn = vanilla.GradientButton(
147            (
148                -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE * 2,
149                PANEL_MARGIN_Y,
150                PANEL_TOGGLE_SIZE,
151                PANEL_TOGGLE_SIZE,
152            ),
153            imageNamed=NSImageNameStatusAvailable,
154            sizeStyle="mini",
155            callback=self._onPersistenceChanged,
156        )
157        self._window.panelMenu.stateBtn = vanilla.GradientButton(
158            (
159                -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE * 3,
160                PANEL_MARGIN_Y,
161                PANEL_TOGGLE_SIZE,
162                PANEL_TOGGLE_SIZE,
163            ),
164            imageNamed=NSImageNameActionTemplate,
165            sizeStyle="mini",
166            callback=self._onStateMenu,
167        )
168        self._window.panelBody = vanilla.Group(
169            (-self._panelWidth, PANEL_MENU_TOP, -0, self._canvasHeight)
170        )
171        if self._tabs:
172            self._buildTabbedBody(self._window.panelBody, self._onPanelChanged)
173        else:
174            self._panelPlugin.build(self._window.panelBody, self._onPanelChanged)
175        self._window.panelBody.show(True)
176
177        self.applyUILayout()
def applyUILayout(self):
179    def applyUILayout(self):
180        if self._window is None:
181            return
182
183        rightInset = self.getRightInset()
184        self._window.canvas.setPosSize((0, 0, -rightInset, self._canvasHeight))
185
186        if self._panelPlugin is None:
187            return
188
189        if self._panelVisible:
190            self._window.panelMenu.setPosSize(
191                (-self._panelWidth, 0, -0, PANEL_EXPANDED_HEIGHT)
192            )
193            self._window.panelMenu.panelToggleBtn.setImage(
194                imageNamed=NSImageNameGoRightTemplate
195            )
196            # Expanded: toggle on left, refresh on right
197            self._window.panelMenu.panelToggleBtn.setPosSize(
198                (PANEL_MARGIN_X, PANEL_MARGIN_Y, PANEL_TOGGLE_SIZE, PANEL_TOGGLE_SIZE)
199            )
200            self._window.panelMenu.refreshBtn.setPosSize(
201                (
202                    -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE,
203                    PANEL_MARGIN_Y,
204                    PANEL_TOGGLE_SIZE,
205                    PANEL_TOGGLE_SIZE,
206                )
207            )
208            self._window.panelMenu.persistenceBtn.setPosSize(
209                (
210                    -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE * 2,
211                    PANEL_MARGIN_Y,
212                    PANEL_TOGGLE_SIZE,
213                    PANEL_TOGGLE_SIZE,
214                )
215            )
216            self._window.panelMenu.stateBtn.setPosSize(
217                (
218                    -PANEL_MARGIN_X - PANEL_TOGGLE_SIZE * 3,
219                    PANEL_MARGIN_Y,
220                    PANEL_TOGGLE_SIZE,
221                    PANEL_TOGGLE_SIZE,
222                )
223            )
224            self._window.panelMenu.persistenceBtn.show(True)
225            self._window.panelMenu.stateBtn.show(True)
226            self._window.panelBody.setPosSize(
227                (-self._panelWidth, PANEL_MENU_TOP, -0, self._canvasHeight)
228            )
229            self._window.panelBody.show(True)
230        else:
231            self._window.panelMenu.setPosSize(
232                (-PANEL_COLLAPSED_WIDTH, 0, -0, PANEL_COLLAPSED_HEIGHT)
233            )
234            self._window.panelMenu.panelToggleBtn.setImage(
235                imageNamed=NSImageNameGoLeftTemplate
236            )
237            # Collapsed: toggle on top, refresh below
238            self._window.panelMenu.panelToggleBtn.setPosSize(
239                (PANEL_MARGIN_X, PANEL_MARGIN_Y, PANEL_TOGGLE_SIZE, PANEL_TOGGLE_SIZE)
240            )
241            self._window.panelMenu.refreshBtn.setPosSize(
242                (
243                    PANEL_MARGIN_X,
244                    PANEL_MARGIN_Y * 2 + PANEL_TOGGLE_SIZE,
245                    PANEL_TOGGLE_SIZE,
246                    PANEL_TOGGLE_SIZE,
247                )
248            )
249            self._window.panelMenu.persistenceBtn.show(False)
250            self._window.panelMenu.stateBtn.show(False)
251            self._window.panelBody.show(False)