Skip to content

Controls & panels

Control

Binds a GUI widget to a filter keyword argument.

The widget type is inferred from the type of value_default and the presence of value_range:

  • bool -> checkbox (value_range must be None)
  • int / float with a 2-element value_range -> slider
  • int / float without value_range -> free numeric parameter
  • str with a list of choices as value_range -> dropdown
  • str without value_range -> free text prompt

Parameters:

Name Type Description Default
value_default Union[int, float, bool, str]

Initial value; its type selects the widget (int, float, bool or str).

required
value_range Optional[List[Union[int, float, str]]]

[min, max] for numeric sliders, or the list of choices for a string dropdown. Must be None for bool.

None
name Optional[str]

Label displayed next to the widget. Auto-generated if omitted.

None
step Optional[Union[int, float]]

Slider increment. Defaults to 1 for int sliders and 1/100 of the range for float sliders.

None
filter_to_connect Optional[FilterCore]

Filter to connect immediately (requires parameter_name_to_connect). Usually left to the @interactive decorator.

None
parameter_name_to_connect Optional[str]

Keyword argument of the connected filter.

None
icons Optional[List]

Icon image paths (one per choice) for string dropdowns.

None
group Optional[Union[str, 'Panel']]

Panel instance or panel name used to group controls; same-named string groups share one panel across filters.

None
tooltip Optional[str]

Hover text displayed on the widget.

None
Example
@interactive(
    gain=Control(1.0, [0.0, 3.0]),  # float slider
    flip=Control(False),  # checkbox
    mode=Control("dark", ["dark", "light"]),  # dropdown
)
def my_filter(img, gain=1.0, flip=False, mode="dark"):
    ...
