💅 style(launcher, osd, switcher): update spacing and background for better consistency with GNOME design guidelines

This commit is contained in:
sankalp committed 2025-06-29 11:21:05 +05:45
1 parent ca4ac4ffad
commit 56a5a738df
8 files changed
+385 -2397

No files matched your search

+383
View File
@@ -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()
-407
View File
@@ -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
-187
View File
@@ -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
View File
@@ -1,7 +1,7 @@
#osd {
background: var(--shadow);
padding: 12px 20px;
margin-bottom: 60px;
margin: 70px;
}
#osd scale {
+1 -5
View File
@@ -1,8 +1,4 @@
/* #application-switcher {
background-color: var(--shadow);
} */
#application-switcher-container{
#application-switcher-container {
background: var(--shadow);
}