diff --git a/core/constants.py b/core/constants.py index ba079a50..014a98f5 100644 --- a/core/constants.py +++ b/core/constants.py @@ -15,6 +15,7 @@ BLACK=(50, 50, 50, 255), GREEN=(142, 219, 176, 255), RED=(241, 100, 100, 255), + YELLOW=(255, 215, 0, 255), BACKGROUND=(240, 240, 240, 255), LIGHTGREY=(220, 220, 220, 255), DARKGREY=(50, 50, 50, 255), diff --git a/plugins/communications.py b/plugins/communications.py index a4cd3b9e..7c584355 100644 --- a/plugins/communications.py +++ b/plugins/communications.py @@ -5,6 +5,7 @@ from string import ascii_uppercase, digits, ascii_lowercase from math import copysign from pathlib import Path +import wave from pyglet.media import Player, SourceGroup, load from plugins.abstractplugin import AbstractPlugin from core.widgets import Radio, Simpletext @@ -75,6 +76,7 @@ def __init__(self, label='', taskplacement='bottomleft', taskupdatetime=80): self.lastradioselected = None self.frequency_modulation = 0.1 self.sound_path = None + self._empty_sample_path = None self.set_sample_sounds() @@ -91,6 +93,7 @@ def set_sample_sounds(self): return self.sound_path = new_path + self._ensure_empty_sample() self.samples_path = [self.sound_path.joinpath(f'{i}.wav') for i in [s for s in digits + ascii_lowercase] + [this_radio.lower() for this_radio in self.parameters['promptlist']] + @@ -101,6 +104,35 @@ def set_sample_sounds(self): print(sample_needed, _(' does not exist')) + def _ensure_empty_sample(self): + if self.sound_path is None: + return None + + empty_path = self.sound_path.joinpath('empty.wav') + if empty_path.exists(): + self._empty_sample_path = empty_path + return empty_path + + empty_path.parent.mkdir(parents=True, exist_ok=True) + with wave.open(str(empty_path), 'wb') as silent_file: + silent_file.setnchannels(1) + silent_file.setsampwidth(2) + silent_file.setframerate(44100) + silent_file.writeframes(b'\x00\x00' * 4410) + + self._empty_sample_path = empty_path + return empty_path + + + def _load_audio_segment(self, name): + target = self.sound_path.joinpath(f'{name}.wav') + if not target.exists(): + if name != 'empty': + print(target, _(' missing, using silence instead')) + target = self._empty_sample_path or self._ensure_empty_sample() + return load(str(target), streaming=False) + + def regenerate_callsigns(self): self.parameters['owncallsign'] = self.get_callsign() for i in range(self.parameters['othercallsignnumber']): @@ -157,8 +189,7 @@ def group_audio_files(self, callsign, radio_name, freq): group = SourceGroup() for f in list_of_sounds: - source = load(str(self.sound_path.joinpath(f'{f}.wav')), streaming=False) - # print(f) + source = self._load_audio_segment(f) group.add(source) return group diff --git a/plugins/visualsearch.py b/plugins/visualsearch.py index 7af77b3a..772914d8 100644 --- a/plugins/visualsearch.py +++ b/plugins/visualsearch.py @@ -2,6 +2,8 @@ import json import math +import random +from copy import deepcopy from pathlib import Path from typing import Dict, List, Optional, Tuple @@ -18,6 +20,9 @@ from core import validation +IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.bmp'} + + def _resolve_path(path_str: str, fallbacks: Optional[List[Path]] = None) -> Optional[Path]: if path_str is None or len(str(path_str).strip()) == 0: return None @@ -40,6 +45,14 @@ def _rgba_to_clear(color: Tuple[int, int, int, int]) -> Tuple[float, float, floa return tuple(channel / 255.0 for channel in color) +def _deep_merge_dict(base: Dict, overlay: Dict): + for key, value in overlay.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + _deep_merge_dict(base[key], value) + else: + base[key] = deepcopy(value) + + class VisualSearchWindow(PygletWindow): def __init__(self, plugin: "Visualsearch"): self.plugin = plugin @@ -70,21 +83,30 @@ def __init__(self, plugin: "Visualsearch"): self.batch = Batch() self.items_graphics: List[Dict[str, object]] = [] self.global_caption: Optional[Label] = None - self.set_background_color(params['display']['backgroundcolor']) + self.overlay_color: Optional[Tuple[int, int, int, int]] = None + self.background_color = params['display']['backgroundcolor'] + self.set_background_color(self.background_color) self.rebuild_layout() def on_draw(self): - glClearColor(*_rgba_to_clear(self.plugin.parameters['display']['backgroundcolor'])) + color = self.overlay_color if self.overlay_color is not None else self.background_color + glClearColor(*_rgba_to_clear(color)) self.clear() - self.batch.draw() + if self.overlay_color is None: + self.batch.draw() def on_resize(self, width: int, height: int): super().on_resize(width, height) self.rebuild_layout() def set_background_color(self, color: Tuple[int, int, int, int]): - glClearColor(*_rgba_to_clear(color)) + self.background_color = color + if self.overlay_color is None: + glClearColor(*_rgba_to_clear(color)) + + def show_overlay(self, color: Optional[Tuple[int, int, int, int]]): + self.overlay_color = color def rebuild_layout(self): self.batch = Batch() @@ -95,9 +117,12 @@ def rebuild_layout(self): caption_height = caption_cfg.get('fontsize', F['MEDIUM']) * 2 if caption_enabled and caption_cfg.get('text') else 0 margin = self.plugin.parameters['layout']['marginpx'] - size = self.plugin.parameters['items']['sizepx'] - spacing = self.plugin.parameters['items']['spacingpx'] - columns = self.plugin.parameters['items']['columns'] + items_cfg = self.plugin.parameters['items'] + size = items_cfg['sizepx'] + spacing = items_cfg.get('distancepx', items_cfg.get('spacingpx', 0)) + spacing = max(0, spacing) + columns = items_cfg['columns'] + fallback_caption = items_cfg.get('captiontext') definitions = self.plugin.items_definitions if len(definitions) == 0: @@ -138,8 +163,15 @@ def rebuild_layout(self): graphic = self._create_graphic(definition, x, y, size) caption_label = None - if definition.get('caption') and caption_enabled: - caption_label = Label(definition['caption'], font_size=caption_font, + caption_text = definition.get('caption') + if (caption_text is None or caption_text == '') and fallback_caption: + try: + caption_text = fallback_caption.format(index=index + 1, id=definition['id']) + except Exception: + caption_text = fallback_caption + + if caption_text and caption_enabled: + caption_label = Label(caption_text, font_size=caption_font, x=x + size / 2, y=y - caption_font * 0.2, anchor_x='center', anchor_y='top', color=caption_color, batch=self.batch) @@ -205,47 +237,87 @@ def __init__(self): 'window-fullscreen': validation.is_boolean, 'items-sizepx': validation.is_positive_integer, 'items-spacingpx': validation.is_natural_integer, + 'items-distancepx': validation.is_natural_integer, 'items-columns': validation.is_natural_integer, 'items-textsize': validation.is_positive_integer, + 'items-folder': validation.is_string, + 'items-captiontext': validation.is_string, 'layout-marginpx': validation.is_natural_integer, 'display-backgroundcolor': validation.is_color, 'display-caption-enabled': validation.is_boolean, 'display-caption-fontsize': validation.is_positive_integer, 'display-caption-color': validation.is_color, 'display-caption-text': validation.is_string, + 'display-maskcolor': validation.is_color, 'audiocue-enabled': validation.is_boolean, 'audiocue-volume': validation.is_in_unit_interval, 'viewingdistancecm': validation.is_positive_number, 'itemphysicalsizecm': validation.is_positive_number, + 'trial-intervalseconds': validation.is_positive_number, + 'trial-targetdisplayseconds': validation.is_positive_number, + 'trial-maskseconds': validation.is_positive_number, + 'trial-searchseconds': validation.is_positive_number, + 'trial-itemspertrial': validation.is_positive_integer, + 'trial-targetid': validation.is_string, + 'feedback-durationms': validation.is_positive_integer, + 'blocks-file': validation.is_string, + 'blocks-active': validation.is_string, } self.parameters.update(dict( screenindex=0, window=dict(width=1024, height=768, fullscreen=False), - items=dict(file='default_items.json', sizepx=140, spacingpx=40, columns=0, textsize=F['LARGE']), + items=dict(file='default_items.json', folder='', sizepx=140, spacingpx=40, + distancepx=40, columns=0, textsize=F['LARGE'], captiontext=''), layout=dict(marginpx=80), - display=dict(backgroundcolor=C['LIGHTGREY'], + display=dict(backgroundcolor=C['BLACK'], + maskcolor=C['BLACK'], caption=dict(enabled=True, text=_('Tap the target item'), - fontsize=F['MEDIUM'], color=C['BLACK'])), + fontsize=F['MEDIUM'], color=C['WHITE'])), audiocue=dict(enabled=False, filepath='', volume=0.8), + trial=dict(intervalseconds=30.0, targetdisplayseconds=1.5, + maskseconds=0.5, searchseconds=10.0, itemspertrial=16, + targetid='target'), + feedback=dict(durationms=800, + colors=dict(correct=C['GREEN'], incorrect=C['RED'], timeout=C['YELLOW'])), viewingdistancecm=None, itemphysicalsizecm=None, + blocks=dict(file='', active=None), )) + self._base_parameters = deepcopy(self.parameters) + self._block_definitions: Dict[str, Dict[str, object]] = {} + self._block_order: List[str] = [] + self._requested_block_id: Optional[str] = None + self._active_block_id: Optional[str] = None + self._last_items_signature: Optional[Tuple[Optional[str], Optional[str]]] = None + self.touch_window: Optional[VisualSearchWindow] = None self.items_definitions: List[Dict[str, object]] = [] + self._items_pool: List[Dict[str, object]] = [] self._audio_player: Optional[Player] = None self._loaded_audio_path: Optional[Path] = None + self._state: str = 'idle' + self._state_end_time: float = 0.0 + self._next_trial_time: float = 0.0 + self._current_trial_items: List[Dict[str, object]] = [] + self._current_target_id: Optional[str] = None + self._current_target_definition: Optional[Dict[str, object]] = None + self._search_active: bool = False + self._selection_recorded: bool = False + self._feedback_color: Optional[Tuple[int, int, int, int]] = None + self._current_trial_start_time: float = 0.0 def create_widgets(self): self.container = None def start(self): super().start() - self.load_items() + self.load_block_definitions() self.ensure_window() if self.parameters['audiocue']['enabled']: self.play_attention_cue() + self._reset_trial_cycle() def ensure_window(self): if self.touch_window is None: @@ -264,6 +336,7 @@ def hide(self): def stop(self): if self.touch_window is not None: + self.touch_window.show_overlay(None) self.touch_window.close() self.touch_window = None if self._audio_player is not None: @@ -272,46 +345,76 @@ def stop(self): def set_parameter(self, keys_str, value): super().set_parameter(keys_str, value) + self._update_base_parameter(keys_str.split('-'), value) + self._normalize_parameter_aliases(self._base_parameters) + self._normalize_parameter_aliases(self.parameters) + + if keys_str == 'blocks-file': + self.load_block_definitions() + return + + if keys_str == 'blocks-active': + self.set_block(value) + return + + block_reapplied = False + if self._active_block_id is not None: + block_reapplied = self._apply_active_block(force_reload=False) if keys_str.startswith('items'): - if keys_str == 'items-file': + if not block_reapplied: self.load_items() elif self.touch_window is not None: self.touch_window.rebuild_layout() elif keys_str.startswith('display-backgroundcolor'): + self._sync_window_background() + elif keys_str.startswith('display-maskcolor'): if self.touch_window is not None: - self.touch_window.set_background_color(self.parameters['display']['backgroundcolor']) + self.touch_window.show_overlay(None) elif keys_str.startswith('display-caption'): if self.touch_window is not None: self.touch_window.rebuild_layout() elif keys_str.startswith('audiocue'): self._loaded_audio_path = None + elif keys_str.startswith('trial'): + self._next_trial_time = max(self._next_trial_time, self.scenario_time) + elif keys_str.startswith('feedback'): + self._feedback_color = None def load_items(self): - file_value = self.parameters['items'].get('file') - fallback_dirs = [P['VISUALSEARCH'], P['IMG']] - path = _resolve_path(file_value, fallback_dirs) + items_cfg = self.parameters['items'] + folder_value = items_cfg.get('folder') or '' + definitions: List[Dict[str, object]] = [] + + if folder_value: + folder_path = _resolve_path(folder_value, [P['VISUALSEARCH'], P['IMG']]) + if folder_path is not None and folder_path.exists() and folder_path.is_dir(): + definitions = self._load_items_from_folder(folder_path) + self.logger.record_state(self.alias, 'items_folder', str(folder_path)) + else: + self.logger.record_state(self.alias, 'items_warning', f'Folder {folder_value} not found') - if path is None: - self.logger.record_state(self.alias, 'items_warning', f'Cannot locate {file_value}, using default set') - default_path = P['VISUALSEARCH'].joinpath('default_items.json') - path = default_path if default_path.exists() else None - - if path is not None and path.exists(): - try: - data = json.loads(path.read_text(encoding='utf8')) - self.items_definitions = data.get('items', []) - except Exception as exc: - self.logger.record_state(self.alias, 'items_error', str(exc)) - self.items_definitions = [] - else: - self.items_definitions = [] + if len(definitions) == 0: + file_value = items_cfg.get('file') + definitions = self._load_items_from_file_path(file_value) - if len(self.items_definitions) == 0: - self.items_definitions = [ + if len(definitions) == 0: + definitions = [ dict(id='placeholder', text='?', caption=_('No items configured'), color=C['GREY']) ] + fallback_caption = items_cfg.get('captiontext') + if fallback_caption: + for idx, definition in enumerate(definitions): + if not definition.get('caption'): + try: + definition['caption'] = fallback_caption.format(index=idx + 1, id=definition.get('id', 'item')) + except Exception: + definition['caption'] = fallback_caption + + self._items_pool = definitions + self._last_items_signature = self._items_source_signature(self.parameters) + if self.touch_window is not None: self.touch_window.rebuild_layout() @@ -333,6 +436,15 @@ def on_item_touched(self, item_id: str, position: Optional[Tuple[float, float]] ) self.logger_record_touch(item_id) + if self._state != 'search' or not self._search_active: + return + + self._search_active = False + self._selection_recorded = True + result = 'correct' if item_id == self._current_target_id else 'incorrect' + self._logger_record_trial_result(result, selected_id=item_id) + self._enter_feedback_state(result) + def play_attention_cue(self): cue_cfg = self.parameters['audiocue'] if not cue_cfg.get('enabled'): @@ -373,3 +485,326 @@ def calculate_visual_angle(self): def logger_record_touch(self, item_id: str): self.logger.record_state(self.alias, 'selected_item', item_id) + if self._current_target_id is not None: + self.logger.record_state(self.alias, 'target_item', self._current_target_id) + + def _reset_trial_cycle(self): + self._state = 'idle' + self._state_end_time = 0.0 + self._next_trial_time = self.scenario_time + self._current_trial_items = [] + self._current_target_id = None + self._current_target_definition = None + self._search_active = False + self._selection_recorded = False + self._feedback_color = None + self._current_trial_start_time = self.scenario_time + self._set_display_items([]) + self._clear_overlay() + + def compute_next_plugin_state(self): + state_updated = super().compute_next_plugin_state() + if not state_updated: + return False + + self._advance_trial_state() + return True + + def _advance_trial_state(self): + now = self.scenario_time + + if self._feedback_color is not None and now >= self._state_end_time: + self._feedback_color = None + self._clear_overlay() + + if self._state == 'idle': + if now >= self._next_trial_time: + self._begin_target_preview() + elif self._state == 'preview' and now >= self._state_end_time: + self._begin_mask_phase() + elif self._state == 'mask' and now >= self._state_end_time: + self._begin_search_phase() + elif self._state == 'search': + if not self._selection_recorded and now >= self._state_end_time: + self._selection_recorded = True + self._search_active = False + self._logger_record_trial_result('timeout') + self._enter_feedback_state('timeout') + elif self._state == 'feedback' and now >= self._state_end_time: + self._state = 'idle' + self._set_display_items([]) + self._clear_overlay() + + def _begin_target_preview(self): + self._selection_recorded = False + self._current_trial_items, self._current_target_definition = self._build_trial_items() + if len(self._current_trial_items) == 0: + return + + self._current_trial_start_time = self.scenario_time + self._next_trial_time = self._current_trial_start_time + self.parameters['trial']['intervalseconds'] + target_preview = deepcopy(self._current_target_definition) + preview_id = target_preview.get('id', 'target_preview') + target_preview['id'] = f"preview_{preview_id}" + self._current_target_id = next( + (item['id'] for item in self._current_trial_items if item.get('_is_target')), + None, + ) + self.logger.record_state(self.alias, 'current_target', self._current_target_id) + + self._set_display_items([target_preview]) + self._clear_overlay() + duration = self.parameters['trial']['targetdisplayseconds'] + self._state = 'preview' + self._state_end_time = self.scenario_time + duration + + def _begin_mask_phase(self): + self._state = 'mask' + self._state_end_time = self.scenario_time + self.parameters['trial']['maskseconds'] + self._set_display_items([]) + self._apply_overlay(self.parameters['display']['maskcolor']) + + def _begin_search_phase(self): + self._state = 'search' + self._state_end_time = self.scenario_time + self.parameters['trial']['searchseconds'] + self._search_active = True + self._set_display_items(self._current_trial_items) + self._clear_overlay() + self.play_attention_cue() + + def _enter_feedback_state(self, outcome: str): + self._state = 'feedback' + duration = self.parameters['feedback']['durationms'] / 1000 + self._state_end_time = self.scenario_time + duration + color = self.parameters['feedback']['colors'].get(outcome) + self._feedback_color = color + self._set_display_items([]) + if color is not None: + self._apply_overlay(color) + else: + self._clear_overlay() + + def _build_trial_items(self) -> Tuple[List[Dict[str, object]], Dict[str, object]]: + if len(self._items_pool) == 0: + return [], dict() + + target_id = self.parameters['trial'].get('targetid') + target_def = next((item for item in self._items_pool if item.get('id') == target_id), None) + target_def = target_def or self._items_pool[0] + distractors = [item for item in self._items_pool if item.get('id') != target_def.get('id')] + if len(distractors) == 0: + distractors = [target_def] + + desired = max(1, self.parameters['trial'].get('itemspertrial', 16)) + items: List[Dict[str, object]] = [] + items.append(self._clone_definition(target_def, index=0, is_target=True)) + + for idx in range(1, desired): + source = random.choice(distractors) + items.append(self._clone_definition(source, index=idx, is_target=False)) + + random.shuffle(items) + return items, target_def + + def _clone_definition(self, definition: Dict[str, object], index: int, is_target: bool): + cloned = deepcopy(definition) + base_id = cloned.get('id', f'item_{index}') + cloned['id'] = f"{base_id}_{index}" + cloned['_is_target'] = is_target + return cloned + + def _apply_overlay(self, color: Tuple[int, int, int, int]): + if self.touch_window is not None: + self.touch_window.show_overlay(color) + + def _clear_overlay(self): + if self.touch_window is not None: + self.touch_window.show_overlay(None) + + def _set_display_items(self, definitions: List[Dict[str, object]]): + self.items_definitions = definitions + if self.touch_window is not None: + self.touch_window.rebuild_layout() + + def _logger_record_trial_result(self, outcome: str, selected_id: Optional[str] = None): + self.logger.record_state(self.alias, 'trial_outcome', outcome) + if selected_id is not None: + self.logger.record_state(self.alias, 'selected_item', selected_id) + + def _load_items_from_folder(self, folder_path: Path) -> List[Dict[str, object]]: + definitions: List[Dict[str, object]] = [] + for entry in sorted(folder_path.iterdir()): + if not entry.is_file(): + continue + if entry.suffix.lower() not in IMAGE_EXTENSIONS: + continue + definitions.append(dict(id=entry.stem, image=str(entry))) + if len(definitions) == 0: + self.logger.record_state(self.alias, 'items_warning', f'No image files found in {folder_path}') + return definitions + + def _load_items_from_file_path(self, file_value: Optional[str]) -> List[Dict[str, object]]: + if not file_value: + file_value = 'default_items.json' + fallback_dirs = [P['VISUALSEARCH'], P['IMG']] + path = _resolve_path(file_value, fallback_dirs) + + if path is None or not path.exists(): + self.logger.record_state(self.alias, 'items_warning', f'Cannot locate {file_value}, using default set') + default_path = P['VISUALSEARCH'].joinpath('default_items.json') + path = default_path if default_path.exists() else None + + if path is not None and path.exists(): + try: + data = json.loads(path.read_text(encoding='utf8')) + self.logger.record_state(self.alias, 'items_file', str(path)) + return list(data.get('items', [])) + except Exception as exc: + self.logger.record_state(self.alias, 'items_error', str(exc)) + return [] + + def load_block_definitions(self): + cfg = self._base_parameters.get('blocks', {}) + file_value = cfg.get('file') or '' + self._block_definitions = {} + self._block_order = [] + + if not file_value: + self._active_block_id = None + self._requested_block_id = None + self._apply_active_block(force_reload=True) + return + + path = _resolve_path(file_value, [P['VISUALSEARCH'], P['SCENARIOS'], Path('.')]) + if path is None or not path.exists(): + self.logger.record_state(self.alias, 'blocks_warning', f'Block file {file_value} not found') + self._active_block_id = None + self._requested_block_id = None + self._apply_active_block(force_reload=True) + return + + try: + data = json.loads(path.read_text(encoding='utf8')) + except Exception as exc: + self.logger.record_state(self.alias, 'blocks_error', str(exc)) + self._active_block_id = None + self._requested_block_id = None + self._apply_active_block(force_reload=True) + return + + entries = data.get('blocks') if isinstance(data, dict) else data + if not isinstance(entries, list): + entries = [] + + for idx, entry in enumerate(entries): + if not isinstance(entry, dict): + continue + block_id = entry.get('id') or entry.get('name') or f'block_{idx + 1}' + overrides = deepcopy(entry.get('overrides')) if 'overrides' in entry else { + key: deepcopy(value) + for key, value in entry.items() + if key not in {'id', 'name', 'label', 'description'} + } + + folder_alias = entry.get('itemsfolder') or entry.get('items_folder') + if folder_alias: + overrides.setdefault('items', {})['folder'] = folder_alias + text_alias = entry.get('textbelow') + if text_alias: + overrides.setdefault('items', {})['captiontext'] = text_alias + distance_alias = entry.get('distance') + if distance_alias is not None: + overrides.setdefault('items', {})['distancepx'] = distance_alias + + self._block_definitions[block_id] = overrides + self._block_order.append(block_id) + + target_id = self._requested_block_id or cfg.get('active') + if target_id in self._block_definitions: + self._active_block_id = target_id + elif len(self._block_order) > 0: + self._active_block_id = self._block_order[0] + else: + self._active_block_id = None + + self._requested_block_id = self._active_block_id + self._base_parameters.setdefault('blocks', {})['active'] = self._active_block_id + self.logger.record_state(self.alias, 'block_file', str(path)) + self._apply_active_block(force_reload=True) + + def set_block(self, block_id: Optional[str]): + if isinstance(block_id, str) and len(block_id.strip()) == 0: + block_id = None + self._requested_block_id = block_id + if block_id is None: + self._active_block_id = None + self._base_parameters.setdefault('blocks', {})['active'] = None + self.logger.record_state(self.alias, 'block_active', 'none') + self._apply_active_block(force_reload=True) + return + + if block_id not in self._block_definitions: + self.logger.record_state(self.alias, 'block_warning', f'Unknown block {block_id}') + return + + self._active_block_id = block_id + self._base_parameters.setdefault('blocks', {})['active'] = block_id + self.logger.record_state(self.alias, 'block_active', block_id) + self._apply_active_block(force_reload=True) + + def _update_base_parameter(self, keys_list: List[str], value): + target = self._base_parameters + for key in keys_list[:-1]: + target = target.setdefault(key, {}) + target[keys_list[-1]] = value + + def _items_source_signature(self, params: Optional[Dict[str, object]] = None) -> Tuple[Optional[str], Optional[str]]: + params = params or self.parameters + items_cfg = params.get('items', {}) if isinstance(params, dict) else {} + folder_value = items_cfg.get('folder') or '' + file_value = items_cfg.get('file') or '' + folder_path = _resolve_path(folder_value, [P['VISUALSEARCH'], P['IMG']]) if folder_value else None + file_path = _resolve_path(file_value, [P['VISUALSEARCH'], P['IMG']]) if file_value else None + folder_sig = str(folder_path) if folder_path is not None else (folder_value or None) + file_sig = str(file_path) if file_path is not None else (file_value or None) + return folder_sig, file_sig + + def _normalize_parameter_aliases(self, params: Dict[str, object]): + items_cfg = params.setdefault('items', {}) + if 'textbelow' in items_cfg and not items_cfg.get('captiontext'): + items_cfg['captiontext'] = items_cfg['textbelow'] + if 'distance' in items_cfg and not items_cfg.get('distancepx'): + try: + items_cfg['distancepx'] = int(items_cfg['distance']) + except Exception: + items_cfg['distancepx'] = items_cfg['distance'] + + def _sync_window_background(self): + if self.touch_window is not None: + self.touch_window.set_background_color(self.parameters['display']['backgroundcolor']) + + def _apply_active_block(self, force_reload: bool = False) -> bool: + merged = deepcopy(self._base_parameters) + self._normalize_parameter_aliases(merged) + + if self._active_block_id and self._active_block_id in self._block_definitions: + overrides = deepcopy(self._block_definitions[self._active_block_id]) + overrides.pop('blocks', None) + self._normalize_parameter_aliases(overrides) + _deep_merge_dict(merged, overrides) + + merged.setdefault('blocks', {})['active'] = self._active_block_id + self.parameters = merged + self._normalize_parameter_aliases(self.parameters) + + previous_signature = self._last_items_signature + new_signature = self._items_source_signature(self.parameters) + need_reload = force_reload or new_signature != previous_signature + + if need_reload: + self.load_items() + elif self.touch_window is not None: + self.touch_window.rebuild_layout() + + self._sync_window_background() + return need_reload