pomera-ai-commander 0.1.0 → 1.2.1
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.
- package/LICENSE +21 -21
- package/README.md +105 -680
- package/bin/pomera-ai-commander.js +62 -62
- package/core/__init__.py +65 -65
- package/core/app_context.py +482 -482
- package/core/async_text_processor.py +421 -421
- package/core/backup_manager.py +655 -655
- package/core/backup_recovery_manager.py +1033 -1033
- package/core/content_hash_cache.py +508 -508
- package/core/context_menu.py +313 -313
- package/core/data_validator.py +1066 -1066
- package/core/database_connection_manager.py +744 -744
- package/core/database_curl_settings_manager.py +608 -608
- package/core/database_promera_ai_settings_manager.py +446 -446
- package/core/database_schema.py +411 -411
- package/core/database_schema_manager.py +395 -395
- package/core/database_settings_manager.py +1507 -1507
- package/core/database_settings_manager_interface.py +456 -456
- package/core/dialog_manager.py +734 -734
- package/core/efficient_line_numbers.py +510 -510
- package/core/error_handler.py +746 -746
- package/core/error_service.py +431 -431
- package/core/event_consolidator.py +511 -511
- package/core/mcp/__init__.py +43 -43
- package/core/mcp/protocol.py +288 -288
- package/core/mcp/schema.py +251 -251
- package/core/mcp/server_stdio.py +299 -299
- package/core/mcp/tool_registry.py +2372 -2345
- package/core/memory_efficient_text_widget.py +711 -711
- package/core/migration_manager.py +914 -914
- package/core/migration_test_suite.py +1085 -1085
- package/core/migration_validator.py +1143 -1143
- package/core/optimized_find_replace.py +714 -714
- package/core/optimized_pattern_engine.py +424 -424
- package/core/optimized_search_highlighter.py +552 -552
- package/core/performance_monitor.py +674 -674
- package/core/persistence_manager.py +712 -712
- package/core/progressive_stats_calculator.py +632 -632
- package/core/regex_pattern_cache.py +529 -529
- package/core/regex_pattern_library.py +350 -350
- package/core/search_operation_manager.py +434 -434
- package/core/settings_defaults_registry.py +1087 -1087
- package/core/settings_integrity_validator.py +1111 -1111
- package/core/settings_serializer.py +557 -557
- package/core/settings_validator.py +1823 -1823
- package/core/smart_stats_calculator.py +709 -709
- package/core/statistics_update_manager.py +619 -619
- package/core/stats_config_manager.py +858 -858
- package/core/streaming_text_handler.py +723 -723
- package/core/task_scheduler.py +596 -596
- package/core/update_pattern_library.py +168 -168
- package/core/visibility_monitor.py +596 -596
- package/core/widget_cache.py +498 -498
- package/mcp.json +51 -61
- package/package.json +61 -57
- package/pomera.py +7482 -7482
- package/pomera_mcp_server.py +183 -144
- package/requirements.txt +32 -0
- package/tools/__init__.py +4 -4
- package/tools/ai_tools.py +2891 -2891
- package/tools/ascii_art_generator.py +352 -352
- package/tools/base64_tools.py +183 -183
- package/tools/base_tool.py +511 -511
- package/tools/case_tool.py +308 -308
- package/tools/column_tools.py +395 -395
- package/tools/cron_tool.py +884 -884
- package/tools/curl_history.py +600 -600
- package/tools/curl_processor.py +1207 -1207
- package/tools/curl_settings.py +502 -502
- package/tools/curl_tool.py +5467 -5467
- package/tools/diff_viewer.py +1071 -1071
- package/tools/email_extraction_tool.py +248 -248
- package/tools/email_header_analyzer.py +425 -425
- package/tools/extraction_tools.py +250 -250
- package/tools/find_replace.py +1750 -1750
- package/tools/folder_file_reporter.py +1463 -1463
- package/tools/folder_file_reporter_adapter.py +480 -480
- package/tools/generator_tools.py +1216 -1216
- package/tools/hash_generator.py +255 -255
- package/tools/html_tool.py +656 -656
- package/tools/jsonxml_tool.py +729 -729
- package/tools/line_tools.py +419 -419
- package/tools/markdown_tools.py +561 -561
- package/tools/mcp_widget.py +1417 -1417
- package/tools/notes_widget.py +973 -973
- package/tools/number_base_converter.py +372 -372
- package/tools/regex_extractor.py +571 -571
- package/tools/slug_generator.py +310 -310
- package/tools/sorter_tools.py +458 -458
- package/tools/string_escape_tool.py +392 -392
- package/tools/text_statistics_tool.py +365 -365
- package/tools/text_wrapper.py +430 -430
- package/tools/timestamp_converter.py +421 -421
- package/tools/tool_loader.py +710 -710
- package/tools/translator_tools.py +522 -522
- package/tools/url_link_extractor.py +261 -261
- package/tools/url_parser.py +204 -204
- package/tools/whitespace_tools.py +355 -355
- package/tools/word_frequency_counter.py +146 -146
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/app_context.cpython-313.pyc +0 -0
- package/core/__pycache__/async_text_processor.cpython-313.pyc +0 -0
- package/core/__pycache__/backup_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/backup_recovery_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/content_hash_cache.cpython-313.pyc +0 -0
- package/core/__pycache__/context_menu.cpython-313.pyc +0 -0
- package/core/__pycache__/data_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/database_connection_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_curl_settings_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_promera_ai_settings_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_schema.cpython-313.pyc +0 -0
- package/core/__pycache__/database_schema_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_settings_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_settings_manager_interface.cpython-313.pyc +0 -0
- package/core/__pycache__/dialog_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/efficient_line_numbers.cpython-313.pyc +0 -0
- package/core/__pycache__/error_handler.cpython-313.pyc +0 -0
- package/core/__pycache__/error_service.cpython-313.pyc +0 -0
- package/core/__pycache__/event_consolidator.cpython-313.pyc +0 -0
- package/core/__pycache__/memory_efficient_text_widget.cpython-313.pyc +0 -0
- package/core/__pycache__/migration_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/migration_test_suite.cpython-313.pyc +0 -0
- package/core/__pycache__/migration_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/optimized_find_replace.cpython-313.pyc +0 -0
- package/core/__pycache__/optimized_pattern_engine.cpython-313.pyc +0 -0
- package/core/__pycache__/optimized_search_highlighter.cpython-313.pyc +0 -0
- package/core/__pycache__/performance_monitor.cpython-313.pyc +0 -0
- package/core/__pycache__/persistence_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/progressive_stats_calculator.cpython-313.pyc +0 -0
- package/core/__pycache__/regex_pattern_cache.cpython-313.pyc +0 -0
- package/core/__pycache__/regex_pattern_library.cpython-313.pyc +0 -0
- package/core/__pycache__/search_operation_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_defaults_registry.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_integrity_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_serializer.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/smart_stats_calculator.cpython-313.pyc +0 -0
- package/core/__pycache__/statistics_update_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/stats_config_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/streaming_text_handler.cpython-313.pyc +0 -0
- package/core/__pycache__/task_scheduler.cpython-313.pyc +0 -0
- package/core/__pycache__/visibility_monitor.cpython-313.pyc +0 -0
- package/core/__pycache__/widget_cache.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/protocol.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/server_stdio.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/tool_registry.cpython-313.pyc +0 -0
- package/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/tools/__pycache__/ai_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/ascii_art_generator.cpython-313.pyc +0 -0
- package/tools/__pycache__/base64_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/base_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/case_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/column_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/cron_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_history.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_processor.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_settings.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/diff_viewer.cpython-313.pyc +0 -0
- package/tools/__pycache__/email_extraction_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/email_header_analyzer.cpython-313.pyc +0 -0
- package/tools/__pycache__/extraction_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/find_replace.cpython-313.pyc +0 -0
- package/tools/__pycache__/folder_file_reporter.cpython-313.pyc +0 -0
- package/tools/__pycache__/folder_file_reporter_adapter.cpython-313.pyc +0 -0
- package/tools/__pycache__/generator_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/hash_generator.cpython-313.pyc +0 -0
- package/tools/__pycache__/html_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/huggingface_helper.cpython-313.pyc +0 -0
- package/tools/__pycache__/jsonxml_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/line_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/list_comparator.cpython-313.pyc +0 -0
- package/tools/__pycache__/markdown_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_widget.cpython-313.pyc +0 -0
- package/tools/__pycache__/notes_widget.cpython-313.pyc +0 -0
- package/tools/__pycache__/number_base_converter.cpython-313.pyc +0 -0
- package/tools/__pycache__/regex_extractor.cpython-313.pyc +0 -0
- package/tools/__pycache__/slug_generator.cpython-313.pyc +0 -0
- package/tools/__pycache__/sorter_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/string_escape_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/text_statistics_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/text_wrapper.cpython-313.pyc +0 -0
- package/tools/__pycache__/timestamp_converter.cpython-313.pyc +0 -0
- package/tools/__pycache__/tool_loader.cpython-313.pyc +0 -0
- package/tools/__pycache__/translator_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/url_link_extractor.cpython-313.pyc +0 -0
- package/tools/__pycache__/url_parser.cpython-313.pyc +0 -0
- package/tools/__pycache__/whitespace_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/word_frequency_counter.cpython-313.pyc +0 -0
|
@@ -1,512 +1,512 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Event Consolidation System for Pomera AI Commander.
|
|
3
|
-
|
|
4
|
-
This module provides intelligent event consolidation to replace multiple event handlers
|
|
5
|
-
with a single consolidated handler, implementing adaptive debouncing and event deduplication
|
|
6
|
-
to optimize statistics calculation performance.
|
|
7
|
-
|
|
8
|
-
Requirements addressed:
|
|
9
|
-
- 1.1: Consolidate multiple event triggers into single statistics update
|
|
10
|
-
- 1.2: Use intelligent debouncing to prevent excessive calculations
|
|
11
|
-
- 1.3: Increase debounce delays automatically for large content (>10,000 characters)
|
|
12
|
-
- 1.4: Prevent duplicate statistics calculations
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
import time
|
|
16
|
-
import threading
|
|
17
|
-
import tkinter as tk
|
|
18
|
-
from typing import Dict, Optional, Callable, Any, Set
|
|
19
|
-
from dataclasses import dataclass, field
|
|
20
|
-
from enum import Enum
|
|
21
|
-
import weakref
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class EventType(Enum):
|
|
25
|
-
"""Types of text widget events that trigger statistics updates."""
|
|
26
|
-
MODIFIED = "<<Modified>>"
|
|
27
|
-
KEY_RELEASE = "<KeyRelease>"
|
|
28
|
-
BUTTON_CLICK = "<Button-1>"
|
|
29
|
-
FOCUS_IN = "<FocusIn>"
|
|
30
|
-
FOCUS_OUT = "<FocusOut>"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class DebounceStrategy(Enum):
|
|
34
|
-
"""Debouncing strategies for different content sizes and user behaviors."""
|
|
35
|
-
IMMEDIATE = "immediate" # No debouncing for very small changes
|
|
36
|
-
FAST = "fast" # 50ms for small content
|
|
37
|
-
NORMAL = "normal" # 300ms for medium content
|
|
38
|
-
SLOW = "slow" # 500ms for large content
|
|
39
|
-
ADAPTIVE = "adaptive" # Automatically adjust based on content size
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@dataclass
|
|
43
|
-
class EventInfo:
|
|
44
|
-
"""Information about a text widget event."""
|
|
45
|
-
widget_id: str
|
|
46
|
-
event_type: EventType
|
|
47
|
-
timestamp: float
|
|
48
|
-
content_size: int = 0
|
|
49
|
-
content_hash: str = ""
|
|
50
|
-
|
|
51
|
-
def __post_init__(self):
|
|
52
|
-
if not self.timestamp:
|
|
53
|
-
self.timestamp = time.time()
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
@dataclass
|
|
57
|
-
class DebounceConfig:
|
|
58
|
-
"""Configuration for debouncing behavior."""
|
|
59
|
-
strategy: DebounceStrategy = DebounceStrategy.ADAPTIVE
|
|
60
|
-
immediate_threshold: int = 100 # Characters below which updates are immediate
|
|
61
|
-
fast_delay_ms: int = 50 # Delay for small content
|
|
62
|
-
normal_delay_ms: int = 300 # Delay for medium content
|
|
63
|
-
slow_delay_ms: int = 500 # Delay for large content
|
|
64
|
-
large_content_threshold: int = 10000 # Characters above which content is considered large
|
|
65
|
-
very_large_threshold: int = 100000 # Characters above which extra delays apply
|
|
66
|
-
max_delay_ms: int = 1000 # Maximum debounce delay
|
|
67
|
-
|
|
68
|
-
def get_delay_for_content_size(self, content_size: int) -> int:
|
|
69
|
-
"""Get appropriate debounce delay based on content size."""
|
|
70
|
-
if self.strategy == DebounceStrategy.IMMEDIATE:
|
|
71
|
-
return 0
|
|
72
|
-
elif self.strategy == DebounceStrategy.FAST:
|
|
73
|
-
return self.fast_delay_ms
|
|
74
|
-
elif self.strategy == DebounceStrategy.NORMAL:
|
|
75
|
-
return self.normal_delay_ms
|
|
76
|
-
elif self.strategy == DebounceStrategy.SLOW:
|
|
77
|
-
return self.slow_delay_ms
|
|
78
|
-
elif self.strategy == DebounceStrategy.ADAPTIVE:
|
|
79
|
-
if content_size < self.immediate_threshold:
|
|
80
|
-
return 0
|
|
81
|
-
elif content_size < 1000:
|
|
82
|
-
return self.fast_delay_ms
|
|
83
|
-
elif content_size < self.large_content_threshold:
|
|
84
|
-
return self.normal_delay_ms
|
|
85
|
-
elif content_size < self.very_large_threshold:
|
|
86
|
-
return self.slow_delay_ms
|
|
87
|
-
else:
|
|
88
|
-
# Extra delay for very large content
|
|
89
|
-
return min(self.max_delay_ms, self.slow_delay_ms + 200)
|
|
90
|
-
|
|
91
|
-
return self.normal_delay_ms
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
@dataclass
|
|
95
|
-
class PendingUpdate:
|
|
96
|
-
"""Information about a pending statistics update."""
|
|
97
|
-
widget_id: str
|
|
98
|
-
event_info: EventInfo
|
|
99
|
-
callback: Callable
|
|
100
|
-
after_id: Optional[str] = None
|
|
101
|
-
scheduled_time: float = field(default_factory=time.time)
|
|
102
|
-
|
|
103
|
-
@property
|
|
104
|
-
def is_expired(self) -> bool:
|
|
105
|
-
"""Check if this update has been waiting too long."""
|
|
106
|
-
return (time.time() - self.scheduled_time) > 2.0 # 2 second timeout
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
class EventConsolidator:
|
|
110
|
-
"""
|
|
111
|
-
Consolidates multiple text widget events into single statistics updates.
|
|
112
|
-
|
|
113
|
-
This class replaces multiple event handlers (<<Modified>>, <KeyRelease>, <Button-1>)
|
|
114
|
-
with a single consolidated handler that implements intelligent debouncing and
|
|
115
|
-
event deduplication to optimize performance.
|
|
116
|
-
"""
|
|
117
|
-
|
|
118
|
-
def __init__(self, debounce_config: Optional[DebounceConfig] = None):
|
|
119
|
-
"""
|
|
120
|
-
Initialize the event consolidator.
|
|
121
|
-
|
|
122
|
-
Args:
|
|
123
|
-
debounce_config: Configuration for debouncing behavior
|
|
124
|
-
"""
|
|
125
|
-
self.debounce_config = debounce_config or DebounceConfig()
|
|
126
|
-
|
|
127
|
-
# Widget registry - use weak references to avoid memory leaks
|
|
128
|
-
self.registered_widgets: Dict[str, weakref.ref] = {}
|
|
129
|
-
self.widget_callbacks: Dict[str, Callable] = {}
|
|
130
|
-
|
|
131
|
-
# Event tracking for deduplication
|
|
132
|
-
self.recent_events: Dict[str, EventInfo] = {}
|
|
133
|
-
self.event_lock = threading.RLock()
|
|
134
|
-
|
|
135
|
-
# Pending updates tracking
|
|
136
|
-
self.pending_updates: Dict[str, PendingUpdate] = {}
|
|
137
|
-
|
|
138
|
-
# Statistics for monitoring
|
|
139
|
-
self.stats = {
|
|
140
|
-
'events_received': 0,
|
|
141
|
-
'events_deduplicated': 0,
|
|
142
|
-
'updates_triggered': 0,
|
|
143
|
-
'average_debounce_delay': 0.0
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
# Tkinter root reference for after() calls
|
|
147
|
-
self._tk_root: Optional[tk.Tk] = None
|
|
148
|
-
|
|
149
|
-
def set_tk_root(self, root: tk.Tk):
|
|
150
|
-
"""Set the Tkinter root for scheduling callbacks."""
|
|
151
|
-
self._tk_root = root
|
|
152
|
-
|
|
153
|
-
def register_text_widget(self, widget_id: str, widget: tk.Text,
|
|
154
|
-
callback: Callable[[str], None]) -> None:
|
|
155
|
-
"""
|
|
156
|
-
Register a text widget for consolidated event handling.
|
|
157
|
-
|
|
158
|
-
Args:
|
|
159
|
-
widget_id: Unique identifier for the widget
|
|
160
|
-
widget: The text widget to monitor
|
|
161
|
-
callback: Function to call when statistics should be updated
|
|
162
|
-
"""
|
|
163
|
-
# Store weak reference to widget to avoid memory leaks
|
|
164
|
-
self.registered_widgets[widget_id] = weakref.ref(widget)
|
|
165
|
-
self.widget_callbacks[widget_id] = callback
|
|
166
|
-
|
|
167
|
-
# Bind single consolidated event handler
|
|
168
|
-
self._bind_consolidated_events(widget, widget_id)
|
|
169
|
-
|
|
170
|
-
def unregister_text_widget(self, widget_id: str) -> None:
|
|
171
|
-
"""
|
|
172
|
-
Unregister a text widget and cancel any pending updates.
|
|
173
|
-
|
|
174
|
-
Args:
|
|
175
|
-
widget_id: Identifier of the widget to unregister
|
|
176
|
-
"""
|
|
177
|
-
# Cancel pending updates
|
|
178
|
-
self.cancel_pending_updates(widget_id)
|
|
179
|
-
|
|
180
|
-
# Remove from registries
|
|
181
|
-
self.registered_widgets.pop(widget_id, None)
|
|
182
|
-
self.widget_callbacks.pop(widget_id, None)
|
|
183
|
-
self.recent_events.pop(widget_id, None)
|
|
184
|
-
|
|
185
|
-
def _bind_consolidated_events(self, widget: tk.Text, widget_id: str) -> None:
|
|
186
|
-
"""
|
|
187
|
-
Bind consolidated event handler to replace multiple event bindings.
|
|
188
|
-
|
|
189
|
-
This replaces the multiple event bindings (<<Modified>>, <KeyRelease>, <Button-1>)
|
|
190
|
-
with a single handler per widget as required.
|
|
191
|
-
"""
|
|
192
|
-
# Create consolidated event handler
|
|
193
|
-
def consolidated_handler(event=None):
|
|
194
|
-
event_type = self._determine_event_type(event)
|
|
195
|
-
self.handle_text_event(widget_id, event_type, event)
|
|
196
|
-
return None # Don't break event propagation
|
|
197
|
-
|
|
198
|
-
# Bind to all relevant events with single handler
|
|
199
|
-
widget.bind("<<Modified>>", consolidated_handler, add=True)
|
|
200
|
-
widget.bind("<KeyRelease>", consolidated_handler, add=True)
|
|
201
|
-
widget.bind("<Button-1>", consolidated_handler, add=True)
|
|
202
|
-
|
|
203
|
-
# Optional: Also handle focus events for better user experience
|
|
204
|
-
widget.bind("<FocusIn>", consolidated_handler, add=True)
|
|
205
|
-
widget.bind("<FocusOut>", consolidated_handler, add=True)
|
|
206
|
-
|
|
207
|
-
def _determine_event_type(self, event) -> EventType:
|
|
208
|
-
"""Determine the type of event from the event object."""
|
|
209
|
-
if not event:
|
|
210
|
-
return EventType.MODIFIED
|
|
211
|
-
|
|
212
|
-
event_str = str(event.type) if hasattr(event, 'type') else str(event)
|
|
213
|
-
|
|
214
|
-
if "KeyRelease" in event_str or hasattr(event, 'keysym'):
|
|
215
|
-
return EventType.KEY_RELEASE
|
|
216
|
-
elif "Button" in event_str and hasattr(event, 'num'):
|
|
217
|
-
return EventType.BUTTON_CLICK
|
|
218
|
-
elif "FocusIn" in event_str:
|
|
219
|
-
return EventType.FOCUS_IN
|
|
220
|
-
elif "FocusOut" in event_str:
|
|
221
|
-
return EventType.FOCUS_OUT
|
|
222
|
-
else:
|
|
223
|
-
return EventType.MODIFIED
|
|
224
|
-
|
|
225
|
-
def handle_text_event(self, widget_id: str, event_type: EventType,
|
|
226
|
-
event=None) -> None:
|
|
227
|
-
"""
|
|
228
|
-
Handle consolidated text widget events with intelligent deduplication.
|
|
229
|
-
|
|
230
|
-
Args:
|
|
231
|
-
widget_id: Identifier of the widget that triggered the event
|
|
232
|
-
event_type: Type of event that occurred
|
|
233
|
-
event: Original event object (optional)
|
|
234
|
-
"""
|
|
235
|
-
with self.event_lock:
|
|
236
|
-
self.stats['events_received'] += 1
|
|
237
|
-
|
|
238
|
-
# Get widget reference
|
|
239
|
-
widget_ref = self.registered_widgets.get(widget_id)
|
|
240
|
-
if not widget_ref:
|
|
241
|
-
return
|
|
242
|
-
|
|
243
|
-
widget = widget_ref()
|
|
244
|
-
if not widget:
|
|
245
|
-
# Widget was garbage collected, clean up
|
|
246
|
-
self.unregister_text_widget(widget_id)
|
|
247
|
-
return
|
|
248
|
-
|
|
249
|
-
# Get current content for analysis
|
|
250
|
-
try:
|
|
251
|
-
content = widget.get("1.0", tk.END)
|
|
252
|
-
content_size = len(content.encode('utf-8'))
|
|
253
|
-
content_hash = self._generate_content_hash(content)
|
|
254
|
-
except tk.TclError:
|
|
255
|
-
# Widget might be destroyed
|
|
256
|
-
return
|
|
257
|
-
|
|
258
|
-
# Create event info
|
|
259
|
-
event_info = EventInfo(
|
|
260
|
-
widget_id=widget_id,
|
|
261
|
-
event_type=event_type,
|
|
262
|
-
timestamp=time.time(),
|
|
263
|
-
content_size=content_size,
|
|
264
|
-
content_hash=content_hash
|
|
265
|
-
)
|
|
266
|
-
|
|
267
|
-
# Check for duplicate events
|
|
268
|
-
if self._is_duplicate_event(widget_id, event_info):
|
|
269
|
-
self.stats['events_deduplicated'] += 1
|
|
270
|
-
return
|
|
271
|
-
|
|
272
|
-
# Store recent event for deduplication
|
|
273
|
-
self.recent_events[widget_id] = event_info
|
|
274
|
-
|
|
275
|
-
# Schedule update with appropriate debouncing
|
|
276
|
-
self._schedule_update(widget_id, event_info)
|
|
277
|
-
|
|
278
|
-
def _is_duplicate_event(self, widget_id: str, event_info: EventInfo) -> bool:
|
|
279
|
-
"""
|
|
280
|
-
Check if this event is a duplicate that should be ignored.
|
|
281
|
-
|
|
282
|
-
Args:
|
|
283
|
-
widget_id: Widget identifier
|
|
284
|
-
event_info: Information about the current event
|
|
285
|
-
|
|
286
|
-
Returns:
|
|
287
|
-
True if this is a duplicate event that should be ignored
|
|
288
|
-
"""
|
|
289
|
-
recent_event = self.recent_events.get(widget_id)
|
|
290
|
-
if not recent_event:
|
|
291
|
-
return False
|
|
292
|
-
|
|
293
|
-
# Check if content hasn't changed
|
|
294
|
-
if recent_event.content_hash == event_info.content_hash:
|
|
295
|
-
# Same content - check if enough time has passed
|
|
296
|
-
time_diff = event_info.timestamp - recent_event.timestamp
|
|
297
|
-
if time_diff < 0.1: # Less than 100ms
|
|
298
|
-
return True
|
|
299
|
-
|
|
300
|
-
# Check for rapid successive events of the same type
|
|
301
|
-
if (recent_event.event_type == event_info.event_type and
|
|
302
|
-
event_info.timestamp - recent_event.timestamp < 0.05): # Less than 50ms
|
|
303
|
-
return True
|
|
304
|
-
|
|
305
|
-
return False
|
|
306
|
-
|
|
307
|
-
def _schedule_update(self, widget_id: str, event_info: EventInfo) -> None:
|
|
308
|
-
"""
|
|
309
|
-
Schedule a statistics update with appropriate debouncing.
|
|
310
|
-
|
|
311
|
-
Args:
|
|
312
|
-
widget_id: Widget identifier
|
|
313
|
-
event_info: Information about the event
|
|
314
|
-
"""
|
|
315
|
-
# Cancel any existing pending update for this widget
|
|
316
|
-
self.cancel_pending_updates(widget_id)
|
|
317
|
-
|
|
318
|
-
# Get callback
|
|
319
|
-
callback = self.widget_callbacks.get(widget_id)
|
|
320
|
-
if not callback:
|
|
321
|
-
return
|
|
322
|
-
|
|
323
|
-
# Determine debounce delay
|
|
324
|
-
delay_ms = self.debounce_config.get_delay_for_content_size(event_info.content_size)
|
|
325
|
-
|
|
326
|
-
# Update statistics
|
|
327
|
-
self.stats['average_debounce_delay'] = (
|
|
328
|
-
(self.stats['average_debounce_delay'] * self.stats['updates_triggered'] + delay_ms) /
|
|
329
|
-
(self.stats['updates_triggered'] + 1)
|
|
330
|
-
)
|
|
331
|
-
|
|
332
|
-
if delay_ms == 0:
|
|
333
|
-
# Immediate update
|
|
334
|
-
self._execute_update(widget_id, callback)
|
|
335
|
-
else:
|
|
336
|
-
# Debounced update
|
|
337
|
-
if self._tk_root:
|
|
338
|
-
after_id = self._tk_root.after(delay_ms,
|
|
339
|
-
lambda: self._execute_update(widget_id, callback))
|
|
340
|
-
|
|
341
|
-
# Track pending update
|
|
342
|
-
self.pending_updates[widget_id] = PendingUpdate(
|
|
343
|
-
widget_id=widget_id,
|
|
344
|
-
event_info=event_info,
|
|
345
|
-
callback=callback,
|
|
346
|
-
after_id=after_id
|
|
347
|
-
)
|
|
348
|
-
|
|
349
|
-
def _execute_update(self, widget_id: str, callback: Callable) -> None:
|
|
350
|
-
"""
|
|
351
|
-
Execute the statistics update callback.
|
|
352
|
-
|
|
353
|
-
Args:
|
|
354
|
-
widget_id: Widget identifier
|
|
355
|
-
callback: Callback function to execute
|
|
356
|
-
"""
|
|
357
|
-
try:
|
|
358
|
-
# Remove from pending updates
|
|
359
|
-
self.pending_updates.pop(widget_id, None)
|
|
360
|
-
|
|
361
|
-
# Execute callback
|
|
362
|
-
callback(widget_id)
|
|
363
|
-
|
|
364
|
-
self.stats['updates_triggered'] += 1
|
|
365
|
-
|
|
366
|
-
except Exception as e:
|
|
367
|
-
# Log error if possible, but don't crash
|
|
368
|
-
print(f"Error executing update callback for {widget_id}: {e}")
|
|
369
|
-
|
|
370
|
-
def cancel_pending_updates(self, widget_id: str) -> None:
|
|
371
|
-
"""
|
|
372
|
-
Cancel any pending updates for a specific widget.
|
|
373
|
-
|
|
374
|
-
Args:
|
|
375
|
-
widget_id: Widget identifier
|
|
376
|
-
"""
|
|
377
|
-
pending = self.pending_updates.get(widget_id)
|
|
378
|
-
if pending and pending.after_id and self._tk_root:
|
|
379
|
-
try:
|
|
380
|
-
self._tk_root.after_cancel(pending.after_id)
|
|
381
|
-
except tk.TclError:
|
|
382
|
-
pass # after_id might be invalid
|
|
383
|
-
|
|
384
|
-
self.pending_updates.pop(widget_id, None)
|
|
385
|
-
|
|
386
|
-
def cancel_all_pending_updates(self) -> None:
|
|
387
|
-
"""Cancel all pending updates."""
|
|
388
|
-
for widget_id in list(self.pending_updates.keys()):
|
|
389
|
-
self.cancel_pending_updates(widget_id)
|
|
390
|
-
|
|
391
|
-
def set_debounce_strategy(self, strategy: DebounceStrategy) -> None:
|
|
392
|
-
"""
|
|
393
|
-
Set the debouncing strategy for all widgets.
|
|
394
|
-
|
|
395
|
-
Args:
|
|
396
|
-
strategy: New debouncing strategy to use
|
|
397
|
-
"""
|
|
398
|
-
self.debounce_config.strategy = strategy
|
|
399
|
-
|
|
400
|
-
def set_debounce_config(self, config: DebounceConfig) -> None:
|
|
401
|
-
"""
|
|
402
|
-
Set a new debounce configuration.
|
|
403
|
-
|
|
404
|
-
Args:
|
|
405
|
-
config: New debounce configuration
|
|
406
|
-
"""
|
|
407
|
-
self.debounce_config = config
|
|
408
|
-
|
|
409
|
-
def get_statistics(self) -> Dict[str, Any]:
|
|
410
|
-
"""
|
|
411
|
-
Get event consolidation statistics.
|
|
412
|
-
|
|
413
|
-
Returns:
|
|
414
|
-
Dictionary with statistics about event handling
|
|
415
|
-
"""
|
|
416
|
-
with self.event_lock:
|
|
417
|
-
stats = self.stats.copy()
|
|
418
|
-
stats.update({
|
|
419
|
-
'registered_widgets': len(self.registered_widgets),
|
|
420
|
-
'pending_updates': len(self.pending_updates),
|
|
421
|
-
'recent_events_tracked': len(self.recent_events),
|
|
422
|
-
'deduplication_rate': (
|
|
423
|
-
self.stats['events_deduplicated'] / max(1, self.stats['events_received'])
|
|
424
|
-
) * 100
|
|
425
|
-
})
|
|
426
|
-
return stats
|
|
427
|
-
|
|
428
|
-
def cleanup_expired_updates(self) -> None:
|
|
429
|
-
"""Clean up any expired pending updates."""
|
|
430
|
-
expired_widgets = []
|
|
431
|
-
|
|
432
|
-
for widget_id, pending in self.pending_updates.items():
|
|
433
|
-
if pending.is_expired:
|
|
434
|
-
expired_widgets.append(widget_id)
|
|
435
|
-
|
|
436
|
-
for widget_id in expired_widgets:
|
|
437
|
-
self.cancel_pending_updates(widget_id)
|
|
438
|
-
|
|
439
|
-
def _generate_content_hash(self, content: str) -> str:
|
|
440
|
-
"""Generate a simple hash for content comparison."""
|
|
441
|
-
# Simple hash based on length and first/last characters
|
|
442
|
-
if not content:
|
|
443
|
-
return "empty"
|
|
444
|
-
|
|
445
|
-
content_clean = content.strip()
|
|
446
|
-
if not content_clean:
|
|
447
|
-
return "whitespace"
|
|
448
|
-
|
|
449
|
-
# Create hash from length + first 10 + last 10 characters
|
|
450
|
-
first_part = content_clean[:10] if len(content_clean) >= 10 else content_clean
|
|
451
|
-
last_part = content_clean[-10:] if len(content_clean) >= 20 else ""
|
|
452
|
-
|
|
453
|
-
return f"{len(content_clean)}_{hash(first_part + last_part) % 10000}"
|
|
454
|
-
|
|
455
|
-
def get_widget_info(self, widget_id: str) -> Optional[Dict[str, Any]]:
|
|
456
|
-
"""
|
|
457
|
-
Get information about a registered widget.
|
|
458
|
-
|
|
459
|
-
Args:
|
|
460
|
-
widget_id: Widget identifier
|
|
461
|
-
|
|
462
|
-
Returns:
|
|
463
|
-
Dictionary with widget information or None if not found
|
|
464
|
-
"""
|
|
465
|
-
if widget_id not in self.registered_widgets:
|
|
466
|
-
return None
|
|
467
|
-
|
|
468
|
-
widget_ref = self.registered_widgets[widget_id]
|
|
469
|
-
widget = widget_ref() if widget_ref else None
|
|
470
|
-
|
|
471
|
-
recent_event = self.recent_events.get(widget_id)
|
|
472
|
-
pending_update = self.pending_updates.get(widget_id)
|
|
473
|
-
|
|
474
|
-
return {
|
|
475
|
-
'widget_id': widget_id,
|
|
476
|
-
'widget_exists': widget is not None,
|
|
477
|
-
'has_callback': widget_id in self.widget_callbacks,
|
|
478
|
-
'recent_event': {
|
|
479
|
-
'timestamp': recent_event.timestamp if recent_event else None,
|
|
480
|
-
'event_type': recent_event.event_type.value if recent_event else None,
|
|
481
|
-
'content_size': recent_event.content_size if recent_event else 0
|
|
482
|
-
} if recent_event else None,
|
|
483
|
-
'pending_update': {
|
|
484
|
-
'scheduled_time': pending_update.scheduled_time,
|
|
485
|
-
'is_expired': pending_update.is_expired
|
|
486
|
-
} if pending_update else None
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
# Global instance for easy access
|
|
491
|
-
_global_event_consolidator: Optional[EventConsolidator] = None
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
def get_event_consolidator() -> EventConsolidator:
|
|
495
|
-
"""Get the global event consolidator instance."""
|
|
496
|
-
global _global_event_consolidator
|
|
497
|
-
if _global_event_consolidator is None:
|
|
498
|
-
_global_event_consolidator = EventConsolidator()
|
|
499
|
-
return _global_event_consolidator
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
def create_event_consolidator(debounce_config: Optional[DebounceConfig] = None) -> EventConsolidator:
|
|
503
|
-
"""
|
|
504
|
-
Create a new event consolidator instance.
|
|
505
|
-
|
|
506
|
-
Args:
|
|
507
|
-
debounce_config: Optional debounce configuration
|
|
508
|
-
|
|
509
|
-
Returns:
|
|
510
|
-
New EventConsolidator instance
|
|
511
|
-
"""
|
|
1
|
+
"""
|
|
2
|
+
Event Consolidation System for Pomera AI Commander.
|
|
3
|
+
|
|
4
|
+
This module provides intelligent event consolidation to replace multiple event handlers
|
|
5
|
+
with a single consolidated handler, implementing adaptive debouncing and event deduplication
|
|
6
|
+
to optimize statistics calculation performance.
|
|
7
|
+
|
|
8
|
+
Requirements addressed:
|
|
9
|
+
- 1.1: Consolidate multiple event triggers into single statistics update
|
|
10
|
+
- 1.2: Use intelligent debouncing to prevent excessive calculations
|
|
11
|
+
- 1.3: Increase debounce delays automatically for large content (>10,000 characters)
|
|
12
|
+
- 1.4: Prevent duplicate statistics calculations
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
import threading
|
|
17
|
+
import tkinter as tk
|
|
18
|
+
from typing import Dict, Optional, Callable, Any, Set
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import Enum
|
|
21
|
+
import weakref
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EventType(Enum):
|
|
25
|
+
"""Types of text widget events that trigger statistics updates."""
|
|
26
|
+
MODIFIED = "<<Modified>>"
|
|
27
|
+
KEY_RELEASE = "<KeyRelease>"
|
|
28
|
+
BUTTON_CLICK = "<Button-1>"
|
|
29
|
+
FOCUS_IN = "<FocusIn>"
|
|
30
|
+
FOCUS_OUT = "<FocusOut>"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DebounceStrategy(Enum):
|
|
34
|
+
"""Debouncing strategies for different content sizes and user behaviors."""
|
|
35
|
+
IMMEDIATE = "immediate" # No debouncing for very small changes
|
|
36
|
+
FAST = "fast" # 50ms for small content
|
|
37
|
+
NORMAL = "normal" # 300ms for medium content
|
|
38
|
+
SLOW = "slow" # 500ms for large content
|
|
39
|
+
ADAPTIVE = "adaptive" # Automatically adjust based on content size
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class EventInfo:
|
|
44
|
+
"""Information about a text widget event."""
|
|
45
|
+
widget_id: str
|
|
46
|
+
event_type: EventType
|
|
47
|
+
timestamp: float
|
|
48
|
+
content_size: int = 0
|
|
49
|
+
content_hash: str = ""
|
|
50
|
+
|
|
51
|
+
def __post_init__(self):
|
|
52
|
+
if not self.timestamp:
|
|
53
|
+
self.timestamp = time.time()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class DebounceConfig:
|
|
58
|
+
"""Configuration for debouncing behavior."""
|
|
59
|
+
strategy: DebounceStrategy = DebounceStrategy.ADAPTIVE
|
|
60
|
+
immediate_threshold: int = 100 # Characters below which updates are immediate
|
|
61
|
+
fast_delay_ms: int = 50 # Delay for small content
|
|
62
|
+
normal_delay_ms: int = 300 # Delay for medium content
|
|
63
|
+
slow_delay_ms: int = 500 # Delay for large content
|
|
64
|
+
large_content_threshold: int = 10000 # Characters above which content is considered large
|
|
65
|
+
very_large_threshold: int = 100000 # Characters above which extra delays apply
|
|
66
|
+
max_delay_ms: int = 1000 # Maximum debounce delay
|
|
67
|
+
|
|
68
|
+
def get_delay_for_content_size(self, content_size: int) -> int:
|
|
69
|
+
"""Get appropriate debounce delay based on content size."""
|
|
70
|
+
if self.strategy == DebounceStrategy.IMMEDIATE:
|
|
71
|
+
return 0
|
|
72
|
+
elif self.strategy == DebounceStrategy.FAST:
|
|
73
|
+
return self.fast_delay_ms
|
|
74
|
+
elif self.strategy == DebounceStrategy.NORMAL:
|
|
75
|
+
return self.normal_delay_ms
|
|
76
|
+
elif self.strategy == DebounceStrategy.SLOW:
|
|
77
|
+
return self.slow_delay_ms
|
|
78
|
+
elif self.strategy == DebounceStrategy.ADAPTIVE:
|
|
79
|
+
if content_size < self.immediate_threshold:
|
|
80
|
+
return 0
|
|
81
|
+
elif content_size < 1000:
|
|
82
|
+
return self.fast_delay_ms
|
|
83
|
+
elif content_size < self.large_content_threshold:
|
|
84
|
+
return self.normal_delay_ms
|
|
85
|
+
elif content_size < self.very_large_threshold:
|
|
86
|
+
return self.slow_delay_ms
|
|
87
|
+
else:
|
|
88
|
+
# Extra delay for very large content
|
|
89
|
+
return min(self.max_delay_ms, self.slow_delay_ms + 200)
|
|
90
|
+
|
|
91
|
+
return self.normal_delay_ms
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class PendingUpdate:
|
|
96
|
+
"""Information about a pending statistics update."""
|
|
97
|
+
widget_id: str
|
|
98
|
+
event_info: EventInfo
|
|
99
|
+
callback: Callable
|
|
100
|
+
after_id: Optional[str] = None
|
|
101
|
+
scheduled_time: float = field(default_factory=time.time)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def is_expired(self) -> bool:
|
|
105
|
+
"""Check if this update has been waiting too long."""
|
|
106
|
+
return (time.time() - self.scheduled_time) > 2.0 # 2 second timeout
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class EventConsolidator:
|
|
110
|
+
"""
|
|
111
|
+
Consolidates multiple text widget events into single statistics updates.
|
|
112
|
+
|
|
113
|
+
This class replaces multiple event handlers (<<Modified>>, <KeyRelease>, <Button-1>)
|
|
114
|
+
with a single consolidated handler that implements intelligent debouncing and
|
|
115
|
+
event deduplication to optimize performance.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, debounce_config: Optional[DebounceConfig] = None):
|
|
119
|
+
"""
|
|
120
|
+
Initialize the event consolidator.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
debounce_config: Configuration for debouncing behavior
|
|
124
|
+
"""
|
|
125
|
+
self.debounce_config = debounce_config or DebounceConfig()
|
|
126
|
+
|
|
127
|
+
# Widget registry - use weak references to avoid memory leaks
|
|
128
|
+
self.registered_widgets: Dict[str, weakref.ref] = {}
|
|
129
|
+
self.widget_callbacks: Dict[str, Callable] = {}
|
|
130
|
+
|
|
131
|
+
# Event tracking for deduplication
|
|
132
|
+
self.recent_events: Dict[str, EventInfo] = {}
|
|
133
|
+
self.event_lock = threading.RLock()
|
|
134
|
+
|
|
135
|
+
# Pending updates tracking
|
|
136
|
+
self.pending_updates: Dict[str, PendingUpdate] = {}
|
|
137
|
+
|
|
138
|
+
# Statistics for monitoring
|
|
139
|
+
self.stats = {
|
|
140
|
+
'events_received': 0,
|
|
141
|
+
'events_deduplicated': 0,
|
|
142
|
+
'updates_triggered': 0,
|
|
143
|
+
'average_debounce_delay': 0.0
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Tkinter root reference for after() calls
|
|
147
|
+
self._tk_root: Optional[tk.Tk] = None
|
|
148
|
+
|
|
149
|
+
def set_tk_root(self, root: tk.Tk):
|
|
150
|
+
"""Set the Tkinter root for scheduling callbacks."""
|
|
151
|
+
self._tk_root = root
|
|
152
|
+
|
|
153
|
+
def register_text_widget(self, widget_id: str, widget: tk.Text,
|
|
154
|
+
callback: Callable[[str], None]) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Register a text widget for consolidated event handling.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
widget_id: Unique identifier for the widget
|
|
160
|
+
widget: The text widget to monitor
|
|
161
|
+
callback: Function to call when statistics should be updated
|
|
162
|
+
"""
|
|
163
|
+
# Store weak reference to widget to avoid memory leaks
|
|
164
|
+
self.registered_widgets[widget_id] = weakref.ref(widget)
|
|
165
|
+
self.widget_callbacks[widget_id] = callback
|
|
166
|
+
|
|
167
|
+
# Bind single consolidated event handler
|
|
168
|
+
self._bind_consolidated_events(widget, widget_id)
|
|
169
|
+
|
|
170
|
+
def unregister_text_widget(self, widget_id: str) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Unregister a text widget and cancel any pending updates.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
widget_id: Identifier of the widget to unregister
|
|
176
|
+
"""
|
|
177
|
+
# Cancel pending updates
|
|
178
|
+
self.cancel_pending_updates(widget_id)
|
|
179
|
+
|
|
180
|
+
# Remove from registries
|
|
181
|
+
self.registered_widgets.pop(widget_id, None)
|
|
182
|
+
self.widget_callbacks.pop(widget_id, None)
|
|
183
|
+
self.recent_events.pop(widget_id, None)
|
|
184
|
+
|
|
185
|
+
def _bind_consolidated_events(self, widget: tk.Text, widget_id: str) -> None:
|
|
186
|
+
"""
|
|
187
|
+
Bind consolidated event handler to replace multiple event bindings.
|
|
188
|
+
|
|
189
|
+
This replaces the multiple event bindings (<<Modified>>, <KeyRelease>, <Button-1>)
|
|
190
|
+
with a single handler per widget as required.
|
|
191
|
+
"""
|
|
192
|
+
# Create consolidated event handler
|
|
193
|
+
def consolidated_handler(event=None):
|
|
194
|
+
event_type = self._determine_event_type(event)
|
|
195
|
+
self.handle_text_event(widget_id, event_type, event)
|
|
196
|
+
return None # Don't break event propagation
|
|
197
|
+
|
|
198
|
+
# Bind to all relevant events with single handler
|
|
199
|
+
widget.bind("<<Modified>>", consolidated_handler, add=True)
|
|
200
|
+
widget.bind("<KeyRelease>", consolidated_handler, add=True)
|
|
201
|
+
widget.bind("<Button-1>", consolidated_handler, add=True)
|
|
202
|
+
|
|
203
|
+
# Optional: Also handle focus events for better user experience
|
|
204
|
+
widget.bind("<FocusIn>", consolidated_handler, add=True)
|
|
205
|
+
widget.bind("<FocusOut>", consolidated_handler, add=True)
|
|
206
|
+
|
|
207
|
+
def _determine_event_type(self, event) -> EventType:
|
|
208
|
+
"""Determine the type of event from the event object."""
|
|
209
|
+
if not event:
|
|
210
|
+
return EventType.MODIFIED
|
|
211
|
+
|
|
212
|
+
event_str = str(event.type) if hasattr(event, 'type') else str(event)
|
|
213
|
+
|
|
214
|
+
if "KeyRelease" in event_str or hasattr(event, 'keysym'):
|
|
215
|
+
return EventType.KEY_RELEASE
|
|
216
|
+
elif "Button" in event_str and hasattr(event, 'num'):
|
|
217
|
+
return EventType.BUTTON_CLICK
|
|
218
|
+
elif "FocusIn" in event_str:
|
|
219
|
+
return EventType.FOCUS_IN
|
|
220
|
+
elif "FocusOut" in event_str:
|
|
221
|
+
return EventType.FOCUS_OUT
|
|
222
|
+
else:
|
|
223
|
+
return EventType.MODIFIED
|
|
224
|
+
|
|
225
|
+
def handle_text_event(self, widget_id: str, event_type: EventType,
|
|
226
|
+
event=None) -> None:
|
|
227
|
+
"""
|
|
228
|
+
Handle consolidated text widget events with intelligent deduplication.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
widget_id: Identifier of the widget that triggered the event
|
|
232
|
+
event_type: Type of event that occurred
|
|
233
|
+
event: Original event object (optional)
|
|
234
|
+
"""
|
|
235
|
+
with self.event_lock:
|
|
236
|
+
self.stats['events_received'] += 1
|
|
237
|
+
|
|
238
|
+
# Get widget reference
|
|
239
|
+
widget_ref = self.registered_widgets.get(widget_id)
|
|
240
|
+
if not widget_ref:
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
widget = widget_ref()
|
|
244
|
+
if not widget:
|
|
245
|
+
# Widget was garbage collected, clean up
|
|
246
|
+
self.unregister_text_widget(widget_id)
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
# Get current content for analysis
|
|
250
|
+
try:
|
|
251
|
+
content = widget.get("1.0", tk.END)
|
|
252
|
+
content_size = len(content.encode('utf-8'))
|
|
253
|
+
content_hash = self._generate_content_hash(content)
|
|
254
|
+
except tk.TclError:
|
|
255
|
+
# Widget might be destroyed
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
# Create event info
|
|
259
|
+
event_info = EventInfo(
|
|
260
|
+
widget_id=widget_id,
|
|
261
|
+
event_type=event_type,
|
|
262
|
+
timestamp=time.time(),
|
|
263
|
+
content_size=content_size,
|
|
264
|
+
content_hash=content_hash
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Check for duplicate events
|
|
268
|
+
if self._is_duplicate_event(widget_id, event_info):
|
|
269
|
+
self.stats['events_deduplicated'] += 1
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
# Store recent event for deduplication
|
|
273
|
+
self.recent_events[widget_id] = event_info
|
|
274
|
+
|
|
275
|
+
# Schedule update with appropriate debouncing
|
|
276
|
+
self._schedule_update(widget_id, event_info)
|
|
277
|
+
|
|
278
|
+
def _is_duplicate_event(self, widget_id: str, event_info: EventInfo) -> bool:
|
|
279
|
+
"""
|
|
280
|
+
Check if this event is a duplicate that should be ignored.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
widget_id: Widget identifier
|
|
284
|
+
event_info: Information about the current event
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
True if this is a duplicate event that should be ignored
|
|
288
|
+
"""
|
|
289
|
+
recent_event = self.recent_events.get(widget_id)
|
|
290
|
+
if not recent_event:
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
# Check if content hasn't changed
|
|
294
|
+
if recent_event.content_hash == event_info.content_hash:
|
|
295
|
+
# Same content - check if enough time has passed
|
|
296
|
+
time_diff = event_info.timestamp - recent_event.timestamp
|
|
297
|
+
if time_diff < 0.1: # Less than 100ms
|
|
298
|
+
return True
|
|
299
|
+
|
|
300
|
+
# Check for rapid successive events of the same type
|
|
301
|
+
if (recent_event.event_type == event_info.event_type and
|
|
302
|
+
event_info.timestamp - recent_event.timestamp < 0.05): # Less than 50ms
|
|
303
|
+
return True
|
|
304
|
+
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
def _schedule_update(self, widget_id: str, event_info: EventInfo) -> None:
|
|
308
|
+
"""
|
|
309
|
+
Schedule a statistics update with appropriate debouncing.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
widget_id: Widget identifier
|
|
313
|
+
event_info: Information about the event
|
|
314
|
+
"""
|
|
315
|
+
# Cancel any existing pending update for this widget
|
|
316
|
+
self.cancel_pending_updates(widget_id)
|
|
317
|
+
|
|
318
|
+
# Get callback
|
|
319
|
+
callback = self.widget_callbacks.get(widget_id)
|
|
320
|
+
if not callback:
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
# Determine debounce delay
|
|
324
|
+
delay_ms = self.debounce_config.get_delay_for_content_size(event_info.content_size)
|
|
325
|
+
|
|
326
|
+
# Update statistics
|
|
327
|
+
self.stats['average_debounce_delay'] = (
|
|
328
|
+
(self.stats['average_debounce_delay'] * self.stats['updates_triggered'] + delay_ms) /
|
|
329
|
+
(self.stats['updates_triggered'] + 1)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if delay_ms == 0:
|
|
333
|
+
# Immediate update
|
|
334
|
+
self._execute_update(widget_id, callback)
|
|
335
|
+
else:
|
|
336
|
+
# Debounced update
|
|
337
|
+
if self._tk_root:
|
|
338
|
+
after_id = self._tk_root.after(delay_ms,
|
|
339
|
+
lambda: self._execute_update(widget_id, callback))
|
|
340
|
+
|
|
341
|
+
# Track pending update
|
|
342
|
+
self.pending_updates[widget_id] = PendingUpdate(
|
|
343
|
+
widget_id=widget_id,
|
|
344
|
+
event_info=event_info,
|
|
345
|
+
callback=callback,
|
|
346
|
+
after_id=after_id
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
def _execute_update(self, widget_id: str, callback: Callable) -> None:
|
|
350
|
+
"""
|
|
351
|
+
Execute the statistics update callback.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
widget_id: Widget identifier
|
|
355
|
+
callback: Callback function to execute
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
# Remove from pending updates
|
|
359
|
+
self.pending_updates.pop(widget_id, None)
|
|
360
|
+
|
|
361
|
+
# Execute callback
|
|
362
|
+
callback(widget_id)
|
|
363
|
+
|
|
364
|
+
self.stats['updates_triggered'] += 1
|
|
365
|
+
|
|
366
|
+
except Exception as e:
|
|
367
|
+
# Log error if possible, but don't crash
|
|
368
|
+
print(f"Error executing update callback for {widget_id}: {e}")
|
|
369
|
+
|
|
370
|
+
def cancel_pending_updates(self, widget_id: str) -> None:
|
|
371
|
+
"""
|
|
372
|
+
Cancel any pending updates for a specific widget.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
widget_id: Widget identifier
|
|
376
|
+
"""
|
|
377
|
+
pending = self.pending_updates.get(widget_id)
|
|
378
|
+
if pending and pending.after_id and self._tk_root:
|
|
379
|
+
try:
|
|
380
|
+
self._tk_root.after_cancel(pending.after_id)
|
|
381
|
+
except tk.TclError:
|
|
382
|
+
pass # after_id might be invalid
|
|
383
|
+
|
|
384
|
+
self.pending_updates.pop(widget_id, None)
|
|
385
|
+
|
|
386
|
+
def cancel_all_pending_updates(self) -> None:
|
|
387
|
+
"""Cancel all pending updates."""
|
|
388
|
+
for widget_id in list(self.pending_updates.keys()):
|
|
389
|
+
self.cancel_pending_updates(widget_id)
|
|
390
|
+
|
|
391
|
+
def set_debounce_strategy(self, strategy: DebounceStrategy) -> None:
|
|
392
|
+
"""
|
|
393
|
+
Set the debouncing strategy for all widgets.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
strategy: New debouncing strategy to use
|
|
397
|
+
"""
|
|
398
|
+
self.debounce_config.strategy = strategy
|
|
399
|
+
|
|
400
|
+
def set_debounce_config(self, config: DebounceConfig) -> None:
|
|
401
|
+
"""
|
|
402
|
+
Set a new debounce configuration.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
config: New debounce configuration
|
|
406
|
+
"""
|
|
407
|
+
self.debounce_config = config
|
|
408
|
+
|
|
409
|
+
def get_statistics(self) -> Dict[str, Any]:
|
|
410
|
+
"""
|
|
411
|
+
Get event consolidation statistics.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Dictionary with statistics about event handling
|
|
415
|
+
"""
|
|
416
|
+
with self.event_lock:
|
|
417
|
+
stats = self.stats.copy()
|
|
418
|
+
stats.update({
|
|
419
|
+
'registered_widgets': len(self.registered_widgets),
|
|
420
|
+
'pending_updates': len(self.pending_updates),
|
|
421
|
+
'recent_events_tracked': len(self.recent_events),
|
|
422
|
+
'deduplication_rate': (
|
|
423
|
+
self.stats['events_deduplicated'] / max(1, self.stats['events_received'])
|
|
424
|
+
) * 100
|
|
425
|
+
})
|
|
426
|
+
return stats
|
|
427
|
+
|
|
428
|
+
def cleanup_expired_updates(self) -> None:
|
|
429
|
+
"""Clean up any expired pending updates."""
|
|
430
|
+
expired_widgets = []
|
|
431
|
+
|
|
432
|
+
for widget_id, pending in self.pending_updates.items():
|
|
433
|
+
if pending.is_expired:
|
|
434
|
+
expired_widgets.append(widget_id)
|
|
435
|
+
|
|
436
|
+
for widget_id in expired_widgets:
|
|
437
|
+
self.cancel_pending_updates(widget_id)
|
|
438
|
+
|
|
439
|
+
def _generate_content_hash(self, content: str) -> str:
|
|
440
|
+
"""Generate a simple hash for content comparison."""
|
|
441
|
+
# Simple hash based on length and first/last characters
|
|
442
|
+
if not content:
|
|
443
|
+
return "empty"
|
|
444
|
+
|
|
445
|
+
content_clean = content.strip()
|
|
446
|
+
if not content_clean:
|
|
447
|
+
return "whitespace"
|
|
448
|
+
|
|
449
|
+
# Create hash from length + first 10 + last 10 characters
|
|
450
|
+
first_part = content_clean[:10] if len(content_clean) >= 10 else content_clean
|
|
451
|
+
last_part = content_clean[-10:] if len(content_clean) >= 20 else ""
|
|
452
|
+
|
|
453
|
+
return f"{len(content_clean)}_{hash(first_part + last_part) % 10000}"
|
|
454
|
+
|
|
455
|
+
def get_widget_info(self, widget_id: str) -> Optional[Dict[str, Any]]:
|
|
456
|
+
"""
|
|
457
|
+
Get information about a registered widget.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
widget_id: Widget identifier
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Dictionary with widget information or None if not found
|
|
464
|
+
"""
|
|
465
|
+
if widget_id not in self.registered_widgets:
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
widget_ref = self.registered_widgets[widget_id]
|
|
469
|
+
widget = widget_ref() if widget_ref else None
|
|
470
|
+
|
|
471
|
+
recent_event = self.recent_events.get(widget_id)
|
|
472
|
+
pending_update = self.pending_updates.get(widget_id)
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
'widget_id': widget_id,
|
|
476
|
+
'widget_exists': widget is not None,
|
|
477
|
+
'has_callback': widget_id in self.widget_callbacks,
|
|
478
|
+
'recent_event': {
|
|
479
|
+
'timestamp': recent_event.timestamp if recent_event else None,
|
|
480
|
+
'event_type': recent_event.event_type.value if recent_event else None,
|
|
481
|
+
'content_size': recent_event.content_size if recent_event else 0
|
|
482
|
+
} if recent_event else None,
|
|
483
|
+
'pending_update': {
|
|
484
|
+
'scheduled_time': pending_update.scheduled_time,
|
|
485
|
+
'is_expired': pending_update.is_expired
|
|
486
|
+
} if pending_update else None
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# Global instance for easy access
|
|
491
|
+
_global_event_consolidator: Optional[EventConsolidator] = None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def get_event_consolidator() -> EventConsolidator:
|
|
495
|
+
"""Get the global event consolidator instance."""
|
|
496
|
+
global _global_event_consolidator
|
|
497
|
+
if _global_event_consolidator is None:
|
|
498
|
+
_global_event_consolidator = EventConsolidator()
|
|
499
|
+
return _global_event_consolidator
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def create_event_consolidator(debounce_config: Optional[DebounceConfig] = None) -> EventConsolidator:
|
|
503
|
+
"""
|
|
504
|
+
Create a new event consolidator instance.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
debounce_config: Optional debounce configuration
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
New EventConsolidator instance
|
|
511
|
+
"""
|
|
512
512
|
return EventConsolidator(debounce_config)
|