BulletLab 0.1.2__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,265 @@
1
+ """
2
+ PropertiesPanel – editable property inspector for the selected object.
3
+
4
+ Detects the type of the selected object (Simulation, Robot, Joint, or Link)
5
+ and renders appropriate editable fields. All changes are applied immediately
6
+ to the simulation.
7
+
8
+ Example::
9
+
10
+ from bulletlab.ui.panels.properties import PropertiesPanel
11
+
12
+ props = PropertiesPanel()
13
+ props.set_target(robot.links["wheel"])
14
+ props.render()
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from typing import Any, TYPE_CHECKING
21
+
22
+ try:
23
+ import imgui
24
+
25
+ _HAS_IMGUI = True
26
+ except ImportError: # pragma: no cover
27
+ imgui = None # type: ignore[assignment]
28
+ _HAS_IMGUI = False
29
+
30
+ if TYPE_CHECKING:
31
+ from bulletlab.core.simulation import Simulation
32
+ from bulletlab.robot.robot import Robot
33
+ from bulletlab.robot.joint import Joint
34
+ from bulletlab.robot.link import Link
35
+
36
+
37
+ class PropertiesPanel:
38
+ """Renders editable properties for the currently selected object.
39
+
40
+ Automatically detects whether the target is a :class:`~bulletlab.core.simulation.Simulation`,
41
+ :class:`~bulletlab.robot.robot.Robot`, :class:`~bulletlab.robot.joint.Joint`, or
42
+ :class:`~bulletlab.robot.link.Link` and renders the appropriate fields.
43
+
44
+ Example::
45
+
46
+ props = PropertiesPanel()
47
+ props.set_target(selected_object)
48
+ props.render()
49
+ """
50
+
51
+ def __init__(self) -> None:
52
+ self._target: Any = None
53
+
54
+ def set_target(self, obj: Any) -> None:
55
+ """Set the object whose properties will be displayed.
56
+
57
+ Args:
58
+ obj: A Simulation, Robot, Joint, Link, or ``None`` to clear.
59
+
60
+ Example::
61
+
62
+ props.set_target(robot.joints["wheel_left"])
63
+ """
64
+ self._target = obj
65
+
66
+ def render(self) -> None:
67
+ """Draw the properties panel contents.
68
+
69
+ Must be called inside an active ImGui window context.
70
+ """
71
+ if not _HAS_IMGUI:
72
+ return
73
+
74
+ if self._target is None:
75
+ imgui.text_colored("Select an item in the Explorer.", 0.5, 0.5, 0.5, 1.0)
76
+ return
77
+
78
+ # Route to appropriate renderer
79
+ type_name = type(self._target).__name__
80
+ imgui.text(f"Type: {type_name}")
81
+ imgui.separator()
82
+
83
+ if type_name == "Simulation":
84
+ self._render_simulation(self._target)
85
+ elif type_name == "Robot":
86
+ self._render_robot(self._target)
87
+ elif type_name == "Joint":
88
+ self._render_joint(self._target)
89
+ elif type_name == "Link":
90
+ self._render_link(self._target)
91
+ else:
92
+ imgui.text(str(self._target))
93
+
94
+ # ------------------------------------------------------------------
95
+ # Simulation properties
96
+ # ------------------------------------------------------------------
97
+
98
+ def _render_simulation(self, sim: "Simulation") -> None:
99
+ imgui.text(f"Status: {'Paused' if sim.is_paused else 'Running'}")
100
+ imgui.text(f"Steps: {sim.step_count}")
101
+ imgui.text(f"Sim Time: {sim.elapsed_time:.3f} s")
102
+ imgui.separator()
103
+
104
+ # Gravity
105
+ gx, gy, gz = sim.gravity
106
+ changed_gz, new_gz = imgui.drag_float("Gravity Z##sim_gz", gz, 0.1, -20.0, 20.0)
107
+ if changed_gz:
108
+ sim.gravity = (gx, gy, new_gz)
109
+
110
+ # Timestep
111
+ changed_ts, new_ts = imgui.drag_float("Timestep##sim_ts", sim.timestep, 0.0001, 0.0001, 0.1, "%.5f")
112
+ if changed_ts:
113
+ sim.timestep = float(new_ts)
114
+
115
+ imgui.separator()
116
+ if sim.is_paused:
117
+ if imgui.button("Resume##sim_resume"):
118
+ sim.resume()
119
+ else:
120
+ if imgui.button("Pause##sim_pause"):
121
+ sim.pause()
122
+ imgui.same_line()
123
+ if imgui.button("Reset##sim_reset"):
124
+ sim.reset()
125
+
126
+ # ------------------------------------------------------------------
127
+ # Robot properties
128
+ # ------------------------------------------------------------------
129
+
130
+ def _render_robot(self, robot: "Robot") -> None:
131
+ imgui.text(f"Name: {robot.name}")
132
+ imgui.text(f"Joints: {robot.num_joints} (controllable: {robot.num_controllable_joints})")
133
+ imgui.text(f"Links: {len(robot.links)}")
134
+ imgui.separator()
135
+
136
+ pos = robot.base_position
137
+ vel = robot.base_velocity
138
+ roll_deg = math.degrees(robot.roll)
139
+ pitch_deg = math.degrees(robot.pitch)
140
+ yaw_deg = math.degrees(robot.yaw)
141
+
142
+ imgui.text(f"Position: ({pos[0]:.3f}, {pos[1]:.3f}, {pos[2]:.3f})")
143
+ imgui.text(f"Velocity: ({vel[0]:.3f}, {vel[1]:.3f}, {vel[2]:.3f})")
144
+ imgui.text(f"Roll: {roll_deg:.2f}°")
145
+ imgui.text(f"Pitch: {pitch_deg:.2f}°")
146
+ imgui.text(f"Yaw: {yaw_deg:.2f}°")
147
+ imgui.text(f"Speed: {robot.speed:.3f} m/s")
148
+ imgui.separator()
149
+
150
+ if imgui.button("Reset Robot##robot_reset"):
151
+ robot.reset()
152
+
153
+ # ------------------------------------------------------------------
154
+ # Joint properties
155
+ # ------------------------------------------------------------------
156
+
157
+ def _render_joint(self, joint: "Joint") -> None:
158
+ imgui.text(f"Name: {joint.name}")
159
+ imgui.text(f"Index: {joint.index}")
160
+ imgui.text(f"Type: {joint.joint_type.name if hasattr(joint.joint_type, 'name') else joint.joint_type}")
161
+ imgui.separator()
162
+
163
+ imgui.text(f"Position: {joint.position:.4f} rad")
164
+ imgui.text(f"Velocity: {joint.velocity:.4f} rad/s")
165
+ imgui.text(f"Torque: {joint.torque:.4f} N·m")
166
+ lo, hi = joint.limits
167
+ imgui.text(f"Limits: [{lo:.3f}, {hi:.3f}]")
168
+ imgui.separator()
169
+
170
+ # Max force
171
+ changed_mf, new_mf = imgui.drag_float(
172
+ "Max Force##jnt_mf", joint.max_force, 1.0, 0.0, 5000.0
173
+ )
174
+ if changed_mf:
175
+ joint.max_force = float(new_mf)
176
+
177
+ # Max velocity
178
+ changed_mv, new_mv = imgui.drag_float(
179
+ "Max Velocity##jnt_mv", joint.max_velocity, 0.1, 0.0, 200.0
180
+ )
181
+ if changed_mv:
182
+ joint.max_velocity = float(new_mv)
183
+
184
+ imgui.separator()
185
+
186
+ # Target velocity control
187
+ changed_vel, new_vel = imgui.drag_float(
188
+ "Target Velocity##jnt_vel", joint.velocity, 0.1, -200.0, 200.0
189
+ )
190
+ if changed_vel:
191
+ joint.velocity = float(new_vel)
192
+
193
+ # Position control
194
+ lo2, hi2 = joint.limits
195
+ range_lo = lo2 if lo2 != 0.0 or hi2 != 0.0 else -6.28
196
+ range_hi = hi2 if lo2 != 0.0 or hi2 != 0.0 else 6.28
197
+ changed_pos, new_pos = imgui.slider_float(
198
+ "Target Position##jnt_pos", joint.position, range_lo, range_hi
199
+ )
200
+ if changed_pos:
201
+ joint.set_position(float(new_pos))
202
+
203
+ imgui.separator()
204
+ if imgui.button("Reset Joint##jnt_reset"):
205
+ joint.reset()
206
+ imgui.same_line()
207
+ if joint.is_enabled:
208
+ if imgui.button("Disable##jnt_disable"):
209
+ joint.disable()
210
+ else:
211
+ if imgui.button("Enable##jnt_enable"):
212
+ joint.enable()
213
+
214
+ # ------------------------------------------------------------------
215
+ # Link properties
216
+ # ------------------------------------------------------------------
217
+
218
+ def _render_link(self, link: "Link") -> None:
219
+ imgui.text(f"Name: {link.name}")
220
+ imgui.text(f"Index: {link.index}")
221
+ imgui.separator()
222
+
223
+ # Mass
224
+ changed_mass, new_mass = imgui.drag_float(
225
+ "Mass (kg)##lnk_mass", link.mass, 0.01, 0.0001, 1000.0
226
+ )
227
+ if changed_mass:
228
+ link.mass = float(new_mass)
229
+
230
+ # Friction
231
+ changed_fric, new_fric = imgui.drag_float(
232
+ "Lateral Friction##lnk_fric", link.friction, 0.01, 0.0, 10.0
233
+ )
234
+ if changed_fric:
235
+ link.friction = float(new_fric)
236
+
237
+ # Restitution
238
+ changed_rest, new_rest = imgui.slider_float(
239
+ "Restitution##lnk_rest", link.restitution, 0.0, 1.0
240
+ )
241
+ if changed_rest:
242
+ link.restitution = float(new_rest)
243
+
244
+ # Linear damping
245
+ changed_ld, new_ld = imgui.drag_float(
246
+ "Linear Damping##lnk_ld", link.linear_damping, 0.001, 0.0, 10.0
247
+ )
248
+ if changed_ld:
249
+ link.linear_damping = float(new_ld)
250
+
251
+ # Angular damping
252
+ changed_ad, new_ad = imgui.drag_float(
253
+ "Angular Damping##lnk_ad", link.angular_damping, 0.001, 0.0, 10.0
254
+ )
255
+ if changed_ad:
256
+ link.angular_damping = float(new_ad)
257
+
258
+ imgui.separator()
259
+ pos = link.position
260
+ vel = link.velocity
261
+ imgui.text(f"Position: ({pos[0]:.3f}, {pos[1]:.3f}, {pos[2]:.3f})")
262
+ imgui.text(f"Velocity: ({vel[0]:.3f}, {vel[1]:.3f}, {vel[2]:.3f})")
263
+
264
+ def __repr__(self) -> str:
265
+ return f"PropertiesPanel(target={type(self._target).__name__ if self._target else None})"
@@ -0,0 +1,105 @@
1
+ """
2
+ TelemetryPanel – displays live robot telemetry data.
3
+
4
+ Shows all channels registered in a TelemetryManager as a table with
5
+ the most recent values. Optionally shows units.
6
+
7
+ Example::
8
+
9
+ from bulletlab.ui.panels.telemetry import TelemetryPanel
10
+ from bulletlab.telemetry import TelemetryManager
11
+
12
+ telemetry = TelemetryManager()
13
+ telemetry.watch("Speed", lambda: robot.speed, unit="m/s")
14
+
15
+ panel = TelemetryPanel(telemetry)
16
+ panel.render()
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ from typing import Any, TYPE_CHECKING
23
+
24
+ try:
25
+ import imgui
26
+
27
+ _HAS_IMGUI = True
28
+ except ImportError: # pragma: no cover
29
+ imgui = None # type: ignore[assignment]
30
+ _HAS_IMGUI = False
31
+
32
+ if TYPE_CHECKING:
33
+ from bulletlab.telemetry.manager import TelemetryManager
34
+
35
+
36
+ class TelemetryPanel:
37
+ """Renders a live key-value table of telemetry channel values.
38
+
39
+ Args:
40
+ telemetry: The :class:`~bulletlab.telemetry.manager.TelemetryManager` to display.
41
+
42
+ Example::
43
+
44
+ panel = TelemetryPanel(telemetry)
45
+ # In render loop:
46
+ panel.render()
47
+ """
48
+
49
+ def __init__(self, telemetry: "TelemetryManager") -> None:
50
+ self._telemetry = telemetry
51
+
52
+ def render(self) -> None:
53
+ """Draw the telemetry panel contents.
54
+
55
+ Must be called inside an active ImGui window context.
56
+ """
57
+ if not _HAS_IMGUI:
58
+ return
59
+
60
+ if not self._telemetry.channel_names:
61
+ imgui.text_colored("No channels registered.", 0.5, 0.5, 0.5, 1.0)
62
+ imgui.text("Use telemetry.watch(...) to add channels.")
63
+ return
64
+
65
+ # Table header
66
+ imgui.columns(3, "telemetry_table", border=True)
67
+ imgui.text("Channel")
68
+ imgui.next_column()
69
+ imgui.text("Value")
70
+ imgui.next_column()
71
+ imgui.text("Unit")
72
+ imgui.next_column()
73
+ imgui.separator()
74
+
75
+ for name, channel in self._telemetry.channels.items():
76
+ val = channel.latest
77
+ unit = channel.unit
78
+
79
+ imgui.text(name)
80
+ imgui.next_column()
81
+
82
+ # Format value
83
+ if val is None:
84
+ val_str = "—"
85
+ elif isinstance(val, float):
86
+ if math.isnan(val):
87
+ val_str = "NaN"
88
+ elif math.isinf(val):
89
+ val_str = "Inf"
90
+ else:
91
+ val_str = f"{val:.4f}"
92
+ elif isinstance(val, (list, tuple)):
93
+ val_str = "(" + ", ".join(f"{v:.3f}" for v in val) + ")"
94
+ else:
95
+ val_str = str(val)
96
+
97
+ imgui.text(val_str)
98
+ imgui.next_column()
99
+ imgui.text(unit)
100
+ imgui.next_column()
101
+
102
+ imgui.columns(1)
103
+
104
+ def __repr__(self) -> str:
105
+ return f"TelemetryPanel(channels={self._telemetry.channel_names})"
@@ -0,0 +1,348 @@
1
+ """
2
+ Widget helper functions for building BulletLab custom panels.
3
+
4
+ Provides a minimal, boilerplate-free API for common ImGui widgets.
5
+ All functions must be called from within an ImGui window context (i.e.,
6
+ between imgui.begin() and imgui.end()).
7
+
8
+ Example::
9
+
10
+ from bulletlab.ui import widgets as ui
11
+
12
+ @app.custom_panel("My Controls")
13
+ def my_panel():
14
+ ui.button("Reset", robot.reset)
15
+ ui.slider("Wheel Mass", robot.links["wheel"].mass, 0.1, 20,
16
+ setter=lambda v: setattr(robot.links["wheel"], "mass", v))
17
+ ui.checkbox("Motors On", lambda: motors_on,
18
+ setter=lambda v: set_motors(v))
19
+ ui.text("Speed", f"{robot.speed:.2f} m/s")
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Callable, Optional
25
+
26
+ # ImGui is optional — graceful fallback
27
+ try:
28
+ import imgui
29
+
30
+ _HAS_IMGUI = True
31
+ except ImportError: # pragma: no cover
32
+ imgui = None # type: ignore[assignment]
33
+ _HAS_IMGUI = False
34
+
35
+
36
+ def _check_imgui() -> bool:
37
+ """Return True if imgui is available, warn otherwise."""
38
+ if not _HAS_IMGUI:
39
+ return False
40
+ return True
41
+
42
+
43
+ # ------------------------------------------------------------------
44
+ # Basic widgets
45
+ # ------------------------------------------------------------------
46
+
47
+
48
+ def button(label: str, callback: Callable[[], Any] | None = None) -> bool:
49
+ """Render a clickable button.
50
+
51
+ Args:
52
+ label: Button text label.
53
+ callback: Function to call when the button is clicked.
54
+
55
+ Returns:
56
+ ``True`` if the button was clicked this frame.
57
+
58
+ Example::
59
+
60
+ ui.button("Reset Robot", robot.reset)
61
+ """
62
+ if not _check_imgui():
63
+ return False
64
+ clicked = imgui.button(label)
65
+ if clicked and callback is not None:
66
+ callback()
67
+ return clicked
68
+
69
+
70
+ def text(label: str, value: Any = "") -> None:
71
+ """Render a read-only text label with an optional value.
72
+
73
+ Args:
74
+ label: Field label.
75
+ value: Value to display (converted to string).
76
+
77
+ Example::
78
+
79
+ ui.text("Speed", f"{robot.speed:.2f} m/s")
80
+ """
81
+ if not _check_imgui():
82
+ return
83
+ if value != "":
84
+ imgui.text(f"{label}: {value}")
85
+ else:
86
+ imgui.text(str(label))
87
+
88
+
89
+ def separator(label: str = "") -> None:
90
+ """Render a horizontal separator, optionally with a label.
91
+
92
+ Args:
93
+ label: Optional section label.
94
+
95
+ Example::
96
+
97
+ ui.separator("Physics")
98
+ """
99
+ if not _check_imgui():
100
+ return
101
+ imgui.separator()
102
+ if label:
103
+ imgui.text(label)
104
+
105
+
106
+ def same_line() -> None:
107
+ """Place the next widget on the same line."""
108
+ if _check_imgui():
109
+ imgui.same_line()
110
+
111
+
112
+ # ------------------------------------------------------------------
113
+ # Input widgets
114
+ # ------------------------------------------------------------------
115
+
116
+
117
+ def slider(
118
+ label: str,
119
+ getter: Callable[[], float] | float,
120
+ min_val: float,
121
+ max_val: float,
122
+ setter: Callable[[float], None] | None = None,
123
+ fmt: str = "%.3f",
124
+ ) -> float:
125
+ """Render a float slider.
126
+
127
+ Args:
128
+ label: Widget label.
129
+ getter: Current value or a callable returning the current value.
130
+ min_val: Minimum value.
131
+ max_val: Maximum value.
132
+ setter: Called with the new value when the slider changes.
133
+ fmt: Printf format string for display.
134
+
135
+ Returns:
136
+ Current slider value.
137
+
138
+ Example::
139
+
140
+ ui.slider("Wheel Mass", lambda: robot.links["wheel"].mass, 0.1, 20,
141
+ setter=lambda v: setattr(robot.links["wheel"], "mass", v))
142
+ """
143
+ if not _check_imgui():
144
+ return 0.0
145
+ current = float(getter()) if callable(getter) else float(getter)
146
+ changed, new_val = imgui.slider_float(label, current, min_val, max_val, fmt)
147
+ if changed and setter is not None:
148
+ setter(float(new_val))
149
+ return float(new_val) if changed else current
150
+
151
+
152
+ def drag_float(
153
+ label: str,
154
+ getter: Callable[[], float] | float,
155
+ setter: Callable[[float], None] | None = None,
156
+ speed: float = 0.1,
157
+ min_val: float = 0.0,
158
+ max_val: float = 0.0,
159
+ fmt: str = "%.3f",
160
+ ) -> float:
161
+ """Render a drag-to-edit float field.
162
+
163
+ Args:
164
+ label: Widget label.
165
+ getter: Current value or callable returning current value.
166
+ setter: Called with the new value when changed.
167
+ speed: Drag sensitivity.
168
+ min_val: Minimum value (0 = no clamp).
169
+ max_val: Maximum value (0 = no clamp).
170
+ fmt: Printf format string for display.
171
+
172
+ Returns:
173
+ Current value.
174
+
175
+ Example::
176
+
177
+ ui.drag_float("Mass", lambda: link.mass, setter=lambda v: setattr(link, "mass", v))
178
+ """
179
+ if not _check_imgui():
180
+ return 0.0
181
+ current = float(getter()) if callable(getter) else float(getter)
182
+ changed, new_val = imgui.drag_float(label, current, speed, min_val, max_val, fmt)
183
+ if changed and setter is not None:
184
+ setter(float(new_val))
185
+ return float(new_val) if changed else current
186
+
187
+
188
+ def input_float(
189
+ label: str,
190
+ getter: Callable[[], float] | float,
191
+ setter: Callable[[float], None] | None = None,
192
+ step: float = 0.1,
193
+ fmt: str = "%.3f",
194
+ ) -> float:
195
+ """Render a float input field.
196
+
197
+ Args:
198
+ label: Widget label.
199
+ getter: Current value or callable.
200
+ setter: Called with the new value when committed.
201
+ step: Increment step for +/- buttons.
202
+ fmt: Display format string.
203
+
204
+ Returns:
205
+ Current value.
206
+
207
+ Example::
208
+
209
+ ui.input_float("Friction", lambda: link.friction,
210
+ setter=lambda v: setattr(link, "friction", v))
211
+ """
212
+ if not _check_imgui():
213
+ return 0.0
214
+ current = float(getter()) if callable(getter) else float(getter)
215
+ changed, new_val = imgui.input_float(label, current, step, step * 10, fmt)
216
+ if changed and setter is not None:
217
+ setter(float(new_val))
218
+ return float(new_val) if changed else current
219
+
220
+
221
+ def checkbox(
222
+ label: str,
223
+ getter: Callable[[], bool] | bool,
224
+ setter: Callable[[bool], None] | None = None,
225
+ ) -> bool:
226
+ """Render a checkbox.
227
+
228
+ Args:
229
+ label: Widget label.
230
+ getter: Current state or callable returning current state.
231
+ setter: Called with the new state when toggled.
232
+
233
+ Returns:
234
+ Current checkbox state.
235
+
236
+ Example::
237
+
238
+ ui.checkbox("Motors Enabled", lambda: motors_on,
239
+ setter=lambda v: set_motors(v))
240
+ """
241
+ if not _check_imgui():
242
+ return False
243
+ current = bool(getter()) if callable(getter) else bool(getter)
244
+ changed, new_val = imgui.checkbox(label, current)
245
+ if changed and setter is not None:
246
+ setter(bool(new_val))
247
+ return bool(new_val) if changed else current
248
+
249
+
250
+ def color_edit(
251
+ label: str,
252
+ getter: Callable[[], tuple[float, float, float]] | tuple[float, float, float],
253
+ setter: Callable[[tuple[float, float, float]], None] | None = None,
254
+ ) -> tuple[float, float, float]:
255
+ """Render an RGB color picker.
256
+
257
+ Args:
258
+ label: Widget label.
259
+ getter: Current color ``(r, g, b)`` normalized to [0, 1] or callable.
260
+ setter: Called with the new color when changed.
261
+
262
+ Returns:
263
+ Current color ``(r, g, b)``.
264
+
265
+ Example::
266
+
267
+ ui.color_edit("Light Color", lambda: color, setter=lambda c: set_color(c))
268
+ """
269
+ if not _check_imgui():
270
+ return (1.0, 1.0, 1.0)
271
+ current = tuple(getter()) if callable(getter) else tuple(getter)
272
+ r, g, b = float(current[0]), float(current[1]), float(current[2])
273
+ changed, (nr, ng, nb) = imgui.color_edit3(label, r, g, b)
274
+ result = (float(nr), float(ng), float(nb))
275
+ if changed and setter is not None:
276
+ setter(result)
277
+ return result if changed else (r, g, b)
278
+
279
+
280
+ def combo(
281
+ label: str,
282
+ items: list[str],
283
+ getter: Callable[[], int] | int,
284
+ setter: Callable[[int], None] | None = None,
285
+ ) -> int:
286
+ """Render a dropdown combo box.
287
+
288
+ Args:
289
+ label: Widget label.
290
+ items: List of selectable items.
291
+ getter: Current selected index or callable.
292
+ setter: Called with new index when changed.
293
+
294
+ Returns:
295
+ Current selected index.
296
+
297
+ Example::
298
+
299
+ ui.combo("Mode", ["Velocity", "Position", "Torque"], lambda: mode_idx,
300
+ setter=lambda i: set_mode(i))
301
+ """
302
+ if not _check_imgui():
303
+ return 0
304
+ current = int(getter()) if callable(getter) else int(getter)
305
+ changed, new_idx = imgui.combo(label, current, items)
306
+ if changed and setter is not None:
307
+ setter(int(new_idx))
308
+ return int(new_idx) if changed else current
309
+
310
+
311
+ def collapsing_header(label: str, default_open: bool = True) -> bool:
312
+ """Render a collapsible section header.
313
+
314
+ Args:
315
+ label: Section title.
316
+ default_open: Whether the section starts expanded.
317
+
318
+ Returns:
319
+ ``True`` if the section is currently expanded.
320
+
321
+ Example::
322
+
323
+ if ui.collapsing_header("Physics Parameters"):
324
+ ui.drag_float("Mass", ...)
325
+ """
326
+ if not _check_imgui():
327
+ return True
328
+ flags = imgui.TREE_NODE_DEFAULT_OPEN if default_open else 0
329
+ return imgui.collapsing_header(label, flags=flags)
330
+
331
+
332
+ def tooltip(text_str: str) -> None:
333
+ """Show a tooltip when the previous widget is hovered.
334
+
335
+ Args:
336
+ text_str: Tooltip text.
337
+
338
+ Example::
339
+
340
+ ui.drag_float("Mass", ...)
341
+ ui.tooltip("Mass of the link in kilograms")
342
+ """
343
+ if not _check_imgui():
344
+ return
345
+ if imgui.is_item_hovered():
346
+ imgui.begin_tooltip()
347
+ imgui.text(text_str)
348
+ imgui.end_tooltip()