Source code in src/interactive_pipe/headless/control.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
class Control:
    """Binds a GUI widget to a filter keyword argument.

    The widget type is inferred from the type of ``value_default`` and the
    presence of ``value_range``:

    - ``bool`` -> checkbox (``value_range`` must be None)
    - ``int`` / ``float`` with a 2-element ``value_range`` -> slider
    - ``int`` / ``float`` without ``value_range`` -> free numeric parameter
    - ``str`` with a list of choices as ``value_range`` -> dropdown
    - ``str`` without ``value_range`` -> free text prompt

    Args:
        value_default: Initial value; its type selects the widget
            (int, float, bool or str).
        value_range: ``[min, max]`` for numeric sliders, or the list of
            choices for a string dropdown. Must be None for bool.
        name: Label displayed next to the widget. Auto-generated if omitted.
        step: Slider increment. Defaults to 1 for int sliders and 1/100 of
            the range for float sliders.
        filter_to_connect: Filter to connect immediately (requires
            ``parameter_name_to_connect``). Usually left to the
            ``@interactive`` decorator.
        parameter_name_to_connect: Keyword argument of the connected filter.
        icons: Icon image paths (one per choice) for string dropdowns.
        group: ``Panel`` instance or panel name used to group controls;
            same-named string groups share one panel across filters.
        tooltip: Hover text displayed on the widget.

    Example:
        ```python
        @interactive(
            gain=Control(1.0, [0.0, 3.0]),  # float slider
            flip=Control(False),  # checkbox
            mode=Control("dark", ["dark", "light"]),  # dropdown
        )
        def my_filter(img, gain=1.0, flip=False, mode="dark"):
            ...
        ```
    """

    counter = 0
    _registry = {}  # Global registry to store controls for each function
    # Process-wide by design: same-named string groups share one Panel across filters
    _panel_cache = {}

    @staticmethod
    def registry_key(func: Callable) -> str:
        # Keyed by module + qualname so same-named functions in different
        # modules do not collide. functools.wraps preserves both, so the key
        # is identical for a decorated function and its wrapper.
        return f"{func.__module__}.{func.__qualname__}"

    @classmethod
    def register(cls, func, param_name, control_instance):
        cls._registry.setdefault(cls.registry_key(func), {})[param_name] = control_instance

    @classmethod
    def get_controls(cls, func):
        if not isinstance(func, Callable):
            raise TypeError(f"get_controls expects the function object, got {type(func)}")
        return cls._registry.get(cls.registry_key(func), {})

    def __init__(
        self,
        value_default: Union[int, float, bool, str],
        value_range: Optional[List[Union[int, float, str]]] = None,
        name: Optional[str] = None,
        step: Optional[Union[int, float]] = None,
        filter_to_connect: Optional[FilterCore] = None,
        parameter_name_to_connect: Optional[str] = None,
        icons: Optional[List] = None,
        group: Optional[Union[str, "Panel"]] = None,
        tooltip: Optional[str] = None,
    ) -> None:
        self.value_default = value_default
        self._type = None
        self._auto_named = False
        self.step = step
        self.tooltip = tooltip

        # Convert string to Panel for backward compatibility
        # Use cached Panel if same string name was used before
        if isinstance(group, str):
            from interactive_pipe.headless.panel import Panel

            if group not in Control._panel_cache:
                Control._panel_cache[group] = Panel(name=group)
            self.panel = Control._panel_cache[group]
        else:
            self.panel = group

        if isinstance(value_default, bool):
            self._type = bool
            self.step = 1
            if value_range is not None:
                raise ValueError("value_range must be None for bool type")
        elif isinstance(value_default, (float, int)):
            if value_range is None:  # free range parameter!
                self._type = int if isinstance(value_default, int) else float
            else:
                if not isinstance(value_range, (list, tuple)):
                    raise TypeError(f"value_range must be a list or tuple, got {type(value_range)}")
                if len(value_range) != 2:
                    raise ValueError(f"value_range must have exactly 2 elements, got {len(value_range)}")
                for choice in value_range:
                    if not isinstance(choice, (float, int)):
                        raise TypeError(f"value_range elements must be int or float, got {type(choice)}")
                if (
                    isinstance(value_default, int)
                    and isinstance(value_range[0], int)
                    and isinstance(value_range[1], int)
                ):
                    if self.step is None:
                        self.step = 1
                    self._type = int
                else:
                    self._type = float
                    if self.step is None:
                        # Type narrowing: we know value_range contains numeric values here
                        self.step = (value_range[1] - value_range[0]) / 100.0  # type: ignore
                if not (value_range[0] <= value_default <= value_range[1]):  # type: ignore
                    raise ValueError(f"value_default {value_default} must be within value_range {value_range}")
        elif isinstance(value_default, str):
            # similar to an enum
            if value_range is None:
                logging.debug("string prompt only - no range")
                self._type = str
            else:
                if not value_range:
                    raise ValueError("value_range cannot be empty for string type")
                if not isinstance(value_range, (list, tuple)):
                    raise TypeError(f"value_range must be a list or tuple, got {type(value_range)}")
                for choice in value_range:
                    if not isinstance(choice, str):
                        raise TypeError(f"value_range elements must be strings, got {type(choice)}")
                if value_default not in value_range:
                    raise ValueError(f"{value_default} must be in {value_range}")
                self._type = str
                if self.step is None:
                    self.step = 1
        else:
            raise TypeError(f"Wrong value type: {type(value_default)}, expected int/float/bool/str")
        self.value_range = value_range

        # init current value
        self.value = value_default
        self.icons = icons
        if self.icons is not None:
            for icon in self.icons:
                if isinstance(icon, str):
                    icon = Path(icon)
                if not icon.exists():
                    raise FileNotFoundError(f"Icon file not found: {icon}")
        if name is None:
            self._auto_named = True
            self.name = f"parameter {Control.counter}"
        else:
            self.name = name
        if not isinstance(self.name, str):
            raise TypeError(f"name must be a string, got {type(self.name)}")
        Control.counter += 1
        if filter_to_connect is not None:
            if parameter_name_to_connect is None:
                raise ValueError("parameter_name_to_connect is required when filter_to_connect is provided")
            self.connect_filter(filter_to_connect, parameter_name_to_connect)
        else:
            self.update_param_func = None

        self.parameter_name_to_connect = parameter_name_to_connect
        self.filter_to_connect = filter_to_connect

        # Register with panel if provided
        if self.panel is not None:
            self.panel._register_control(self)

    def check_value(self, value):
        if isinstance(value, int) and self._type is float:
            value = float(value)
        elif isinstance(value, float) and self._type is int:
            # Convert float to int (e.g., from matplotlib sliders that return floats)
            value = int(round(value))
        if self._type is not None and not isinstance(value, self._type):
            raise TypeError(f"Expected {self._type}, got {type(value)}")
        if isinstance(value, (float, int)) and self.value_range is not None:
            return max(self.value_range[0], min(value, self.value_range[1]))  # type: ignore
        elif self._type is str and self.value_range is not None:
            if value not in self.value_range:
                raise ValueError(f"{value} must be in {self.value_range}")
            return value
        else:
            return value

    def __repr__(self) -> str:
        if self._type in [float, int]:
            if self.value_range:
                return (
                    f"{self.name} | {self.value} - range {self.value_range} "
                    f"default = {self.value_default} type: {self._type} - step={self.step}"
                )
            else:
                return (
                    f"{self.name} | {self.value} - RANGELESS - default = {self.value_default} "
                    f"type: {self._type} - step={self.step}"
                )
        elif self._type is bool:
            return f"{self.name} | Bool {self.value} - default {self.value_default}"
        elif self._type is str:
            return (
                f"{self.name} | {self.value} - choices {self.value_range} default = {self.value_default} "
                f"type: {self._type} - step={self.step}"
            )
        else:
            raise NotImplementedError

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, value=None):
        self._value = deepcopy(self.check_value(value) if value is not None else self.value_default)

    def reset(self):
        self.value = None

    def update(self, new_value):
        # Plug button
        self.value = new_value
        if self.update_param_func is not None:
            self.update_param_func(self.value)

    def _clone_unconnected(self, name: str) -> "Control":
        """Shallow clone for a repeated filter instance: same value spec,
        panel and widget type, but a fresh name, the default value and no
        filter connection (the pipeline connects it to the repeat)."""
        clone = copy(self)
        clone.name = name
        clone.filter_to_connect = None
        clone.parameter_name_to_connect = None
        clone.update_param_func = None
        clone.reset()
        if clone.panel is not None:
            clone.panel._register_control(clone)
        return clone

    def connect_parameter(self, update_param_func: Callable):
        self.update_param_func = update_param_func

    def connect_filter(self, filt: FilterCore, parameter_name):
        def update_param_func(val):
            logging.info(f"update filter {filt.name} - param {parameter_name} - value {val}")
            filt.values = {parameter_name: val}

        self.update_param_func = update_param_func
        self.parameter_name_to_connect = parameter_name
        self.filter_to_connect = filt

