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
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)