Skip to content

Commit 0bcbb6e

Browse files
committed
refactor the data structures
1 parent e16c1dc commit 0bcbb6e

File tree

9 files changed

+652
-81
lines changed

9 files changed

+652
-81
lines changed

app/config.py

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
11
import json
22
from pathlib import Path
3-
from typing import Literal
3+
from typing import Literal, Optional, Union
4+
5+
from pydantic import ValidationError
46
from pydantic_settings import BaseSettings
57

6-
APP_PATH = Path(__file__).parent
8+
APP_PATH = Path(__file__).parent
9+
DEFAULT_CONFIG_PATH = APP_PATH.parent / "config.json"
10+
11+
_config_path = DEFAULT_CONFIG_PATH
12+
13+
_config_cache: Optional["Config"] = None
14+
15+
16+
def _ensure_config_dir() -> None:
17+
get_config_path().parent.mkdir(parents=True, exist_ok=True)
18+
19+
20+
def set_config_path(new_path: Union[str, Path]) -> None:
21+
"""Update the configuration file path and clear the in-memory cache."""
22+
global _config_path, _config_cache
23+
_config_path = Path(new_path)
24+
_config_cache = None
25+
26+
27+
def get_config_path() -> Path:
28+
return _config_path
729

830

931
class Config(BaseSettings):
@@ -19,15 +41,40 @@ class Config(BaseSettings):
1941
joystick_side_identifier_right: str = "R"
2042
modifier_key: str = "rctrl"
2143

22-
def save(self):
23-
with open(APP_PATH.parent / "config.json", "w") as f:
24-
f.write(self.model_dump_json(indent=4))
44+
def save(self) -> None:
45+
_ensure_config_dir()
46+
get_config_path().write_text(self.model_dump_json(indent=4))
47+
self._cache_self()
48+
49+
def _cache_self(self) -> None:
50+
global _config_cache
51+
_config_cache = self
2552

2653
@classmethod
27-
def get_config(cls) -> "Config":
28-
if not (APP_PATH.parent / "config.json").exists():
29-
with open(APP_PATH.parent / "config.json", "w") as f:
30-
f.write(cls().model_dump_json(indent=4))
31-
with open(APP_PATH.parent / "config.json", "r") as f: # should be next to the executable
32-
config_data = json.load(f)
33-
return cls(**config_data)
54+
def get_config(cls, force_reload: bool = False) -> "Config":
55+
global _config_cache
56+
if not force_reload and _config_cache is not None:
57+
return _config_cache
58+
59+
config_path = get_config_path()
60+
61+
if not config_path.exists():
62+
config = cls()
63+
_ensure_config_dir()
64+
config_path.write_text(config.model_dump_json(indent=4))
65+
config._cache_self()
66+
return config
67+
68+
try:
69+
data = config_path.read_text()
70+
config_data = json.loads(data)
71+
except json.JSONDecodeError as exc:
72+
raise ValueError(f"Invalid configuration file: {config_path}") from exc
73+
74+
try:
75+
config = cls(**config_data)
76+
except ValidationError as exc:
77+
raise ValueError(f"Configuration file contains invalid values: {config_path}") from exc
78+
79+
config._cache_self()
80+
return config

app/domain/models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,14 @@ class BindingPlan:
141141
"""Represents a staged set of binding mutations to be applied."""
142142

143143
to_add: list[Binding] = field(default_factory=list)
144-
to_remove: list[str] = field(default_factory=list)
144+
to_remove: list[Binding] = field(default_factory=list)
145145
validation: ValidationReport = field(default_factory=ValidationReport)
146146

147147
def record_add(self, binding: Binding) -> None:
148148
self.to_add.append(binding)
149149

150-
def record_remove(self, binding_key: str) -> None:
151-
self.to_remove.append(binding_key)
150+
def record_remove(self, binding: Binding) -> None:
151+
self.to_remove.append(binding)
152152