CircularControl

Bases: Control

Circular (dial) variant of a numeric slider.

Behaves like a numeric Control but is rendered as a rotary dial. With modulo=True the value wraps around at the value_range bounds instead of saturating (useful for angles).

Source code in src/interactive_pipe/headless/control.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
class CircularControl(Control):
    """Circular (dial) variant of a numeric slider.

    Behaves like a numeric ``Control`` but is rendered as a rotary dial.
    With ``modulo=True`` the value wraps around at the ``value_range``
    bounds instead of saturating (useful for angles).
    """

    def __init__(
        self,
        value_default: Union[int, float],
        value_range: Optional[List[Union[int, float]]] = None,
        modulo: bool = True,
        name: Optional[str] = None,
        step: Optional[Union[int, float]] = None,
        filter_to_connect: Optional[FilterCore] = None,
        parameter_name_to_connect: Optional[str] = None,
        group: Optional[Union[str, Panel]] = None,
        tooltip: Optional[str] = None,
    ) -> None:
        super().__init__(
            value_default=value_default,
            value_range=value_range,  # type: ignore
            name=name,
            step=step,
            filter_to_connect=filter_to_connect,
            parameter_name_to_connect=parameter_name_to_connect,
            icons=None,
            group=group,
            tooltip=tooltip,
        )
        self.modulo = modulo

    def __repr__(self) -> str:
        return super().__repr__() + f"| modulo:{self.modulo}"

