git.lucas.co / hou-control
git clone https://git.lucas.co/hou-control.git

python3.11libs/hc/hcsettings.py (8.2K)

  1 import hou
  2 import json
  3 from pathlib import Path
  4 
  5 from PySide6.QtCore import QFileSystemWatcher, Qt
  6 from PySide6.QtWidgets import (
  7     QCheckBox,
  8     QComboBox,
  9     QDialog,
 10     QDialogButtonBox,
 11     QDoubleSpinBox,
 12     QFormLayout,
 13     QGroupBox,
 14     QLabel,
 15     QLineEdit,
 16     QScrollArea,
 17     QSpinBox,
 18     QTabWidget,
 19     QVBoxLayout,
 20     QWidget,
 21 )
 22 
 23 
 24 DESKTOP_MODES = ("attached", "detached")
 25 NODE_SHAPES = ("rect", "rounded_rect", "circle", "diamond", "tilted_rect", "trapezoid_down", "trapezoid_up")
 26 
 27 
 28 class HCSettings:
 29     DEFAULTS = {
 30         "desktop_mode": "attached",
 31         "node_graph": {
 32             "node_shape": "rect",
 33             "node_color": "#607070",
 34         },
 35     }
 36 
 37     def __init__(self):
 38         self.node_color = (0.38, 0.38, 0.56)
 39 
 40     def _path(self):
 41         base = hou.getenv("HC_PATH")
 42         if not base:
 43             return None
 44         return Path(base) / "hc_settings.json"
 45 
 46     def prefs(self):
 47         path = self._path()
 48         if path is None or not path.exists():
 49             return dict(self.DEFAULTS)
 50         try:
 51             return json.loads(path.read_text())
 52         except Exception:
 53             return dict(self.DEFAULTS)
 54 
 55     def get(self, key, default=None):
 56         return self.prefs().get(key, default)
 57 
 58     def set(self, key, value):
 59         path = self._path()
 60         if path is None:
 61             return False
 62         data = self.prefs()
 63         data[key] = value
 64         path.write_text(json.dumps(data, indent=4) + "\n")
 65         return True
 66 
 67     def write(self, data):
 68         path = self._path()
 69         if path is None:
 70             return False
 71         path.write_text(json.dumps(data, indent=4) + "\n")
 72         return True
 73 
 74     def desktopMode(self):
 75         return self.get("desktop_mode", self.DEFAULTS["desktop_mode"])
 76 
 77     def setDesktopMode(self, mode):
 78         if mode not in DESKTOP_MODES:
 79             raise ValueError(f"invalid desktop mode: {mode}")
 80         self.set("desktop_mode", mode)
 81 
 82 
 83 class HCSettingsPanel(QDialog):
 84     OBJECT_NAME = "hc_settings_panel"
 85     BOOL_HINT_KEYS = {
 86         "bbox", "cam_axis", "cam_geo", "perim",
 87         "pivot_2d", "pivot_3d", "pivot_axis", "ray",
 88         "tie_axis_to_radius", "center_on_geo", "lock_cam", "reset",
 89     }
 90 
 91     def __init__(self):
 92         super().__init__(hou.qt.mainWindow())
 93         self.setObjectName(self.OBJECT_NAME)
 94         self.setWindowTitle("HC Settings")
 95         self.setWindowFlags(Qt.Tool)
 96         self.resize(720, 600) # Adjusted height for tabbed view
 97         self.settings = HCSettings()
 98 
 99         # path_tuple (e.g. ("keycam","guides","axis_size")) -> (widget, original_value)
