mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
fix: reduce CPU usage, fix Vegas refresh, throttle high-FPS ticks (#304)
* fix: reduce CPU usage, fix Vegas mid-cycle refresh, and throttle high-FPS plugin ticks Web UI Info plugin was causing 90%+ CPU on RPi4 due to frequent subprocess calls and re-rendering. Fixed by: trying socket-based IP detection first (zero subprocess overhead), caching AP mode checks with 60s TTL, reducing IP refresh from 30s to 5m, caching rendered display images, and loading fonts once at init. Vegas mode was not updating the display mid-cycle because hot_swap_content() reset the scroll position to 0 on every recomposition. Now saves and restores scroll position for mid-cycle updates. High-FPS display loop was calling _tick_plugin_updates() 125x/sec with no benefit. Added throttled wrapper that limits to 1 call/sec. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — respect plugin update_interval, narrow exception handlers Make _tick_plugin_updates_throttled default to no-throttle (min_interval=0) so plugin-configured update_interval values are never silently capped. The high-FPS call site passes an explicit 1.0s interval. Narrow _load_font exception handler from bare Exception to FileNotFoundError | OSError so unexpected errors surface. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(vegas): scale scroll position proportionally on mid-cycle hot-swap When content width changes during a mid-cycle recomposition (e.g., a plugin gains or loses items), blindly restoring the old scroll_position and total_distance_scrolled could overshoot the new total_scroll_width and trigger immediate false completion. Scale both values proportionally to the new width and clamp scroll_position to stay in bounds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,26 +47,55 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
|
|
||||||
# IP refresh tracking
|
# IP refresh tracking
|
||||||
self.last_ip_refresh = time.time()
|
self.last_ip_refresh = time.time()
|
||||||
self.ip_refresh_interval = 30.0 # Refresh IP every 30 seconds
|
self.ip_refresh_interval = 300.0 # Refresh IP every 5 minutes
|
||||||
|
|
||||||
|
# AP mode cache
|
||||||
|
self._ap_mode_cached = False
|
||||||
|
self._ap_mode_cache_time = 0.0
|
||||||
|
self._ap_mode_cache_ttl = 60.0 # Cache AP mode check for 60 seconds
|
||||||
|
|
||||||
# Rotation state
|
# Rotation state
|
||||||
self.current_display_mode = "hostname" # "hostname" or "ip"
|
self.current_display_mode = "hostname" # "hostname" or "ip"
|
||||||
self.last_rotation_time = time.time()
|
self.last_rotation_time = time.time()
|
||||||
self.rotation_interval = 10.0 # Rotate every 10 seconds
|
self.rotation_interval = 10.0 # Rotate every 10 seconds
|
||||||
|
|
||||||
self.web_ui_url = f"http://{self.device_id}:5000"
|
self.web_ui_url = f"http://{self.device_id}:5000"
|
||||||
|
|
||||||
|
# Display cache - avoid re-rendering when nothing changed
|
||||||
|
self._cached_display_image = None
|
||||||
|
self._display_dirty = True
|
||||||
|
self._font_small = self._load_font()
|
||||||
|
|
||||||
self.logger.info(f"Web UI Info plugin initialized - Hostname: {self.device_id}, IP: {self.device_ip}")
|
self.logger.info(f"Web UI Info plugin initialized - Hostname: {self.device_id}, IP: {self.device_ip}")
|
||||||
|
|
||||||
|
def _load_font(self):
|
||||||
|
"""Load and cache the display font."""
|
||||||
|
try:
|
||||||
|
current_dir = Path(__file__).resolve().parent
|
||||||
|
project_root = current_dir.parent.parent
|
||||||
|
font_path = project_root / "assets" / "fonts" / "4x6-font.ttf"
|
||||||
|
if font_path.exists():
|
||||||
|
return ImageFont.truetype(str(font_path), 6)
|
||||||
|
font_path = "assets/fonts/4x6-font.ttf"
|
||||||
|
if os.path.exists(font_path):
|
||||||
|
return ImageFont.truetype(font_path, 6)
|
||||||
|
return ImageFont.load_default()
|
||||||
|
except (FileNotFoundError, OSError) as e:
|
||||||
|
self.logger.debug(f"Could not load custom font: {e}, using default")
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
def _is_ap_mode_active(self) -> bool:
|
def _is_ap_mode_active(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if AP mode is currently active.
|
Check if AP mode is currently active (cached with TTL).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if AP mode is active, False otherwise
|
bool: True if AP mode is active, False otherwise
|
||||||
"""
|
"""
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - self._ap_mode_cache_time < self._ap_mode_cache_ttl:
|
||||||
|
return self._ap_mode_cached
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if hostapd service is running
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["systemctl", "is-active", "hostapd"],
|
["systemctl", "is-active", "hostapd"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -74,9 +103,10 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
timeout=2
|
timeout=2
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout.strip() == "active":
|
if result.returncode == 0 and result.stdout.strip() == "active":
|
||||||
|
self._ap_mode_cached = True
|
||||||
|
self._ap_mode_cache_time = current_time
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check if wlan0 has AP mode IP (192.168.4.1)
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["ip", "addr", "show", "wlan0"],
|
["ip", "addr", "show", "wlan0"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -84,18 +114,24 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
timeout=2
|
timeout=2
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and "192.168.4.1" in result.stdout:
|
if result.returncode == 0 and "192.168.4.1" in result.stdout:
|
||||||
|
self._ap_mode_cached = True
|
||||||
|
self._ap_mode_cache_time = current_time
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
self._ap_mode_cached = False
|
||||||
|
self._ap_mode_cache_time = current_time
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug(f"Error checking AP mode status: {e}")
|
self.logger.debug(f"Error checking AP mode status: {e}")
|
||||||
|
self._ap_mode_cached = False
|
||||||
|
self._ap_mode_cache_time = current_time
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _get_local_ip(self) -> str:
|
def _get_local_ip(self) -> str:
|
||||||
"""
|
"""
|
||||||
Get the local IP address of the device using network interfaces.
|
Get the local IP address of the device using network interfaces.
|
||||||
Handles AP mode, no internet connectivity, and network state changes.
|
Handles AP mode, no internet connectivity, and network state changes.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Local IP address, or "localhost" if unable to determine
|
str: Local IP address, or "localhost" if unable to determine
|
||||||
"""
|
"""
|
||||||
@@ -103,9 +139,23 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
if self._is_ap_mode_active():
|
if self._is_ap_mode_active():
|
||||||
self.logger.debug("AP mode detected, returning AP IP: 192.168.4.1")
|
self.logger.debug("AP mode detected, returning AP IP: 192.168.4.1")
|
||||||
return "192.168.4.1"
|
return "192.168.4.1"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Try using 'hostname -I' first (fastest, gets all IPs)
|
# Try socket method first (zero subprocess overhead, fastest)
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
s.connect(('8.8.8.8', 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
|
||||||
|
self.logger.debug(f"Found IP via socket method: {ip}")
|
||||||
|
return ip
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: Try using 'hostname -I'
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["hostname", "-I"],
|
["hostname", "-I"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -114,13 +164,12 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
ips = result.stdout.strip().split()
|
ips = result.stdout.strip().split()
|
||||||
# Filter out loopback and AP mode IPs
|
|
||||||
for ip in ips:
|
for ip in ips:
|
||||||
ip = ip.strip()
|
ip = ip.strip()
|
||||||
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
|
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
|
||||||
self.logger.debug(f"Found IP via hostname -I: {ip}")
|
self.logger.debug(f"Found IP via hostname -I: {ip}")
|
||||||
return ip
|
return ip
|
||||||
|
|
||||||
# Fallback: Use 'ip addr show' to get interface IPs
|
# Fallback: Use 'ip addr show' to get interface IPs
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["ip", "-4", "addr", "show"],
|
["ip", "-4", "addr", "show"],
|
||||||
@@ -132,22 +181,18 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
current_interface = None
|
current_interface = None
|
||||||
for line in result.stdout.split('\n'):
|
for line in result.stdout.split('\n'):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
# Check for interface name
|
|
||||||
if ':' in line and not line.startswith('inet'):
|
if ':' in line and not line.startswith('inet'):
|
||||||
parts = line.split(':')
|
parts = line.split(':')
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
current_interface = parts[1].strip().split('@')[0]
|
current_interface = parts[1].strip().split('@')[0]
|
||||||
# Check for inet address
|
|
||||||
elif line.startswith('inet '):
|
elif line.startswith('inet '):
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
ip_with_cidr = parts[1]
|
ip_with_cidr = parts[1]
|
||||||
ip = ip_with_cidr.split('/')[0]
|
ip = ip_with_cidr.split('/')[0]
|
||||||
# Skip loopback and AP mode IPs
|
|
||||||
if not ip.startswith("127.") and ip != "192.168.4.1":
|
if not ip.startswith("127.") and ip != "192.168.4.1":
|
||||||
# Prefer eth0/ethernet interfaces, then wlan0, then others
|
|
||||||
if current_interface and (
|
if current_interface and (
|
||||||
current_interface.startswith("eth") or
|
current_interface.startswith("eth") or
|
||||||
current_interface.startswith("enp")
|
current_interface.startswith("enp")
|
||||||
):
|
):
|
||||||
self.logger.debug(f"Found Ethernet IP: {ip} on {current_interface}")
|
self.logger.debug(f"Found Ethernet IP: {ip} on {current_interface}")
|
||||||
@@ -155,19 +200,6 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
elif current_interface == "wlan0":
|
elif current_interface == "wlan0":
|
||||||
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
|
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
|
||||||
return ip
|
return ip
|
||||||
|
|
||||||
# Fallback: Try socket method (requires internet connectivity)
|
|
||||||
try:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
try:
|
|
||||||
# Connect to a public DNS server (doesn't actually connect)
|
|
||||||
s.connect(('8.8.8.8', 80))
|
|
||||||
ip = s.getsockname()[0]
|
|
||||||
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
|
|
||||||
self.logger.debug(f"Found IP via socket method: {ip}")
|
|
||||||
return ip
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -190,24 +222,24 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
def update(self) -> None:
|
def update(self) -> None:
|
||||||
"""
|
"""
|
||||||
Update method - refreshes IP address periodically to handle network state changes.
|
Update method - refreshes IP address periodically to handle network state changes.
|
||||||
|
|
||||||
The hostname is determined at initialization and doesn't change,
|
The hostname is determined at initialization and doesn't change,
|
||||||
but IP address can change when network state changes (WiFi connect/disconnect, AP mode, etc.)
|
but IP address can change when network state changes (WiFi connect/disconnect, AP mode, etc.)
|
||||||
"""
|
"""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if current_time - self.last_ip_refresh >= self.ip_refresh_interval:
|
if current_time - self.last_ip_refresh >= self.ip_refresh_interval:
|
||||||
# Refresh IP address to handle network state changes
|
|
||||||
new_ip = self._get_local_ip()
|
new_ip = self._get_local_ip()
|
||||||
if new_ip != self.device_ip:
|
if new_ip != self.device_ip:
|
||||||
self.logger.info(f"IP address changed from {self.device_ip} to {new_ip}")
|
self.logger.info(f"IP address changed from {self.device_ip} to {new_ip}")
|
||||||
self.device_ip = new_ip
|
self.device_ip = new_ip
|
||||||
|
self._display_dirty = True
|
||||||
self.last_ip_refresh = current_time
|
self.last_ip_refresh = current_time
|
||||||
|
|
||||||
def display(self, force_clear: bool = False) -> None:
|
def display(self, force_clear: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
Display the web UI URL message.
|
Display the web UI URL message.
|
||||||
Rotates between hostname and IP address every 10 seconds.
|
Rotates between hostname and IP address every 10 seconds.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
force_clear: If True, clear display before rendering
|
force_clear: If True, clear display before rendering
|
||||||
"""
|
"""
|
||||||
@@ -215,93 +247,66 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
# Check if we need to rotate between hostname and IP
|
# Check if we need to rotate between hostname and IP
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if current_time - self.last_rotation_time >= self.rotation_interval:
|
if current_time - self.last_rotation_time >= self.rotation_interval:
|
||||||
# Switch display mode
|
|
||||||
if self.current_display_mode == "hostname":
|
if self.current_display_mode == "hostname":
|
||||||
self.current_display_mode = "ip"
|
self.current_display_mode = "ip"
|
||||||
else:
|
else:
|
||||||
self.current_display_mode = "hostname"
|
self.current_display_mode = "hostname"
|
||||||
self.last_rotation_time = current_time
|
self.last_rotation_time = current_time
|
||||||
|
self._display_dirty = True
|
||||||
self.logger.debug(f"Rotated to display mode: {self.current_display_mode}")
|
self.logger.debug(f"Rotated to display mode: {self.current_display_mode}")
|
||||||
|
|
||||||
if force_clear:
|
if force_clear:
|
||||||
self.display_manager.clear()
|
self.display_manager.clear()
|
||||||
|
self._display_dirty = True
|
||||||
|
|
||||||
|
# Use cached image if nothing changed
|
||||||
|
if not self._display_dirty and self._cached_display_image is not None:
|
||||||
|
self.display_manager.image = self._cached_display_image
|
||||||
|
self.display_manager.update_display()
|
||||||
|
return
|
||||||
|
|
||||||
# Get display dimensions
|
# Get display dimensions
|
||||||
width = self.display_manager.matrix.width
|
width = self.display_manager.matrix.width
|
||||||
height = self.display_manager.matrix.height
|
height = self.display_manager.matrix.height
|
||||||
|
|
||||||
# Create a new image for the display
|
# Create a new image for the display
|
||||||
img = Image.new('RGB', (width, height), (0, 0, 0))
|
img = Image.new('RGB', (width, height), (0, 0, 0))
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
# Try to load a small font
|
|
||||||
# Try to find project root and use assets/fonts
|
|
||||||
font_small = None
|
|
||||||
try:
|
|
||||||
# Try to find project root (parent of plugins directory)
|
|
||||||
current_dir = Path(__file__).resolve().parent
|
|
||||||
project_root = current_dir.parent.parent
|
|
||||||
font_path = project_root / "assets" / "fonts" / "4x6-font.ttf"
|
|
||||||
|
|
||||||
if font_path.exists():
|
|
||||||
font_small = ImageFont.truetype(str(font_path), 6)
|
|
||||||
else:
|
|
||||||
# Try relative path from current working directory
|
|
||||||
font_path = "assets/fonts/4x6-font.ttf"
|
|
||||||
if os.path.exists(font_path):
|
|
||||||
font_small = ImageFont.truetype(font_path, 6)
|
|
||||||
else:
|
|
||||||
font_small = ImageFont.load_default()
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.debug(f"Could not load custom font: {e}, using default")
|
|
||||||
font_small = ImageFont.load_default()
|
|
||||||
|
|
||||||
# Determine which address to display
|
# Determine which address to display
|
||||||
if self.current_display_mode == "ip":
|
if self.current_display_mode == "ip":
|
||||||
address = self.device_ip
|
address = self.device_ip
|
||||||
else:
|
else:
|
||||||
address = self.device_id
|
address = self.device_id
|
||||||
|
|
||||||
# Prepare text to display
|
|
||||||
lines = [
|
lines = [
|
||||||
"visit web ui",
|
"visit web ui",
|
||||||
f"at {address}:5000"
|
f"at {address}:5000"
|
||||||
]
|
]
|
||||||
|
|
||||||
# Calculate text positions (centered)
|
|
||||||
y_start = 5
|
y_start = 5
|
||||||
line_height = 8
|
line_height = 8
|
||||||
total_height = len(lines) * line_height
|
|
||||||
|
|
||||||
# Draw each line
|
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
# Get text size for centering
|
bbox = draw.textbbox((0, 0), line, font=self._font_small)
|
||||||
bbox = draw.textbbox((0, 0), line, font=font_small)
|
|
||||||
text_width = bbox[2] - bbox[0]
|
text_width = bbox[2] - bbox[0]
|
||||||
text_height = bbox[3] - bbox[1]
|
|
||||||
|
|
||||||
# Center horizontally
|
|
||||||
x = (width - text_width) // 2
|
x = (width - text_width) // 2
|
||||||
y = y_start + (i * line_height)
|
y = y_start + (i * line_height)
|
||||||
|
draw.text((x, y), line, font=self._font_small, fill=(255, 255, 255))
|
||||||
# Draw text in white
|
|
||||||
draw.text((x, y), line, font=font_small, fill=(255, 255, 255))
|
self._cached_display_image = img
|
||||||
|
self._display_dirty = False
|
||||||
# Set the image on the display manager
|
|
||||||
self.display_manager.image = img
|
self.display_manager.image = img
|
||||||
|
|
||||||
# Update the display
|
|
||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
|
|
||||||
self.logger.debug(f"Displayed web UI info: {address}:5000 (mode: {self.current_display_mode})")
|
self.logger.debug(f"Displayed web UI info: {address}:5000 (mode: {self.current_display_mode})")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error displaying web UI info: {e}")
|
self.logger.error(f"Error displaying web UI info: {e}")
|
||||||
# Fallback: just clear the display
|
|
||||||
try:
|
try:
|
||||||
self.display_manager.clear()
|
self.display_manager.clear()
|
||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_display_duration(self) -> float:
|
def get_display_duration(self) -> float:
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ class DisplayController:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
logger.info("Starting DisplayController initialization")
|
logger.info("Starting DisplayController initialization")
|
||||||
|
|
||||||
|
# Throttle tracking for _tick_plugin_updates in high-FPS loops
|
||||||
|
self._last_plugin_tick_time = 0.0
|
||||||
|
|
||||||
# Initialize ConfigManager and wrap with ConfigService for hot-reload
|
# Initialize ConfigManager and wrap with ConfigService for hot-reload
|
||||||
config_manager = ConfigManager()
|
config_manager = ConfigManager()
|
||||||
enable_hot_reload = os.environ.get('LEDMATRIX_HOT_RELOAD', 'true').lower() == 'true'
|
enable_hot_reload = os.environ.get('LEDMATRIX_HOT_RELOAD', 'true').lower() == 'true'
|
||||||
@@ -719,6 +722,22 @@ class DisplayController:
|
|||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
logger.exception("Error running scheduled plugin updates")
|
logger.exception("Error running scheduled plugin updates")
|
||||||
|
|
||||||
|
def _tick_plugin_updates_throttled(self, min_interval: float = 0.0):
|
||||||
|
"""Throttled version of _tick_plugin_updates for high-FPS loops.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
min_interval: Minimum seconds between calls. When <= 0 the
|
||||||
|
call passes straight through to _tick_plugin_updates so
|
||||||
|
plugin-configured update_interval values are never capped.
|
||||||
|
"""
|
||||||
|
if min_interval <= 0:
|
||||||
|
self._tick_plugin_updates()
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
if now - self._last_plugin_tick_time >= min_interval:
|
||||||
|
self._last_plugin_tick_time = now
|
||||||
|
self._tick_plugin_updates()
|
||||||
|
|
||||||
def _sleep_with_plugin_updates(self, duration: float, tick_interval: float = 1.0):
|
def _sleep_with_plugin_updates(self, duration: float, tick_interval: float = 1.0):
|
||||||
"""Sleep while continuing to service plugin update schedules."""
|
"""Sleep while continuing to service plugin update schedules."""
|
||||||
if duration <= 0:
|
if duration <= 0:
|
||||||
@@ -1787,7 +1806,7 @@ class DisplayController:
|
|||||||
logger.exception("Error during display update")
|
logger.exception("Error during display update")
|
||||||
|
|
||||||
time.sleep(display_interval)
|
time.sleep(display_interval)
|
||||||
self._tick_plugin_updates()
|
self._tick_plugin_updates_throttled(min_interval=1.0)
|
||||||
self._poll_on_demand_requests()
|
self._poll_on_demand_requests()
|
||||||
self._check_on_demand_expiration()
|
self._check_on_demand_expiration()
|
||||||
|
|
||||||
|
|||||||
@@ -275,13 +275,19 @@ class RenderPipeline:
|
|||||||
"""
|
"""
|
||||||
Hot-swap to new composed content.
|
Hot-swap to new composed content.
|
||||||
|
|
||||||
Called when staging buffer has updated content.
|
Called when staging buffer has updated content or pending updates exist.
|
||||||
Swaps atomically to prevent visual glitches.
|
Preserves scroll position for mid-cycle updates to prevent visual jumps.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if swap occurred
|
True if swap occurred
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Save scroll position for mid-cycle updates
|
||||||
|
saved_position = self.scroll_helper.scroll_position
|
||||||
|
saved_total_distance = self.scroll_helper.total_distance_scrolled
|
||||||
|
saved_total_width = max(1, self.scroll_helper.total_scroll_width)
|
||||||
|
was_mid_cycle = not self._cycle_complete
|
||||||
|
|
||||||
# Process any pending updates
|
# Process any pending updates
|
||||||
self.stream_manager.process_updates()
|
self.stream_manager.process_updates()
|
||||||
self.stream_manager.swap_buffers()
|
self.stream_manager.swap_buffers()
|
||||||
@@ -289,7 +295,19 @@ class RenderPipeline:
|
|||||||
# Recompose with updated content
|
# Recompose with updated content
|
||||||
if self.compose_scroll_content():
|
if self.compose_scroll_content():
|
||||||
self.stats['hot_swaps'] += 1
|
self.stats['hot_swaps'] += 1
|
||||||
logger.debug("Hot-swap completed")
|
# Restore scroll position for mid-cycle updates so the
|
||||||
|
# scroll continues from where it was instead of jumping to 0
|
||||||
|
if was_mid_cycle:
|
||||||
|
new_total_width = max(1, self.scroll_helper.total_scroll_width)
|
||||||
|
progress_ratio = min(saved_total_distance / saved_total_width, 0.999)
|
||||||
|
self.scroll_helper.total_distance_scrolled = progress_ratio * new_total_width
|
||||||
|
self.scroll_helper.scroll_position = min(
|
||||||
|
saved_position,
|
||||||
|
float(new_total_width - 1)
|
||||||
|
)
|
||||||
|
self.scroll_helper.scroll_complete = False
|
||||||
|
self._cycle_complete = False
|
||||||
|
logger.debug("Hot-swap completed (mid_cycle_restore=%s)", was_mid_cycle)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|||||||
Reference in New Issue
Block a user