💅 style(launcher, osd, switcher): update spacing and background for better consistency with GNOME design guidelines
This commit is contained in:
8 files changed
+385
-2397
No files matched your search
Executable
+383
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Memory Monitor Script for Modus Components
|
||||
Shows memory usage for each component in the modules/ directory.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import psutil
|
||||
import subprocess
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import argparse
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComponentMemory:
|
||||
"""Data class to store component memory information."""
|
||||
name: str
|
||||
pid: Optional[int]
|
||||
memory_mb: float
|
||||
memory_percent: float
|
||||
cpu_percent: float
|
||||
status: str
|
||||
children_count: int = 0
|
||||
children_memory_mb: float = 0.0
|
||||
|
||||
|
||||
class ModusMemoryMonitor:
|
||||
"""Monitor memory usage of Modus components."""
|
||||
|
||||
def __init__(self):
|
||||
self.modus_processes = {}
|
||||
self.component_mapping = {
|
||||
# Main components from modules/
|
||||
'dock': ['dock', 'Dock'],
|
||||
'launcher': ['launcher', 'Launcher'],
|
||||
'notification_popup': ['notification', 'NotificationPopup'],
|
||||
'osd': ['osd', 'OSD'],
|
||||
'switcher': ['switcher', 'ApplicationSwitcher'],
|
||||
'corners': ['corners', 'Corners'],
|
||||
|
||||
# Dock components
|
||||
'dock_applications': ['applications'],
|
||||
'dock_battery': ['battery'],
|
||||
'dock_controls': ['controls'],
|
||||
'dock_indicators': ['indicators'],
|
||||
'dock_metrics': ['metrics'],
|
||||
'dock_music_player': ['music_player', 'music'],
|
||||
'dock_notifications': ['dock_notifications'],
|
||||
'dock_workspaces': ['workspaces'],
|
||||
|
||||
# Launcher plugins
|
||||
'plugin_applications': ['plugin_applications'],
|
||||
'plugin_bluetooth': ['plugin_bluetooth'],
|
||||
'plugin_bookmarks': ['plugin_bookmarks'],
|
||||
'plugin_caffeine': ['plugin_caffeine'],
|
||||
'plugin_calculator': ['plugin_calculator'],
|
||||
'plugin_calendar': ['plugin_calendar'],
|
||||
'plugin_clipboard': ['plugin_clipboard'],
|
||||
'plugin_emoji': ['plugin_emoji'],
|
||||
'plugin_kanban': ['plugin_kanban'],
|
||||
'plugin_network': ['plugin_network'],
|
||||
'plugin_otp': ['plugin_otp'],
|
||||
'plugin_password': ['plugin_password'],
|
||||
'plugin_power': ['plugin_power'],
|
||||
'plugin_process': ['plugin_process'],
|
||||
'plugin_reminders': ['plugin_reminders'],
|
||||
'plugin_screencapture': ['plugin_screencapture'],
|
||||
'plugin_system': ['plugin_system'],
|
||||
'plugin_wallpaper': ['plugin_wallpaper'],
|
||||
'plugin_websearch': ['plugin_websearch'],
|
||||
|
||||
# Services
|
||||
'service_brightness': ['brightness'],
|
||||
'service_network': ['network'],
|
||||
'service_battery': ['battery_service'],
|
||||
'service_mpris': ['mpris'],
|
||||
'service_notification': ['notification_service'],
|
||||
}
|
||||
|
||||
def find_modus_processes(self) -> List[psutil.Process]:
|
||||
"""Find all processes related to Modus."""
|
||||
modus_processes = []
|
||||
|
||||
try:
|
||||
for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'ppid']):
|
||||
try:
|
||||
info = proc.info
|
||||
cmdline = ' '.join(info['cmdline']) if info['cmdline'] else ''
|
||||
|
||||
# Check if this is a Modus-related process
|
||||
if (info['name'] == 'modus' or
|
||||
'main.py' in cmdline or
|
||||
'Modus' in cmdline or
|
||||
any(module in cmdline.lower() for module in ['dock', 'launcher', 'notification'])):
|
||||
modus_processes.append(proc)
|
||||
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error finding Modus processes: {e}")
|
||||
|
||||
return modus_processes
|
||||
|
||||
def get_process_memory_info(self, proc: psutil.Process) -> Tuple[float, float, float]:
|
||||
"""Get memory and CPU information for a process."""
|
||||
try:
|
||||
memory_info = proc.memory_info()
|
||||
memory_mb = memory_info.rss / (1024 * 1024)
|
||||
memory_percent = proc.memory_percent()
|
||||
cpu_percent = proc.cpu_percent()
|
||||
return memory_mb, memory_percent, cpu_percent
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
return 0.0, 0.0, 0.0
|
||||
|
||||
def categorize_process(self, proc: psutil.Process) -> str:
|
||||
"""Categorize a process based on its command line and name."""
|
||||
try:
|
||||
info = proc.info
|
||||
cmdline = ' '.join(info['cmdline']) if info['cmdline'] else ''
|
||||
name = info['name'].lower()
|
||||
|
||||
# Check for main process
|
||||
if 'main.py' in cmdline:
|
||||
return 'main_process'
|
||||
|
||||
# Check for specific components based on command line or imports
|
||||
for component, keywords in self.component_mapping.items():
|
||||
if any(keyword.lower() in cmdline.lower() or keyword.lower() in name for keyword in keywords):
|
||||
return component
|
||||
|
||||
# Default categorization
|
||||
if 'python' in name:
|
||||
return 'python_subprocess'
|
||||
else:
|
||||
return 'other'
|
||||
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
return 'unknown'
|
||||
|
||||
def get_component_memory_usage(self) -> Dict[str, ComponentMemory]:
|
||||
"""Get memory usage for each Modus component."""
|
||||
components = {}
|
||||
modus_processes = self.find_modus_processes()
|
||||
|
||||
if not modus_processes:
|
||||
print("No Modus processes found. Make sure Modus is running.")
|
||||
return components
|
||||
|
||||
# Initialize CPU monitoring for better readings
|
||||
for proc in modus_processes:
|
||||
try:
|
||||
proc.cpu_percent()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
|
||||
# Wait a bit for CPU percentage calculation
|
||||
time.sleep(0.1)
|
||||
|
||||
for proc in modus_processes:
|
||||
try:
|
||||
category = self.categorize_process(proc)
|
||||
memory_mb, memory_percent, cpu_percent = self.get_process_memory_info(proc)
|
||||
|
||||
if category not in components:
|
||||
components[category] = ComponentMemory(
|
||||
name=category,
|
||||
pid=proc.pid,
|
||||
memory_mb=memory_mb,
|
||||
memory_percent=memory_percent,
|
||||
cpu_percent=cpu_percent,
|
||||
status=proc.status(),
|
||||
children_count=0,
|
||||
children_memory_mb=0.0
|
||||
)
|
||||
else:
|
||||
# Aggregate memory for components with multiple processes
|
||||
existing = components[category]
|
||||
existing.memory_mb += memory_mb
|
||||
existing.memory_percent += memory_percent
|
||||
existing.cpu_percent += cpu_percent
|
||||
existing.children_count += 1
|
||||
existing.children_memory_mb += memory_mb
|
||||
|
||||
# Also check for child processes
|
||||
try:
|
||||
children = proc.children(recursive=True)
|
||||
for child in children:
|
||||
child_memory_mb, child_memory_percent, child_cpu_percent = self.get_process_memory_info(child)
|
||||
components[category].children_memory_mb += child_memory_mb
|
||||
components[category].memory_mb += child_memory_mb
|
||||
components[category].memory_percent += child_memory_percent
|
||||
components[category].cpu_percent += child_cpu_percent
|
||||
components[category].children_count += 1
|
||||
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
return components
|
||||
|
||||
def get_module_file_sizes(self) -> Dict[str, float]:
|
||||
"""Get file sizes for modules in the modules/ directory."""
|
||||
module_sizes = {}
|
||||
modules_dir = os.path.join(os.path.dirname(__file__), 'modules')
|
||||
|
||||
if not os.path.exists(modules_dir):
|
||||
return module_sizes
|
||||
|
||||
try:
|
||||
for root, _, files in os.walk(modules_dir):
|
||||
for file in files:
|
||||
if file.endswith('.py'):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
size_kb = os.path.getsize(file_path) / 1024
|
||||
relative_path = os.path.relpath(file_path, modules_dir)
|
||||
module_sizes[relative_path] = size_kb
|
||||
except OSError:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Error getting module file sizes: {e}")
|
||||
|
||||
return module_sizes
|
||||
|
||||
def print_memory_report(self, components: Dict[str, ComponentMemory],
|
||||
module_sizes: Dict[str, float],
|
||||
format_type: str = 'table'):
|
||||
"""Print a formatted memory usage report."""
|
||||
if not components:
|
||||
print("No Modus components found running.")
|
||||
return
|
||||
|
||||
total_memory = sum(comp.memory_mb for comp in components.values())
|
||||
total_cpu = sum(comp.cpu_percent for comp in components.values())
|
||||
|
||||
if format_type == 'json':
|
||||
self._print_json_report(components, module_sizes, total_memory, total_cpu)
|
||||
else:
|
||||
self._print_table_report(components, module_sizes, total_memory, total_cpu)
|
||||
|
||||
def _print_table_report(self, components: Dict[str, ComponentMemory],
|
||||
module_sizes: Dict[str, float],
|
||||
total_memory: float, total_cpu: float):
|
||||
"""Print memory report in table format."""
|
||||
print("\n" + "="*80)
|
||||
print("MODUS MEMORY USAGE REPORT")
|
||||
print("="*80)
|
||||
|
||||
# Sort components by memory usage
|
||||
sorted_components = sorted(components.items(), key=lambda x: x[1].memory_mb, reverse=True)
|
||||
|
||||
print(f"\n{'Component':<25} {'Memory (MB)':<12} {'Memory %':<10} {'CPU %':<8} {'PID':<8} {'Status':<10}")
|
||||
print("-" * 80)
|
||||
|
||||
for name, comp in sorted_components:
|
||||
print(f"{name:<25} {comp.memory_mb:>8.2f} {comp.memory_percent:>6.2f}% "
|
||||
f"{comp.cpu_percent:>5.2f}% {comp.pid or 'N/A':<8} {comp.status:<10}")
|
||||
|
||||
if comp.children_count > 0:
|
||||
print(f" └─ Children: {comp.children_count} processes, "
|
||||
f"{comp.children_memory_mb:.2f} MB")
|
||||
|
||||
print("-" * 80)
|
||||
print(f"{'TOTAL':<25} {total_memory:>8.2f} {'-':<10} {total_cpu:>5.2f}%")
|
||||
|
||||
# Module file sizes
|
||||
if module_sizes:
|
||||
print(f"\n{'MODULE FILE SIZES':<25} {'Size (KB)':<12}")
|
||||
print("-" * 40)
|
||||
sorted_modules = sorted(module_sizes.items(), key=lambda x: x[1], reverse=True)
|
||||
for module, size in sorted_modules[:10]: # Top 10 largest files
|
||||
print(f"{module:<25} {size:>8.2f}")
|
||||
|
||||
print("\n" + "="*80)
|
||||
|
||||
def _print_json_report(self, components: Dict[str, ComponentMemory],
|
||||
module_sizes: Dict[str, float],
|
||||
total_memory: float, total_cpu: float):
|
||||
"""Print memory report in JSON format."""
|
||||
report = {
|
||||
'timestamp': time.time(),
|
||||
'total_memory_mb': round(total_memory, 2),
|
||||
'total_cpu_percent': round(total_cpu, 2),
|
||||
'components': {},
|
||||
'module_file_sizes_kb': module_sizes
|
||||
}
|
||||
|
||||
for name, comp in components.items():
|
||||
report['components'][name] = {
|
||||
'memory_mb': round(comp.memory_mb, 2),
|
||||
'memory_percent': round(comp.memory_percent, 2),
|
||||
'cpu_percent': round(comp.cpu_percent, 2),
|
||||
'pid': comp.pid,
|
||||
'status': comp.status,
|
||||
'children_count': comp.children_count,
|
||||
'children_memory_mb': round(comp.children_memory_mb, 2)
|
||||
}
|
||||
|
||||
print(json.dumps(report, indent=2))
|
||||
|
||||
def monitor_continuously(self, interval: int = 5, format_type: str = 'table'):
|
||||
"""Monitor memory usage continuously."""
|
||||
try:
|
||||
while True:
|
||||
os.system('clear' if os.name == 'posix' else 'cls')
|
||||
components = self.get_component_memory_usage()
|
||||
module_sizes = self.get_module_file_sizes()
|
||||
self.print_memory_report(components, module_sizes, format_type)
|
||||
print(f"\nRefreshing every {interval} seconds... (Press Ctrl+C to stop)")
|
||||
time.sleep(interval)
|
||||
except KeyboardInterrupt:
|
||||
print("\nMonitoring stopped.")
|
||||
|
||||
def get_memory_summary(self) -> Dict[str, float]:
|
||||
"""Get a summary of memory usage by category."""
|
||||
components = self.get_component_memory_usage()
|
||||
|
||||
summary = {
|
||||
'main_components': 0.0,
|
||||
'dock_components': 0.0,
|
||||
'launcher_plugins': 0.0,
|
||||
'services': 0.0,
|
||||
'other': 0.0
|
||||
}
|
||||
|
||||
for name, comp in components.items():
|
||||
if name in ['main_process', 'dock', 'launcher', 'notification_popup', 'osd', 'switcher', 'corners']:
|
||||
summary['main_components'] += comp.memory_mb
|
||||
elif name.startswith('dock_'):
|
||||
summary['dock_components'] += comp.memory_mb
|
||||
elif name.startswith('plugin_'):
|
||||
summary['launcher_plugins'] += comp.memory_mb
|
||||
elif name.startswith('service_'):
|
||||
summary['services'] += comp.memory_mb
|
||||
else:
|
||||
summary['other'] += comp.memory_mb
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to run the memory monitor."""
|
||||
parser = argparse.ArgumentParser(description='Monitor memory usage of Modus components')
|
||||
parser.add_argument('--format', choices=['table', 'json'], default='table',
|
||||
help='Output format (default: table)')
|
||||
parser.add_argument('--monitor', action='store_true',
|
||||
help='Monitor continuously')
|
||||
parser.add_argument('--interval', type=int, default=5,
|
||||
help='Refresh interval in seconds for continuous monitoring (default: 5)')
|
||||
parser.add_argument('--summary', action='store_true',
|
||||
help='Show only summary by category')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
monitor = ModusMemoryMonitor()
|
||||
|
||||
if args.monitor:
|
||||
monitor.monitor_continuously(args.interval, args.format)
|
||||
elif args.summary:
|
||||
summary = monitor.get_memory_summary()
|
||||
print("\nMODUS MEMORY SUMMARY BY CATEGORY")
|
||||
print("="*40)
|
||||
total = sum(summary.values())
|
||||
for category, memory in summary.items():
|
||||
percentage = (memory / total * 100) if total > 0 else 0
|
||||
print(f"{category.replace('_', ' ').title():<20} {memory:>8.2f} MB ({percentage:>5.1f}%)")
|
||||
print("-"*40)
|
||||
print(f"{'Total':<20} {total:>8.2f} MB")
|
||||
else:
|
||||
components = monitor.get_component_memory_usage()
|
||||
module_sizes = monitor.get_module_file_sizes()
|
||||
monitor.print_memory_report(components, module_sizes, args.format)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,407 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Dict, List
|
||||
|
||||
from thefuzz import fuzz
|
||||
|
||||
import utils.icons as icons
|
||||
from modules.launcher.plugin_base import PluginBase
|
||||
from modules.launcher.result import Result
|
||||
|
||||
|
||||
class FilesPlugin(PluginBase):
|
||||
"""
|
||||
High-performance plugin for searching files only (excludes directories).
|
||||
Uses advanced fuzzy matching, caching, and threading for optimal performance.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.display_name = "Files"
|
||||
self.description = "Search for files only"
|
||||
|
||||
# Performance-optimized search paths - focus on user directories
|
||||
self.search_paths = [
|
||||
os.path.expanduser("~/Documents"),
|
||||
os.path.expanduser("~/Downloads"),
|
||||
os.path.expanduser("~/Desktop"),
|
||||
os.path.expanduser("~/Pictures"),
|
||||
os.path.expanduser("~/Videos"),
|
||||
os.path.expanduser("~/Music"),
|
||||
os.path.expanduser("~"), # Home directory but with limited depth
|
||||
]
|
||||
|
||||
# Performance settings
|
||||
self.max_results = 15
|
||||
self.max_depth = 2 # Reduced depth for better performance
|
||||
self.search_timeout = 0.5 # Reduced timeout
|
||||
self.min_query_length = 1 # Allow single character searches
|
||||
|
||||
# Fuzzy search settings - optimized for performance
|
||||
self.fuzzy_thresholds = {
|
||||
"ratio": 70, # Increased for better precision
|
||||
"partial": 80, # Increased for better precision
|
||||
"token_sort": 75, # Increased for better precision
|
||||
"token_set": 75, # Increased for better precision
|
||||
}
|
||||
|
||||
# Enhanced caching
|
||||
self.file_cache: Dict[str, List[str]] = {}
|
||||
self.cache_timestamp: Dict[str, float] = {}
|
||||
self.cache_ttl = 300
|
||||
self.cache_lock = threading.Lock()
|
||||
|
||||
# Thread pool for concurrent searches
|
||||
self.executor = ThreadPoolExecutor(
|
||||
max_workers=4, thread_name_prefix="files-search"
|
||||
)
|
||||
|
||||
def initialize(self):
|
||||
"""Initialize the files plugin."""
|
||||
self.set_triggers(
|
||||
[
|
||||
"file",
|
||||
"find",
|
||||
]
|
||||
)
|
||||
self._warm_cache()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the files plugin."""
|
||||
if hasattr(self, "executor"):
|
||||
self.executor.shutdown(wait=False)
|
||||
with self.cache_lock:
|
||||
self.file_cache.clear()
|
||||
self.cache_timestamp.clear()
|
||||
|
||||
def query(self, query_string: str) -> List[Result]:
|
||||
"""Fast search for files matching the query."""
|
||||
query = query_string.strip()
|
||||
|
||||
if len(query) < self.min_query_length:
|
||||
return []
|
||||
|
||||
# Handle special path queries
|
||||
if query.startswith("/") or query.startswith("~"):
|
||||
return self._handle_path_query(query)
|
||||
|
||||
# Always search for files only since this is the files plugin
|
||||
# triggered by "file" or "find" keywords
|
||||
is_file_only = True
|
||||
|
||||
# Use cached search for better performance
|
||||
results = self._fast_search(query, file_only=is_file_only)
|
||||
|
||||
# Sort by relevance and limit results
|
||||
results.sort(key=lambda r: r.relevance, reverse=True)
|
||||
return results[: self.max_results]
|
||||
|
||||
def _warm_cache(self):
|
||||
"""Pre-warm cache for common directories in background."""
|
||||
|
||||
def warm_directory(path):
|
||||
try:
|
||||
if os.path.exists(path) and os.path.isdir(path):
|
||||
self._get_cached_files(path)
|
||||
except:
|
||||
pass
|
||||
|
||||
for path in self.search_paths:
|
||||
threading.Thread(target=warm_directory, args=(path,), daemon=True).start()
|
||||
|
||||
def _handle_path_query(self, query: str) -> List[Result]:
|
||||
"""Handle path-based queries like '/home' or '~/Documents'."""
|
||||
try:
|
||||
expanded_path = (
|
||||
os.path.expanduser(query) if query.startswith("~") else query
|
||||
)
|
||||
|
||||
if os.path.isdir(expanded_path):
|
||||
return self._list_directory_contents(expanded_path)
|
||||
|
||||
parent_dir = os.path.dirname(expanded_path)
|
||||
if os.path.isdir(parent_dir):
|
||||
basename = os.path.basename(expanded_path)
|
||||
return self._search_in_directory(parent_dir, basename, max_results=10)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return []
|
||||
|
||||
def _fast_search(self, query: str, file_only: bool = False) -> List[Result]:
|
||||
"""Fast search using cached file lists and optimized matching."""
|
||||
results = []
|
||||
query_lower = query.lower()
|
||||
|
||||
# Early exit for empty queries
|
||||
if len(query_lower) < self.min_query_length:
|
||||
return results
|
||||
|
||||
# Search in cached directories with early termination
|
||||
for search_path in self.search_paths:
|
||||
if not os.path.exists(search_path):
|
||||
continue
|
||||
|
||||
# Skip home directory if we already have enough results from specific folders
|
||||
if (
|
||||
search_path == os.path.expanduser("~")
|
||||
and len(results) >= self.max_results
|
||||
):
|
||||
continue
|
||||
|
||||
cached_files = self._get_cached_files(search_path)
|
||||
for file_path in cached_files:
|
||||
if file_only and os.path.isdir(file_path):
|
||||
continue
|
||||
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
# Fast pre-filter: check if query is in filename before expensive fuzzy matching
|
||||
if query_lower not in filename.lower():
|
||||
# Only do fuzzy matching if simple substring match fails
|
||||
if not self._simple_fuzzy_match(query_lower, filename):
|
||||
continue
|
||||
|
||||
relevance = self._calculate_relevance(filename, query_lower)
|
||||
result = self._create_file_result(file_path, relevance)
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
# Early termination when we have enough results
|
||||
if len(results) >= self.max_results * 3:
|
||||
return results
|
||||
|
||||
return results
|
||||
|
||||
def _get_cached_files(self, directory: str) -> List[str]:
|
||||
"""Get cached file list for directory, refresh if needed."""
|
||||
current_time = time.time()
|
||||
|
||||
with self.cache_lock:
|
||||
if (
|
||||
directory in self.file_cache
|
||||
and directory in self.cache_timestamp
|
||||
and current_time - self.cache_timestamp[directory] < self.cache_ttl
|
||||
):
|
||||
return self.file_cache[directory]
|
||||
|
||||
files = self._scan_directory_fast(directory)
|
||||
self.file_cache[directory] = files
|
||||
self.cache_timestamp[directory] = current_time
|
||||
|
||||
return files
|
||||
|
||||
def _scan_directory_fast(self, directory: str) -> List[str]:
|
||||
"""Fast directory scan with optimized depth and early termination."""
|
||||
files = []
|
||||
current_depth = 0
|
||||
max_files_per_dir = 1000 # Limit files per directory to prevent memory issues
|
||||
|
||||
def scan_dir(path: str, depth: int):
|
||||
if depth > self.max_depth or len(files) >= max_files_per_dir:
|
||||
return
|
||||
|
||||
try:
|
||||
with os.scandir(path) as entries:
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
|
||||
# Skip common large directories that are unlikely to contain user files
|
||||
if entry.is_dir() and entry.name in {
|
||||
"node_modules",
|
||||
".git",
|
||||
".cache",
|
||||
"__pycache__",
|
||||
".npm",
|
||||
".cargo",
|
||||
".rustup",
|
||||
"target",
|
||||
"build",
|
||||
".vscode",
|
||||
".idea",
|
||||
"venv",
|
||||
".env",
|
||||
}:
|
||||
continue
|
||||
|
||||
files.append(entry.path)
|
||||
|
||||
# Early termination if we have too many files
|
||||
if len(files) >= max_files_per_dir:
|
||||
return
|
||||
|
||||
# Only recurse into directories if we're not at max depth
|
||||
# and for home directory, be more selective
|
||||
if entry.is_dir() and depth < self.max_depth:
|
||||
if path == os.path.expanduser("~"):
|
||||
# For home directory, only scan important subdirectories
|
||||
if self._is_important_directory(entry.name):
|
||||
scan_dir(entry.path, depth + 1)
|
||||
else:
|
||||
scan_dir(entry.path, depth + 1)
|
||||
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
scan_dir(directory, current_depth)
|
||||
return files
|
||||
|
||||
def _is_important_directory(self, dirname: str) -> bool:
|
||||
"""Check if directory is important enough to scan deeper."""
|
||||
important_dirs = {
|
||||
"Documents",
|
||||
"Downloads",
|
||||
"Desktop",
|
||||
"Pictures",
|
||||
"Videos",
|
||||
"Music",
|
||||
"Projects",
|
||||
"Code",
|
||||
"Development",
|
||||
"Work",
|
||||
"src",
|
||||
"bin",
|
||||
}
|
||||
return dirname in important_dirs
|
||||
|
||||
def _search_in_directory(
|
||||
self, directory: str, query: str, max_results: int = 10
|
||||
) -> List[Result]:
|
||||
"""Search for files in a specific directory."""
|
||||
results = []
|
||||
query_lower = query.lower()
|
||||
|
||||
try:
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
if self._simple_fuzzy_match(query_lower, entry.name):
|
||||
relevance = self._calculate_relevance(entry.name, query_lower)
|
||||
result = self._create_file_result(entry.path, relevance)
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
def _list_directory_contents(
|
||||
self, directory: str, max_results: int = 15
|
||||
) -> List[Result]:
|
||||
"""List contents of a directory."""
|
||||
results = []
|
||||
|
||||
try:
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
if not entry.name.startswith("."):
|
||||
result = self._create_file_result(entry.path, 0.8)
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
def _create_file_result(self, file_path: str, relevance: float) -> Result:
|
||||
"""Create a Result object for a file or directory."""
|
||||
try:
|
||||
filename = os.path.basename(file_path)
|
||||
is_dir = os.path.isdir(file_path)
|
||||
|
||||
return Result(
|
||||
title=filename,
|
||||
subtitle=file_path,
|
||||
icon_markup=icons.file,
|
||||
action=lambda p=file_path: self._open_path(p),
|
||||
relevance=relevance,
|
||||
plugin_name=self.display_name,
|
||||
data={"path": file_path, "type": "directory" if is_dir else "file"},
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _open_path(self, path: str):
|
||||
"""Open a file or directory with the default application."""
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["xdg-open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to open {path}: {e}")
|
||||
|
||||
def _open_file(self, file_path: str):
|
||||
"""Open a file with the default application."""
|
||||
self._open_path(file_path)
|
||||
|
||||
def _open_directory(self, dir_path: str):
|
||||
"""Open a directory in the file manager."""
|
||||
self._open_path(dir_path)
|
||||
|
||||
def _simple_fuzzy_match(self, query: str, text: str) -> bool:
|
||||
"""Optimized fuzzy matching with performance improvements."""
|
||||
text_lower = text.lower()
|
||||
query_lower = query.lower()
|
||||
|
||||
# Fast exact substring match
|
||||
if query_lower in text_lower:
|
||||
return True
|
||||
|
||||
# Fast prefix match
|
||||
if text_lower.startswith(query_lower):
|
||||
return True
|
||||
|
||||
# Only do expensive fuzzy matching for longer queries
|
||||
if len(query_lower) >= 3:
|
||||
# Use only the most effective fuzzy matching methods
|
||||
return (
|
||||
fuzz.partial_ratio(query_lower, text_lower)
|
||||
>= self.fuzzy_thresholds["partial"]
|
||||
or fuzz.ratio(query_lower, text_lower) >= self.fuzzy_thresholds["ratio"]
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def _calculate_relevance(self, filename: str, query: str) -> float:
|
||||
"""Calculate relevance score with optimized performance."""
|
||||
filename_lower = filename.lower()
|
||||
query_lower = query.lower()
|
||||
|
||||
# Fast exact match
|
||||
if query_lower == filename_lower:
|
||||
return 1.0
|
||||
|
||||
# Fast prefix match
|
||||
if filename_lower.startswith(query_lower):
|
||||
return 0.95
|
||||
|
||||
# Fast substring match
|
||||
if query_lower in filename_lower:
|
||||
# Calculate position-based relevance
|
||||
position = filename_lower.index(query_lower)
|
||||
position_score = 1.0 - (position / len(filename_lower))
|
||||
return 0.8 + (position_score * 0.1)
|
||||
|
||||
# Only do expensive fuzzy matching for longer queries
|
||||
if len(query_lower) >= 3:
|
||||
partial = fuzz.partial_ratio(query_lower, filename_lower)
|
||||
ratio = fuzz.ratio(query_lower, filename_lower)
|
||||
|
||||
# Simplified relevance calculation
|
||||
relevance = (partial * 0.6 + ratio * 0.4) / 100.0
|
||||
return max(relevance, 0.3) # Minimum relevance for fuzzy matches
|
||||
|
||||
return 0.3 # Default relevance for short queries
|
||||
@@ -1,611 +0,0 @@
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.label import Label
|
||||
from gi.repository import GdkPixbuf
|
||||
|
||||
import utils.icons as icons
|
||||
from modules.launcher.plugin_base import PluginBase
|
||||
from modules.launcher.result import Result
|
||||
from services import notification_service
|
||||
|
||||
|
||||
class NotificationDetailWidget(Box):
|
||||
"""Widget for displaying detailed notification content."""
|
||||
|
||||
def __init__(self, notification_data: dict, plugin_instance):
|
||||
super().__init__(name="notification-detail", orientation="v", spacing=8)
|
||||
|
||||
self.notification_data = notification_data
|
||||
self.plugin = plugin_instance
|
||||
|
||||
# Set size constraints to fit within launcher
|
||||
self.set_size_request(530, 200)
|
||||
self.set_vexpand(False)
|
||||
self.set_hexpand(True)
|
||||
|
||||
self.setup_ui()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Setup the detail view UI."""
|
||||
# Header with title and close button
|
||||
header_box = Box(orientation="h", spacing=8)
|
||||
|
||||
# Title
|
||||
title_label = Label(
|
||||
label=f"📧 {self.notification_data.get('app_name', 'Unknown')}",
|
||||
name="notification-detail-title",
|
||||
h_align="start",
|
||||
h_expand=True,
|
||||
)
|
||||
header_box.add(title_label)
|
||||
|
||||
# Close button
|
||||
close_btn = Button(
|
||||
child=Label(markup=icons.cancel, name="notification-close-icon"),
|
||||
name="notification-close-btn",
|
||||
on_clicked=lambda *_: self.plugin._close_detail_view(),
|
||||
)
|
||||
header_box.add(close_btn)
|
||||
|
||||
self.add(header_box)
|
||||
|
||||
# Content area
|
||||
content_box = Box(orientation="v", spacing=4)
|
||||
|
||||
# Summary
|
||||
summary = self.notification_data.get("summary", "No title")
|
||||
summary_label = Label(
|
||||
label=f"Subject: {summary}",
|
||||
name="notification-detail-summary",
|
||||
h_align="start",
|
||||
ellipsize="end",
|
||||
)
|
||||
content_box.add(summary_label)
|
||||
|
||||
# Body content
|
||||
body = self.notification_data.get("body", "")
|
||||
if body:
|
||||
body_label = Label(
|
||||
label=body,
|
||||
name="notification-detail-body",
|
||||
h_align="start",
|
||||
v_align="start",
|
||||
ellipsize="none",
|
||||
)
|
||||
# Set line wrapping using properties
|
||||
body_label.set_property("wrap", True)
|
||||
body_label.set_property("wrap-mode", 2) # WORD wrap mode
|
||||
content_box.add(body_label)
|
||||
|
||||
# Timestamp
|
||||
timestamp = self.notification_data.get("timestamp", time.time())
|
||||
time_str = self.plugin._format_timestamp(timestamp)
|
||||
time_label = Label(
|
||||
label=f"Time: {time_str}", name="notification-detail-time", h_align="start"
|
||||
)
|
||||
content_box.add(time_label)
|
||||
|
||||
self.add(content_box)
|
||||
|
||||
# Action buttons
|
||||
actions_box = Box(orientation="h", spacing=8)
|
||||
|
||||
# Remove button
|
||||
remove_btn = Button(
|
||||
child=Label(
|
||||
markup=f"{icons.trash} Remove", name="notification-action-label"
|
||||
),
|
||||
name="notification-action-btn",
|
||||
on_clicked=lambda *_: self._remove_notification(),
|
||||
)
|
||||
actions_box.add(remove_btn)
|
||||
|
||||
self.add(actions_box)
|
||||
|
||||
def _remove_notification(self):
|
||||
"""Remove this notification."""
|
||||
notif_id = self.notification_data.get("notification_id", 0)
|
||||
self.plugin._clear_notification(notif_id)
|
||||
|
||||
|
||||
class NotificationsPlugin(PluginBase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "notifications"
|
||||
self.display_name = "Notification History"
|
||||
self.description = "Search and manage notification history"
|
||||
|
||||
# Settings
|
||||
self.max_results = 15
|
||||
self.notification_service = notification_service
|
||||
self.showing_detail_for = None # Track which notification is showing details
|
||||
|
||||
def initialize(self):
|
||||
"""Initialize the plugin."""
|
||||
self.set_triggers(["notif"])
|
||||
|
||||
# Connect to notification service signals to refresh when notifications change
|
||||
self.notification_service.connect(
|
||||
"notification_count", self._on_notification_count_changed
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the plugin."""
|
||||
# Disconnect from notification service signals
|
||||
try:
|
||||
self.notification_service.disconnect(
|
||||
"notification_count", self._on_notification_count_changed
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
def _format_timestamp(self, timestamp: float) -> str:
|
||||
"""Format timestamp to human-readable string."""
|
||||
try:
|
||||
dt = datetime.fromtimestamp(timestamp)
|
||||
now = datetime.now()
|
||||
|
||||
# If today, show time only
|
||||
if dt.date() == now.date():
|
||||
return dt.strftime("%H:%M")
|
||||
|
||||
# If this week, show day and time
|
||||
days_diff = (now.date() - dt.date()).days
|
||||
if days_diff < 7:
|
||||
return dt.strftime("%a %H:%M")
|
||||
|
||||
# Otherwise show date
|
||||
return dt.strftime("%m/%d %H:%M")
|
||||
except:
|
||||
return "Unknown"
|
||||
|
||||
def _get_notification_icon(self, notification) -> Optional[GdkPixbuf.Pixbuf]:
|
||||
"""Get icon for notification."""
|
||||
try:
|
||||
# Try to use notification's image first
|
||||
if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
|
||||
return notification.image_pixbuf.scale_simple(
|
||||
32, 32, GdkPixbuf.InterpType.BILINEAR
|
||||
)
|
||||
|
||||
# Try app icon
|
||||
if hasattr(notification, "app_icon") and notification.app_icon:
|
||||
if notification.app_icon.startswith("file://"):
|
||||
icon_path = notification.app_icon[7:]
|
||||
try:
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path)
|
||||
return pixbuf.scale_simple(
|
||||
32, 32, GdkPixbuf.InterpType.BILINEAR
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
except:
|
||||
return None
|
||||
|
||||
def _truncate_text(self, text: str, max_length: int = 60) -> str:
|
||||
"""Truncate text to specified length."""
|
||||
if not text:
|
||||
return ""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[: max_length - 3] + "..."
|
||||
|
||||
def _trigger_refresh(self):
|
||||
"""Trigger launcher refresh to return to default notification view."""
|
||||
try:
|
||||
from gi.repository import GLib
|
||||
|
||||
def trigger_refresh():
|
||||
try:
|
||||
# Try to access the launcher through the fabric Application
|
||||
from fabric import Application
|
||||
|
||||
app = Application.get_default()
|
||||
|
||||
if app and hasattr(app, "launcher"):
|
||||
launcher = app.launcher
|
||||
if launcher and hasattr(launcher, "search_entry"):
|
||||
# Get current search text to preserve the query
|
||||
current_text = launcher.search_entry.get_text()
|
||||
# Trigger the search to refresh results
|
||||
if hasattr(launcher, "_perform_search"):
|
||||
launcher._perform_search(current_text)
|
||||
return False
|
||||
|
||||
# Fallback: try to find launcher instance through other means
|
||||
import gc
|
||||
|
||||
for obj in gc.get_objects():
|
||||
if (
|
||||
hasattr(obj, "__class__")
|
||||
and obj.__class__.__name__ == "Launcher"
|
||||
):
|
||||
if hasattr(obj, "search_entry") and hasattr(
|
||||
obj, "_perform_search"
|
||||
):
|
||||
current_text = obj.search_entry.get_text()
|
||||
obj._perform_search(current_text)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error forcing launcher refresh: {e}")
|
||||
|
||||
return False # Don't repeat
|
||||
|
||||
# Use immediate refresh
|
||||
GLib.timeout_add(10, trigger_refresh)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Could not trigger refresh: {e}")
|
||||
|
||||
def _clear_notification(self, notification_id: int):
|
||||
"""Remove a specific notification from history."""
|
||||
try:
|
||||
# Debug: Print notification removal
|
||||
print(f"[NotificationPlugin] Removing notification ID: {notification_id}")
|
||||
|
||||
# Get count before removal for comparison
|
||||
count_before = len(self.notification_service.all_notifications)
|
||||
|
||||
# Remove the notification
|
||||
self.notification_service.remove_notification(notification_id)
|
||||
|
||||
# Get count after removal
|
||||
count_after = len(self.notification_service.all_notifications)
|
||||
|
||||
# Debug: Print removal result
|
||||
print(
|
||||
f"[NotificationPlugin] Notifications before: {count_before}, after: {
|
||||
count_after
|
||||
}"
|
||||
)
|
||||
|
||||
if count_after < count_before:
|
||||
print(
|
||||
f"[NotificationPlugin] Successfully removed notification {
|
||||
notification_id
|
||||
}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"[NotificationPlugin] Warning: Notification {
|
||||
notification_id
|
||||
} may not have been removed"
|
||||
)
|
||||
|
||||
# Trigger immediate refresh using the same pattern as OTP plugin
|
||||
self._trigger_refresh()
|
||||
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[NotificationPlugin] Error removing notification {notification_id}: {
|
||||
e
|
||||
}"
|
||||
)
|
||||
# Still try to refresh even if there was an error
|
||||
self._trigger_refresh()
|
||||
|
||||
def _clear_all_notifications(self):
|
||||
"""Clear all notifications from history."""
|
||||
self.notification_service.clear_all_notifications()
|
||||
|
||||
def _toggle_dnd(self, enable: bool = None):
|
||||
"""Toggle or set DND mode."""
|
||||
try:
|
||||
if enable is None:
|
||||
# Toggle current state
|
||||
new_state = not self.notification_service.dont_disturb
|
||||
else:
|
||||
# Set specific state
|
||||
new_state = enable
|
||||
|
||||
self.notification_service.dont_disturb = new_state
|
||||
status = "enabled" if new_state else "disabled"
|
||||
print(f"[NotificationPlugin] Do Not Disturb {status}")
|
||||
|
||||
# Trigger refresh to show updated status
|
||||
self._trigger_refresh()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[NotificationPlugin] Error toggling DND: {e}")
|
||||
|
||||
def _on_notification_count_changed(self, service, count: int):
|
||||
"""Handle notification count changes (new notifications added or removed)."""
|
||||
try:
|
||||
print(
|
||||
f"[NotificationPlugin] Notification count changed to {
|
||||
count
|
||||
}, triggering refresh"
|
||||
)
|
||||
self._trigger_refresh()
|
||||
except Exception as e:
|
||||
print(f"[NotificationPlugin] Error handling notification count change: {e}")
|
||||
|
||||
def _get_notification_count(self) -> int:
|
||||
"""Get total number of notifications."""
|
||||
try:
|
||||
return len(self.notification_service.all_notifications)
|
||||
except:
|
||||
return 0
|
||||
|
||||
def _show_notification_details(self, notification_data: dict):
|
||||
"""Show detailed view of a notification."""
|
||||
notif_id = notification_data.get("notification_id", 0)
|
||||
self.showing_detail_for = notif_id
|
||||
self._trigger_refresh()
|
||||
|
||||
def _close_detail_view(self):
|
||||
"""Close the detail view and return to list."""
|
||||
self.showing_detail_for = None
|
||||
self._trigger_refresh()
|
||||
|
||||
def query(self, query_string: str) -> List[Result]:
|
||||
"""Search notification history."""
|
||||
results = []
|
||||
|
||||
# Check if we should show detail view for a specific notification
|
||||
if self.showing_detail_for is not None:
|
||||
try:
|
||||
notifications = self.notification_service.get_deserialized()
|
||||
for notif in notifications:
|
||||
notif_data = (
|
||||
notif.serialize() if hasattr(notif, "serialize") else {}
|
||||
)
|
||||
notif_id = notif_data.get("id", 0)
|
||||
|
||||
if notif_id == self.showing_detail_for:
|
||||
# Create detail widget for this notification
|
||||
detail_data = {
|
||||
"notification_id": notif_id,
|
||||
"app_name": getattr(notif, "app_name", "Unknown"),
|
||||
"timestamp": getattr(notif, "timestamp", time.time()),
|
||||
"summary": getattr(notif, "summary", "No title"),
|
||||
"body": getattr(notif, "body", ""),
|
||||
}
|
||||
|
||||
detail_widget = NotificationDetailWidget(detail_data, self)
|
||||
|
||||
results.append(
|
||||
Result(
|
||||
title="Notification Details",
|
||||
subtitle="Press Escape to go back to list",
|
||||
icon_markup=icons.notifications,
|
||||
action=lambda: None,
|
||||
relevance=1.0,
|
||||
plugin_name=self.name,
|
||||
custom_widget=detail_widget,
|
||||
data={
|
||||
"type": "detail_view",
|
||||
"keep_launcher_open": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"Error showing notification details: {e}")
|
||||
self.showing_detail_for = None
|
||||
|
||||
# Handle special commands
|
||||
if query_string.lower() in ["clear"]:
|
||||
results.append(
|
||||
Result(
|
||||
title="Clear All Notifications",
|
||||
subtitle="Remove all notifications from history",
|
||||
description="This will permanently delete all notification history",
|
||||
icon_markup=icons.trash,
|
||||
relevance=1.0,
|
||||
plugin_name=self.name,
|
||||
action=self._clear_all_notifications,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
# Handle DND commands
|
||||
if query_string.lower() in ["off"]:
|
||||
current_dnd = self.notification_service.dont_disturb
|
||||
results.append(
|
||||
Result(
|
||||
title="Enable Do Not Disturb",
|
||||
subtitle="Turn off notification popups"
|
||||
+ (" (already enabled)" if current_dnd else ""),
|
||||
description="Notifications will still be saved to history but won't show popups",
|
||||
icon_markup=icons.notifications_off,
|
||||
relevance=1.0,
|
||||
plugin_name=self.name,
|
||||
action=lambda: self._toggle_dnd(True),
|
||||
data={"keep_launcher_open": True},
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
if query_string.lower() in ["on"]:
|
||||
current_dnd = self.notification_service.dont_disturb
|
||||
results.append(
|
||||
Result(
|
||||
title="Disable Do Not Disturb",
|
||||
subtitle="Turn on notification popups"
|
||||
+ (" (already disabled)" if not current_dnd else ""),
|
||||
description="Notifications will show popups normally",
|
||||
icon_markup=icons.notifications,
|
||||
relevance=1.0,
|
||||
plugin_name=self.name,
|
||||
action=lambda: self._toggle_dnd(False),
|
||||
data={"keep_launcher_open": True},
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
# Handle remove command
|
||||
if query_string.lower().startswith(
|
||||
"remove "
|
||||
) or query_string.lower().startswith("delete "):
|
||||
search_term = (
|
||||
query_string[7:].strip()
|
||||
if query_string.lower().startswith("remove ")
|
||||
else query_string[7:].strip()
|
||||
)
|
||||
if search_term:
|
||||
results.append(
|
||||
Result(
|
||||
title=f"Remove notifications containing '{search_term}'",
|
||||
subtitle="Search for notifications to remove",
|
||||
description="This will show notifications matching your search term for removal",
|
||||
icon_markup=icons.trash,
|
||||
relevance=1.0,
|
||||
plugin_name=self.name,
|
||||
action=lambda: None, # Will be handled by showing matching notifications
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Get notifications from service
|
||||
notifications = self.notification_service.get_deserialized()
|
||||
|
||||
if not notifications:
|
||||
results.append(
|
||||
Result(
|
||||
title="No notifications found",
|
||||
subtitle="Your notification history is empty",
|
||||
icon_markup=icons.notifications,
|
||||
relevance=0.5,
|
||||
plugin_name=self.name,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
# Show count if no query
|
||||
if not query_string:
|
||||
count = len(notifications)
|
||||
dnd_status = self.notification_service.dont_disturb
|
||||
|
||||
# Add DND status result
|
||||
if dnd_status:
|
||||
results.append(
|
||||
Result(
|
||||
title="Do Not Disturb: ON",
|
||||
subtitle="Notification popups are disabled • Type 'on' to enable",
|
||||
icon_markup=icons.notifications_off,
|
||||
relevance=0.9,
|
||||
plugin_name=self.name,
|
||||
action=lambda: self._toggle_dnd(False),
|
||||
data={"keep_launcher_open": True},
|
||||
)
|
||||
)
|
||||
else:
|
||||
results.append(
|
||||
Result(
|
||||
title="Do Not Disturb: OFF",
|
||||
subtitle="Notification popups are enabled • Type 'off' to disable",
|
||||
icon_markup=icons.notifications,
|
||||
relevance=0.9,
|
||||
plugin_name=self.name,
|
||||
action=lambda: self._toggle_dnd(True),
|
||||
data={"keep_launcher_open": True},
|
||||
)
|
||||
)
|
||||
|
||||
results.append(
|
||||
Result(
|
||||
title=f"Notification History ({count} notifications)",
|
||||
subtitle="Type to search notifications or 'clear' to remove all",
|
||||
icon_markup=icons.notifications,
|
||||
relevance=0.8,
|
||||
plugin_name=self.name,
|
||||
)
|
||||
)
|
||||
|
||||
# Filter notifications based on query
|
||||
query_lower = query_string.lower() if query_string else ""
|
||||
filtered_notifications = []
|
||||
|
||||
for notif in notifications:
|
||||
# Get notification data
|
||||
notif_data = notif.serialize() if hasattr(notif, "serialize") else {}
|
||||
notif_id = notif_data.get("id", 0)
|
||||
timestamp = getattr(notif, "timestamp", time.time())
|
||||
|
||||
# Search in summary, body, and app name
|
||||
searchable_text = " ".join(
|
||||
[
|
||||
getattr(notif, "summary", ""),
|
||||
getattr(notif, "body", ""),
|
||||
getattr(notif, "app_name", ""),
|
||||
]
|
||||
).lower()
|
||||
|
||||
if not query_lower or query_lower in searchable_text:
|
||||
filtered_notifications.append((notif, notif_id, timestamp))
|
||||
|
||||
# Sort by timestamp (newest first)
|
||||
filtered_notifications.sort(key=lambda x: x[2], reverse=True)
|
||||
|
||||
# Limit results (leave room for header if no query)
|
||||
max_notif_results = self.max_results - (1 if not query_string else 0)
|
||||
filtered_notifications = filtered_notifications[:max_notif_results]
|
||||
|
||||
# Create results
|
||||
for notif, notif_id, timestamp in filtered_notifications:
|
||||
summary = getattr(notif, "summary", "No title")
|
||||
body = getattr(notif, "body", "")
|
||||
app_name = getattr(notif, "app_name", "Unknown")
|
||||
|
||||
# Create title and subtitle
|
||||
title = self._truncate_text(summary, 50)
|
||||
subtitle_parts = []
|
||||
|
||||
if body:
|
||||
subtitle_parts.append(self._truncate_text(body, 40))
|
||||
|
||||
subtitle_parts.append(f"from {app_name}")
|
||||
subtitle_parts.append(self._format_timestamp(timestamp))
|
||||
subtitle = " • ".join(subtitle_parts)
|
||||
|
||||
# Get icon
|
||||
icon = self._get_notification_icon(notif)
|
||||
|
||||
result = Result(
|
||||
title=title,
|
||||
subtitle=subtitle,
|
||||
description="", # Don't show description by default
|
||||
icon=icon,
|
||||
icon_markup=icons.notifications if not icon else None,
|
||||
relevance=1.0,
|
||||
plugin_name=self.name,
|
||||
action=lambda nd={
|
||||
"notification_id": notif_id,
|
||||
"app_name": app_name,
|
||||
"timestamp": timestamp,
|
||||
"summary": summary,
|
||||
"body": body,
|
||||
}: self._show_notification_details(nd),
|
||||
data={
|
||||
"notification_id": notif_id,
|
||||
"app_name": app_name,
|
||||
"timestamp": timestamp,
|
||||
"can_remove": True,
|
||||
"alt_action": lambda nid=notif_id: self._clear_notification(
|
||||
nid
|
||||
),
|
||||
"keep_launcher_open": True,
|
||||
},
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
except Exception as e:
|
||||
results.append(
|
||||
Result(
|
||||
title="Error loading notifications",
|
||||
subtitle=str(e),
|
||||
icon_markup=icons.alert,
|
||||
relevance=0.0,
|
||||
plugin_name=self.name,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -1,422 +0,0 @@
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
import utils.icons as icons
|
||||
from gi.repository import GLib
|
||||
from modules.launcher.plugin_base import PluginBase
|
||||
from modules.launcher.result import Result
|
||||
|
||||
|
||||
class PomodoroTimer:
|
||||
"""
|
||||
Core Pomodoro timer implementation.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.is_break = True
|
||||
self.timer = None
|
||||
self.countdown_timers = [] # Store countdown timer references
|
||||
self.remaining_till_long_break = 0
|
||||
self.end_time = 0
|
||||
self.pomodoro_duration = 0
|
||||
self.break_duration = 0
|
||||
self.long_break_duration = 0
|
||||
self.count = 0
|
||||
self.current_cycle = 0
|
||||
self.total_cycles_completed = 0
|
||||
self._cached_status = "Inactive"
|
||||
self._last_status_update = 0
|
||||
|
||||
def _send_notification_async(self, title, message):
|
||||
"""Send a desktop notification asynchronously."""
|
||||
|
||||
def send_notification():
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"notify-send",
|
||||
"-a",
|
||||
"Pomodoro Timer",
|
||||
"-i",
|
||||
"timer",
|
||||
title,
|
||||
message,
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to send notification: {e}")
|
||||
|
||||
# Run in background thread to avoid blocking UI
|
||||
thread = threading.Thread(target=send_notification, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _send_countdown_notification_async(self, seconds_left):
|
||||
"""Send countdown notification asynchronously."""
|
||||
|
||||
def send_countdown():
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"notify-send",
|
||||
"-a",
|
||||
"Pomodoro Timer",
|
||||
"-i",
|
||||
"timer",
|
||||
"-t",
|
||||
"1000", # Show for 1 second
|
||||
"⏰ Pomodoro Timer",
|
||||
f"Time's up in {seconds_left} seconds!",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to send countdown notification: {e}")
|
||||
|
||||
# Run in background thread to avoid blocking UI
|
||||
thread = threading.Thread(target=send_countdown, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _schedule_countdown_notifications(self, duration_seconds):
|
||||
"""Schedule countdown notifications for the last 3 seconds."""
|
||||
# Clear any existing countdown timers
|
||||
self._clear_countdown_timers()
|
||||
|
||||
if duration_seconds > 3:
|
||||
# Schedule countdown notifications for 3, 2, 1 seconds before end
|
||||
for countdown in [3, 2, 1]:
|
||||
delay = duration_seconds - countdown
|
||||
if delay > 0:
|
||||
timer = threading.Timer(
|
||||
delay, self._send_countdown_notification_async, [countdown]
|
||||
)
|
||||
self.countdown_timers.append(timer)
|
||||
timer.start()
|
||||
|
||||
def _clear_countdown_timers(self):
|
||||
"""Clear all countdown timers."""
|
||||
for timer in self.countdown_timers:
|
||||
if timer.is_alive():
|
||||
timer.cancel()
|
||||
self.countdown_timers.clear()
|
||||
|
||||
def timeout(self):
|
||||
"""Handle timer timeout and switch between work/break periods."""
|
||||
if self.is_break:
|
||||
# Starting work period
|
||||
duration = self.pomodoro_duration * 60
|
||||
self.timer = threading.Timer(duration, self.timeout)
|
||||
self.end_time = time.time() + duration
|
||||
self.current_cycle += 1
|
||||
|
||||
# Send notification for work period start
|
||||
self._send_notification_async(
|
||||
"🍅 Work Time!",
|
||||
f"Starting work period {self.current_cycle} ({
|
||||
self.pomodoro_duration
|
||||
} minutes)",
|
||||
)
|
||||
|
||||
# Schedule countdown notifications
|
||||
self._schedule_countdown_notifications(duration)
|
||||
|
||||
self.timer.start()
|
||||
else:
|
||||
# Starting break period
|
||||
self.remaining_till_long_break -= 1
|
||||
self.total_cycles_completed += 1
|
||||
|
||||
if self.remaining_till_long_break == 0:
|
||||
self.remaining_till_long_break = self.count
|
||||
duration = self.long_break_duration * 60
|
||||
|
||||
# Send notification for long break
|
||||
self._send_notification_async(
|
||||
"🌟 Long Break Time!",
|
||||
f"Take a long break ({
|
||||
self.long_break_duration
|
||||
} minutes)\nYou've completed {self.count} cycles! Total: {
|
||||
self.total_cycles_completed
|
||||
}",
|
||||
)
|
||||
|
||||
else:
|
||||
duration = self.break_duration * 60
|
||||
|
||||
# Send notification for short break
|
||||
self._send_notification_async(
|
||||
"☕ Break Time!",
|
||||
f"Take a short break ({self.break_duration} minutes)\nCycle {
|
||||
self.current_cycle
|
||||
} completed! Total: {self.total_cycles_completed}",
|
||||
)
|
||||
|
||||
# Schedule countdown notifications for break end
|
||||
self._schedule_countdown_notifications(duration)
|
||||
|
||||
self.end_time = time.time() + duration
|
||||
self.timer = threading.Timer(duration, self.timeout)
|
||||
self.timer.start()
|
||||
|
||||
self.is_break = not self.is_break
|
||||
# Update cached status after state change
|
||||
self._update_cached_status()
|
||||
|
||||
def start(self, pomodoro_duration, break_duration, long_break_duration, count):
|
||||
"""Start the Pomodoro timer with specified durations."""
|
||||
self.stop()
|
||||
self.pomodoro_duration = pomodoro_duration
|
||||
self.break_duration = break_duration
|
||||
self.long_break_duration = long_break_duration
|
||||
self.count = count
|
||||
self.remaining_till_long_break = count
|
||||
self.current_cycle = 0
|
||||
self.total_cycles_completed = 0
|
||||
self.is_break = True
|
||||
|
||||
# Send initial notification
|
||||
self._send_notification_async(
|
||||
"🍅 Pomodoro Started!",
|
||||
f"Timer started: {pomodoro_duration}min work, {break_duration}min break\n{
|
||||
count
|
||||
} cycles with {long_break_duration}min long breaks",
|
||||
)
|
||||
|
||||
self.timeout()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the Pomodoro timer."""
|
||||
if self.is_active():
|
||||
self.timer.cancel()
|
||||
self.timer = None
|
||||
|
||||
# Clear countdown timers
|
||||
self._clear_countdown_timers()
|
||||
|
||||
# Send stop notification
|
||||
self._send_notification_async(
|
||||
"⏹️ Pomodoro Stopped",
|
||||
f"Timer stopped after {
|
||||
self.total_cycles_completed
|
||||
} completed cycles\nCurrent work period: {self.current_cycle}",
|
||||
)
|
||||
|
||||
# Update cached status
|
||||
self._cached_status = "Inactive"
|
||||
|
||||
def is_active(self):
|
||||
"""Check if the timer is currently active."""
|
||||
return self.timer is not None
|
||||
|
||||
def _update_cached_status(self):
|
||||
"""Update the cached status string."""
|
||||
current_time = time.time()
|
||||
|
||||
if not self.is_active():
|
||||
self._cached_status = "Inactive"
|
||||
return
|
||||
|
||||
remaining_seconds = max(0, int(self.end_time - current_time))
|
||||
remaining_minutes = remaining_seconds // 60
|
||||
remaining_seconds = remaining_seconds % 60
|
||||
|
||||
if self.is_break:
|
||||
if self.remaining_till_long_break == self.count:
|
||||
period_type = "Long Break"
|
||||
else:
|
||||
period_type = "Short Break"
|
||||
else:
|
||||
period_type = f"Work (Cycle {self.current_cycle})"
|
||||
|
||||
self._cached_status = f"{period_type} - {remaining_minutes:02d}:{remaining_seconds:02d} | Total: {self.total_cycles_completed}"
|
||||
self._last_status_update = current_time
|
||||
|
||||
def get_status(self):
|
||||
"""Get current timer status with caching."""
|
||||
current_time = time.time()
|
||||
|
||||
# Update cache if it's been more than 1 second or timer is inactive
|
||||
if (current_time - self._last_status_update > 1.0) or not self.is_active():
|
||||
self._update_cached_status()
|
||||
|
||||
return self._cached_status
|
||||
|
||||
|
||||
class PomodoroPlugin(PluginBase):
|
||||
"""
|
||||
Pomodoro Technique plugin for the launcher.
|
||||
"""
|
||||
|
||||
# Default settings
|
||||
DEFAULT_POMODORO_DURATION = 25
|
||||
DEFAULT_BREAK_DURATION = 5
|
||||
DEFAULT_LONG_BREAK_DURATION = 15
|
||||
DEFAULT_POMODORO_COUNT = 4
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.display_name = "Pomodoro"
|
||||
self.description = "Pomodoro Technique timer for productivity"
|
||||
self.pomodoro = PomodoroTimer()
|
||||
|
||||
def initialize(self):
|
||||
"""Initialize the Pomodoro plugin."""
|
||||
self.set_triggers(["pomo"])
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the Pomodoro plugin."""
|
||||
self.pomodoro.stop()
|
||||
|
||||
def _force_refresh(self):
|
||||
"""Force refresh the launcher to update the display."""
|
||||
try:
|
||||
|
||||
def trigger_refresh():
|
||||
try:
|
||||
# Fallback: try to find launcher instance through other means
|
||||
import gc
|
||||
|
||||
for obj in gc.get_objects():
|
||||
if (
|
||||
hasattr(obj, "__class__")
|
||||
and obj.__class__.__name__ == "Launcher"
|
||||
):
|
||||
if hasattr(obj, "search_entry") and hasattr(
|
||||
obj, "_perform_search"
|
||||
):
|
||||
obj.search_entry.set_text("pomo ")
|
||||
obj.search_entry.set_position(-1)
|
||||
obj._perform_search("pomo ")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error forcing launcher refresh: {e}")
|
||||
|
||||
return False # Don't repeat
|
||||
|
||||
# Use a small delay to ensure the action completes first
|
||||
GLib.timeout_add(50, trigger_refresh)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Could not trigger refresh: {e}")
|
||||
|
||||
def query(self, query_string: str) -> List[Result]:
|
||||
"""Process Pomodoro queries."""
|
||||
results = []
|
||||
|
||||
if self.pomodoro.is_active():
|
||||
# Timer is running - show status and stop option
|
||||
status = self.pomodoro.get_status()
|
||||
|
||||
# Show current status
|
||||
results.append(
|
||||
Result(
|
||||
title="Pomodoro Timer Active",
|
||||
subtitle=status,
|
||||
icon_markup=icons.timer_on,
|
||||
action=lambda: None, # No action, just status display
|
||||
relevance=1.0,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "status"},
|
||||
)
|
||||
)
|
||||
|
||||
# Show stop option with refresh
|
||||
def stop_and_refresh():
|
||||
self.pomodoro.stop()
|
||||
self._force_refresh()
|
||||
|
||||
results.append(
|
||||
Result(
|
||||
title="Stop Pomodoro",
|
||||
subtitle="Stop the current timer",
|
||||
icon_markup=icons.timer_off,
|
||||
action=stop_and_refresh,
|
||||
relevance=0.9,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "stop"},
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Timer is not running - show start options
|
||||
if not query_string.strip():
|
||||
# No parameters - show default start option
|
||||
def start_default_and_refresh():
|
||||
self.pomodoro.start(
|
||||
self.DEFAULT_POMODORO_DURATION,
|
||||
self.DEFAULT_BREAK_DURATION,
|
||||
self.DEFAULT_LONG_BREAK_DURATION,
|
||||
self.DEFAULT_POMODORO_COUNT,
|
||||
)
|
||||
self._force_refresh()
|
||||
|
||||
results.append(
|
||||
Result(
|
||||
title="Start Pomodoro",
|
||||
subtitle=f"{self.DEFAULT_POMODORO_DURATION} min work, {self.DEFAULT_BREAK_DURATION} min break, {self.DEFAULT_LONG_BREAK_DURATION} min long break, {self.DEFAULT_POMODORO_COUNT} cycles",
|
||||
icon_markup=icons.timer_on,
|
||||
action=start_default_and_refresh,
|
||||
relevance=1.0,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "start_default"},
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Parse parameters
|
||||
tokens = query_string.strip().split()
|
||||
|
||||
# Validate parameters
|
||||
if len(tokens) > 4 or not all(t.isdigit() for t in tokens):
|
||||
results.append(
|
||||
Result(
|
||||
title="Invalid Parameters",
|
||||
subtitle="Usage: [work_minutes] [break_minutes] [long_break_minutes] [cycle_count]",
|
||||
icon_markup=icons.alert,
|
||||
action=lambda: None,
|
||||
relevance=0.5,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "error"},
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Parse valid parameters
|
||||
p = (
|
||||
int(tokens[0])
|
||||
if len(tokens) > 0
|
||||
else self.DEFAULT_POMODORO_DURATION
|
||||
)
|
||||
b = (
|
||||
int(tokens[1])
|
||||
if len(tokens) > 1
|
||||
else self.DEFAULT_BREAK_DURATION
|
||||
)
|
||||
lb = (
|
||||
int(tokens[2])
|
||||
if len(tokens) > 2
|
||||
else self.DEFAULT_LONG_BREAK_DURATION
|
||||
)
|
||||
c = (
|
||||
int(tokens[3])
|
||||
if len(tokens) > 3
|
||||
else self.DEFAULT_POMODORO_COUNT
|
||||
)
|
||||
|
||||
def start_custom_and_refresh(_p=p, _b=b, _lb=lb, _c=c):
|
||||
self.pomodoro.start(_p, _b, _lb, _c)
|
||||
self._force_refresh()
|
||||
|
||||
results.append(
|
||||
Result(
|
||||
title="Start Pomodoro",
|
||||
subtitle=f"{p} min work, {b} min break, {lb} min long break, {c} cycles",
|
||||
icon_markup=icons.timer_on,
|
||||
action=start_custom_and_refresh,
|
||||
relevance=1.0,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "start_custom", "params": [p, b, lb, c]},
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -1,764 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
from pathlib import Path
|
||||
|
||||
from gi.repository import GLib, Gtk, Gdk
|
||||
from fabric.utils.helpers import get_relative_path
|
||||
import utils.icons as icons
|
||||
from utils.icon_resolver import IconResolver
|
||||
from modules.launcher.plugin_base import PluginBase
|
||||
from modules.launcher.result import Result
|
||||
|
||||
|
||||
class ScreenTimeBarWidget(Gtk.Box):
|
||||
"""Custom widget to display screen time statistics like GNOME Digital Wellbeing."""
|
||||
|
||||
def __init__(self, usage_data: Dict[str, float], total_time: float, weekly_data: Dict[str, float] = None):
|
||||
super().__init__(
|
||||
name="screentime-widget",
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
spacing=12
|
||||
)
|
||||
|
||||
# Compact size for summary view only
|
||||
self.set_size_request(540, 150)
|
||||
self.set_vexpand(False)
|
||||
self.set_hexpand(True)
|
||||
|
||||
self.usage_data = usage_data
|
||||
self.total_time = total_time
|
||||
self.weekly_data = weekly_data or {}
|
||||
|
||||
self.setup_ui()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Setup the GNOME-style digital wellbeing UI."""
|
||||
# Main container with minimal padding for launcher
|
||||
main_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
main_container.set_margin_left(12)
|
||||
main_container.set_margin_right(12)
|
||||
main_container.set_margin_top(0)
|
||||
main_container.set_margin_bottom(0)
|
||||
|
||||
# Header section with large time display
|
||||
self.create_header_section(main_container)
|
||||
|
||||
# Summary cards section
|
||||
self.create_summary_cards(main_container)
|
||||
|
||||
self.pack_start(main_container, True, True, 0)
|
||||
self.show_all()
|
||||
|
||||
def create_header_section(self, container):
|
||||
"""Create the main header with large time display."""
|
||||
header_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
|
||||
# Main title
|
||||
title_label = Gtk.Label()
|
||||
title_label.set_markup('<span size="medium" weight="bold">Digital Wellbeing</span>')
|
||||
title_label.set_halign(Gtk.Align.CENTER)
|
||||
title_label.get_style_context().add_class("screentime-title")
|
||||
|
||||
# Large time display
|
||||
total_hours = int(self.total_time // 3600)
|
||||
total_minutes = int((self.total_time % 3600) // 60)
|
||||
|
||||
time_display = Gtk.Label()
|
||||
if total_hours > 0:
|
||||
time_text = f"{total_hours}h {total_minutes}m"
|
||||
else:
|
||||
time_text = f"{total_minutes}m"
|
||||
|
||||
time_display.set_markup(f'<span size="xx-large" weight="bold" color="#3584e4">{time_text}</span>')
|
||||
time_display.set_halign(Gtk.Align.CENTER)
|
||||
time_display.get_style_context().add_class("screentime-time-display")
|
||||
|
||||
# Subtitle
|
||||
subtitle = Gtk.Label()
|
||||
subtitle.set_markup('<span color="#666666">Screen time today</span>')
|
||||
subtitle.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
header_box.pack_start(title_label, False, False, 0)
|
||||
header_box.pack_start(time_display, False, False, 0)
|
||||
header_box.pack_start(subtitle, False, False, 0)
|
||||
|
||||
container.pack_start(header_box, False, False, 0)
|
||||
|
||||
def create_summary_cards(self, container):
|
||||
"""Create summary statistics cards."""
|
||||
if not self.usage_data:
|
||||
return
|
||||
|
||||
cards_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
cards_box.set_homogeneous(True)
|
||||
|
||||
# Most used app card
|
||||
most_used_app = max(self.usage_data.items(), key=lambda x: x[1])
|
||||
most_used_hours = int(most_used_app[1] // 3600)
|
||||
most_used_minutes = int((most_used_app[1] % 3600) // 60)
|
||||
most_used_time = f"{most_used_hours}h {most_used_minutes}m" if most_used_hours > 0 else f"{most_used_minutes}m"
|
||||
|
||||
most_used_card = self.create_info_card("Most Used", most_used_app[0], most_used_time)
|
||||
|
||||
# App count card
|
||||
app_count = len([app for app, time in self.usage_data.items() if time >= 60]) # Apps with 1+ minute
|
||||
app_count_card = self.create_info_card("Apps Used", str(app_count), "applications")
|
||||
|
||||
# Average session card (estimate)
|
||||
if app_count > 0:
|
||||
avg_session = self.total_time / app_count / 60 # minutes
|
||||
avg_text = f"{int(avg_session)}m"
|
||||
else:
|
||||
avg_text = "0m"
|
||||
avg_card = self.create_info_card("Avg Session", avg_text, "per app")
|
||||
|
||||
cards_box.pack_start(most_used_card, True, True, 0)
|
||||
cards_box.pack_start(app_count_card, True, True, 0)
|
||||
cards_box.pack_start(avg_card, True, True, 0)
|
||||
|
||||
container.pack_start(cards_box, False, False, 0)
|
||||
|
||||
def create_info_card(self, title, value, subtitle):
|
||||
"""Create an information card widget."""
|
||||
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
card.get_style_context().add_class("screentime-card")
|
||||
card.set_margin_left(8)
|
||||
card.set_margin_right(8)
|
||||
card.set_margin_top(8)
|
||||
card.set_margin_bottom(8)
|
||||
|
||||
title_label = Gtk.Label()
|
||||
title_label.set_markup(f'<span size="small" color="#666666">{title}</span>')
|
||||
title_label.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
value_label = Gtk.Label()
|
||||
value_label.set_markup(f'<span size="large" weight="bold">{value}</span>')
|
||||
value_label.set_halign(Gtk.Align.CENTER)
|
||||
value_label.set_ellipsize(3) # ELLIPSIZE_END
|
||||
value_label.set_max_width_chars(12)
|
||||
|
||||
subtitle_label = Gtk.Label()
|
||||
subtitle_label.set_markup(f'<span size="x-small" color="#888888">{subtitle}</span>')
|
||||
subtitle_label.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
card.pack_start(title_label, False, False, 0)
|
||||
card.pack_start(value_label, False, False, 0)
|
||||
card.pack_start(subtitle_label, False, False, 0)
|
||||
|
||||
return card
|
||||
|
||||
def create_app_usage_section(self, container):
|
||||
"""Create the application usage section with grid layout."""
|
||||
if not self.usage_data:
|
||||
no_data_label = Gtk.Label()
|
||||
no_data_label.set_markup('<span color="#666666">No usage data available</span>')
|
||||
no_data_label.set_halign(Gtk.Align.CENTER)
|
||||
no_data_label.set_margin_top(20)
|
||||
container.pack_start(no_data_label, True, True, 0)
|
||||
return
|
||||
|
||||
# Section title
|
||||
section_title = Gtk.Label()
|
||||
section_title.set_markup('<span weight="bold">App Usage</span>')
|
||||
section_title.set_halign(Gtk.Align.START)
|
||||
section_title.set_margin_top(8)
|
||||
|
||||
# Scrollable area for app grid
|
||||
scrolled = Gtk.ScrolledWindow()
|
||||
scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
scrolled.set_vexpand(True)
|
||||
scrolled.set_min_content_height(120)
|
||||
|
||||
# Container for app grid
|
||||
self.grid_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
scrolled.add(self.grid_box)
|
||||
|
||||
container.pack_start(section_title, False, False, 0)
|
||||
container.pack_start(scrolled, True, True, 0)
|
||||
|
||||
self.create_app_grid()
|
||||
|
||||
def create_app_grid(self):
|
||||
"""Create grid layout for app usage display."""
|
||||
if not self.usage_data:
|
||||
return
|
||||
|
||||
# Sort apps by usage time (descending)
|
||||
sorted_apps = sorted(
|
||||
self.usage_data.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# Show top 12 apps in grid format
|
||||
max_apps = 12
|
||||
apps_to_show = [app for app in sorted_apps[:max_apps] if app[1] >= 60] # 1+ minute
|
||||
|
||||
if not apps_to_show:
|
||||
return
|
||||
|
||||
# Create grid with 3 columns
|
||||
cols = 3
|
||||
rows = (len(apps_to_show) + cols - 1) // cols # Ceiling division
|
||||
|
||||
for row in range(rows):
|
||||
row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||
row_box.set_homogeneous(True)
|
||||
|
||||
for col in range(cols):
|
||||
app_index = row * cols + col
|
||||
if app_index < len(apps_to_show):
|
||||
app_name, usage_time = apps_to_show[app_index]
|
||||
app_card = self.create_app_grid_card(app_name, usage_time)
|
||||
row_box.pack_start(app_card, True, True, 0)
|
||||
else:
|
||||
# Empty placeholder to maintain grid alignment
|
||||
placeholder = Gtk.Box()
|
||||
row_box.pack_start(placeholder, True, True, 0)
|
||||
|
||||
self.grid_box.pack_start(row_box, False, False, 0)
|
||||
|
||||
def create_app_grid_card(self, app_name: str, usage_time: float) -> Gtk.Box:
|
||||
"""Create a grid card for application usage display."""
|
||||
# Main card container
|
||||
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
card.get_style_context().add_class("screentime-grid-card")
|
||||
card.set_margin_left(1)
|
||||
card.set_margin_right(1)
|
||||
card.set_margin_top(1)
|
||||
card.set_margin_bottom(1)
|
||||
|
||||
# Icon container
|
||||
icon_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
icon_container.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
# Get real application icon (compact size for launcher)
|
||||
icon_pixbuf = self.icon_resolver.get_icon_pixbuf(app_name.lower(), 32)
|
||||
if not icon_pixbuf:
|
||||
# Fallback to default application icon
|
||||
icon_pixbuf = self.icon_resolver.get_icon_pixbuf("application-x-executable", 32)
|
||||
|
||||
if icon_pixbuf:
|
||||
icon_image = Gtk.Image()
|
||||
icon_image.set_from_pixbuf(icon_pixbuf)
|
||||
icon_image.set_size_request(32, 32)
|
||||
else:
|
||||
# Final fallback to colored box
|
||||
icon_image = Gtk.Box()
|
||||
icon_image.set_size_request(32, 32)
|
||||
icon_image.get_style_context().add_class("screentime-grid-icon")
|
||||
|
||||
icon_container.pack_start(icon_image, False, False, 0)
|
||||
|
||||
# App name
|
||||
display_name = app_name if len(app_name) <= 10 else app_name[:7] + "..."
|
||||
name_label = Gtk.Label()
|
||||
name_label.set_markup(f'<span weight="500" size="x-small">{display_name}</span>')
|
||||
name_label.set_halign(Gtk.Align.CENTER)
|
||||
name_label.set_ellipsize(3) # ELLIPSIZE_END
|
||||
name_label.set_max_width_chars(10)
|
||||
|
||||
# Usage time
|
||||
hours = int(usage_time // 3600)
|
||||
minutes = int((usage_time % 3600) // 60)
|
||||
time_text = f"{hours}h {minutes}m" if hours > 0 else f"{minutes}m"
|
||||
|
||||
time_label = Gtk.Label()
|
||||
time_label.set_markup(f'<span size="x-small" weight="500" color="#3584e4">{time_text}</span>')
|
||||
time_label.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
# Pack elements
|
||||
card.pack_start(icon_container, False, False, 0)
|
||||
card.pack_start(name_label, False, False, 0)
|
||||
card.pack_start(time_label, False, False, 0)
|
||||
|
||||
return card
|
||||
|
||||
def create_modern_app_bar(self, app_name: str, usage_time: float, max_time: float) -> Gtk.Box:
|
||||
"""Create a modern GNOME-style application usage bar."""
|
||||
# Main container with rounded corners styling
|
||||
container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||
container.get_style_context().add_class("screentime-app-item")
|
||||
container.set_margin_left(4)
|
||||
container.set_margin_right(4)
|
||||
container.set_margin_top(4)
|
||||
container.set_margin_bottom(4)
|
||||
|
||||
# Top row with app name and time
|
||||
top_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
|
||||
# App icon and name
|
||||
app_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
|
||||
# Get real application icon
|
||||
icon_pixbuf = self.icon_resolver.get_icon_pixbuf(app_name.lower(), 32)
|
||||
if not icon_pixbuf:
|
||||
# Fallback to default application icon
|
||||
icon_pixbuf = self.icon_resolver.get_icon_pixbuf("application-x-executable", 32)
|
||||
|
||||
if icon_pixbuf:
|
||||
icon_image = Gtk.Image()
|
||||
icon_image.set_from_pixbuf(icon_pixbuf)
|
||||
icon_image.set_size_request(32, 32)
|
||||
else:
|
||||
# Final fallback to colored box if no icon found
|
||||
icon_image = Gtk.Box()
|
||||
icon_image.set_size_request(32, 32)
|
||||
icon_image.get_style_context().add_class("screentime-app-icon")
|
||||
|
||||
# App name with better typography
|
||||
display_name = app_name if len(app_name) <= 20 else app_name[:17] + "..."
|
||||
name_label = Gtk.Label()
|
||||
name_label.set_markup(f'<span weight="500">{display_name}</span>')
|
||||
name_label.set_halign(Gtk.Align.START)
|
||||
name_label.set_ellipsize(3) # ELLIPSIZE_END
|
||||
|
||||
app_info_box.pack_start(icon_image, False, False, 0)
|
||||
app_info_box.pack_start(name_label, False, False, 0)
|
||||
|
||||
# Time and percentage info
|
||||
time_info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||
time_info_box.set_halign(Gtk.Align.END)
|
||||
|
||||
# Usage time
|
||||
hours = int(usage_time // 3600)
|
||||
minutes = int((usage_time % 3600) // 60)
|
||||
time_text = f"{hours}h {minutes}m" if hours > 0 else f"{minutes}m"
|
||||
|
||||
time_label = Gtk.Label()
|
||||
time_label.set_markup(f'<span weight="500">{time_text}</span>')
|
||||
time_label.set_halign(Gtk.Align.END)
|
||||
|
||||
# Percentage of total time
|
||||
percentage = (usage_time / self.total_time * 100) if self.total_time > 0 else 0
|
||||
percentage_label = Gtk.Label()
|
||||
percentage_label.set_markup(f'<span size="small" color="#666666">{percentage:.0f}%</span>')
|
||||
percentage_label.set_halign(Gtk.Align.END)
|
||||
|
||||
time_info_box.pack_start(time_label, False, False, 0)
|
||||
time_info_box.pack_start(percentage_label, False, False, 0)
|
||||
|
||||
top_row.pack_start(app_info_box, False, False, 0)
|
||||
top_row.pack_start(time_info_box, True, True, 0)
|
||||
|
||||
# Modern progress bar with rounded corners
|
||||
progress_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
progress_container.get_style_context().add_class("screentime-progress-container")
|
||||
|
||||
progress_bar = Gtk.ProgressBar()
|
||||
progress_bar.set_fraction(usage_time / max_time if max_time > 0 else 0)
|
||||
progress_bar.set_show_text(False)
|
||||
progress_bar.set_size_request(-1, 6)
|
||||
progress_bar.get_style_context().add_class("screentime-progress")
|
||||
|
||||
# Color based on usage intensity with modern colors
|
||||
if percentage > 25:
|
||||
progress_bar.get_style_context().add_class("high-usage")
|
||||
elif percentage > 10:
|
||||
progress_bar.get_style_context().add_class("medium-usage")
|
||||
else:
|
||||
progress_bar.get_style_context().add_class("low-usage")
|
||||
|
||||
progress_container.pack_start(progress_bar, True, True, 0)
|
||||
|
||||
container.pack_start(top_row, False, False, 0)
|
||||
container.pack_start(progress_container, False, False, 0)
|
||||
|
||||
return container
|
||||
|
||||
|
||||
class ScreenTimePlugin(PluginBase):
|
||||
"""
|
||||
Screen time tracking plugin that monitors application usage.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.display_name = "Screen Time"
|
||||
self.description = "Track application usage and screen time"
|
||||
|
||||
# Data storage - single JSON file
|
||||
self.data_file = get_relative_path("../../../config/json/screentime.json")
|
||||
|
||||
# Icon resolver for application icons
|
||||
self.icon_resolver = IconResolver()
|
||||
|
||||
# Current tracking state
|
||||
self.current_app = None
|
||||
self.current_start_time = None
|
||||
self.screentime_data = {"sessions": []} # Main data structure
|
||||
|
||||
# Threading for monitoring
|
||||
self.monitor_thread = None
|
||||
self.stop_monitoring = threading.Event()
|
||||
|
||||
# Last activity time for session tracking
|
||||
self.last_activity_time = None
|
||||
|
||||
def initialize(self):
|
||||
"""Initialize the screen time plugin."""
|
||||
self.set_triggers(["screentime", "st"])
|
||||
self.load_data()
|
||||
self.start_monitoring()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the screen time plugin."""
|
||||
self.stop_monitoring.set()
|
||||
if self.monitor_thread:
|
||||
self.monitor_thread.join(timeout=2)
|
||||
self.save_data()
|
||||
|
||||
def load_data(self):
|
||||
"""Load screentime data from the JSON file."""
|
||||
try:
|
||||
if os.path.exists(self.data_file):
|
||||
with open(self.data_file, 'r') as f:
|
||||
self.screentime_data = json.load(f)
|
||||
if "sessions" not in self.screentime_data:
|
||||
self.screentime_data["sessions"] = []
|
||||
else:
|
||||
self.screentime_data = {"sessions": []}
|
||||
|
||||
# Initialize today's session if it doesn't exist
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
today_session = self.get_session_for_date(today)
|
||||
if not today_session:
|
||||
self.screentime_data["sessions"].append({
|
||||
"date": today,
|
||||
"active_time": 0,
|
||||
"apps": {}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"ScreenTimePlugin: Error loading data: {e}")
|
||||
self.screentime_data = {"sessions": []}
|
||||
|
||||
def save_data(self):
|
||||
"""Save screentime data to the JSON file."""
|
||||
try:
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(self.data_file), exist_ok=True)
|
||||
|
||||
with open(self.data_file, 'w') as f:
|
||||
json.dump(self.screentime_data, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"ScreenTimePlugin: Error saving data: {e}")
|
||||
|
||||
def get_session_for_date(self, date_str: str) -> Optional[Dict]:
|
||||
"""Get session data for a specific date."""
|
||||
for session in self.screentime_data["sessions"]:
|
||||
if session["date"] == date_str:
|
||||
return session
|
||||
return None
|
||||
|
||||
def get_today_active_time(self) -> float:
|
||||
"""Get today's total active time in seconds."""
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
session = self.get_session_for_date(today)
|
||||
return session["active_time"] if session else 0
|
||||
|
||||
def update_today_active_time(self, additional_seconds: float):
|
||||
"""Add time to today's active time."""
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
session = self.get_session_for_date(today)
|
||||
|
||||
if session:
|
||||
session["active_time"] += additional_seconds
|
||||
else:
|
||||
# Create new session for today
|
||||
self.screentime_data["sessions"].append({
|
||||
"date": today,
|
||||
"active_time": additional_seconds,
|
||||
"apps": {}
|
||||
})
|
||||
|
||||
def update_app_usage(self, app_name: str, additional_seconds: float):
|
||||
"""Add time to today's app usage."""
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
session = self.get_session_for_date(today)
|
||||
|
||||
if session:
|
||||
if "apps" not in session:
|
||||
session["apps"] = {}
|
||||
if app_name not in session["apps"]:
|
||||
session["apps"][app_name] = 0
|
||||
session["apps"][app_name] += additional_seconds
|
||||
else:
|
||||
# Create new session for today
|
||||
self.screentime_data["sessions"].append({
|
||||
"date": today,
|
||||
"active_time": 0,
|
||||
"apps": {app_name: additional_seconds}
|
||||
})
|
||||
|
||||
def get_today_apps(self) -> Dict[str, float]:
|
||||
"""Get today's app usage data."""
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
session = self.get_session_for_date(today)
|
||||
return session.get("apps", {}) if session else {}
|
||||
|
||||
def get_weekly_total(self) -> float:
|
||||
"""Get total active time for the last 7 days."""
|
||||
today = datetime.now()
|
||||
total_time = 0
|
||||
|
||||
for i in range(7):
|
||||
date = (today - timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
session = self.get_session_for_date(date)
|
||||
if session:
|
||||
total_time += session["active_time"]
|
||||
|
||||
return total_time
|
||||
|
||||
def _get_yesterday_total(self) -> float:
|
||||
"""Get yesterday's total active time."""
|
||||
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
session = self.get_session_for_date(yesterday)
|
||||
return session["active_time"] if session else 0
|
||||
|
||||
def start_monitoring(self):
|
||||
"""Start background monitoring of active windows."""
|
||||
if self.monitor_thread and self.monitor_thread.is_alive():
|
||||
return
|
||||
|
||||
self.stop_monitoring.clear()
|
||||
self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self.monitor_thread.start()
|
||||
|
||||
def _monitor_loop(self):
|
||||
"""Main monitoring loop that tracks active time and app usage."""
|
||||
while not self.stop_monitoring.wait(2.0): # Check every 2 seconds
|
||||
try:
|
||||
current_time = time.time()
|
||||
current_app = self._get_active_window_class()
|
||||
|
||||
# If app changed, record time for previous app
|
||||
if self.current_app and self.current_app != current_app:
|
||||
if self.current_start_time:
|
||||
elapsed = current_time - self.current_start_time
|
||||
# Only count if elapsed time is reasonable (not more than 5 seconds)
|
||||
if elapsed <= 5.0:
|
||||
self.update_today_active_time(elapsed)
|
||||
self.update_app_usage(self.current_app, elapsed)
|
||||
|
||||
# Update current app tracking
|
||||
if current_app:
|
||||
self.current_app = current_app
|
||||
self.current_start_time = current_time
|
||||
else:
|
||||
# No active window, reset tracking
|
||||
self.current_app = None
|
||||
self.current_start_time = None
|
||||
|
||||
# Save data periodically (every 30 seconds)
|
||||
if int(current_time) % 30 == 0:
|
||||
self.save_data()
|
||||
|
||||
except Exception as e:
|
||||
print(f"ScreenTimePlugin: Error in monitor loop: {e}")
|
||||
time.sleep(5) # Wait longer on error
|
||||
|
||||
def _get_active_window_class(self) -> Optional[str]:
|
||||
"""Get the class name of the currently active window."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["hyprctl", "-j", "activewindow"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
window_data = json.loads(result.stdout)
|
||||
# Use class name, fallback to title if class is empty
|
||||
app_class = window_data.get("class", "").strip()
|
||||
if not app_class:
|
||||
app_class = window_data.get("title", "Unknown").strip()
|
||||
|
||||
# Filter out empty or system windows
|
||||
if app_class and app_class not in ["", "Unknown", "Desktop"]:
|
||||
return app_class
|
||||
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception) as e:
|
||||
print(f"ScreenTimePlugin: Error getting active window: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def query(self, query_string: str) -> List[Result]:
|
||||
"""Process screen time queries with GNOME Digital Wellbeing style."""
|
||||
results = []
|
||||
query = query_string.strip().lower()
|
||||
|
||||
# Get today's data
|
||||
today_time = self.get_today_active_time()
|
||||
today_apps = self.get_today_apps()
|
||||
weekly_time = self.get_weekly_total()
|
||||
|
||||
# Main screen time display (compact view)
|
||||
if not query or query in ["today", "stats", "usage", "wellbeing"]:
|
||||
today_hours = int(today_time // 3600)
|
||||
today_minutes = int((today_time % 3600) // 60)
|
||||
|
||||
# Enhanced subtitle with more context
|
||||
if today_time > 0:
|
||||
subtitle = f"Today: {today_hours}h {today_minutes}m"
|
||||
|
||||
# Add comparison with yesterday if available
|
||||
yesterday_total = self._get_yesterday_total()
|
||||
if yesterday_total > 0:
|
||||
if today_time > yesterday_total:
|
||||
diff = today_time - yesterday_total
|
||||
diff_minutes = int(diff // 60)
|
||||
subtitle += f" (+{diff_minutes}m vs yesterday)"
|
||||
elif today_time < yesterday_total:
|
||||
diff = yesterday_total - today_time
|
||||
diff_minutes = int(diff // 60)
|
||||
subtitle += f" (-{diff_minutes}m vs yesterday)"
|
||||
|
||||
# Add weekly average
|
||||
weekly_avg = weekly_time / 7 if weekly_time > 0 else 0
|
||||
if weekly_avg > 0:
|
||||
avg_hours = int(weekly_avg // 3600)
|
||||
avg_minutes = int((weekly_avg % 3600) // 60)
|
||||
avg_text = f"{avg_hours}h {avg_minutes}m" if avg_hours > 0 else f"{avg_minutes}m"
|
||||
subtitle += f" • Weekly avg: {avg_text}/day"
|
||||
else:
|
||||
subtitle = "No screen time recorded today"
|
||||
|
||||
results.append(
|
||||
Result(
|
||||
title="Digital Wellbeing",
|
||||
subtitle=subtitle,
|
||||
icon_markup=icons.screentime,
|
||||
action=lambda: None,
|
||||
relevance=1.0,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "screentime_display"}
|
||||
)
|
||||
)
|
||||
|
||||
# App list view (individual result items)
|
||||
elif query in ["list", "apps", "applications"]:
|
||||
if today_apps:
|
||||
# Sort apps by usage time (descending)
|
||||
sorted_apps = sorted(
|
||||
today_apps.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# Show top 12 apps as individual results
|
||||
for i, (app_name, usage_time) in enumerate(sorted_apps[:12]):
|
||||
if usage_time < 60: # Skip apps with less than 1 minute
|
||||
continue
|
||||
|
||||
hours = int(usage_time // 3600)
|
||||
minutes = int((usage_time % 3600) // 60)
|
||||
time_text = f"{hours}h {minutes}m" if hours > 0 else f"{minutes}m"
|
||||
|
||||
# Calculate percentage of total time
|
||||
percentage = (usage_time / today_time * 100) if today_time > 0 else 0
|
||||
|
||||
subtitle = f"Used for {time_text} today ({percentage:.0f}%)"
|
||||
|
||||
# Get application icon
|
||||
app_icon_pixbuf = self.icon_resolver.get_icon_pixbuf(app_name.lower(), 48)
|
||||
|
||||
results.append(
|
||||
Result(
|
||||
title=app_name,
|
||||
subtitle=subtitle,
|
||||
icon=app_icon_pixbuf,
|
||||
icon_markup=icons.screentime if not app_icon_pixbuf else None,
|
||||
action=lambda: None,
|
||||
relevance=1.0 - (i * 0.1), # Decrease relevance for lower usage
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "app_usage_item", "app": app_name}
|
||||
)
|
||||
)
|
||||
else:
|
||||
results.append(
|
||||
Result(
|
||||
title="No App Usage Data",
|
||||
subtitle="Start using applications to see usage statistics",
|
||||
icon_markup=icons.info,
|
||||
action=lambda: None,
|
||||
relevance=0.8,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "no_data"}
|
||||
)
|
||||
)
|
||||
|
||||
# Weekly summary option
|
||||
elif query in ["week", "weekly"]:
|
||||
weekly_hours = int(weekly_time // 3600)
|
||||
weekly_minutes = int((weekly_time % 3600) // 60)
|
||||
daily_avg = weekly_time / 7
|
||||
avg_hours = int(daily_avg // 3600)
|
||||
avg_minutes = int((daily_avg % 3600) // 60)
|
||||
|
||||
results.append(
|
||||
Result(
|
||||
title="Weekly Screen Time",
|
||||
subtitle=f"Total: {weekly_hours}h {weekly_minutes}m • Daily avg: {avg_hours}h {avg_minutes}m",
|
||||
icon_markup=icons.calendar,
|
||||
action=lambda: None,
|
||||
relevance=1.0,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "weekly_stats"}
|
||||
)
|
||||
)
|
||||
|
||||
# Show help if no data
|
||||
if not today_apps and not query:
|
||||
results.append(
|
||||
Result(
|
||||
title="Digital Wellbeing",
|
||||
subtitle="Start using applications to see your screen time statistics",
|
||||
icon_markup=icons.info,
|
||||
action=lambda: None,
|
||||
relevance=0.8,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "help"}
|
||||
)
|
||||
)
|
||||
|
||||
# Quick stats for specific app queries
|
||||
if query and query not in ["today", "stats", "usage", "wellbeing", "week", "weekly"]:
|
||||
matching_apps = [
|
||||
(app, time_spent) for app, time_spent in today_apps.items()
|
||||
if query in app.lower()
|
||||
]
|
||||
|
||||
for app_name, time_spent in matching_apps[:3]:
|
||||
hours = int(time_spent // 3600)
|
||||
minutes = int((time_spent % 3600) // 60)
|
||||
percentage = (time_spent / today_time * 100) if today_time > 0 else 0
|
||||
time_text = f"{hours}h {minutes}m" if hours > 0 else f"{minutes}m"
|
||||
|
||||
subtitle = f"Today: {time_text} ({percentage:.0f}%)"
|
||||
|
||||
# Get application icon pixbuf
|
||||
app_icon_pixbuf = self.icon_resolver.get_icon_pixbuf(app_name.lower(), 48)
|
||||
|
||||
results.append(
|
||||
Result(
|
||||
title=f"{app_name}",
|
||||
subtitle=subtitle,
|
||||
icon=app_icon_pixbuf,
|
||||
icon_markup=icons.screentime if not app_icon_pixbuf else None,
|
||||
action=lambda: None,
|
||||
relevance=0.9,
|
||||
plugin_name=self.display_name,
|
||||
data={"type": "app_stat", "app": app_name}
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Keyboard focus styling for header buttons */
|
||||
#close-button.focused,
|
||||
#config-button.focused {
|
||||
background-color: var(--primary);
|
||||
@@ -175,189 +174,3 @@
|
||||
border-radius: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Launcher notification widget styling */
|
||||
#launcher-notification-wrapper {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
#launcher-notification-wrapper #notification-box {
|
||||
margin: 0;
|
||||
min-width: 500px;
|
||||
}
|
||||
|
||||
#launcher-notification-actions {
|
||||
padding: 8px 16px;
|
||||
background-color: var(--surface);
|
||||
border-radius: 0 0 20px 20px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
#launcher-notification-actions #notification-action-btn {
|
||||
background-color: var(--surface-bright);
|
||||
border-radius: 12px;
|
||||
padding: 6px 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#launcher-notification-actions #notification-action-btn:hover {
|
||||
background-color: var(--red-dim);
|
||||
}
|
||||
|
||||
#launcher-notification-actions #notification-action-btn:hover #notification-action-label {
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
#launcher-notification-actions #notification-action-label {
|
||||
color: var(--red-dim);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Screen Time Widget Styling - GNOME Digital Wellbeing Style */
|
||||
#screentime-widget {
|
||||
background-color: var(--surface);
|
||||
border-radius: 20px;
|
||||
padding: 0;
|
||||
margin: 8px 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Title styling */
|
||||
.screentime-title {
|
||||
color: var(--foreground);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Large time display */
|
||||
.screentime-time-display {
|
||||
margin: 12px 0;
|
||||
font-family: "SF Pro Display", system-ui, sans-serif;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* Summary cards */
|
||||
.screentime-card {
|
||||
background-color: var(--surface-bright);
|
||||
border-radius: 12px;
|
||||
padding: 16px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.screentime-card:hover {
|
||||
background-color: var(--surface-container);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* App item styling */
|
||||
.screentime-app-item {
|
||||
background-color: var(--surface-bright);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.screentime-app-item:hover {
|
||||
background-color: var(--surface-container);
|
||||
}
|
||||
|
||||
/* Grid card styling - compact for launcher */
|
||||
.screentime-grid-card {
|
||||
background-color: var(--surface-bright);
|
||||
border-radius: 8px;
|
||||
padding: 6px 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
min-width: 80px;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.screentime-grid-card:hover {
|
||||
background-color: var(--surface-container);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Grid icon styling */
|
||||
.screentime-grid-icon {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-bright));
|
||||
border-radius: 8px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* App icon placeholder */
|
||||
.screentime-app-icon {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-bright));
|
||||
border-radius: 8px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* Progress bar container */
|
||||
.screentime-progress-container {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Modern progress bars */
|
||||
.screentime-progress {
|
||||
border-radius: 3px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.screentime-progress trough {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
min-height: 6px;
|
||||
}
|
||||
|
||||
.screentime-progress progress {
|
||||
border-radius: 3px;
|
||||
min-height: 6px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Modern usage intensity colors */
|
||||
.screentime-progress.high-usage progress {
|
||||
background: linear-gradient(90deg, #ff6b6b, #ee5a52);
|
||||
}
|
||||
|
||||
.screentime-progress.medium-usage progress {
|
||||
background: linear-gradient(90deg, #ffd93d, #ffb700);
|
||||
}
|
||||
|
||||
.screentime-progress.low-usage progress {
|
||||
background: linear-gradient(90deg, #51cf66, #37b24d);
|
||||
}
|
||||
|
||||
/* Scrolled window styling */
|
||||
#screentime-widget scrolledwindow {
|
||||
border-radius: 12px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#screentime-widget scrolledwindow scrollbar {
|
||||
background-color: transparent;
|
||||
border-radius: 6px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
#screentime-widget scrolledwindow scrollbar slider {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
min-width: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#screentime-widget scrolledwindow scrollbar slider:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
#screentime-widget label {
|
||||
color: var(--foreground);
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
#osd {
|
||||
background: var(--shadow);
|
||||
padding: 12px 20px;
|
||||
margin-bottom: 60px;
|
||||
margin: 70px;
|
||||
}
|
||||
|
||||
#osd scale {
|
||||
|
||||
+1
-5
@@ -1,8 +1,4 @@
|
||||
/* #application-switcher {
|
||||
background-color: var(--shadow);
|
||||
} */
|
||||
|
||||
#application-switcher-container{
|
||||
#application-switcher-container {
|
||||
background: var(--shadow);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user