100         self._fields = {}
101 
102         self.tabs = QTabWidget()
103 
104         buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Close)
105         buttons.accepted.connect(self._save)
106         buttons.rejected.connect(self.close)
107 
108         layout = QVBoxLayout(self)
109         layout.addWidget(self.tabs)
110         layout.addWidget(QLabel("Some settings (e.g. Desktop Mode) take effect on next start."))
111         layout.addWidget(buttons)
112 
113         self._watcher = QFileSystemWatcher(self)
114         self._watcher.fileChanged.connect(self._onFileChanged)
115         self._startWatching()
116         self._rebuild()
117 
118     def showEvent(self, event):
119         self._rebuild()
120         self._startWatching()
121         super().showEvent(event)
122 
123     def _startWatching(self):
124         path = self.settings._path()
125         if path is None:
126             return
127         if str(path) not in self._watcher.files():
128             self._watcher.addPath(str(path))
129 
130     def _onFileChanged(self, path):
131         # Some editors replace the file on save, which drops the watch.
132         self._startWatching()
133         self._rebuild()
134 
135 
136     """ Form builder """
137 
138 
139     def _rebuild(self):
140         # Clear existing tabs
141         self._fields.clear()
142         while self.tabs.count():
143             w = self.tabs.widget(0)
144             self.tabs.removeTab(0)
145             if w:
146                 w.deleteLater()
147 
148         data = self.settings.prefs()
149         
150         # Separate root-level scalars and top-level dicts
151         scalars = {k: v for k, v in data.items() if not isinstance(v, dict)}
152         dicts = {k: v for k, v in data.items() if isinstance(v, dict)}
153 
154         if scalars:
155             self.tabs.addTab(self._createTabPage(scalars, ()), "General")
156         
157         for key, value in dicts.items():
158             self.tabs.addTab(self._createTabPage(value, (key,)), self._prettify(key))
159 
160     def _createTabPage(self, data, path):
161         # Create a scrollable page for the tab
162         scroll = QScrollArea()
163         scroll.setWidgetResizable(True)
164         content = QWidget()
165         layout = QVBoxLayout(content)
166         self._addSection(data, layout, path)
167         layout.addStretch()
168         scroll.setWidget(content)
169         return scroll
170 
171     def _addSection(self, data, parent_layout, path):
172         scalars = {k: v for k, v in data.items() if not isinstance(v, dict)}
173         dicts = {k: v for k, v in data.items() if isinstance(v, dict)}
174 
175         if scalars:
176             form = QFormLayout()
177             parent_layout.addLayout(form)
178             for key, value in scalars.items():
179                 sub_path = path + (key,)
180                 widget = self._makeWidget(key, value)
181                 self._fields[sub_path] = widget
182                 form.addRow(self._prettify(key) + ":", widget)
183 
184         for key, value in dicts.items():
185             sub_path = path + (key,)
186             group = QGroupBox(self._prettify(key))
187             gl = QVBoxLayout(group)
188             self._addSection(value, gl, sub_path)
189             parent_layout.addWidget(group)
190 
191     def _prettify(self, key):
192         return key.replace("_", " ").title()
193 
194     def _makeWidget(self, key, value):
195         if isinstance(value, bool):
196             w = QCheckBox()
197             w.setChecked(value)
198             return w
199         if isinstance(value, int) and (key in self.BOOL_HINT_KEYS or value in (0, 1)):
200             w = QCheckBox()
201             w.setChecked(bool(value))
202             return w
203         if isinstance(value, float):
204             w = QDoubleSpinBox()
205             w.setDecimals(4)
206             w.setRange(-1e9, 1e9)
207             w.setSingleStep(0.1)
208             w.setValue(value)
209             return w
210         if isinstance(value, int):
211             w = QSpinBox()
212             w.setRange(-1000000, 1000000)
213             w.setValue(value)
214             return w
215         if isinstance(value, str):
216             if key == "desktop_mode":
217                 w = QComboBox()
218                 w.addItems(DESKTOP_MODES)
219                 w.setCurrentText(value)
220                 return w
221             if key == "node_shape":
222                 w = QComboBox()
223                 w.addItems(NODE_SHAPES)
224                 if value not in NODE_SHAPES:
225                     w.addItem(value)
226                 w.setCurrentText(value)
227                 return w
228             w = QLineEdit(value)
229             return w
230         return QLabel(repr(value))
231 
232 
233     """ IO """
234 
235 
236     def _readWidget(self, widget, original_value):
237         if isinstance(widget, QCheckBox):
238             return int(widget.isChecked()) if isinstance(original_value, int) and not isinstance(original_value, bool) else widget.isChecked()
239         if isinstance(widget, QDoubleSpinBox):
240             return widget.value()
241         if isinstance(widget, QSpinBox):
242             return widget.value()
243         if isinstance(widget, QComboBox):
244             return widget.currentText()
245         if isinstance(widget, QLineEdit):
246             return widget.text()
247         return original_value
248 
249     def _save(self):
250         data = self.settings.prefs()
251         for path, widget in self._fields.items():
252             original = self._lookup(data, path)
253             new_value = self._readWidget(widget, original)
254             self._assign(data, path, new_value)
255         self.settings.write(data)
256 
257     def _lookup(self, data, path):
258         node = data
259         for key in path:
260             if isinstance(node, dict) and key in node:
261                 node = node[key]
262             else:
263                 return None
264         return node
265 
266     def _assign(self, data, path, value):
267         node = data
268         for key in path[:-1]:
269             if key not in node or not isinstance(node[key], dict):
270                 node[key] = {}
271             node = node[key]
272         node[path[-1]] = value