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