Spaces:
Paused
Paused
| """ | |
| Keybinding Allocator | |
| Centralized allocation of non-conflicting keyboard shortcuts across all | |
| annotation schemas. When multiple schemas use sequential_key_binding: true, | |
| this module assigns keys from separate pools so they don't overlap. | |
| Key pools (QWERTY layout): | |
| Pool 0: 1 2 3 4 5 6 7 8 9 0 (number row) | |
| Pool 1: q w e r t y u i o p (top letter row) | |
| Pool 2: a s d f g h j k l (home row) | |
| Schemas that self-manage keys (pairwise, bws) pre-claim their hardcoded keys. | |
| Explicit per-label key_value overrides are always honored. | |
| """ | |
| import logging | |
| from collections.abc import Mapping | |
| logger = logging.getLogger(__name__) | |
| KEY_POOLS = [ | |
| ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], | |
| ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'], | |
| ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'], | |
| ] | |
| # Schema types that manage their own keybindings internally | |
| SELF_MANAGED_TYPES = {'pairwise', 'bws', 'triage'} | |
| def _get_label_name(label_data): | |
| """Extract the label name from a label entry (string or dict).""" | |
| if isinstance(label_data, str): | |
| return label_data | |
| if isinstance(label_data, Mapping): | |
| return label_data.get("name", "") | |
| return str(label_data) | |
| def _get_explicit_key(label_data): | |
| """Extract explicit key_value from a label entry, or None.""" | |
| if isinstance(label_data, Mapping): | |
| kv = label_data.get("key_value") | |
| if kv is not None: | |
| return str(kv).lower() | |
| return None | |
| def _needs_allocation(scheme): | |
| """Check if a schema needs keybinding allocation.""" | |
| ann_type = scheme.get("annotation_type", "") | |
| if ann_type in SELF_MANAGED_TYPES: | |
| return False | |
| strategy = scheme.get("keybinding_strategy", "") | |
| if strategy == "none": | |
| return False | |
| # Explicit sequential_key_binding | |
| if scheme.get("sequential_key_binding"): | |
| return True | |
| # keybinding_strategy set to sequential or mnemonic | |
| if strategy in ("sequential", "mnemonic"): | |
| return True | |
| return False | |
| def _assign_mnemonic_keys(labels, used_keys): | |
| """ | |
| Assign mnemonic keys based on first available letter of each label name. | |
| Falls back to next available letter if the preferred one is taken. | |
| Returns list of (label_name, key) tuples. | |
| """ | |
| # All available mnemonic letters | |
| all_letters = list('abcdefghijklmnopqrstuvwxyz') | |
| available = [c for c in all_letters if c not in used_keys] | |
| assignments = [] | |
| for label_data in labels: | |
| label_name = _get_label_name(label_data) | |
| explicit = _get_explicit_key(label_data) | |
| if explicit: | |
| assignments.append((label_name, explicit)) | |
| continue | |
| # Try each character of the label name | |
| assigned = False | |
| for char in label_name.lower(): | |
| if char.isalpha() and char in available: | |
| assignments.append((label_name, char)) | |
| available.remove(char) | |
| assigned = True | |
| break | |
| if not assigned: | |
| # Fall back to next available letter | |
| if available: | |
| key = available.pop(0) | |
| assignments.append((label_name, key)) | |
| logger.warning( | |
| f"No mnemonic match for '{label_name}', " | |
| f"assigned fallback key '{key}'" | |
| ) | |
| else: | |
| assignments.append((label_name, None)) | |
| logger.warning( | |
| f"No keys available for label '{label_name}'" | |
| ) | |
| return assignments | |
| def allocate_keybindings(annotation_schemes): | |
| """ | |
| Pre-allocate non-conflicting keys across all annotation schemas. | |
| Args: | |
| annotation_schemes: List of annotation scheme dicts from config. | |
| Returns: | |
| dict: {schema_name: [{"label": str, "key": str|None}, ...]} | |
| Only schemas that need allocation are included. | |
| """ | |
| # Step 1: Collect all explicitly-set keys across all schemas | |
| globally_used = set() | |
| for scheme in annotation_schemes: | |
| if not _needs_allocation(scheme): | |
| continue | |
| for label_data in scheme.get("labels", []): | |
| explicit = _get_explicit_key(label_data) | |
| if explicit: | |
| globally_used.add(explicit) | |
| # Step 2: Pre-claim keys used by self-managed schemas (pairwise, bws) | |
| for scheme in annotation_schemes: | |
| ann_type = scheme.get("annotation_type", "") | |
| if ann_type == "pairwise": | |
| if scheme.get("sequential_key_binding", True): | |
| globally_used.update({'1', '2', '0'}) | |
| elif ann_type == "bws": | |
| if scheme.get("sequential_key_binding", True): | |
| tuple_size = scheme.get("tuple_size", 4) | |
| for i in range(1, tuple_size + 1): | |
| globally_used.add(str(i)) | |
| for i in range(tuple_size): | |
| if i < 26: | |
| globally_used.add(chr(ord('a') + i)) | |
| # Step 3: Build available pools (excluding globally used keys) | |
| available_pools = [] | |
| for pool in KEY_POOLS: | |
| available = [k for k in pool if k not in globally_used] | |
| available_pools.append(available) | |
| # Step 4: Allocate keys to schemas that need them | |
| allocation = {} | |
| next_pool_idx = 0 | |
| for scheme in annotation_schemes: | |
| if not _needs_allocation(scheme): | |
| continue | |
| name = scheme.get("name", "") | |
| labels = scheme.get("labels", []) | |
| strategy = scheme.get("keybinding_strategy", "sequential") | |
| if strategy == "mnemonic": | |
| # Mnemonic allocation uses label name letters | |
| assignments = _assign_mnemonic_keys(labels, globally_used) | |
| result = [] | |
| for label_name, key in assignments: | |
| result.append({"label": label_name, "key": key}) | |
| if key: | |
| globally_used.add(key) | |
| allocation[name] = result | |
| continue | |
| # Sequential allocation from pools | |
| # Count how many keys we need (subtract explicit ones) | |
| needed = 0 | |
| for label_data in labels: | |
| if _get_explicit_key(label_data) is None: | |
| needed += 1 | |
| # Find a pool with enough capacity | |
| assigned_pool = None | |
| for pool_idx in range(next_pool_idx, len(available_pools)): | |
| if len(available_pools[pool_idx]) >= needed: | |
| assigned_pool = pool_idx | |
| break | |
| if assigned_pool is None: | |
| # Try earlier pools too (in case first schema used mnemonic) | |
| for pool_idx in range(len(available_pools)): | |
| if len(available_pools[pool_idx]) >= needed: | |
| assigned_pool = pool_idx | |
| break | |
| if assigned_pool is None: | |
| # Not enough keys in any single pool — assign what we can | |
| logger.warning( | |
| f"Schema '{name}' has {needed} labels needing keys " | |
| f"but no single pool has enough capacity. " | |
| f"Some labels will not have keybindings." | |
| ) | |
| # Use the pool with the most remaining keys | |
| assigned_pool = max( | |
| range(len(available_pools)), | |
| key=lambda i: len(available_pools[i]) | |
| ) | |
| pool_keys = available_pools[assigned_pool] | |
| key_iter = iter(pool_keys) | |
| result = [] | |
| consumed = [] | |
| for label_data in labels: | |
| label_name = _get_label_name(label_data) | |
| explicit = _get_explicit_key(label_data) | |
| if explicit: | |
| result.append({"label": label_name, "key": explicit}) | |
| else: | |
| key = next(key_iter, None) | |
| if key: | |
| result.append({"label": label_name, "key": key}) | |
| consumed.append(key) | |
| globally_used.add(key) | |
| else: | |
| result.append({"label": label_name, "key": None}) | |
| # Remove consumed keys from the pool | |
| available_pools[assigned_pool] = [ | |
| k for k in available_pools[assigned_pool] if k not in consumed | |
| ] | |
| allocation[name] = result | |
| # Advance to next pool for the next schema | |
| if assigned_pool == next_pool_idx: | |
| next_pool_idx = assigned_pool + 1 | |
| return allocation | |