| """ |
| 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'], |
| ] |
|
|
| |
| 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 |
|
|
| |
| if scheme.get("sequential_key_binding"): |
| return True |
|
|
| |
| 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_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 |
|
|
| |
| 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: |
| |
| 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. |
| """ |
| |
| 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) |
|
|
| |
| 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)) |
|
|
| |
| available_pools = [] |
| for pool in KEY_POOLS: |
| available = [k for k in pool if k not in globally_used] |
| available_pools.append(available) |
|
|
| |
| 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": |
| |
| 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 |
|
|
| |
| |
| needed = 0 |
| for label_data in labels: |
| if _get_explicit_key(label_data) is None: |
| needed += 1 |
|
|
| |
| 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: |
| |
| 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: |
| |
| 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." |
| ) |
| |
| 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}) |
|
|
| |
| available_pools[assigned_pool] = [ |
| k for k in available_pools[assigned_pool] if k not in consumed |
| ] |
|
|
| allocation[name] = result |
|
|
| |
| if assigned_pool == next_pool_idx: |
| next_pool_idx = assigned_pool + 1 |
|
|
| return allocation |
|
|