TextPrompt

Bases: Control

Free-text entry box bound to a string keyword argument.

Unlike a string Control with a value_range (a dropdown of fixed choices), a TextPrompt accepts arbitrary text typed by the user.

Source code in src/interactive_pipe/headless/control.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
class TextPrompt(Control):
    """Free-text entry box bound to a string keyword argument.

    Unlike a string ``Control`` with a ``value_range`` (a dropdown of fixed
    choices), a ``TextPrompt`` accepts arbitrary text typed by the user.
    """

    def __init__(
        self,
        value_default: str,
        name: Optional[str] = None,
        filter_to_connect: Optional[FilterCore] = None,
        parameter_name_to_connect: Optional[str] = None,
        group: Optional[Union[str, Panel]] = None,
        tooltip: Optional[str] = None,
    ) -> None:
        super().__init__(
            value_default=value_default,
            value_range=None,
            name=name,
            step=None,
            filter_to_connect=filter_to_connect,
            parameter_name_to_connect=parameter_name_to_connect,
            icons=None,
            group=group,
            tooltip=tooltip,
        )

    def __repr__(self) -> str:
        return super().__repr__()

TimeControl

Bases: Control

Auto-incrementing time control for animations.

Starts at 0.0 and increments automatically. Press 'p' to pause/resume.

Note

Only supported on Qt backend. Falls back to a regular slider on other backends (tooltip/group params are only relevant in that fallback case).

Parameters:

Name Type Description Default
update_interval_ms int

Interval between updates in milliseconds (default: 1000).

1000
pause_resume_key str

Key to pause/resume (default: "p").

'p'
Example
@interactive(time=TimeControl(update_interval_ms=50))
def animate(time=0.0):
    return time
Source code in src/interactive_pipe/headless/control.py
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
class TimeControl(Control):
    """
    Auto-incrementing time control for animations.

    Starts at 0.0 and increments automatically. Press 'p' to pause/resume.

    Note:
        Only supported on Qt backend. Falls back to a regular slider on other backends
        (tooltip/group params are only relevant in that fallback case).

    Args:
        update_interval_ms: Interval between updates in milliseconds (default: 1000).
        pause_resume_key: Key to pause/resume (default: "p").

    Example:
        ```python
        @interactive(time=TimeControl(update_interval_ms=50))
        def animate(time=0.0):
            return time
        ```
    """

    def __init__(
        self,
        name: Optional[str] = None,
        update_interval_ms: int = 1000,
        pause_resume_key: str = "p",
        filter_to_connect: Optional[FilterCore] = None,
        parameter_name_to_connect: Optional[str] = None,
        group: Optional[Union[str, Panel]] = None,
        tooltip: Optional[str] = None,
    ) -> None:
        """Time control. Start at 0.0. Time can be paused/resumed"""
        super().__init__(
            value_default=0.0,
            value_range=[0.0, 3600.0],
            name=name,
            step=None,
            filter_to_connect=filter_to_connect,
            parameter_name_to_connect=parameter_name_to_connect,
            icons=None,
            group=group,
            tooltip=tooltip,
        )
        self.update_interval_ms = update_interval_ms
        self.pause_resume_key = pause_resume_key

    def __repr__(self) -> str:
        return super().__repr__()

__init__

__init__(name: Optional[str] = None, update_interval_ms: int = 1000, pause_resume_key: str = 'p', filter_to_connect: Optional[FilterCore] = None, parameter_name_to_connect: Optional[str] = None, group: Optional[Union[str, Panel]] = None, tooltip: Optional[str] = None) -> None

Time control. Start at 0.0. Time can be paused/resumed