153153
def merge(self, other: "BindingPlan") -> None:
154154
self.to_add.extend(other.to_add)

app/services/binding_planner.py

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
from __future__ import annotations
44

5+
from collections import defaultdict
56
from dataclasses import dataclass
6-
from typing import Iterable, Optional
7+
from typing import Dict, Iterable, List, Optional, Tuple
78

89
from app.domain import Binding, BindingPlan, ControlProfile, ValidationIssue, ValidationReport
910

@@ -41,7 +42,7 @@ def plan_diff(
4142
desired_keys = {binding.key: binding for binding in desired_bindings}
4243

4344
for key in current_keys.keys() - desired_keys.keys():
44-
plan.record_remove(key)
45+
plan.record_remove(current_keys[key])
4546

4647
for key, binding in desired_keys.items():
4748
if key not in current_keys:
@@ -60,4 +61,82 @@ def validate_plan(self, plan: BindingPlan) -> ValidationReport:
6061
message="No binding changes detected.",
6162
)
6263
)
64+
return report
65+
66+
slot_key = self._make_slot_key
67+
68+
occupancy: Dict[Tuple[str, str, str], List[Binding]] = defaultdict(list)
69+
if self.context.default_profile is not None:
70+
for binding in self.context.default_profile.iter_bindings():
71+
occupancy[slot_key(binding)].append(binding)
72+
73+
for binding in plan.to_remove:
74+
key = slot_key(binding)
75+
if key not in occupancy:
76+
continue
77+
remaining = [existing for existing in occupancy[key] if existing.key != binding.key]
78+
if remaining:
79+
occupancy[key] = remaining
80+
else:
81+
occupancy.pop(key)
82+
83+
additions_by_slot: Dict[Tuple[str, str, str], List[Binding]] = defaultdict(list)
84+
for binding in plan.to_add:
85+
additions_by_slot[slot_key(binding)].append(binding)
86+
87+
for key, bindings in additions_by_slot.items():
88+
slot_desc = f"{key[0]}:{key[2]}"
89+
actions_list = ", ".join(sorted({binding.action.name for binding in bindings}))
90+
91+
if len(bindings) > 1:
92+
modifier_values = {binding.modifier for binding in bindings}
93+
if len(modifier_values) > 1:
94+
report.add(
95+
ValidationIssue(
96+
level="error",
97+
message=(
98+
f"Modifier conflict: slot {slot_desc} receives both modifier and "
99+
f"non-modifier bindings ({actions_list})."
100+
),
101+
slot=bindings[0].slot,
102+
)
103+
)
104+
else:
105+
report.add(
106+
ValidationIssue(
107+
level="error",
108+
message=(
109+
f"Duplicate slot assignment: slot {slot_desc} receives multiple "
110+
f"bindings ({actions_list})."
111+
),
112+
slot=bindings[0].slot,
113+
)
114+
)
115+
116+
existing_bindings = occupancy.get(key, [])
117+
if existing_bindings:
118+
existing_actions = ", ".join(
119+
sorted({binding.action.name for binding in existing_bindings})
120+
)
121+
existing_modifiers = {binding.modifier for binding in existing_bindings}
122+
addition_modifiers = {binding.modifier for binding in bindings}
123+
if existing_modifiers ^ addition_modifiers:
124+
reason = "modifier conflict"
125+
else:
126+
reason = "slot already mapped"
127+
report.add(
128+
ValidationIssue(
129+
level="error",
130+
message=(
131+
f"{reason.capitalize()}: slot {slot_desc} currently mapped to {existing_actions}; "
132+
f"cannot add {actions_list}."
133+
),
134+
slot=bindings[0].slot,
135+
)
136+
)
137+
63138
return report
139+
140+
@staticmethod
141+
def _make_slot_key(binding: Binding) -> Tuple[str, str, str]:
142+
return binding.slot.device_uid, binding.slot.side, binding.slot.slot_id

0 commit comments

Comments
 (0)