napari-padbound 0.2.0__tar.gz

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 @@
1
+ launch_napari.py
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024, Utz H. Ermel
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: napari-padbound
3
+ Version: 0.2.0
4
+ Summary: A napari plugin for padbound
5
+ Project-URL: Bug Tracker, https://github.com/uermel/napari-padbound/issues
6
+ Project-URL: Documentation, https://github.com/uermel/napari-padbound#README.md
7
+ Project-URL: Source Code, https://github.com/uermel/napari-padbound
8
+ Author-email: "Utz H. Ermel" <utz@ermel.me>
9
+ License-Expression: BSD-3-Clause
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: napari
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: BSD License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: magicgui
26
+ Requires-Dist: napari
27
+ Requires-Dist: numpy
28
+ Requires-Dist: padbound>=0.3.0
29
+ Requires-Dist: qtpy
30
+ Provides-Extra: dev
31
+ Requires-Dist: black; extra == 'dev'
32
+ Requires-Dist: pre-commit; extra == 'dev'
33
+ Requires-Dist: ruff; extra == 'dev'
34
+ Provides-Extra: testing
35
+ Requires-Dist: napari; extra == 'testing'
36
+ Requires-Dist: pyqt5; extra == 'testing'
37
+ Requires-Dist: pytest; extra == 'testing'
38
+ Requires-Dist: pytest-cov; extra == 'testing'
39
+ Requires-Dist: pytest-qt; extra == 'testing'
40
+ Requires-Dist: tox; extra == 'testing'
41
+ Description-Content-Type: text/markdown
42
+
43
+ # napari-padbound
44
+
45
+ [![License BSD-3](https://img.shields.io/pypi/l/napari-padbound.svg?color=green)](https://github.com/uermel/napari-padbound/raw/main/LICENSE)
46
+ [![PyPI](https://img.shields.io/pypi/v/napari-padbound.svg?color=green)](https://pypi.org/project/napari-padbound)
47
+ [![Python Version](https://img.shields.io/pypi/pyversions/napari-padbound.svg?color=green)](https://python.org)
48
+ [![napari hub](https://img.shields.io/endpoint?url=https://api.napari-hub.org/shields/napari-padbound)](https://napari-hub.org/plugins/napari-padbound)
49
+
50
+ A [napari] plugin for controlling image annotation workflows with MIDI controllers via [padbound].
51
+
52
+ Use physical pads, knobs, faders, and buttons to navigate slices, select labels, adjust brush size, zoom, undo/redo, and more &mdash; with real-time LED feedback showing your current label colors on the controller.
53
+
54
+ ## Features
55
+
56
+ - **Auto-detection** &mdash; Automatically finds and connects to any [padbound]-supported MIDI controller
57
+ - **Smart control mapping** &mdash; Automatically assigns available hardware controls to napari functions based on controller capabilities
58
+ - **Slice navigation** &mdash; Coarse and fine navigation through 3D+ data volumes via faders or knobs
59
+ - **Slice stepping** &mdash; Increment/decrement slices one at a time via navigation buttons
60
+ - **Zoom control** &mdash; Logarithmic zoom mapping (0.01x&ndash;10x) via knobs or faders
61
+ - **Brush size control** &mdash; Logarithmic brush size adjustment (1&ndash;100px) for label painting
62
+ - **Label selection** &mdash; Select labels by pressing pads; pad 1 is the eraser, remaining pads map to labels 1, 2, 3, ...
63
+ - **LED color feedback** &mdash; Pads display actual label colors from the napari colormap, with the selected label pulsing (on RGB-capable controllers)
64
+ - **Dimension rolling** &mdash; Cycle through dimension views (XY, YZ, XZ) via navigation buttons
65
+ - **Undo/redo** &mdash; Transport buttons for edit history on the active Labels layer
66
+ - **Graceful degradation** &mdash; Three feedback strategies (RGB color, binary toggle, none) adapt automatically to controller capabilities
67
+
68
+ ## Supported Controllers
69
+
70
+ Any controller with a [padbound] plugin works automatically. Currently supported:
71
+
72
+ | Controller | Best for | Key controls |
73
+ |---|---|---|
74
+ | **AKAI APC mini MK2** | Full RGB feedback, many faders | 64 RGB pads, 9 faders, 17 buttons |
75
+ | **AKAI LPD8 MK2** | Compact RGB + knobs | 8 RGB pads, 8 knobs, 4 banks |
76
+ | **AKAI MPD218** | Velocity-sensitive pads | 16 pads, 6 encoders, 3 banks |
77
+ | **PreSonus ATOM** | RGB pads + encoders + buttons | 16 RGB pads, 4 encoders, 20 buttons |
78
+ | **Synido TempoPad P16** | RGB pads + transport | 16 RGB pads, 4 encoders, 6 buttons |
79
+ | **Behringer X-Touch Mini** | Encoders with LED rings | 16 pads, 8 encoders, 1 fader |
80
+ | **Xjam** | Budget option, multi-bank | 16 pads, 6 knobs, 3 banks |
81
+
82
+ ## How Control Mapping Works
83
+
84
+ The plugin automatically discovers available controls and assigns them by priority:
85
+
86
+ **Continuous controls** (assigned in order: faders first, then knobs, then encoders):
87
+ 1. First control &rarr; **Coarse slice** (full range of the data volume)
88
+ 2. Second control &rarr; **Fine slice** (&plusmn;64 slices around the coarse position)
89
+ 3. Third control &rarr; **Brush size** (logarithmic, 1&ndash;100px)
90
+ 4. Fourth control &rarr; **Zoom** (logarithmic, 0.01x&ndash;10x)
91
+
92
+ **Pads** &rarr; **Label selection** (pad 1 = eraser, pad 2+ = labels)
93
+
94
+ **Navigation buttons** &rarr; Up/Down = slice step, Left/Right = dimension roll
95
+
96
+ **Transport buttons** &rarr; Stop = undo, Play = redo
97
+
98
+ The widget displays the detected controller and its mapped controls so you can see what each physical control does.
99
+
100
+ ## Installation
101
+
102
+ ```bash
103
+ pip install napari-padbound
104
+ ```
105
+
106
+ For development:
107
+
108
+ ```bash
109
+ git clone https://github.com/uermel/napari-padbound.git
110
+ cd napari-padbound
111
+ pip install -e ".[dev,testing]"
112
+ ```
113
+
114
+ ## Usage
115
+
116
+ 1. Connect a supported MIDI controller via USB
117
+ 2. Open [napari]
118
+ 3. Go to **Plugins > padbound** to open the widget
119
+ 4. Load a 3D image and create a Labels layer
120
+ 5. Use your controller to navigate slices, select labels, and annotate
121
+
122
+ The widget shows the connected controller name and the mapping of physical controls to napari functions. If no controller is detected, the widget will indicate this.
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ # Linting
128
+ ruff check src/
129
+ ruff format src/
130
+ black src/
131
+
132
+ # Run tests
133
+ pytest
134
+ ```
135
+
136
+ ## Contributing
137
+
138
+ Contributions are welcome! Please feel free to submit a Pull Request.
139
+
140
+ ## License
141
+
142
+ Distributed under the terms of the [BSD-3] license, napari-padbound is free and open source software.
143
+
144
+ ## Issues
145
+
146
+ If you encounter any problems, please [file an issue] along with a detailed description.
147
+
148
+ [napari]: https://github.com/napari/napari
149
+ [padbound]: https://github.com/uermel/padbound
150
+ [BSD-3]: http://opensource.org/licenses/BSD-3-Clause
151
+ [file an issue]: https://github.com/uermel/napari-padbound/issues
@@ -0,0 +1,109 @@
1
+ # napari-padbound
2
+
3
+ [![License BSD-3](https://img.shields.io/pypi/l/napari-padbound.svg?color=green)](https://github.com/uermel/napari-padbound/raw/main/LICENSE)
4
+ [![PyPI](https://img.shields.io/pypi/v/napari-padbound.svg?color=green)](https://pypi.org/project/napari-padbound)
5
+ [![Python Version](https://img.shields.io/pypi/pyversions/napari-padbound.svg?color=green)](https://python.org)
6
+ [![napari hub](https://img.shields.io/endpoint?url=https://api.napari-hub.org/shields/napari-padbound)](https://napari-hub.org/plugins/napari-padbound)
7
+
8
+ A [napari] plugin for controlling image annotation workflows with MIDI controllers via [padbound].
9
+
10
+ Use physical pads, knobs, faders, and buttons to navigate slices, select labels, adjust brush size, zoom, undo/redo, and more &mdash; with real-time LED feedback showing your current label colors on the controller.
11
+
12
+ ## Features
13
+
14
+ - **Auto-detection** &mdash; Automatically finds and connects to any [padbound]-supported MIDI controller
15
+ - **Smart control mapping** &mdash; Automatically assigns available hardware controls to napari functions based on controller capabilities
16
+ - **Slice navigation** &mdash; Coarse and fine navigation through 3D+ data volumes via faders or knobs
17
+ - **Slice stepping** &mdash; Increment/decrement slices one at a time via navigation buttons
18
+ - **Zoom control** &mdash; Logarithmic zoom mapping (0.01x&ndash;10x) via knobs or faders
19
+ - **Brush size control** &mdash; Logarithmic brush size adjustment (1&ndash;100px) for label painting
20
+ - **Label selection** &mdash; Select labels by pressing pads; pad 1 is the eraser, remaining pads map to labels 1, 2, 3, ...
21
+ - **LED color feedback** &mdash; Pads display actual label colors from the napari colormap, with the selected label pulsing (on RGB-capable controllers)
22
+ - **Dimension rolling** &mdash; Cycle through dimension views (XY, YZ, XZ) via navigation buttons
23
+ - **Undo/redo** &mdash; Transport buttons for edit history on the active Labels layer
24
+ - **Graceful degradation** &mdash; Three feedback strategies (RGB color, binary toggle, none) adapt automatically to controller capabilities
25
+
26
+ ## Supported Controllers
27
+
28
+ Any controller with a [padbound] plugin works automatically. Currently supported:
29
+
30
+ | Controller | Best for | Key controls |
31
+ |---|---|---|
32
+ | **AKAI APC mini MK2** | Full RGB feedback, many faders | 64 RGB pads, 9 faders, 17 buttons |
33
+ | **AKAI LPD8 MK2** | Compact RGB + knobs | 8 RGB pads, 8 knobs, 4 banks |
34
+ | **AKAI MPD218** | Velocity-sensitive pads | 16 pads, 6 encoders, 3 banks |
35
+ | **PreSonus ATOM** | RGB pads + encoders + buttons | 16 RGB pads, 4 encoders, 20 buttons |
36
+ | **Synido TempoPad P16** | RGB pads + transport | 16 RGB pads, 4 encoders, 6 buttons |
37
+ | **Behringer X-Touch Mini** | Encoders with LED rings | 16 pads, 8 encoders, 1 fader |
38
+ | **Xjam** | Budget option, multi-bank | 16 pads, 6 knobs, 3 banks |
39
+
40
+ ## How Control Mapping Works
41
+
42
+ The plugin automatically discovers available controls and assigns them by priority:
43
+
44
+ **Continuous controls** (assigned in order: faders first, then knobs, then encoders):
45
+ 1. First control &rarr; **Coarse slice** (full range of the data volume)
46
+ 2. Second control &rarr; **Fine slice** (&plusmn;64 slices around the coarse position)
47
+ 3. Third control &rarr; **Brush size** (logarithmic, 1&ndash;100px)
48
+ 4. Fourth control &rarr; **Zoom** (logarithmic, 0.01x&ndash;10x)
49
+
50
+ **Pads** &rarr; **Label selection** (pad 1 = eraser, pad 2+ = labels)
51
+
52
+ **Navigation buttons** &rarr; Up/Down = slice step, Left/Right = dimension roll
53
+
54
+ **Transport buttons** &rarr; Stop = undo, Play = redo
55
+
56
+ The widget displays the detected controller and its mapped controls so you can see what each physical control does.
57
+
58
+ ## Installation
59
+
60
+ ```bash
61
+ pip install napari-padbound
62
+ ```
63
+
64
+ For development:
65
+
66
+ ```bash
67
+ git clone https://github.com/uermel/napari-padbound.git
68
+ cd napari-padbound
69
+ pip install -e ".[dev,testing]"
70
+ ```
71
+
72
+ ## Usage
73
+
74
+ 1. Connect a supported MIDI controller via USB
75
+ 2. Open [napari]
76
+ 3. Go to **Plugins > padbound** to open the widget
77
+ 4. Load a 3D image and create a Labels layer
78
+ 5. Use your controller to navigate slices, select labels, and annotate
79
+
80
+ The widget shows the connected controller name and the mapping of physical controls to napari functions. If no controller is detected, the widget will indicate this.
81
+
82
+ ## Development
83
+
84
+ ```bash
85
+ # Linting
86
+ ruff check src/
87
+ ruff format src/
88
+ black src/
89
+
90
+ # Run tests
91
+ pytest
92
+ ```
93
+
94
+ ## Contributing
95
+
96
+ Contributions are welcome! Please feel free to submit a Pull Request.
97
+
98
+ ## License
99
+
100
+ Distributed under the terms of the [BSD-3] license, napari-padbound is free and open source software.
101
+
102
+ ## Issues
103
+
104
+ If you encounter any problems, please [file an issue] along with a detailed description.
105
+
106
+ [napari]: https://github.com/napari/napari
107
+ [padbound]: https://github.com/uermel/padbound
108
+ [BSD-3]: http://opensource.org/licenses/BSD-3-Clause
109
+ [file an issue]: https://github.com/uermel/napari-padbound/issues
@@ -0,0 +1,96 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "napari-padbound"
7
+ dynamic = ["version"]
8
+ description = "A napari plugin for padbound"
9
+ readme = "README.md"
10
+ license = "BSD-3-Clause"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "Utz H. Ermel", email = "utz@ermel.me" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Framework :: napari",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: BSD License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.9",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Topic :: Scientific/Engineering :: Image Processing",
29
+ ]
30
+ dependencies = [
31
+ "napari",
32
+ "numpy",
33
+ "magicgui",
34
+ "qtpy",
35
+ "padbound>=0.3.0",
36
+ ]
37
+
38
+ [project.optional-dependencies]
39
+ testing = [
40
+ "tox",
41
+ "pytest",
42
+ "pytest-cov",
43
+ "pytest-qt",
44
+ "napari",
45
+ "pyqt5",
46
+ ]
47
+ dev = [
48
+ "black",
49
+ "ruff",
50
+ "pre-commit",
51
+ ]
52
+
53
+ [project.entry-points."napari.manifest"]
54
+ napari-padbound = "napari_padbound:napari.yaml"
55
+
56
+ [project.urls]
57
+ "Bug Tracker" = "https://github.com/uermel/napari-padbound/issues"
58
+ "Documentation" = "https://github.com/uermel/napari-padbound#README.md"
59
+ "Source Code" = "https://github.com/uermel/napari-padbound"
60
+
61
+ [tool.hatch.version]
62
+ source = "vcs"
63
+
64
+ [tool.hatch.build.hooks.vcs]
65
+ version-file = "src/napari_padbound/_version.py"
66
+
67
+ [tool.hatch.build.targets.sdist]
68
+ include = ["/src"]
69
+
70
+ [tool.hatch.build.targets.wheel]
71
+ packages = ["src/napari_padbound"]
72
+
73
+ [tool.black]
74
+ line-length = 120
75
+ target-version = ["py311"]
76
+
77
+ [tool.ruff]
78
+ line-length = 120
79
+ lint.select = [
80
+ "E", # pycodestyle errors
81
+ "W", # pycodestyle warnings
82
+ "F", # Pyflakes
83
+ "B", # flake8-bugbear
84
+ "I", # isort
85
+ ]
86
+ lint.ignore = [
87
+ "E501", # line too long (handled by black)
88
+ ]
89
+
90
+ [tool.pytest.ini_options]
91
+ minversion = "6.0"
92
+ addopts = "-v --color=yes"
93
+ testpaths = ["src/napari_padbound/_tests"]
94
+ filterwarnings = [
95
+ "ignore::DeprecationWarning",
96
+ ]
@@ -0,0 +1,27 @@
1
+ try:
2
+ from ._version import version as __version__
3
+ except ImportError:
4
+ __version__ = "unknown"
5
+
6
+ from .control_mapper import ControlMapper, ControlMapping
7
+ from .label_feedback import (
8
+ LabelFeedbackStrategy,
9
+ NoFeedbackStrategy,
10
+ RGBColorStrategy,
11
+ ToggleStrategy,
12
+ create_feedback_strategy,
13
+ )
14
+ from .viewer_controller import ViewerController
15
+ from .widget import PadboundWidget
16
+
17
+ __all__ = (
18
+ "ControlMapper",
19
+ "ControlMapping",
20
+ "LabelFeedbackStrategy",
21
+ "NoFeedbackStrategy",
22
+ "RGBColorStrategy",
23
+ "ToggleStrategy",
24
+ "create_feedback_strategy",
25
+ "ViewerController",
26
+ "PadboundWidget",
27
+ )
@@ -0,0 +1,7 @@
1
+ def test_widget(make_napari_viewer):
2
+ from napari_padbound import PadboundWidget
3
+
4
+ viewer = make_napari_viewer()
5
+ widget = PadboundWidget(viewer)
6
+
7
+ assert widget is not None
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.2.0'
32
+ __version_tuple__ = version_tuple = (0, 2, 0)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,155 @@
1
+ """Control mapping for auto-discovering and assigning MIDI controls to features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ if TYPE_CHECKING:
10
+ from padbound import Controller
11
+ from padbound.controls import ControlDefinition
12
+
13
+
14
+ class ControlMapping(BaseModel):
15
+ """Mapping of physical controls to napari features."""
16
+
17
+ coarse_slice: str | None = None # control_id for coarse slice
18
+ fine_slice: str | None = None # control_id for fine slice
19
+ zoom: str | None = None # control_id for zoom
20
+ brush_size: str | None = None # control_id for brush size
21
+ label_pads: list[str] = Field(default_factory=list) # control_ids for labels
22
+
23
+ # Navigation button mappings
24
+ slice_up: str | None = None # +1 slice step
25
+ slice_down: str | None = None # -1 slice step
26
+ roll_left: str | None = None # Roll dims left
27
+ roll_right: str | None = None # Roll dims right
28
+
29
+ # Transport button mappings
30
+ undo: str | None = None # Undo action (stop button)
31
+ redo: str | None = None # Redo action (play button)
32
+
33
+
34
+ class ControlMapper:
35
+ """Discovers and maps controller controls to napari features.
36
+
37
+ Automatically assigns controls based on their type and capabilities:
38
+ - Faders preferred for coarse slice control
39
+ - Knobs/encoders for fine slice, brush size, zoom
40
+ - Pads for label selection
41
+ """
42
+
43
+ def __init__(self, controller: Controller) -> None:
44
+ """Initialize the control mapper.
45
+
46
+ Args:
47
+ controller: The padbound Controller instance.
48
+ """
49
+ self.controller = controller
50
+ self.controls: list[ControlDefinition] = controller.get_controls()
51
+
52
+ def create_mapping(self) -> ControlMapping:
53
+ """Auto-discover and map controls based on capabilities.
54
+
55
+ Priority for continuous controls: fader > knob > encoder
56
+ All mapped controls come from the same bank (for multi-bank controllers).
57
+
58
+ Returns:
59
+ ControlMapping with assigned control IDs.
60
+ """
61
+ mapping = ControlMapping()
62
+
63
+ # Group controls by category
64
+ faders = [c for c in self.controls if c.category == "fader"]
65
+ knobs = [c for c in self.controls if c.category == "knob"]
66
+ encoders = [c for c in self.controls if c.category == "encoder"]
67
+ pads = [c for c in self.controls if c.category == "pad"]
68
+
69
+ # Determine primary bank from first fader (or first continuous control)
70
+ all_continuous = faders + knobs + encoders
71
+ primary_bank = all_continuous[0].bank_id if all_continuous else None
72
+
73
+ # Filter to primary bank only (None matches None for bankless controllers)
74
+ faders = [c for c in faders if c.bank_id == primary_bank]
75
+ knobs = [c for c in knobs if c.bank_id == primary_bank]
76
+ encoders = [c for c in encoders if c.bank_id == primary_bank]
77
+ pads = [c for c in pads if c.bank_id == primary_bank]
78
+
79
+ # Assign continuous controls (priority: fader > knob > encoder)
80
+ continuous = faders + knobs + encoders
81
+ if len(continuous) >= 1:
82
+ mapping.coarse_slice = continuous[0].control_id
83
+ if len(continuous) >= 2:
84
+ mapping.fine_slice = continuous[1].control_id
85
+ if len(continuous) >= 3:
86
+ mapping.brush_size = continuous[2].control_id
87
+ if len(continuous) >= 4:
88
+ mapping.zoom = continuous[3].control_id
89
+
90
+ # Assign pads for label selection
91
+ mapping.label_pads = [p.control_id for p in pads]
92
+
93
+ # Discover navigation buttons (for slice stepping and dim rolling)
94
+ nav_controls = [
95
+ c for c in self.controls
96
+ if c.category == "navigation" and c.bank_id == primary_bank
97
+ ]
98
+ for c in nav_controls:
99
+ cid = c.control_id.lower()
100
+ if cid in ("up", "nav_up") and mapping.slice_up is None:
101
+ mapping.slice_up = c.control_id
102
+ elif cid in ("down", "nav_down") and mapping.slice_down is None:
103
+ mapping.slice_down = c.control_id
104
+ elif cid in ("left", "nav_left") and mapping.roll_left is None:
105
+ mapping.roll_left = c.control_id
106
+ elif cid in ("right", "nav_right") and mapping.roll_right is None:
107
+ mapping.roll_right = c.control_id
108
+
109
+ # Discover transport buttons (for undo/redo)
110
+ transport_controls = [
111
+ c for c in self.controls
112
+ if c.category == "transport" and c.bank_id == primary_bank
113
+ ]
114
+ for c in transport_controls:
115
+ cid = c.control_id.lower()
116
+ if cid == "stop" and mapping.undo is None:
117
+ mapping.undo = c.control_id
118
+ elif cid == "play" and mapping.redo is None:
119
+ mapping.redo = c.control_id
120
+
121
+ return mapping
122
+
123
+ def get_mapping_info(self) -> str:
124
+ """Get human-readable description of the control mapping.
125
+
126
+ Returns:
127
+ Multi-line string describing the mapping.
128
+ """
129
+ mapping = self.create_mapping()
130
+ lines = []
131
+
132
+ if mapping.coarse_slice:
133
+ lines.append(f"Coarse slice: {mapping.coarse_slice}")
134
+ if mapping.fine_slice:
135
+ lines.append(f"Fine slice: {mapping.fine_slice}")
136
+ if mapping.brush_size:
137
+ lines.append(f"Brush size: {mapping.brush_size}")
138
+ if mapping.zoom:
139
+ lines.append(f"Zoom: {mapping.zoom}")
140
+ if mapping.label_pads:
141
+ lines.append(f"Label pads: {len(mapping.label_pads)} pads")
142
+ if mapping.slice_up:
143
+ lines.append(f"Slice up: {mapping.slice_up}")
144
+ if mapping.slice_down:
145
+ lines.append(f"Slice down: {mapping.slice_down}")
146
+ if mapping.roll_left:
147
+ lines.append(f"Roll left: {mapping.roll_left}")
148
+ if mapping.roll_right:
149
+ lines.append(f"Roll right: {mapping.roll_right}")
150
+ if mapping.undo:
151
+ lines.append(f"Undo: {mapping.undo}")
152
+ if mapping.redo:
153
+ lines.append(f"Redo: {mapping.redo}")
154
+
155
+ return "\n".join(lines) if lines else "No controls mapped"