Source code in src/interactive_pipe/headless/control.py
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
def __init__(
    self,
    name: Optional[str] = None,
    update_interval_ms: int = 1000,
    pause_resume_key: str = "p",
    filter_to_connect: Optional[FilterCore] = None,
    parameter_name_to_connect: Optional[str] = None,
    group: Optional[Union[str, Panel]] = None,
    tooltip: Optional[str] = None,
) -> None:
    """Time control. Start at 0.0. Time can be paused/resumed"""
    super().__init__(
        value_default=0.0,
        value_range=[0.0, 3600.0],
        name=name,
        step=None,
        filter_to_connect=filter_to_connect,
        parameter_name_to_connect=parameter_name_to_connect,
        icons=None,
        group=group,
        tooltip=tooltip,
    )
    self.update_interval_ms = update_interval_ms
    self.pause_resume_key = pause_resume_key

KeyboardControl

Bases: Control

Control driven by keyboard keys instead of a slider widget.

Pressing keydown decreases the value by step (or moves to the previous choice for a string control); keyup increases it. A bool control toggles on each keydown press. Special keys such as arrows, page up/down, spacebar and F1-F12 are supported (see SPECIAL_KEYS_LIST); other keys are single characters.

Parameters:

Name Type Description Default
value_default Union[int, float, bool, str]

Initial value; its type selects the behavior (int, float, bool or str).

required
value_range Optional[List[Union[int, float, str]]]

[min, max] for numeric values, or the list of choices for a string control.

None
keydown Optional[str]

Key that decreases the value / toggles a bool.

None
keyup Optional[str]

Key that increases the value. Omit for a toggle-only binding.

None
modulo bool

When True, the value wraps around at the range bounds instead of saturating.

False
name Optional[str]

Label of the control. Auto-generated if omitted.

None
step Optional[Union[int, float]]

Increment applied on each key press (same defaults as Control).

None
filter_to_connect Optional[FilterCore]

Filter to connect immediately (requires parameter_name_to_connect).

None
parameter_name_to_connect Optional[str]

Keyword argument of the connected filter.

None
group Optional[Union[str, Panel]]

Panel instance or panel name used to group controls.

None
Example
@interactive(
    gain=KeyboardControl(1.0, [0.0, 3.0], keydown="g", keyup="h")
)
def amplify(img, gain=1.0):
    return gain * img
