imgui_debugger 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,553 @@
1
+ """The :class:`Debugger` widget and its configuration.
2
+
3
+ Examples
4
+ --------
5
+ >>> from imgui_debugger import attach
6
+ >>> class W:
7
+ ... def __init__(self):
8
+ ... self.visible = True
9
+ >>> dbg = attach(W(), show_frame=False, show_runtime=False)
10
+ >>> [s.name for s in dbg.scopes()]
11
+ ['instance', 'properties', 'class']
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import functools
17
+ import sys
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, List, Optional, Sequence, Tuple
20
+
21
+ from imgui_bundle import imgui, imgui_ctx
22
+
23
+ from .scopes import Scope, Watch, frame_scopes, object_scopes, runtime_scope
24
+ from .search import clear_cache
25
+ from .theme import Theme, to_vec4
26
+ from .tree import TreeStyle, draw_scope
27
+
28
+
29
+ @dataclass
30
+ class DebuggerConfig:
31
+ """What the debugger inspects and how it draws it.
32
+
33
+ Parameters
34
+ ----------
35
+ target : object | None
36
+ The object whose scopes are shown first, usually the widget being
37
+ debugged.
38
+ title : str
39
+ Header text above the tree.
40
+ theme : Theme
41
+ Palette.
42
+ private : bool
43
+ Show ``_name`` attributes.
44
+ properties : bool
45
+ Show the ``properties`` scope and expand properties of nested values.
46
+ class_attrs : bool
47
+ Show the ``class`` scope.
48
+ editable : bool
49
+ Draw inline editors for writable leaves.
50
+ show_frame : bool
51
+ Show ``locals`` and ``globals`` from the captured call frame.
52
+ show_runtime : bool
53
+ Show the live ``imgui`` scope.
54
+ max_depth : int
55
+ Deepest level the tree expands.
56
+ max_items : int
57
+ Rows rendered per container.
58
+ value_col : float
59
+ Pixel column values are aligned at; ``0`` packs them after the name.
60
+ show_toolbar : bool
61
+ Draw the filter box and toggles above the tree.
62
+ window_title : str
63
+ OS window title in one-shot mode.
64
+ window_size : tuple[int, int]
65
+ OS window size in one-shot mode.
66
+ resizable : bool
67
+ Whether the one-shot window can be resized.
68
+ ini_path : str | None
69
+ Where hello_imgui saves the window layout.
70
+ assets_folder : str | None
71
+ Folder providing the icon font; unset never overrides a host app's.
72
+
73
+ Examples
74
+ --------
75
+ >>> from imgui_debugger import DebuggerConfig, Theme
76
+ >>> cfg = DebuggerConfig(title="Voltage tab", theme=Theme.light(), private=True)
77
+ >>> cfg.title, cfg.private
78
+ ('Voltage tab', True)
79
+ """
80
+
81
+ target: Any = None
82
+ title: str = "Debugger"
83
+ theme: Theme = field(default_factory=Theme.dark)
84
+ private: bool = False
85
+ properties: bool = True
86
+ class_attrs: bool = True
87
+ editable: bool = True
88
+ show_frame: bool = True
89
+ show_runtime: bool = True
90
+ max_depth: int = 8
91
+ max_items: int = 200
92
+ value_col: float = 0.0
93
+ show_toolbar: bool = True
94
+ window_title: str = ""
95
+ window_size: Tuple[int, int] = (520, 780)
96
+ resizable: bool = True
97
+ ini_path: Optional[str] = None
98
+ assets_folder: Optional[str] = None
99
+
100
+
101
+ class Debugger:
102
+ """A live variable inspector drawn inside any imgui frame.
103
+
104
+ Parameters
105
+ ----------
106
+ config : DebuggerConfig | None
107
+ What to inspect and how. Defaults to an empty debugger you add
108
+ watches to.
109
+ frame_depth : int
110
+ Which stack frame to capture for the ``locals`` / ``globals`` scopes;
111
+ ``1`` is the caller of the constructor.
112
+
113
+ Examples
114
+ --------
115
+ >>> from imgui_debugger import Debugger, DebuggerConfig
116
+ >>> class Widget:
117
+ ... def __init__(self):
118
+ ... self.open = True
119
+ >>> cfg = DebuggerConfig(target=Widget(), show_frame=False, show_runtime=False)
120
+ >>> dbg = Debugger(cfg)
121
+ >>> dbg.watch("counters", {"frames": 0})
122
+ >>> [s.name for s in dbg.scopes()][-1]
123
+ 'counters'
124
+ >>> dbg.render() # doctest: +SKIP
125
+ """
126
+
127
+ def __init__(self, config: Optional[DebuggerConfig] = None, frame_depth: int = 1):
128
+ self.config = config or DebuggerConfig()
129
+ self.visible = True
130
+ self.filter = ""
131
+ self._watches: List[Watch] = []
132
+ self._frame = None
133
+ self._force_open: Optional[bool] = None
134
+ self._focus_filter = False
135
+ if self.config.show_frame:
136
+ self.capture(frame_depth + 1)
137
+
138
+ @property
139
+ def theme(self) -> Theme:
140
+ """The active palette.
141
+
142
+ Examples
143
+ --------
144
+ >>> from imgui_debugger import Debugger
145
+ >>> Debugger().theme.frame_rounding
146
+ 4.0
147
+ """
148
+ return self.config.theme
149
+
150
+ @property
151
+ def target(self):
152
+ """The object whose scopes are listed first.
153
+
154
+ Examples
155
+ --------
156
+ >>> from imgui_debugger import attach
157
+ >>> attach({"fs": 9.6}).target
158
+ {'fs': 9.6}
159
+ """
160
+ return self.config.target
161
+
162
+ def set_target(self, target) -> None:
163
+ """Point the debugger at another object.
164
+
165
+ Parameters
166
+ ----------
167
+ target : object
168
+ The new object to inspect.
169
+
170
+ Examples
171
+ --------
172
+ >>> from imgui_debugger import attach
173
+ >>> dbg = attach({"a": 1})
174
+ >>> dbg.set_target({"b": 2})
175
+ >>> dbg.target
176
+ {'b': 2}
177
+ """
178
+ self.config.target = target
179
+
180
+ def capture(self, depth: int = 1) -> None:
181
+ """Capture a stack frame for the ``locals`` / ``globals`` scopes.
182
+
183
+ Call it from inside the function you want to watch — a widget's draw
184
+ method, say — to follow that function's locals live.
185
+
186
+ Parameters
187
+ ----------
188
+ depth : int
189
+ ``1`` is the caller of :meth:`capture`.
190
+
191
+ Examples
192
+ --------
193
+ >>> from imgui_debugger import Debugger
194
+ >>> dbg = Debugger()
195
+ >>> rows = 12
196
+ >>> dbg.capture()
197
+ >>> any(s.name == "locals" for s in dbg.scopes())
198
+ True
199
+ """
200
+ try:
201
+ self._frame = sys._getframe(depth)
202
+ except ValueError:
203
+ self._frame = None
204
+
205
+ def watch(self, name: str, source, role: str = "name", start_open: bool = True) -> None:
206
+ """Add an extra scope for a value or a callable re-read every frame.
207
+
208
+ Parameters
209
+ ----------
210
+ name : str
211
+ Scope header text; adding the same name twice replaces it.
212
+ source : object | callable
213
+ The value, or a zero-argument callable returning it.
214
+ role : str
215
+ Theme color role for its rows.
216
+ start_open : bool
217
+ Whether the header starts expanded.
218
+
219
+ Examples
220
+ --------
221
+ >>> from imgui_debugger import Debugger, DebuggerConfig
222
+ >>> dbg = Debugger(DebuggerConfig(show_frame=False, show_runtime=False))
223
+ >>> state = {"frames": 0}
224
+ >>> dbg.watch("state", state)
225
+ >>> dbg.watch("fps", lambda: {"now": 60.0}, role="runtime")
226
+ >>> [s.name for s in dbg.scopes()]
227
+ ['state', 'fps']
228
+ """
229
+ self.unwatch(name)
230
+ self._watches.append(Watch(name, source, role, start_open, self.config.private))
231
+
232
+ def unwatch(self, name: str) -> None:
233
+ """Remove a watch added by :meth:`watch`.
234
+
235
+ Parameters
236
+ ----------
237
+ name : str
238
+ The watch's scope name.
239
+
240
+ Examples
241
+ --------
242
+ >>> from imgui_debugger import Debugger, DebuggerConfig
243
+ >>> dbg = Debugger(DebuggerConfig(show_frame=False, show_runtime=False))
244
+ >>> dbg.watch("state", {"a": 1})
245
+ >>> dbg.unwatch("state")
246
+ >>> dbg.scopes()
247
+ []
248
+ """
249
+ self._watches = [w for w in self._watches if w.name != name]
250
+
251
+ def scopes(self) -> List[Scope]:
252
+ """Every scope the tree draws this frame, in display order.
253
+
254
+ Examples
255
+ --------
256
+ >>> from imgui_debugger import attach
257
+ >>> dbg = attach({"fs": 9.6}, show_frame=False, show_runtime=False)
258
+ >>> [s.name for s in dbg.scopes()]
259
+ ['instance', 'properties', 'class']
260
+ """
261
+ cfg = self.config
262
+ out: List[Scope] = []
263
+ if cfg.target is not None:
264
+ out += object_scopes(cfg.target, cfg.private, cfg.properties, cfg.class_attrs)
265
+ out += [w.scope() for w in self._watches]
266
+ if cfg.show_frame and self._frame is not None:
267
+ out += frame_scopes(self._frame, cfg.private)
268
+ if cfg.show_runtime:
269
+ out.append(runtime_scope())
270
+ return out
271
+
272
+ def style(self) -> TreeStyle:
273
+ """The :class:`~imgui_debugger.tree.TreeStyle` for this frame.
274
+
275
+ Examples
276
+ --------
277
+ >>> from imgui_debugger import Debugger, DebuggerConfig
278
+ >>> Debugger(DebuggerConfig(value_col=200.0)).style().value_col
279
+ 200.0
280
+ """
281
+ cfg = self.config
282
+ return TreeStyle(
283
+ theme=cfg.theme,
284
+ filter=self.filter,
285
+ editable=cfg.editable,
286
+ private=cfg.private,
287
+ properties=cfg.properties,
288
+ max_depth=cfg.max_depth,
289
+ max_items=cfg.max_items,
290
+ value_col=cfg.value_col,
291
+ force_open=self._force_open,
292
+ )
293
+
294
+ def show(self) -> None:
295
+ """Make :meth:`render_window` draw the window again.
296
+
297
+ Examples
298
+ --------
299
+ >>> from imgui_debugger import Debugger
300
+ >>> dbg = Debugger()
301
+ >>> dbg.hide(); dbg.show(); dbg.visible
302
+ True
303
+ """
304
+ self.visible = True
305
+
306
+ def hide(self) -> None:
307
+ """Stop :meth:`render_window` from drawing the window.
308
+
309
+ Examples
310
+ --------
311
+ >>> from imgui_debugger import Debugger
312
+ >>> dbg = Debugger()
313
+ >>> dbg.hide(); dbg.visible
314
+ False
315
+ """
316
+ self.visible = False
317
+
318
+ def toggle(self) -> None:
319
+ """Flip :attr:`visible`, for a menu item or a hotkey.
320
+
321
+ Examples
322
+ --------
323
+ >>> from imgui_debugger import Debugger
324
+ >>> dbg = Debugger()
325
+ >>> dbg.toggle(); dbg.visible
326
+ False
327
+ """
328
+ self.visible = not self.visible
329
+
330
+ def expand_all(self) -> None:
331
+ """Expand every node on the next frame.
332
+
333
+ Examples
334
+ --------
335
+ >>> from imgui_debugger import Debugger
336
+ >>> dbg = Debugger()
337
+ >>> dbg.expand_all()
338
+ """
339
+ self._force_open = True
340
+
341
+ def collapse_all(self) -> None:
342
+ """Collapse every node on the next frame.
343
+
344
+ Examples
345
+ --------
346
+ >>> from imgui_debugger import Debugger
347
+ >>> dbg = Debugger()
348
+ >>> dbg.collapse_all()
349
+ """
350
+ self._force_open = False
351
+
352
+ def set_filter(self, text: str) -> None:
353
+ """Set the filter text and drop the stale match cache.
354
+
355
+ Parameters
356
+ ----------
357
+ text : str
358
+ Case-insensitive filter over names and leaf values.
359
+
360
+ Examples
361
+ --------
362
+ >>> from imgui_debugger import Debugger
363
+ >>> dbg = Debugger()
364
+ >>> dbg.set_filter("fs")
365
+ >>> dbg.filter
366
+ 'fs'
367
+ """
368
+ self.filter = text
369
+ clear_cache()
370
+
371
+ def render(self) -> None:
372
+ """Draw the toolbar and the whole tree at the current cursor.
373
+
374
+ Call it inside your own frame, e.g. from a dock space window or a
375
+ pipeline widget's config panel.
376
+
377
+ Examples
378
+ --------
379
+ >>> from imgui_debugger import attach
380
+ >>> dbg = attach(object())
381
+ >>> dbg.render() # doctest: +SKIP
382
+ """
383
+ if self.config.show_toolbar:
384
+ self.draw_toolbar()
385
+ self.draw_tree()
386
+ self._force_open = None
387
+
388
+ def draw_toolbar(self) -> None:
389
+ """Draw the title, filter box, expand/collapse and the display toggles.
390
+
391
+ Examples
392
+ --------
393
+ >>> from imgui_debugger import attach
394
+ >>> attach(object()).draw_toolbar() # doctest: +SKIP
395
+ """
396
+ cfg = self.config
397
+ if cfg.title:
398
+ imgui.text_colored(to_vec4(cfg.theme.accent), cfg.title)
399
+ if cfg.target is not None and imgui.is_item_hovered():
400
+ imgui.set_tooltip(f"{type(cfg.target).__name__} at 0x{id(cfg.target):x}")
401
+ imgui.same_line()
402
+ imgui.text_disabled(f"{len(self.scopes())} scopes")
403
+
404
+ if self._focus_filter:
405
+ imgui.set_keyboard_focus_here()
406
+ self._focus_filter = False
407
+ imgui.set_next_item_width(-1)
408
+ changed, text = imgui.input_text_with_hint(
409
+ "##debug_filter", "filter by name or value...", self.filter
410
+ )
411
+ if changed:
412
+ self.set_filter(text)
413
+
414
+ if imgui.small_button("expand"):
415
+ self.expand_all()
416
+ imgui.same_line()
417
+ if imgui.small_button("collapse"):
418
+ self.collapse_all()
419
+ imgui.same_line()
420
+ _, cfg.private = imgui.checkbox("private", cfg.private)
421
+ imgui.same_line()
422
+ _, cfg.editable = imgui.checkbox("edit", cfg.editable)
423
+ imgui.separator()
424
+
425
+ def draw_tree(self) -> None:
426
+ """Draw every scope in a scrollable child, without the toolbar.
427
+
428
+ Examples
429
+ --------
430
+ >>> from imgui_debugger import attach
431
+ >>> attach(object()).draw_tree() # doctest: +SKIP
432
+ """
433
+ style = self.style()
434
+ with imgui_ctx.begin_child("##debug_tree"):
435
+ imgui.push_style_var(imgui.StyleVar_.item_spacing, imgui.ImVec2(8, 4))
436
+ try:
437
+ for scope in self.scopes():
438
+ draw_scope(scope, style)
439
+ finally:
440
+ imgui.pop_style_var()
441
+
442
+ def render_window(self, flags: int = 0) -> bool:
443
+ """Draw the debugger in its own imgui window, honoring :attr:`visible`.
444
+
445
+ Parameters
446
+ ----------
447
+ flags : int
448
+ Extra ``imgui.WindowFlags_`` bits.
449
+
450
+ Returns
451
+ -------
452
+ bool
453
+ True when the window was drawn this frame.
454
+
455
+ Examples
456
+ --------
457
+ >>> from imgui_debugger import attach
458
+ >>> dbg = attach(object())
459
+ >>> dbg.render_window() # doctest: +SKIP
460
+ True
461
+ """
462
+ if not self.visible:
463
+ return False
464
+ title = self.config.window_title or self.config.title or "Debugger"
465
+ expanded, self.visible = imgui.begin(f"{title}##imgui_debugger", True, flags)
466
+ if expanded:
467
+ self.render()
468
+ imgui.end()
469
+ return expanded
470
+
471
+ def focus_filter(self) -> None:
472
+ """Put the keyboard cursor in the filter box on the next frame.
473
+
474
+ Examples
475
+ --------
476
+ >>> from imgui_debugger import Debugger
477
+ >>> dbg = Debugger()
478
+ >>> dbg.focus_filter()
479
+ """
480
+ self._focus_filter = True
481
+
482
+
483
+ def attach(target=None, config: Optional[DebuggerConfig] = None, **kwargs) -> Debugger:
484
+ """Build a :class:`Debugger` for one object in a single call.
485
+
486
+ Parameters
487
+ ----------
488
+ target : object | None
489
+ The object to inspect.
490
+ config : DebuggerConfig | None
491
+ A prebuilt config; ``target`` and ``kwargs`` override its fields.
492
+ **kwargs
493
+ Any :class:`DebuggerConfig` field.
494
+
495
+ Returns
496
+ -------
497
+ Debugger
498
+ Ready to render; keep it on your widget, not per frame.
499
+
500
+ Examples
501
+ --------
502
+ >>> from imgui_debugger import attach
503
+ >>> class Tab:
504
+ ... def __init__(self):
505
+ ... self.selected = 2
506
+ >>> dbg = attach(Tab(), title="Voltage tab", show_runtime=False)
507
+ >>> dbg.config.title
508
+ 'Voltage tab'
509
+ >>> dbg.render_window() # doctest: +SKIP
510
+ """
511
+ cfg = config or DebuggerConfig()
512
+ if target is not None:
513
+ cfg.target = target
514
+ for key, value in kwargs.items():
515
+ if not hasattr(cfg, key):
516
+ raise TypeError(f"DebuggerConfig has no field {key!r}")
517
+ setattr(cfg, key, value)
518
+ return Debugger(cfg, frame_depth=2)
519
+
520
+
521
+ def watch_all(target, names: Sequence[str], **kwargs) -> Debugger:
522
+ """Attach to an object and add one watch per named attribute.
523
+
524
+ Parameters
525
+ ----------
526
+ target : object
527
+ The object to inspect.
528
+ names : Sequence[str]
529
+ Attribute names to promote to top-level scopes.
530
+ **kwargs
531
+ Any :class:`DebuggerConfig` field.
532
+
533
+ Returns
534
+ -------
535
+ Debugger
536
+ With one extra scope per name, re-read every frame.
537
+
538
+ Examples
539
+ --------
540
+ >>> from imgui_debugger import watch_all
541
+ >>> class Viewer:
542
+ ... def __init__(self):
543
+ ... self.metadata = {"fs": 9.6}
544
+ ... self.indices = {"t": 0}
545
+ >>> dbg = watch_all(Viewer(), ["metadata", "indices"],
546
+ ... show_frame=False, show_runtime=False)
547
+ >>> [s.name for s in dbg.scopes()][-2:]
548
+ ['metadata', 'indices']
549
+ """
550
+ dbg = attach(target, **kwargs)
551
+ for name in names:
552
+ dbg.watch(name, functools.partial(getattr, target, name))
553
+ return dbg
imgui_debugger/edit.py ADDED
@@ -0,0 +1,125 @@
1
+ """Inline editors that write a leaf value back onto the object it came from.
2
+
3
+ Examples
4
+ --------
5
+ >>> from imgui_debugger.edit import can_edit
6
+ >>> can_edit(1.5), can_edit((0.2, 0.4, 0.6)), can_edit(object())
7
+ (True, True, False)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from imgui_bundle import imgui
13
+
14
+ from .theme import Theme, to_vec4
15
+
16
+ MAX_EDIT_STR = 512
17
+
18
+
19
+ def is_color(value) -> bool:
20
+ """Whether a value looks like an ``(r, g, b[, a])`` float tuple.
21
+
22
+ Parameters
23
+ ----------
24
+ value : object
25
+ The value to test.
26
+
27
+ Examples
28
+ --------
29
+ >>> from imgui_debugger.edit import is_color
30
+ >>> is_color((0.1, 0.2, 0.3)), is_color((1, 2, 3, 4, 5)), is_color("red")
31
+ (True, False, False)
32
+ """
33
+ if not isinstance(value, (tuple, list)) or len(value) not in (3, 4):
34
+ return False
35
+ return all(isinstance(v, float) and 0.0 <= v <= 1.0 for v in value)
36
+
37
+
38
+ def can_edit(value) -> bool:
39
+ """Whether :func:`edit_value` knows how to render an editor for a value.
40
+
41
+ Parameters
42
+ ----------
43
+ value : object
44
+ The value to test.
45
+
46
+ Examples
47
+ --------
48
+ >>> from imgui_debugger.edit import can_edit
49
+ >>> can_edit(True), can_edit("path"), can_edit([1, 2])
50
+ (True, True, False)
51
+ """
52
+ if isinstance(value, bool) or isinstance(value, (int, float)):
53
+ return True
54
+ if isinstance(value, str):
55
+ return len(value) <= MAX_EDIT_STR
56
+ return is_color(value)
57
+
58
+
59
+ def edit_value(ident: str, value, setter, theme: Theme = None, width: float = -1.0):
60
+ """Draw an editor for one leaf and call ``setter`` when it changes.
61
+
62
+ Parameters
63
+ ----------
64
+ ident : str
65
+ Unique imgui id for the widget, usually the row's dotted path.
66
+ value : object
67
+ Current value; its type picks the editor.
68
+ setter : callable
69
+ ``setter(new_value)``, called only on a real change.
70
+ theme : Theme | None
71
+ Palette for the editor frame.
72
+ width : float
73
+ Item width; ``-1`` fills the remaining line.
74
+
75
+ Returns
76
+ -------
77
+ bool
78
+ True when the value changed this frame.
79
+
80
+ Examples
81
+ --------
82
+ >>> from imgui_debugger.edit import edit_value
83
+ >>> box = {"fs": 9.6}
84
+ >>> edit_value("md.fs", box["fs"], box.__setitem__) # doctest: +SKIP
85
+ False
86
+ """
87
+ theme = theme or Theme.dark()
88
+ imgui.push_style_color(imgui.Col_.frame_bg, to_vec4(theme.frame_bg))
89
+ imgui.push_style_var(imgui.StyleVar_.frame_rounding, theme.frame_rounding)
90
+ imgui.set_next_item_width(width)
91
+ try:
92
+ changed, new_value = _draw_editor(ident, value)
93
+ finally:
94
+ imgui.pop_style_var()
95
+ imgui.pop_style_color()
96
+ if changed:
97
+ try:
98
+ setter(new_value)
99
+ except Exception:
100
+ return False
101
+ return changed
102
+
103
+
104
+ def _draw_editor(ident: str, value):
105
+ """Draw the type-appropriate imgui input and return ``(changed, value)``.
106
+
107
+ Examples
108
+ --------
109
+ >>> from imgui_debugger.edit import _draw_editor
110
+ >>> _draw_editor("##n", 3) # doctest: +SKIP
111
+ (False, 3)
112
+ """
113
+ label = f"##{ident}"
114
+ if isinstance(value, bool):
115
+ return imgui.checkbox(label, value)
116
+ if is_color(value):
117
+ rgba = list(value) + ([1.0] if len(value) == 3 else [])
118
+ changed, out = imgui.color_edit4(label, imgui.ImVec4(*rgba))
119
+ new = (out.x, out.y, out.z) if len(value) == 3 else (out.x, out.y, out.z, out.w)
120
+ return changed, type(value)(new)
121
+ if isinstance(value, int):
122
+ return imgui.input_int(label, value)
123
+ if isinstance(value, float):
124
+ return imgui.input_float(label, value, 0.0, 0.0, "%.6g")
125
+ return imgui.input_text(label, value)