Source code in src/interactive_pipe/headless/keyboard.py
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
class KeyboardControl(Control):
    """Control driven by keyboard keys instead of a slider widget.

    Pressing ``keydown`` decreases the value by ``step`` (or moves to the
    previous choice for a string control); ``keyup`` increases it. A bool
    control toggles on each ``keydown`` press. Special keys such as arrows,
    page up/down, spacebar and F1-F12 are supported (see
    ``SPECIAL_KEYS_LIST``); other keys are single characters.

    Args:
        value_default: Initial value; its type selects the behavior
            (int, float, bool or str).
        value_range: ``[min, max]`` for numeric values, or the list of
            choices for a string control.
        keydown: Key that decreases the value / toggles a bool.
        keyup: Key that increases the value. Omit for a toggle-only binding.
        modulo: When True, the value wraps around at the range bounds
            instead of saturating.
        name: Label of the control. Auto-generated if omitted.
        step: Increment applied on each key press (same defaults as
            ``Control``).
        filter_to_connect: Filter to connect immediately (requires
            ``parameter_name_to_connect``).
        parameter_name_to_connect: Keyword argument of the connected filter.
        group: ``Panel`` instance or panel name used to group controls.

    Example:
        ```python
        @interactive(
            gain=KeyboardControl(1.0, [0.0, 3.0], keydown="g", keyup="h")
        )
        def amplify(img, gain=1.0):
            return gain * img
        ```
    """

    KEY_UP = "up"
    KEY_DOWN = "down"
    KEY_LEFT = "left"
    KEY_RIGHT = "right"
    KEY_PAGEUP = "pageup"
    KEY_PAGEDOWN = "pagedown"
    KEY_SPACEBAR = " "
    SPECIAL_KEYS_LIST = [
        KEY_UP,
        KEY_DOWN,
        KEY_LEFT,
        KEY_RIGHT,
        KEY_PAGEUP,
        KEY_PAGEDOWN,
        KEY_SPACEBAR,
    ] + [f"f{i}" for i in range(1, 13)]

    def __init__(
        self,
        value_default: Union[int, float, bool, str],
        value_range: Optional[List[Union[int, float, str]]] = None,
        keydown: Optional[str] = None,
        keyup: Optional[str] = None,
        modulo: bool = False,
        name: Optional[str] = None,
        step: Optional[Union[int, float]] = None,
        filter_to_connect: Optional[FilterCore] = None,
        parameter_name_to_connect: Optional[str] = None,
        group: Optional[Union[str, Panel]] = None,
    ) -> None:
        super().__init__(
            value_default=value_default,
            value_range=value_range,
            name=name,
            step=step,
            filter_to_connect=filter_to_connect,
            parameter_name_to_connect=parameter_name_to_connect,
            icons=None,
            group=group,
        )
        self.keyup = keyup
        self.keydown = keydown
        self.modulo = modulo

    def on_key(self, down=True):
        if self._type is bool:
            new_val = not self.value
            self.value = new_val
            return
        if self._type is int or self._type is float:
            if self.value_range is None:
                return
            current_val = self.value
            sign = -1 if down else +1
            step = self.step
            mini, maxi = self.value_range[0], self.value_range[1]  # type: ignore
        elif self._type is str:
            if self.value_range is None:
                return
            current_val = self.value_range.index(self.value)  # type: ignore
            sign = -1 if down else +1
            step = 1
            mini, maxi = 0, len(self.value_range) - 1
        else:
            return
        new_val = current_val + sign * step  # type: ignore
        if new_val > maxi:  # type: ignore
            new_val = mini if self.modulo else maxi
        if new_val < mini:  # type: ignore
            new_val = maxi if self.modulo else mini
        if self._type is str and self.value_range is not None:
            new_val = self.value_range[int(new_val)]  # type: ignore
        self.value = new_val

    def on_key_down(self):
        self.on_key(down=True)

    def on_key_up(self):
        if self.keyup is not None:
            self.on_key(down=False)

    def __repr__(self) -> str:
        return (
            super().__repr__()
            + f" | down:{'' if self.keydown is None else self.keydown} |  "
            + f"up:{'' if self.keyup is None else self.keyup}  | modulo:{self.modulo}"
        )

    @staticmethod
    def sanity_check_key(key):
        if not isinstance(key, str):
            raise TypeError(f"key must be a string, got {type(key)}")
        key = key.lower()
        if len(key) == 0:
            raise ValueError("key cannot be empty")
        if len(key) > 1:
            if key not in KeyboardControl.SPECIAL_KEYS_LIST:
                raise ValueError(f"key '{key}' is not supported. Use one of {KeyboardControl.SPECIAL_KEYS_LIST}")
        return key

Panel

Panel for organizing controls into groups with optional nesting and grid layouts.

Panels can contain: - Controls (assigned via Control(..., group=panel) or Control(..., group="name")) - Other Panels (nested hierarchy) - Grid layouts (via list of lists)

Example
# Simple panel
text_panel = Panel("Text Settings")

# Collapsible panel
color_panel = Panel("Colors", collapsible=True, collapsed=False)

# Detached panel (Qt backend only - opens in separate window)
tools_panel = Panel("Tools", detached=True, detached_size=(400, 600))

# Panel with position (left, right, top, or bottom)
left_panel = Panel("Tools", position="left")
right_panel = Panel("Settings", position="right")

# Nested panels with grid layout
main_panel = Panel("Main").add_elements([
    [text_panel, color_panel],  # Row 1: side by side
    [effects_panel],            # Row 2: full width
])
Source code in src/interactive_pipe/headless/panel.py
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
class Panel:
    """Panel for organizing controls into groups with optional nesting and grid layouts.

    Panels can contain:
    - Controls (assigned via Control(..., group=panel) or Control(..., group="name"))
    - Other Panels (nested hierarchy)
    - Grid layouts (via list of lists)

    Example:
        ```python
        # Simple panel
        text_panel = Panel("Text Settings")

        # Collapsible panel
        color_panel = Panel("Colors", collapsible=True, collapsed=False)

        # Detached panel (Qt backend only - opens in separate window)
        tools_panel = Panel("Tools", detached=True, detached_size=(400, 600))

        # Panel with position (left, right, top, or bottom)
        left_panel = Panel("Tools", position="left")
        right_panel = Panel("Settings", position="right")

        # Nested panels with grid layout
        main_panel = Panel("Main").add_elements([
            [text_panel, color_panel],  # Row 1: side by side
            [effects_panel],            # Row 2: full width
        ])
        ```
    """

    def __init__(
        self,
        name: Optional[str] = None,
        collapsible: bool = False,
        collapsed: bool = False,
        detached: bool = False,
        detached_size: Optional[tuple] = None,
        position: Optional[str] = None,
    ) -> None:
        """Initialize a Panel.

        Args:
            name: Display name for the panel (shown in group box title)
            collapsible: Whether the panel can be collapsed/expanded
            collapsed: Initial collapsed state (only used if collapsible=True)
            detached: Whether to render panel in a separate window (Qt backend only)
            detached_size: Optional (width, height) tuple for detached window size
            position: Position relative to images - "left", "right", "top", "bottom", or None (defaults to "bottom")
        """
        # Validate position
        valid_positions = {None, "left", "right", "top", "bottom"}
        if position not in valid_positions:
            raise ValueError(f"position must be one of {valid_positions}, got {position}")
        self.name = name
        self.collapsible = collapsible
        self.collapsed = collapsed
        self.detached = detached
        self.detached_size = detached_size
        self.position = position
        self.elements = []  # List of Panels or list of lists (grid)
        self.parent = None  # Parent panel in hierarchy
        self._controls = []  # Controls assigned to this panel

    def add_elements(self, elements: Union[List["Panel"], List[List["Panel"]]]) -> "Panel":
        """Add child panels with optional grid layout.

        Args:
            elements: Can be:
                - List of Panels: [panel1, panel2] - vertical stack
                - List of lists: [[panel1, panel2], [panel3]] - grid layout

        Returns:
            self for method chaining

        Example:
            ```python
            main_panel.add_elements([
                [text_panel, color_panel],  # Row 1
                [effects_panel],            # Row 2
            ])
            ```
        """
        self.elements = elements
        self._set_parent_refs()
        return self

    def _set_parent_refs(self) -> None:
        """Set parent references for all child panels."""
        if not self.elements:
            return

        # Check if grid layout (list of lists)
        if isinstance(self.elements[0], list):
            # Grid layout - cast to List[List[Panel]] for type checker
            grid_elements = cast(List[List["Panel"]], self.elements)
            for row in grid_elements:
                for panel in row:
                    if isinstance(panel, Panel):
                        panel.parent = self
        else:
            # Flat list - cast to List[Panel] for type checker
            flat_elements = cast(List["Panel"], self.elements)
            for panel in flat_elements:
                if isinstance(panel, Panel):
                    panel.parent = self

    def _register_control(self, control) -> None:
        """Called when a control is assigned to this panel.

        Args:
            control: The Control instance being registered
        """
        if control not in self._controls:
            self._controls.append(control)

    def __repr__(self) -> str:
        name_str = f'"{self.name}"' if self.name else "None"
        controls_count = len(self._controls)
        elements_count = len(self.elements) if self.elements else 0
        detached_str = f", detached={self.detached}" if self.detached else ""
        position_str = f", position={self.position}" if self.position else ""
        return (
            f"Panel(name={name_str}, collapsible={self.collapsible}, "
            f"collapsed={self.collapsed}{detached_str}{position_str}, "
            f"controls={controls_count}, elements={elements_count})"
        )

    def __eq__(self, other) -> bool:
        """Panels are equal if they're the same object (for deduplication)."""
        return self is other

    def __hash__(self) -> int:
        """Use object id for hashing (for set/dict usage)."""
        return id(self)

    def get_root(self) -> "Panel":
        """Get the root panel in the hierarchy (walks up parent chain)."""
        current = self
        while current.parent is not None:
            current = current.parent
        return current

__init__

__init__(name: Optional[str] = None, collapsible: bool = False, collapsed: bool = False, detached: bool = False, detached_size: Optional[tuple] = None, position: Optional[str] = None) -> None

Initialize a Panel.

Parameters:

Name Type Description Default
name Optional[str]

Display name for the panel (shown in group box title)

None
collapsible bool

Whether the panel can be collapsed/expanded

False
collapsed bool

Initial collapsed state (only used if collapsible=True)

False
detached bool

Whether to render panel in a separate window (Qt backend only)

False
detached_size Optional[tuple]

Optional (width, height) tuple for detached window size

None
position Optional[str]

Position relative to images - "left", "right", "top", "bottom", or None (defaults to "bottom")

None
Source code in src/interactive_pipe/headless/panel.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def __init__(
    self,
    name: Optional[str] = None,
    collapsible: bool = False,
    collapsed: bool = False,
    detached: bool = False,
    detached_size: Optional[tuple] = None,
    position: Optional[str] = None,
) -> None:
    """Initialize a Panel.

    Args:
        name: Display name for the panel (shown in group box title)
        collapsible: Whether the panel can be collapsed/expanded
        collapsed: Initial collapsed state (only used if collapsible=True)
        detached: Whether to render panel in a separate window (Qt backend only)
        detached_size: Optional (width, height) tuple for detached window size
        position: Position relative to images - "left", "right", "top", "bottom", or None (defaults to "bottom")
    """
    # Validate position
    valid_positions = {None, "left", "right", "top", "bottom"}
    if position not in valid_positions:
        raise ValueError(f"position must be one of {valid_positions}, got {position}")
    self.name = name
    self.collapsible = collapsible
    self.collapsed = collapsed
    self.detached = detached
    self.detached_size = detached_size
    self.position = position
    self.elements = []  # List of Panels or list of lists (grid)
    self.parent = None  # Parent panel in hierarchy
    self._controls = []  # Controls assigned to this panel

add_elements

add_elements(elements: Union[List[Panel], List[List[Panel]]]) -> Panel

Add child panels with optional grid layout.

Parameters:

Name Type Description Default
elements Union[List[Panel], List[List[Panel]]]

Can be: - List of Panels: [panel1, panel2] - vertical stack - List of lists: [[panel1, panel2], [panel3]] - grid layout

required

Returns:

Type Description
Panel

self for method chaining

Example
main_panel.add_elements([
    [text_panel, color_panel],  # Row 1
    [effects_panel],            # Row 2
])
Source code in src/interactive_pipe/headless/panel.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def add_elements(self, elements: Union[List["Panel"], List[List["Panel"]]]) -> "Panel":
    """Add child panels with optional grid layout.

    Args:
        elements: Can be:
            - List of Panels: [panel1, panel2] - vertical stack
            - List of lists: [[panel1, panel2], [panel3]] - grid layout

    Returns:
        self for method chaining

    Example:
        ```python
        main_panel.add_elements([
            [text_panel, color_panel],  # Row 1
            [effects_panel],            # Row 2
        ])
        ```
    """
    self.elements = elements
    self._set_parent_refs()
    return self

__eq__

__eq__(other) -> bool

Panels are equal if they're the same object (for deduplication).

Source code in src/interactive_pipe/headless/panel.py
132
133
134
def __eq__(self, other) -> bool:
    """Panels are equal if they're the same object (for deduplication)."""
    return self is other

__hash__

__hash__() -> int

Use object id for hashing (for set/dict usage).

Source code in src/interactive_pipe/headless/panel.py
136
137
138
def __hash__(self) -> int:
    """Use object id for hashing (for set/dict usage)."""
    return id(self)

get_root

get_root() -> Panel

Get the root panel in the hierarchy (walks up parent chain).

Source code in src/interactive_pipe/headless/panel.py
140
141
142
143
144
145
def get_root(self) -> "Panel":
    """Get the root panel in the hierarchy (walks up parent chain)."""
    current = self
    while current.parent is not None:
        current = current.parent
    return current