diff --git a/config/hypr/colors.conf b/config/hypr/colors.conf index ff92bcb..0721802 100644 --- a/config/hypr/colors.conf +++ b/config/hypr/colors.conf @@ -1,27 +1,27 @@ -$wallpaper = /home/sankalp/Pictures/wallpapers/-13.webp -$background = 191114 -$foreground = eedfe3 +$wallpaper = /home/sankalp/Pictures/wallpapers/155.jpg +$background = 101418 +$foreground = e0e2e8 -$primary = fdb0d4 -$secondary = e0bdcb -$tertiary = f2bb9a -$surface = 191114 -$surface_bright = 40373a -$outline = 9d8d92 +$primary = 99ccfa +$secondary = b8c8da +$tertiary = d2bfe7 +$surface = 101418 +$surface_bright = 36393e +$outline = 8c9198 $error = ff5945 $shadow = 000000 $red = ffb2b9 -$green = b7d085 -$yellow = dec56e -$blue = cebdfe -$magenta = f7b1de +$green = 95d5a7 +$yellow = b8cf84 +$blue = afc6ff +$magenta = e4b7f3 $cyan = 82d3e2 $white = 82d3e0 $red_dim = ff4c5c -$green_dim = 89ad42 -$yellow_dim = bb9b2b -$blue_dim = 8359fc -$magenta_dim = ee54b7 +$green_dim = 4cb86a +$yellow_dim = 8baa43 +$blue_dim = 497dff +$magenta_dim = c360e4 $cyan_dim = 30b5ce diff --git a/config/hypr/hyprlock.conf b/config/hypr/hyprlock.conf index 50adaaf..7da736f 100644 --- a/config/hypr/hyprlock.conf +++ b/config/hypr/hyprlock.conf @@ -14,7 +14,9 @@ background { # GENERAL general { + no_fade_in = false grace = 0 + disable_loading_bar = true hide_cursor = true } diff --git a/config/launcher.json b/config/launcher.json index 5cd51e4..1ac2dba 100644 --- a/config/launcher.json +++ b/config/launcher.json @@ -1,53 +1,85 @@ { - "launcher_config": { - "clip": { - "icon": "clipboard", - "description": "Clipboard - Search and manage clipboard history" + "launcher_config": { + "emoji": { + "icon": "emoji", + "description": "Emoji - Search and copy emojis" + }, + ";": { + "icon": "emoji", + "description": "Emoji - Search and copy emojis" + }, + "clip": { + "icon": "clipboard", + "description": "Clipboard - Search and manage clipboard history" + }, + "=": { + "examples": [ + "= 10*5", + "= 2^8", + "= sin(30)" + ], + "icon": "calculator", + "description": "Quick Math - Fast mathematical expressions" + }, + "app": { + "icon": "apps", + "description": "Applications - Launch installed applications" + }, + "file": { + "icon": "file", + "description": "Files - Search for files and documents" + }, + "find": { + "icon": "file", + "description": "Find - Locate files and folders" + }, + "bin": { + "icon": "terminal", + "description": "Bins - Search and run executable binaries" + }, + "power": { + "icon": "shutdown", + "description": "Power - System power management and session control" + }, + "caffeine": { + "examples": [ + "caffeine 30m", + "caffeine 1h", + "caffeine 2h", + "caffeine on" + ], + "icon": "coffee", + "description": "Caffeine - Prevent system from going idle" + }, + "sc": { + "icon": "camera-video-symbolic", + "description": "Screencapture - Record screen and audio" + }, + "wall": { + "icon": "wallpapers", + "description": "Wallpapers - Set wallpapers, and tons of features" + }, + "pomo": { + "icon": "timer_on", + "description": "Pomodoro - Productivity timer with notifications" + }, + "web": { + "icon": "world", + "description": "Web Search - Search the web using various search engines" + }, + "?": { + "icon": "world", + "description": "Quick Web Search - Fast web search with question mark" + }, + "kanban": { + "icon": "kanban", + "description": "Kanban - Task management with kanban-style columns" + } }, - "=": { - "examples": ["= 10*5", "= 2^8", "= sin(30)"], - "icon": "calculator", - "description": "Quick Math - Fast mathematical expressions" - }, - "app": { - "icon": "apps", - "description": "Applications - Launch installed applications" - }, - "file": { - "icon": "file", - "description": "Files - Search for files and documents" - }, - "find": { - "icon": "apps", - "description": "Find - Locate files and folders" - }, - "/": { - "icon": "file", - "description": "Root Path - Navigate from filesystem root" - }, - "bin": { - "icon": "terminal", - "description": "System Control - Quick system actions and power management" - }, - "power": { - "icon": "shutdown", - "description": "Power - System power management and session control" - }, - "caffeine": { - "examples": [ - "caffeine 30m", - "caffeine 1h", - "caffeine 2h", - "caffeine indefinite" - ], - "icon": "coffee", - "description": "Caffeine - Prevent system from going idle" + "settings": { + "max_examples_shown": 2, + "default_icon": "apps", + "fallback_example_template": "{trigger} ", + "config_version": "1.0" } - }, - "settings": { - "max_examples_shown": 2, - "default_icon": "apps", - "fallback_example_template": "{trigger} ", - "config_version": "1.0" - } } diff --git a/main.css b/main.css index f37c7db..9922d1e 100644 --- a/main.css +++ b/main.css @@ -6,7 +6,8 @@ @import url("./styles/workspaces.css"); @import url("./styles/switcher.css"); @import url("./styles/osd.css"); -@import url("./styles/launcher_simple.css"); +@import url("./styles/launcher.css"); +@import url("./styles/kanban.css"); * { all: unset; @@ -47,3 +48,20 @@ tooltip > * { padding: 6px 10px; border-radius: 10px; } + +#workspaces, +#workspaces-container, +#launcher, +#date-time, +#music-player, +#osd, +#tray, +#battery, +#controls, +#notification-stack-box, +#metrics, +#language, +#applications-dock, +#dock { + box-shadow: 0 0 3px alpha(black, 0.7); +} diff --git a/main.py b/main.py index b980135..53569d1 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ from modules.dock.main import Dock from modules.switcher import ApplicationSwitcher from modules.osd import OSD from modules.launcher import Launcher +from services.screencapture import ScreenCapture gi.require_version("GLib", "2.0") @@ -48,6 +49,7 @@ if __name__ == "__main__": switcher = ApplicationSwitcher() osd = OSD() launcher = Launcher() + sc = ScreenCapture() # Set corners visibility based on config corners_visible = config.get("corners_visible", True) corners.set_visible(corners_visible) diff --git a/modules/dock/components/music_player.py b/modules/dock/components/music_player.py index c9a6787..691deda 100644 --- a/modules/dock/components/music_player.py +++ b/modules/dock/components/music_player.py @@ -164,13 +164,13 @@ class MusicPlayer(EventBox): with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file: temp_file.write(response.content) temp_path = temp_file.name - + # Load the image from the temporary file pixbuf = GdkPixbuf.Pixbuf.new_from_file(temp_path) self.album_thumbnail.set_image_from_pixbuf(pixbuf) self._show_album_art() self._last_art_url = art_url - + # Clean up the temporary file os.unlink(temp_path) else: @@ -463,7 +463,7 @@ class MusicPlayer(EventBox): self._marquee_timeout_id = None if self._title_needs_marquee: - self._marquee_timeout_id = GLib.timeout_add(250, self._animate_marquee) + self._marquee_timeout_id = GLib.timeout_add(250, self._animate_marquee) def _animate_marquee(self): """Animate the marquee scrolling""" @@ -568,8 +568,7 @@ class MusicPlayer(EventBox): def _on_music_player_clicked(self, _widget, _event): """Handle music player click to toggle between text and visualizer view""" - # Only allow toggling if we have a current player and the click is not on the album thumbnail or player indicators - if self._current_player and _event.get_source() not in [self.album_thumbnail, self.player_indicators_box]: + if self._current_player: self._show_visualizer = not self._show_visualizer self._update_stack_display() return True diff --git a/modules/launcher/main.py b/modules/launcher/main.py index afe06e1..95aac5a 100644 --- a/modules/launcher/main.py +++ b/modules/launcher/main.py @@ -1,47 +1,45 @@ -""" -Main launcher window implementation. -Provides a search interface with plugin-based results. -""" +from typing import List -import gi -from typing import List, Optional -from fabric.widgets.window import Window +from fabric.core.service import Property from fabric.widgets.box import Box from fabric.widgets.entry import Entry -from fabric.widgets.label import Label from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.core.service import Property -from utils.wayland import WaylandWindow -from .plugin_manager import PluginManager -from .result_item import ResultItem -from .result import Result -from .trigger_config import TriggerConfig - -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk, GLib +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from fabric.utils import exec_shell_command_async, get_relative_path +from gi.repository import Gdk, GLib +from modules.launcher.plugin_manager import PluginManager +from modules.launcher.result import Result +from modules.launcher.result_item import ResultItem +from modules.launcher.trigger_config import TriggerConfig +from utils.wayland import WaylandWindow as Window +import utils.icons as icons -class Launcher(WaylandWindow): +class Launcher(Window): """ Main launcher window with search functionality and plugin system. Similar to Albert Launcher interface. """ - + # Properties query = Property(str, flags="read-write", default_value="") visible = Property(bool, flags="read-write", default_value=False) active_trigger = Property(str, flags="read-write", default_value="") - + def __init__(self, **kwargs): super().__init__( name="launcher-window", - layer="overlay", + layer="top", anchor="center", - exclusivity="auto", - keyboard_mode="on-demand", + exclusivity="none", + keyboard_mode="exclusive", **kwargs, ) - + + # Initialization flag to prevent callbacks during setup + self._initializing = True + # Initialize plugin manager self.plugin_manager = PluginManager() @@ -56,48 +54,64 @@ class Launcher(WaylandWindow): # Trigger system self.triggered_plugin = None # Currently active triggered plugin - # Setup UI - self._setup_ui() - - # Connect signals - self.connect("key-press-event", self._on_key_press) - - - # Initially hide - self.hide() - - def _setup_ui(self): - """Setup the launcher UI components.""" - # Main container main_box = Box( - name="launcher-main", + name="launcher", orientation="v", spacing=0, h_align="center", v_align="center", ) self.add(main_box) - + # Search entry with overlay for trigger indication self.search_entry = Entry( name="launcher-search", placeholder="Type to search...", h_expand=True, + h_align="fill", + notify_text=lambda entry, *_: self._on_search_changed(entry), ) self.search_entry.connect("changed", self._on_search_changed) self.search_entry.connect("activate", self._on_entry_activate) - main_box.add(self.search_entry) - + + self.header_box = Box( + name="header_box", + spacing=10, + orientation="h", + children=[ + Button( + name="config-button", + child=Label(name="config-label", markup=icons.config), + on_clicked=lambda *_: ( + exec_shell_command_async( + f"python {get_relative_path('../../config/config.py')}" + ), + self.close_launcher(), + ), + ), + self.search_entry, + Button( + name="close-button", + child=Label(name="close-label", markup=icons.cancel), + tooltip_text="Exit", + on_clicked=lambda *_: self.close_launcher(), + ), + ], + ) + + main_box.add(self.header_box) + # Results container self.results_scroll = ScrolledWindow( name="launcher-results-scroll", h_scrollbar_policy="never", - v_scrollbar_policy="automatic", - min_content_size=(-1, 550), - max_content_size=(-1, 550), + min_content_size=(550, 260), + max_content_size=(550, 260), + propagate_width=False, + propagate_height=False, ) - + self.results_box = Box( name="launcher-results", orientation="v", @@ -109,16 +123,26 @@ class Launcher(WaylandWindow): # Initially hide the results container self.results_scroll.hide() - + self.connect("key-press-event", self._on_key_press) + self.hide() + + # Mark initialization as complete + self._initializing = False + + # Hide trigger suggestions at startup + self._clear_results() + def show_launcher(self): """Show the launcher and focus the search entry.""" self.show_all() - # Show available triggers when first opened - self._show_available_triggers() + self.search_entry.set_text("") self.search_entry.grab_focus() + # Do not show available triggers at startup + # self._show_available_triggers() + self._clear_results() self.visible = True - - def hide_launcher(self): + + def close_launcher(self): """Hide the launcher and clear search.""" self.hide() self.search_entry.set_text("") @@ -126,19 +150,30 @@ class Launcher(WaylandWindow): self.triggered_plugin = None self.active_trigger = "" self.visible = False - + def _on_search_changed(self, entry): """Handle search text changes.""" + # Skip if still initializing + if getattr(self, '_initializing', True): + return + query = entry.get_text().strip() self.query = query - if query: + # If query is exactly ':', show all triggers + if query == ":": + self._show_available_triggers() + # If query matches a trigger exactly (with or without space), do not re-show suggestions + elif any(query == trig or query == f"{trig} " for trig in [t.strip() for p in self.plugin_manager.get_active_plugins() for t in p.get_triggers()]): + # Do nothing, let the user type after the trigger + GLib.timeout_add(150, self._perform_search, query) + elif query: # Debounce search to avoid too many queries GLib.timeout_add(150, self._perform_search, query) else: - # Show available triggers when query is empty - self._show_available_triggers() - + # Hide results when query is empty + self._clear_results() + def _perform_search(self, query: str) -> bool: """Perform search across all plugins.""" # Only search if query hasn't changed @@ -157,7 +192,9 @@ class Launcher(WaylandWindow): # We're in trigger mode - search within the triggered plugin try: # Extract the search query after the trigger - remaining_query = self._extract_query_after_trigger(query, self.active_trigger) + remaining_query = self._extract_query_after_trigger( + query, self.active_trigger + ) all_results = self.triggered_plugin.query(remaining_query) except Exception as e: print(f"Error in triggered plugin {self.triggered_plugin.name}: {e}") @@ -174,15 +211,12 @@ class Launcher(WaylandWindow): # Extract search query after trigger remaining_query = self._extract_query_after_trigger(query, trigger) - if remaining_query: - # Search with remaining content - try: - all_results = triggered_plugin.query(remaining_query) - except Exception as e: - print(f"Error in triggered plugin {triggered_plugin.name}: {e}") - all_results = [] - else: - # Just trigger keyword - show empty results or trigger help + # Always call the plugin's query method, even with empty remaining query + # This allows plugins to show default options when just the trigger is typed + try: + all_results = triggered_plugin.query(remaining_query) + except Exception as e: + print(f"Error in triggered plugin {triggered_plugin.name}: {e}") all_results = [] else: # No trigger detected - try global search across all plugins @@ -204,14 +238,19 @@ class Launcher(WaylandWindow): # Check if any results have bypass_max_results flag has_bypass = any( - hasattr(r, 'data') and r.data and r.data.get('bypass_max_results') + hasattr(r, "data") and r.data and r.data.get("bypass_max_results") for r in all_results ) - # Only limit results if no bypass flag is present - if not has_bypass: - self.results = all_results[:self.max_results] + # Don't limit results for triggered plugin queries, only for global searches and trigger suggestions + if self.triggered_plugin and self.active_trigger: + # In trigger mode - show all results from the triggered plugin + self.results = all_results + elif not has_bypass: + # Global search or trigger suggestions - apply max_results limit + self.results = all_results[: self.max_results] else: + # Has bypass flag - show all results self.results = all_results self.selected_index = 0 @@ -238,8 +277,8 @@ class Launcher(WaylandWindow): trigger_lower = trigger.lower() # Handle trigger with space (e.g., "app ") - if trigger_lower.endswith(' ') and query_lower.startswith(trigger_lower): - return query[len(trigger):].strip() + if trigger_lower.endswith(" ") and query_lower.startswith(trigger_lower): + return query[len(trigger) :].strip() # Handle trigger without space (e.g., "app") trigger_word = trigger.strip().lower() @@ -247,11 +286,11 @@ class Launcher(WaylandWindow): # Check if it's followed by space or end of string if len(query) == len(trigger_word): return "" - elif len(query) > len(trigger_word) and query[len(trigger_word)] == ' ': - return query[len(trigger_word) + 1:].strip() + elif len(query) > len(trigger_word) and query[len(trigger_word)] == " ": + return query[len(trigger_word) + 1 :].strip() elif len(query) > len(trigger_word): # No space after trigger word, extract rest - return query[len(trigger_word):].strip() + return query[len(trigger_word) :].strip() return "" @@ -280,7 +319,7 @@ class Launcher(WaylandWindow): def _show_available_triggers(self): """Show available triggers when launcher is first opened.""" trigger_suggestions = self._get_trigger_suggestions("") - self.results = trigger_suggestions[:self.max_results] + self.results = trigger_suggestions # Show all available triggers without limit self.selected_index = 0 self._update_results_display() @@ -328,9 +367,9 @@ class Launcher(WaylandWindow): trigger_clean = trigger.strip() if trigger_clean not in all_triggers: all_triggers[trigger_clean] = { - 'plugin': plugin, - 'trigger': trigger, - 'examples': [] + "plugin": plugin, + "trigger": trigger, + "examples": [], } # Get max examples to show from configuration @@ -343,16 +382,21 @@ class Launcher(WaylandWindow): if trigger_clean.lower().startswith(query_lower): examples = self.trigger_config.get_trigger_examples(trigger_clean) icon_name = self.trigger_config.get_trigger_icon(trigger_clean) - description = self.trigger_config.get_trigger_description(trigger_clean) + description = self.trigger_config.get_trigger_description( + trigger_clean + ) # Create result for this trigger result = Result( title=f"{trigger_clean}", - subtitle=f"{description} - {', '.join(examples[:max_examples])}", + subtitle=f"{description} - { + ', '.join(examples[:max_examples]) + }", icon_markup=icon_name, action=lambda t=trigger_clean: self._activate_trigger(t), - relevance=100 - len(trigger_clean), # Shorter triggers get higher relevance - data={"type": "trigger_suggestion", "trigger": trigger_clean} + # Shorter triggers get higher relevance + relevance=100 - len(trigger_clean), + data={"type": "trigger_suggestion", "trigger": trigger_clean}, ) suggestions.append(result) else: @@ -368,12 +412,13 @@ class Launcher(WaylandWindow): subtitle=f"{description} - {', '.join(examples[:max_examples])}", icon_markup=icon_name, action=lambda t=trigger_clean: self._activate_trigger(t), - relevance=100 - len(trigger_clean), # Shorter triggers get higher relevance - data={"type": "trigger_suggestion", "trigger": trigger_clean} + # Shorter triggers get higher relevance + relevance=100 - len(trigger_clean), + data={"type": "trigger_suggestion", "trigger": trigger_clean}, ) suggestions.append(result) - return suggestions[:self.max_results] + return suggestions # Return all trigger suggestions without limit def _activate_trigger(self, trigger: str): """ @@ -385,7 +430,15 @@ class Launcher(WaylandWindow): # Set the trigger text in the search entry trigger_text = f"{trigger} " self.search_entry.set_text(trigger_text) - self.search_entry.set_position(-1) # Move cursor to end + self.search_entry.grab_focus() + + def clear_selection(): + if hasattr(self.search_entry, 'set_position'): + self.search_entry.set_position(-1) # Move caret to end + if hasattr(self.search_entry, 'select_region'): + self.search_entry.select_region(len(trigger_text), len(trigger_text)) # No selection + return False # Only run once + GLib.idle_add(clear_selection) # Manually set the trigger mode to avoid search processing issues triggered_plugin, detected_trigger = self._detect_trigger(trigger_text) @@ -405,6 +458,10 @@ class Launcher(WaylandWindow): def _update_results_display(self): """Update the results display.""" + # Skip if still initializing or results_box not ready + if getattr(self, '_initializing', True) or not hasattr(self, 'results_box'): + return + # Update input field with trigger indication (Albert-style) self._update_input_action_text() @@ -414,12 +471,24 @@ class Launcher(WaylandWindow): # Add new results for i, result in enumerate(self.results): - result_item = ResultItem( - result=result, - selected=(i == self.selected_index) - ) - result_item.clicked.connect(lambda _, idx=i: self._on_result_clicked(result_item, idx)) - self.results_box.add(result_item) + # Check if this result has a custom widget + if result.custom_widget: + # Ensure the widget is not already parented + parent = result.custom_widget.get_parent() + if parent: + parent.remove(result.custom_widget) + + result.custom_widget.show_all() # Ensure widget is visible + self.results_box.add(result.custom_widget) + else: + # Create normal result item + result_item = ResultItem( + result=result, selected=(i == self.selected_index) + ) + result_item.clicked.connect( + lambda _, idx=i: self._on_result_clicked(result_item, idx) + ) + self.results_box.add(result_item) self.results_box.show_all() @@ -431,6 +500,10 @@ class Launcher(WaylandWindow): def _update_input_action_text(self): """Update the input field with action text (Albert-style).""" + # Check if search_entry is initialized + if not hasattr(self, 'search_entry') or self.search_entry is None: + return + # Get the current input text current_text = self.search_entry.get_text() @@ -441,29 +514,43 @@ class Launcher(WaylandWindow): action_text = first_result.title # For calculator results, show the evaluation - if hasattr(first_result, 'data') and 'result' in first_result.data: - action_text = str(first_result.data['result']) + if hasattr(first_result, "data") and "result" in first_result.data: + action_text = str(first_result.data["result"]) # Set placeholder to show the action text if action_text and action_text != current_text: - self.search_entry.set_placeholder_text(f"{current_text} → {action_text}") + self.search_entry.set_placeholder_text( + f"{current_text} → {action_text}" + ) else: - self.search_entry.set_placeholder_text(f"[{self.active_trigger.strip()}] searching...") + self.search_entry.set_placeholder_text( + f"[{self.active_trigger.strip()}] searching..." + ) else: # In trigger mode but no results yet if current_text == self.active_trigger.strip(): # Just the trigger keyword - self.search_entry.set_placeholder_text(f"[{self.active_trigger.strip()}] ready - type to search") + self.search_entry.set_placeholder_text( + f"[{self.active_trigger.strip()}] ready - type to search" + ) else: # Searching within trigger - self.search_entry.set_placeholder_text(f"[{self.active_trigger.strip()}] searching...") + self.search_entry.set_placeholder_text( + f"[{self.active_trigger.strip()}] searching..." + ) else: # Not in trigger mode - show trigger help - if current_text: - self.search_entry.set_placeholder_text("Type trigger keyword (calc, app, file, system...)") + if current_text == ":": + self.search_entry.set_placeholder_text("Showing all available triggers.") + elif current_text: + self.search_entry.set_placeholder_text( + "Type trigger keyword (calc, app, file, system...)" + ) else: - self.search_entry.set_placeholder_text("Type trigger keyword: calc, app, file, system...") - + self.search_entry.set_placeholder_text( + "Type trigger keyword: calc, app, file, system..." + ) + def _clear_results(self): """Clear all results.""" self.results = [] @@ -471,11 +558,11 @@ class Launcher(WaylandWindow): for child in self.results_box.get_children(): self.results_box.remove(child) self.results_scroll.hide() - + def _on_key_press(self, widget, event): """Handle key press events.""" keyval = event.keyval - + # Escape - exit trigger mode or hide launcher if keyval == Gdk.KEY_Escape: if self.triggered_plugin: @@ -487,22 +574,22 @@ class Launcher(WaylandWindow): return True else: # Hide launcher - self.hide_launcher() + self.close_launcher() return True - + # Up/Down - navigate results if keyval == Gdk.KEY_Up: if self.results: self.selected_index = (self.selected_index - 1) % len(self.results) self._update_selection() return True - + if keyval == Gdk.KEY_Down: if self.results: self.selected_index = (self.selected_index + 1) % len(self.results) self._update_selection() return True - + # Enter - activate selected result if keyval == Gdk.KEY_Return: # Check for Shift+Enter to pin application @@ -515,7 +602,7 @@ class Launcher(WaylandWindow): # Normal Enter behavior self._activate_selected() return True - + # Tab - cycle through results if keyval == Gdk.KEY_Tab: if self.results: @@ -524,24 +611,21 @@ class Launcher(WaylandWindow): return True return False - + def _on_entry_activate(self, entry): """Handle entry activation (Enter key).""" self._activate_selected() - + def _on_result_clicked(self, result_item, index): """Handle result item click.""" self.selected_index = index self._activate_selected() - - def _is_mouse_over_results(self): """Check if mouse is over the results area.""" # Simple check - if we have results visible, assume user might be interacting return len(self.results) > 0 and self.results_scroll.get_visible() - def _update_selection(self): """Update the visual selection of results.""" children = self.results_box.get_children() @@ -549,10 +633,12 @@ class Launcher(WaylandWindow): for i, child in enumerate(children): if isinstance(child, ResultItem): - is_selected = (i == self.selected_index) + is_selected = i == self.selected_index child.set_selected(is_selected) if is_selected: selected_widget = child + # For custom widgets, we don't need to handle selection visually + # since they manage their own interaction # Scroll to make the selected item visible if selected_widget and self.results_scroll.get_visible(): @@ -622,7 +708,11 @@ class Launcher(WaylandWindow): def _ensure_selected_visible(self): """Alternative method to ensure selected item is visible using GTK methods.""" - if not self.results or self.selected_index < 0 or self.selected_index >= len(self.results): + if ( + not self.results + or self.selected_index < 0 + or self.selected_index >= len(self.results) + ): return False children = self.results_box.get_children() @@ -637,8 +727,12 @@ class Launcher(WaylandWindow): if vadjustment: # Calculate the position to center the selected item page_size = vadjustment.get_page_size() - target_pos = allocation.y - (page_size / 2) + (allocation.height / 2) - target_pos = max(0, min(target_pos, vadjustment.get_upper() - page_size)) + target_pos = ( + allocation.y - (page_size / 2) + (allocation.height / 2) + ) + target_pos = max( + 0, min(target_pos, vadjustment.get_upper() - page_size) + ) vadjustment.set_value(target_pos) except Exception as e: print(f"Error in _ensure_selected_visible: {e}") @@ -650,10 +744,15 @@ class Launcher(WaylandWindow): if self.results and 0 <= self.selected_index < len(self.results): result = self.results[self.selected_index] try: + # Check if this result has a custom widget + if result.custom_widget: + # For custom widgets, we don't activate them since they're already displayed + # The widget handles its own interactions + return + # Check if this is a trigger suggestion is_trigger_suggestion = ( - result.data and - result.data.get("type") == "trigger_suggestion" + result.data and result.data.get("type") == "trigger_suggestion" ) # Activate the result @@ -661,7 +760,7 @@ class Launcher(WaylandWindow): # Only hide launcher for non-trigger suggestions if not is_trigger_suggestion: - self.hide_launcher() + self.close_launcher() # For trigger suggestions, the launcher stays open and # the user can continue typing after the trigger diff --git a/modules/launcher/plugin_base.py b/modules/launcher/plugin_base.py index d63ca88..5e68d79 100644 --- a/modules/launcher/plugin_base.py +++ b/modules/launcher/plugin_base.py @@ -4,7 +4,8 @@ Base class for launcher plugins. from abc import ABC, abstractmethod from typing import List -from .result import Result + +from modules.launcher.result import Result class PluginBase(ABC): @@ -12,7 +13,7 @@ class PluginBase(ABC): Abstract base class for launcher plugins. All plugins must inherit from this class. """ - + def __init__(self): self.name = self.__class__.__name__.lower() self.display_name = self.__class__.__name__ @@ -20,7 +21,7 @@ class PluginBase(ABC): self.version = "1.0.0" self.enabled = True self._triggers = [] # List of trigger keywords - + @abstractmethod def initialize(self): """ @@ -28,7 +29,7 @@ class PluginBase(ABC): Called when the plugin is activated. """ pass - + @abstractmethod def cleanup(self): """ @@ -36,20 +37,20 @@ class PluginBase(ABC): Called when the plugin is deactivated. """ pass - + @abstractmethod def query(self, query_string: str) -> List[Result]: """ Process a search query and return results. - + Args: query_string: The search query from the user - + Returns: List of Result objects """ pass - + def get_triggers(self) -> List[str]: """ Get list of trigger keywords for this plugin. @@ -68,7 +69,7 @@ class PluginBase(ABC): triggers: List of trigger keywords """ self._triggers = triggers - + def handles_query(self, query_string: str) -> bool: """ Check if this plugin should handle the given query. @@ -120,8 +121,10 @@ class PluginBase(ABC): # Match trigger word followed by space trigger_word = trigger.strip().lower() - if (query_lower.startswith(trigger_word + " ") or - query_lower == trigger_word): + if ( + query_lower.startswith(trigger_word + " ") + or query_lower == trigger_word + ): return trigger return "" @@ -139,13 +142,13 @@ class PluginBase(ABC): List of Result objects """ # Remove trigger from query and process remaining text - remaining_query = query_string[len(trigger):].strip() + remaining_query = query_string[len(trigger) :].strip() return self.query(remaining_query) - + def get_config(self) -> dict: """ Get plugin configuration. - + Returns: Dictionary of configuration options """ @@ -156,18 +159,18 @@ class PluginBase(ABC): "version": self.version, "enabled": self.enabled, } - + def set_config(self, config: dict): """ Set plugin configuration. - + Args: config: Dictionary of configuration options """ self.enabled = config.get("enabled", self.enabled) - + def __str__(self): return f"Plugin({self.name})" - + def __repr__(self): return self.__str__() diff --git a/modules/launcher/plugin_manager.py b/modules/launcher/plugin_manager.py index 837007e..125a2bf 100644 --- a/modules/launcher/plugin_manager.py +++ b/modules/launcher/plugin_manager.py @@ -3,10 +3,11 @@ Plugin manager for the launcher. Handles loading, managing, and executing plugins. """ -import os import importlib import importlib.util -from typing import List, Dict, Type +import os +from typing import Dict, List, Type + from modules.launcher.plugin_base import PluginBase @@ -14,21 +15,21 @@ class PluginManager: """ Manages launcher plugins. """ - + def __init__(self): self.plugins: Dict[str, PluginBase] = {} self.plugin_classes: Dict[str, Type[PluginBase]] = {} self.active_plugins: List[str] = [] - + # Load built-in plugins self._load_builtin_plugins() - + # Load external plugins self._load_external_plugins() - + # Activate default plugins self._activate_default_plugins() - + def _load_builtin_plugins(self): """Load built-in plugins from the plugins directory.""" plugins_dir = os.path.join(os.path.dirname(__file__), "plugins") @@ -40,23 +41,26 @@ class PluginManager: if filename.endswith(".py") and not filename.startswith("_"): plugin_name = filename[:-3] # Remove .py extension self._load_plugin_from_file(plugins_dir, plugin_name) - + def _load_external_plugins(self): """Load external plugins from user directory.""" # Could be implemented to load from ~/.config/launcher/plugins/ pass - + def _load_plugin_from_file(self, plugins_dir: str, plugin_name: str): """Load a plugin from a Python file.""" try: plugin_path = os.path.join(plugins_dir, f"{plugin_name}.py") - spec = importlib.util.spec_from_file_location(f"modules.launcher.plugins.{plugin_name}", plugin_path) + spec = importlib.util.spec_from_file_location( + f"modules.launcher.plugins.{plugin_name}", plugin_path + ) if spec and spec.loader: module = importlib.util.module_from_spec(spec) # Add the module to sys.modules to support relative imports import sys + sys.modules[f"modules.launcher.plugins.{plugin_name}"] = module spec.loader.exec_module(module) @@ -64,113 +68,118 @@ class PluginManager: # Look for plugin class for attr_name in dir(module): attr = getattr(module, attr_name) - if (isinstance(attr, type) and - issubclass(attr, PluginBase) and - attr != PluginBase): - + if ( + isinstance(attr, type) + and issubclass(attr, PluginBase) + and attr != PluginBase + ): self.plugin_classes[plugin_name] = attr - print(f"Loaded plugin class: {plugin_name}") break except Exception as e: print(f"Failed to load plugin {plugin_name}: {e}") - + def _activate_default_plugins(self): """Activate default plugins.""" default_plugins = [ "applications", - "files", + "files", "calculator", "system", "clipboard", "power", "caffeine", + "screencapture", + "emoji", + "wallpaper", + "pomodoro", + "websearch", + "kanban", ] - + for plugin_name in default_plugins: self.activate_plugin(plugin_name) - + def activate_plugin(self, plugin_name: str) -> bool: """Activate a plugin by name.""" if plugin_name in self.plugins: # Already activated return True - + if plugin_name not in self.plugin_classes: - print(f"Plugin {plugin_name} not found") return False - + try: # Instantiate plugin plugin_class = self.plugin_classes[plugin_name] plugin_instance = plugin_class() - + # Initialize plugin plugin_instance.initialize() - + # Store plugin self.plugins[plugin_name] = plugin_instance self.active_plugins.append(plugin_name) - - print(f"Activated plugin: {plugin_name}") + return True - + except Exception as e: print(f"Failed to activate plugin {plugin_name}: {e}") return False - + def deactivate_plugin(self, plugin_name: str) -> bool: """Deactivate a plugin by name.""" if plugin_name not in self.plugins: return False - + try: # Cleanup plugin plugin = self.plugins[plugin_name] plugin.cleanup() - + # Remove from active plugins del self.plugins[plugin_name] if plugin_name in self.active_plugins: self.active_plugins.remove(plugin_name) - - print(f"Deactivated plugin: {plugin_name}") + return True - + except Exception as e: print(f"Failed to deactivate plugin {plugin_name}: {e}") return False - + def get_active_plugins(self) -> List[PluginBase]: """Get list of active plugin instances.""" - return [self.plugins[name] for name in self.active_plugins if name in self.plugins] - + return [ + self.plugins[name] for name in self.active_plugins if name in self.plugins + ] + def get_plugin_names(self) -> List[str]: """Get list of available plugin names.""" return list(self.plugin_classes.keys()) - + def get_active_plugin_names(self) -> List[str]: """Get list of active plugin names.""" return self.active_plugins.copy() - + def reload_plugin(self, plugin_name: str) -> bool: """Reload a plugin.""" was_active = plugin_name in self.active_plugins - + # Deactivate if active if was_active: self.deactivate_plugin(plugin_name) - + # Remove from classes if plugin_name in self.plugin_classes: del self.plugin_classes[plugin_name] - + # Reload from file plugins_dir = os.path.join(os.path.dirname(__file__), "plugins") self._load_plugin_from_file(plugins_dir, plugin_name) - + # Reactivate if it was active if was_active and plugin_name in self.plugin_classes: return self.activate_plugin(plugin_name) - + return plugin_name in self.plugin_classes diff --git a/modules/launcher/plugins/applications.py b/modules/launcher/plugins/applications.py index 2df9f11..76cb81f 100644 --- a/modules/launcher/plugins/applications.py +++ b/modules/launcher/plugins/applications.py @@ -1,22 +1,15 @@ -""" -Applications plugin for the launcher. -Searches and launches desktop applications. -""" - -import re import json +import re from typing import List + +from fabric.utils import DesktopApp from fabric.utils.helpers import get_desktop_applications, get_relative_path -from ..plugin_base import PluginBase -from ..result import Result from modules.dock.main import Dock +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result class ApplicationsPlugin(PluginBase): - """ - Plugin for searching and launching desktop applications. - """ - def __init__(self): super().__init__() self.display_name = "Applications" @@ -24,21 +17,16 @@ class ApplicationsPlugin(PluginBase): self.applications = [] def initialize(self): - """Initialize the applications plugin.""" # Set up triggers for applications - both with and without spaces self.set_triggers(["app", "app "]) - print("Initializing Applications plugin...") self._load_applications() def cleanup(self): - """Cleanup the applications plugin.""" self.applications = [] def _load_applications(self): - """Load all desktop applications.""" try: self.applications = get_desktop_applications(include_hidden=False) - print(f"Loaded {len(self.applications)} applications") except Exception as e: print(f"Failed to load applications: {e}") self.applications = [] @@ -99,16 +87,21 @@ class ApplicationsPlugin(PluginBase): for app in self.applications: relevance = self._calculate_relevance(app, query) if relevance > 0: + # Truncate description to prevent overflow beyond 550px window + description = app.description or app.generic_name or "" + if len(description) > 80: # Limit to ~80 characters to fit in 550px + description = description[:70] + "..." + result = Result( title=app.display_name or app.name, - subtitle=app.description or app.generic_name or "", + subtitle=description, icon=app.get_icon_pixbuf(size=48), action=lambda a=app: self._launch_application(a), relevance=relevance, plugin_name=self.display_name, data={ "app": app, - "pin_action": lambda a=app: self._pin_application(a) + "pin_action": lambda a=app: self._pin_application(a), }, ) results.append(result) @@ -167,29 +160,5 @@ class ApplicationsPlugin(PluginBase): pattern = ".*".join(re.escape(char) for char in query) return bool(re.search(pattern, text, re.IGNORECASE)) - def _launch_application(self, app): - """Launch a desktop application.""" - try: - success = app.launch() - if not success: - # Fallback to command line execution - if app.command_line: - import subprocess - - subprocess.Popen( - app.command_line, - shell=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - elif app.executable: - import subprocess - - subprocess.Popen( - [app.executable], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - print(f"Launched application: {app.name}") - except Exception as e: - print(f"Failed to launch application {app.name}: {e}") + def _launch_application(self, app: DesktopApp): + app.launch() diff --git a/modules/launcher/plugins/caffeine.py b/modules/launcher/plugins/caffeine.py index 80673f9..87c5420 100644 --- a/modules/launcher/plugins/caffeine.py +++ b/modules/launcher/plugins/caffeine.py @@ -4,27 +4,28 @@ Prevents system from going idle for a specified duration. """ import subprocess -import os from typing import List + import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GdkPixbuf, GLib -from ..plugin_base import PluginBase -from ..result import Result -from fabric.widgets.label import Label import utils.icons as icons +from fabric.utils import get_relative_path +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + +gi.require_version("Gtk", "3.0") + class CaffeinePlugin(PluginBase): """ Plugin for preventing system idle using the inhibit script. """ - + def __init__(self): super().__init__() self.display_name = "Caffeine" self.description = "Prevent system from going idle" - self.inhibit_script = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), "utils", "inhibit.py") - + self.inhibit_script = get_relative_path("../../../utils/inhibit.py") + # Predefined durations self.durations = { "30m": "30 minutes", @@ -32,8 +33,8 @@ class CaffeinePlugin(PluginBase): "2h": "2 hours", "4h": "4 hours", "8h": "8 hours", - "indefinite": "Indefinitely", - "off": "Off" + "on": "On", + "off": "Off", } def initialize(self): @@ -46,6 +47,7 @@ class CaffeinePlugin(PluginBase): def _create_inhibit_action(self, duration: str): """Create an inhibit action that properly captures the duration.""" + def action(): try: # Run the script in the background without waiting @@ -53,10 +55,11 @@ class CaffeinePlugin(PluginBase): ["python3", self.inhibit_script, duration], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - start_new_session=True # Run in a new session to prevent hanging + start_new_session=True, # Run in a new session to prevent hanging ) except Exception as e: print(f"Error starting inhibit script: {e}") + return action def _is_valid_duration(self, query: str) -> bool: @@ -65,7 +68,7 @@ class CaffeinePlugin(PluginBase): return True if query.isdigit(): return True - if query.endswith(('h', 'm', 's')) and query[:-1].replace('.', '').isdigit(): + if query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit(): return True return False @@ -77,8 +80,9 @@ class CaffeinePlugin(PluginBase): def query(self, query_string: str) -> List[Result]: """Search caffeine durations based on query.""" + # For empty queries, show all available durations if not query_string.strip(): - return [] + query_string = "" # Will match all durations in the loop below query = query_string.lower().strip() results = [] @@ -86,15 +90,32 @@ class CaffeinePlugin(PluginBase): # Handle direct search entry (e.g., "caffeine 30m" or just "30m") if query.startswith("caffeine "): query = query[9:].strip() # Remove "caffeine " prefix - elif query in self.durations or (query.endswith(('h', 'm', 's')) and query[:-1].replace('.', '').isdigit()): + # If query becomes empty after removing prefix, show all durations + if not query: + query = "" # Will match all durations in the loop below + elif query == "caffeine": + # Handle just "caffeine" without space - show all durations + query = "" + elif not query: + # Handle empty query - show all durations + pass + elif ( + query in self.durations + or query.isdigit() + or ( + query.endswith(("h", "m", "s")) + and query[:-1].replace(".", "").isdigit() + ) + ): # If query is a valid duration without prefix, use it directly pass else: return [] - # Add custom duration option - if query.isdigit() or (query.endswith(('h', 'm', 's')) and query[:-1].replace('.', '').isdigit()): + if query.isdigit() or ( + query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit() + ): result = Result( title=f"Custom: {query}", subtitle="Set custom duration", @@ -102,21 +123,28 @@ class CaffeinePlugin(PluginBase): action=self._create_inhibit_action(query), relevance=1.0, plugin_name=self.display_name, - data={"duration": query} + data={"duration": query}, ) results.append(result) # Add predefined durations for duration, description in self.durations.items(): - if query in duration or query in description.lower(): + # If query is empty, show all durations; otherwise filter by query + if not query or query in duration or query in description.lower(): + # Special handling for "off" - it should stop inhibition + if duration.lower() == "off": + subtitle = "Stop idle inhibition" + else: + subtitle = f"Prevent idle for {description.lower()}" + result = Result( title=description, - subtitle=f"Prevent idle for {description.lower()}", + subtitle=subtitle, icon_markup=icons.coffee, action=self._create_inhibit_action(duration), relevance=0.9 if query == duration else 0.7, plugin_name=self.display_name, - data={"duration": duration} + data={"duration": duration}, ) results.append(result) @@ -124,4 +152,4 @@ class CaffeinePlugin(PluginBase): if results: results[0].default_action = self._get_default_action(query) - return results \ No newline at end of file + return results diff --git a/modules/launcher/plugins/calculator.py b/modules/launcher/plugins/calculator.py index e30e307..cbae294 100644 --- a/modules/launcher/plugins/calculator.py +++ b/modules/launcher/plugins/calculator.py @@ -3,15 +3,16 @@ Calculator plugin for the launcher. Evaluates mathematical expressions and converts units. """ -import re import math -import time +import re import subprocess -from typing import List, Dict, Tuple -from ..plugin_base import PluginBase -from ..result import Result -from utils.conversion import Conversion +import time +from typing import List + import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result +from utils.conversion import Conversion class CalculatorPlugin(PluginBase): diff --git a/modules/launcher/plugins/clipboard.py b/modules/launcher/plugins/clipboard.py index 02b6aed..78b119b 100644 --- a/modules/launcher/plugins/clipboard.py +++ b/modules/launcher/plugins/clipboard.py @@ -3,14 +3,13 @@ Clipboard plugin using cliphist for clipboard history management. """ import os -import re import subprocess import sys import tempfile from typing import List, Dict -from ..plugin_base import PluginBase -from ..result import Result -from gi.repository import GdkPixbuf, GLib, Gdk +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result +from gi.repository import GdkPixbuf, GLib class ClipboardPlugin(PluginBase): @@ -19,7 +18,6 @@ class ClipboardPlugin(PluginBase): self.name = "clipboard" self.display_name = "Clipboard History" self.description = "Search and manage clipboard history using cliphist" - self.set_triggers(["clip"]) # Initialize cache and temp directory self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-") @@ -29,6 +27,7 @@ class ClipboardPlugin(PluginBase): self._pending_updates = False def initialize(self): + self.set_triggers(["clip", "clip "]) """Initialize the plugin.""" try: subprocess.run(["cliphist", "list"], capture_output=True, check=True) @@ -154,7 +153,7 @@ class ClipboardPlugin(PluginBase): relevance=1.0, plugin_name=self.name, action=lambda id=item_id: self._copy_to_clipboard(id), - data={"bypass_max_results": True} + data={"bypass_max_results": True}, ) results.append(result) continue @@ -169,7 +168,7 @@ class ClipboardPlugin(PluginBase): relevance=1.0, plugin_name=self.name, action=lambda id=item_id: self._copy_to_clipboard(id), - data={"bypass_max_results": True} + data={"bypass_max_results": True}, ) results.append(result) @@ -182,7 +181,7 @@ class ClipboardPlugin(PluginBase): icon_name="dialog-error", relevance=0.0, plugin_name=self.name, - data={"bypass_max_results": True} + data={"bypass_max_results": True}, ) ) diff --git a/modules/launcher/plugins/emoji.py b/modules/launcher/plugins/emoji.py new file mode 100644 index 0000000..bb42dda --- /dev/null +++ b/modules/launcher/plugins/emoji.py @@ -0,0 +1,119 @@ +""" +Emoji plugin for the launcher. +Provides quick access to emojis with search functionality. +""" + +import json +import os +import subprocess +from typing import Dict, List + +from fabric.utils import get_relative_path +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class EmojiPlugin(PluginBase): + """ + Plugin for searching and copying emojis. + """ + + def __init__(self): + super().__init__() + self.name = "emoji" + self.display_name = "Emoji" + self.description = "Search and copy emojis" + self.emoji_data = {} + self.emoji_path = get_relative_path("../../../config/emoji.json") + + def initialize(self): + """Initialize the emoji plugin.""" + self.set_triggers(["emoji", "emoji ", ";", "; "]) + self._load_emoji_data() + + def cleanup(self): + """Cleanup the emoji plugin.""" + pass + + def _load_emoji_data(self): + """Load emoji data from JSON file.""" + try: + if os.path.exists(self.emoji_path): + with open(self.emoji_path, "r", encoding="utf-8") as f: + self.emoji_data = json.load(f) + else: + print(f"Emoji file not found: {self.emoji_path}") + except Exception as e: + print(f"Error loading emoji data: {e}") + + def _copy_to_clipboard(self, emoji: str): + """Copy emoji to clipboard.""" + try: + # Try Wayland first + try: + subprocess.run(["wl-copy"], input=emoji.encode(), check=True) + except subprocess.SubprocessError: + # Fall back to X11 + subprocess.run( + ["xclip", "-selection", "clipboard"], + input=emoji.encode(), + check=True, + ) + except Exception as e: + print(f"Failed to copy to clipboard: {e}") + + def query(self, query_string: str) -> List[Result]: + """Search emojis based on query.""" + results = [] + query = query_string.lower().strip() + + # If no query, show some popular emojis + if not query: + popular_emojis = ["😀", "👍", "❤️", "🎉", "🔥", "✨", "🚀", "🌈"] + for emoji in popular_emojis: + if emoji in self.emoji_data: + emoji_info = self.emoji_data[emoji] + results.append(self._create_emoji_result(emoji, emoji_info, 1.0)) + return results + + # Search by name, group, or the emoji itself + for emoji, info in self.emoji_data.items(): + relevance = 0 + name = info.get("name", "").lower() + group = info.get("group", "").lower() + slug = info.get("slug", "").lower() + + # Exact match with emoji + if query == emoji: + relevance = 1.0 + # Name contains query + elif query in name: + relevance = 0.9 + # Slug contains query + elif query in slug: + relevance = 0.8 + # Group contains query + elif query in group: + relevance = 0.7 + + if relevance > 0: + results.append(self._create_emoji_result(emoji, info, relevance)) + + # Sort by relevance + results.sort(key=lambda x: x.relevance, reverse=True) + return results[:20] # Limit to 20 results + + def _create_emoji_result(self, emoji: str, info: Dict, relevance: float) -> Result: + """Create a Result object for an emoji.""" + name = info.get("name", "") + group = info.get("group", "") + + return Result( + title=name, # Show only the name, not the emoji + subtitle=f"{group}", + icon_markup=emoji, # Use the emoji itself as the icon + action=lambda e=emoji: self._copy_to_clipboard(e), + relevance=relevance, + plugin_name=self.display_name, + data={"emoji": emoji, "name": name, "group": group}, + ) diff --git a/modules/launcher/plugins/files.py b/modules/launcher/plugins/files.py index 5dc1a11..d2e145c 100644 --- a/modules/launcher/plugins/files.py +++ b/modules/launcher/plugins/files.py @@ -64,13 +64,10 @@ class FilesPlugin(PluginBase): [ "file", "find", - "/", "file ", "find ", - "/ ", ] ) - print("Initializing Files plugin...") self._warm_cache() def cleanup(self): @@ -300,7 +297,6 @@ class FilesPlugin(PluginBase): subprocess.Popen( ["xdg-open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) - print(f"Opened: {path}") except Exception as e: print(f"Failed to open {path}: {e}") diff --git a/modules/launcher/plugins/kanban.py b/modules/launcher/plugins/kanban.py new file mode 100644 index 0000000..e05787c --- /dev/null +++ b/modules/launcher/plugins/kanban.py @@ -0,0 +1,484 @@ +""" +Kanban plugin for the launcher. +Provides a kanban board interface for task management. +""" + +import json +import os +from pathlib import Path +from typing import List + +import cairo +import gi +import utils.icons as icons +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import Gdk, GLib, GObject, Gtk +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + +gi.require_version("Gtk", "3.0") + + +def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface: + alloc = widget.get_allocation() + surface = cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height) + cr = cairo.Context(surface) + + cr.set_source_rgba(0, 0, 0, 0) + cr.rectangle(0, 0, alloc.width, alloc.height) + cr.fill() + widget.draw(cr) + return surface + + +class InlineEditor(Gtk.Box): + __gsignals__ = { + "confirmed": (GObject.SignalFlags.RUN_LAST, None, (str,)), + "canceled": (GObject.SignalFlags.RUN_LAST, None, ()), + } + + def __init__(self, initial_text=""): + super().__init__(name="inline-editor", spacing=4) + + self.text_view = Gtk.TextView() + self.text_view.set_wrap_mode(Gtk.WrapMode.WORD) + buffer = self.text_view.get_buffer() + buffer.set_text(initial_text) + + self.text_view.connect("key-press-event", self.on_key_press) + + # Create labels for buttons + confirm_label = Gtk.Label() + confirm_label.set_markup(icons.accept) + confirm_label.set_name("kanban-btn-label") + + cancel_label = Gtk.Label() + cancel_label.set_markup(icons.cancel) + cancel_label.set_name("kanban-btn-neg") + + confirm_btn = Gtk.Button() + confirm_btn.set_name("kanban-btn") + confirm_btn.add(confirm_label) + confirm_btn.connect("clicked", self.on_confirm) + confirm_btn.get_style_context().add_class("flat") + + cancel_btn = Gtk.Button() + cancel_btn.set_name("kanban-btn") + cancel_btn.add(cancel_label) + cancel_btn.connect("clicked", self.on_cancel) + cancel_btn.get_style_context().add_class("flat") + + sw = Gtk.ScrolledWindow() + sw.set_name("scrolled-window") + sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + sw.set_min_content_height(50) + sw.add(self.text_view) + + self.button_box = Gtk.Box(spacing=4) + self.button_box.pack_start(confirm_btn, False, False, 0) + self.button_box.pack_start(cancel_btn, False, False, 0) + + self.center_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.center_box.set_halign(Gtk.Align.CENTER) + self.center_box.pack_start(self.button_box, False, False, 0) + + self.pack_start(sw, True, True, 0) + self.pack_start(self.center_box, False, False, 0) + self.show_all() + + def on_confirm(self, widget): + buffer = self.text_view.get_buffer() + start, end = buffer.get_bounds() + text = buffer.get_text(start, end, True).strip() + if text: + self.emit("confirmed", text) + else: + self.emit("canceled") + + def on_cancel(self, widget): + self.emit("canceled") + + def on_key_press(self, widget, event): + if event.keyval == Gdk.KEY_Escape: + self.emit("canceled") + return True + + if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + state = event.get_state() + if state & Gdk.ModifierType.SHIFT_MASK: + buffer = self.text_view.get_buffer() + cursor_iter = buffer.get_iter_at_mark(buffer.get_insert()) + buffer.insert(cursor_iter, "\n") + return True + else: + self.on_confirm(widget) + return True + return False + + +class KanbanNote(Gtk.EventBox): + __gsignals__ = { + "changed": (GObject.SignalFlags.RUN_LAST, None, ()), + } + + def __init__(self, text): + super().__init__() + self.text = text + + self.setup_ui() + self.setup_dnd() + self.connect("button-press-event", self.on_button_press) + + def setup_ui(self): + self.box = Gtk.Box(name="kanban-note", spacing=4) + self.label = Gtk.Label(label=self.text) + self.label.set_line_wrap(True) + + self.label.set_line_wrap_mode(Gtk.WrapMode.WORD) + + self.delete_btn = Gtk.Button( + name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.trash) + ) + self.delete_btn.connect("clicked", self.on_delete_clicked) + + self.center_btn = CenterBox(orientation="v", start_children=[self.delete_btn]) + + self.box.pack_start(self.label, True, True, 0) + self.box.pack_start(self.center_btn, False, False, 0) + self.add(self.box) + self.show_all() + + def setup_dnd(self): + self.drag_source_set( + Gdk.ModifierType.BUTTON1_MASK, + [Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)], + Gdk.DragAction.MOVE, + ) + self.connect("drag-data-get", self.on_drag_data_get) + self.connect("drag-data-delete", self.on_drag_data_delete) + + self.connect("drag-begin", self.on_drag_begin) + + def on_button_press(self, widget, event): + if event.type != Gdk.EventType._2BUTTON_PRESS: + return True + self.start_edit() + return False + + def on_drag_begin(self, widget, context): + surface = createSurfaceFromWidget(self) + Gtk.drag_set_icon_surface(context, surface) + + def on_drag_data_get(self, widget, drag_context, data, info, time): + data.set_text(self.label.get_text(), -1) + + def on_drag_data_delete(self, widget, drag_context): + self.get_parent().destroy() + + def on_delete_clicked(self, button): + self.get_parent().destroy() + + def start_edit(self): + row = self.get_parent() + editor = InlineEditor(self.label.get_text()) + + def on_confirmed(editor, text): + self.label.set_text(text) + # Ensure editor is properly removed before adding self back + if editor.get_parent(): + editor.get_parent().remove(editor) + if not self.get_parent(): + row.add(self) + row.show_all() + self.emit("changed") + + def on_canceled(editor): + # Ensure editor is properly removed before adding self back + if editor.get_parent(): + editor.get_parent().remove(editor) + if not self.get_parent(): + row.add(self) + row.show_all() + + editor.connect("confirmed", on_confirmed) + editor.connect("canceled", on_canceled) + + # Only remove self if it has a parent + if self.get_parent(): + row.remove(self) + row.add(editor) + row.show_all() + + GLib.timeout_add(50, lambda: (editor.text_view.grab_focus(), False)) + + +class KanbanColumn(Gtk.Frame): + __gsignals__ = { + "changed": (GObject.SignalFlags.RUN_LAST, None, ()), + } + + def __init__(self, title): + super().__init__(name="kanban-column") + self.title = title + self.setup_ui() + self.setup_dnd() + self.set_hexpand(True) + self.set_vexpand(True) + # Set size constraints for launcher compatibility + self.set_size_request(-1, 150) # Fixed height per column + + def setup_ui(self): + self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + self.listbox = Gtk.ListBox() + self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) + + self.add_btn = Gtk.Button( + name="kanban-btn-add", + child=Label(name="kanban-btn-label", markup=icons.add), + ) + header = CenterBox( + name="kanban-header", + center_children=[Label(name="column-header", label=self.title)], + end_children=[self.add_btn], + ) + self.box.pack_start(header, False, False, 0) + + self.add_btn.connect("clicked", self.on_add_clicked) + + self.scroller = ScrolledWindow( + name="scrolled-window", propagate_height=False, propagate_width=False + ) + self.scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.scroller.add(self.listbox) + self.scroller.set_vexpand(True) + + self.box.pack_start(self.scroller, True, True, 0) + self.box.pack_start(self.add_btn, False, False, 0) + self.add(self.box) + self.show_all() + + def setup_dnd(self): + self.listbox.drag_dest_set( + Gtk.DestDefaults.ALL, + [Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)], + Gdk.DragAction.MOVE, + ) + + self.listbox.connect("drag-data-received", self.on_drag_data_received) + self.listbox.connect("drag-motion", self.on_drag_motion) + self.listbox.connect("drag-leave", self.on_drag_leave) + + def on_add_clicked(self, button): + editor = InlineEditor() + row = Gtk.ListBoxRow(name="kanban-row") + row.add(editor) + self.listbox.add(row) + self.listbox.show_all() + editor.text_view.grab_focus() + + def on_confirmed(editor, text): + note = KanbanNote(text) + note.connect("changed", lambda x: self.emit("changed")) + # Ensure editor is properly removed before adding note + if editor.get_parent(): + editor.get_parent().remove(editor) + row.add(note) + self.listbox.show_all() + self.emit("changed") + + def on_canceled(editor): + row.destroy() + + def scroll_to_bottom(): + adj = self.scroller.get_vadjustment() + adj.set_value(adj.get_upper()) + + editor.connect("confirmed", on_confirmed) + editor.connect("canceled", on_canceled) + + # ensure this is called after row is loaded + GLib.idle_add(scroll_to_bottom) + + def add_note(self, text, suppress_signal=False): + note = KanbanNote(text) + note.connect("changed", lambda x: self.emit("changed")) + row = Gtk.ListBoxRow(name="kanban-row") + row.add(note) + row.connect("destroy", lambda x: self.emit("changed")) + self.listbox.add(row) + self.listbox.show_all() + if not suppress_signal: + self.emit("changed") + + def get_notes(self): + return [ + row.get_children()[0].label.get_text() + for row in self.listbox.get_children() + if isinstance(row.get_children()[0], KanbanNote) + ] + + def clear_notes(self, suppress_signal=False): + for row in self.listbox.get_children(): + row.destroy() + if not suppress_signal: + self.emit("changed") + + def on_drag_data_received(self, widget, drag_context, x, y, data, info, time): + text = data.get_text() + if text: + row = self.listbox.get_row_at_y(y) + new_note = KanbanNote(text) + new_note.connect("changed", lambda x: self.emit("changed")) + new_row = Gtk.ListBoxRow(name="kanban-row") + new_row.add(new_note) + new_row.connect("destroy", lambda x: self.emit("changed")) + + if row: + self.listbox.insert(new_row, row.get_index()) + else: + self.listbox.add(new_row) + + self.listbox.show_all() + drag_context.finish(True, False, time) + self.emit("changed") + + def on_drag_motion(self, widget, drag_context, x, y, time): + Gdk.drag_status(drag_context, Gdk.DragAction.MOVE, time) + return True + + def on_drag_leave(self, widget, drag_context, time): + widget.get_parent().get_parent().drag_unhighlight() + + +class Kanban(Gtk.Box): + STATE_FILE = Path(os.path.expanduser("~/.kanban.json")) + + def __init__(self): + super().__init__(name="kanban", orientation=Gtk.Orientation.VERTICAL) + + # Set size constraints to fit within launcher + self.set_size_request(-1, 500) # Fixed height to fit in launcher + self.set_vexpand(False) + self.set_hexpand(True) + + self.grid = Gtk.Grid( + column_spacing=4, + column_homogeneous=True, + row_spacing=4, + row_homogeneous=True, + ) + self.grid.set_vexpand(True) + self.grid.set_hexpand(True) + self.add(self.grid) + + self.columns = [ + KanbanColumn("To Do"), + KanbanColumn("In Progress"), + KanbanColumn("Done"), + ] + + # Always use vertical layout (columns stacked vertically) + for i, column in enumerate(self.columns): + # Vertical layout - columns stacked on top of each other + self.grid.attach(column, 0, i, 1, 1) + column.connect("changed", lambda x: self.save_state()) + + self.load_state() + self.show_all() + + def save_state(self): + state = { + "columns": [ + {"title": col.title, "notes": col.get_notes()} for col in self.columns + ] + } + try: + with open(self.STATE_FILE, "w") as f: + json.dump(state, f, indent=2) + except Exception as e: + print(f"Error saving state: {e}") + + def load_state(self): + try: + with open(self.STATE_FILE, "r") as f: + state = json.load(f) + for col_data in state["columns"]: + for column in self.columns: + if column.title == col_data["title"]: + column.clear_notes(suppress_signal=True) + for note_text in col_data["notes"]: + column.add_note(note_text, suppress_signal=True) + break + except FileNotFoundError: + pass + except Exception as e: + print(f"Error loading state: {e}") + + +class KanbanPlugin(PluginBase): + """ + Kanban board plugin for the launcher. + Provides a kanban-style task management interface. + """ + + def __init__(self): + super().__init__() + self.display_name = "Kanban" + self.description = "Kanban board for task management" + self._current_widget = None + + def initialize(self): + """Initialize the Kanban plugin.""" + self.set_triggers(["todo", "kanban", "task"]) + self.description = "Kanban board for task management. Use 'kanban add ' to quickly add tasks." + + def cleanup(self): + """Cleanup the Kanban plugin.""" + if self._current_widget: + # Properly destroy the current widget + if self._current_widget.get_parent(): + self._current_widget.get_parent().remove(self._current_widget) + self._current_widget.destroy() + self._current_widget = None + + def query(self, query_string: str) -> List[Result]: + """Process Kanban queries.""" + results = [] + + # Clean up any existing widget first + self.cleanup() + + # Create a new kanban widget each time to avoid parent issues + kanban_widget = Kanban() + self._current_widget = kanban_widget + + # Check if this is an "add" command + if query_string.startswith("add "): + # Extract the note text + note_text = query_string[4:].strip() + if note_text: + # Create an action that will add the note when executed + def add_task_action(): + # Add to the "To Do" column (first column) + kanban_widget.columns[0].add_note(note_text) + kanban_widget.save_state() + return None + + add_task_action() + # Always show the kanban board directly when triggered + results.append( + Result( + title="Kanban Board", + subtitle="Task management with kanban-style columns", + icon_markup=icons.kanban, + action=lambda: None, # No action needed since widget is displayed directly + relevance=1.0, + plugin_name=self.display_name, + custom_widget=kanban_widget, + data={"type": "kanban_board"}, + ) + ) + + return results diff --git a/modules/launcher/plugins/pomodoro.py b/modules/launcher/plugins/pomodoro.py new file mode 100644 index 0000000..edc46a0 --- /dev/null +++ b/modules/launcher/plugins/pomodoro.py @@ -0,0 +1,333 @@ +""" +Pomodoro plugin for the launcher. +Implements the Pomodoro Technique timer with work/break cycles. +""" + +import threading +import time +import subprocess +from typing import List +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result +import utils.icons as icons + + +class PomodoroTimer: + """ + Core Pomodoro timer implementation. + """ + + def __init__(self): + self.is_break = True + self.timer = None + self.countdown_timer = None + 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 + + def _send_notification(self, title, message, icon="🍅"): + """Send a desktop 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}") + + def _send_countdown_notification(self, seconds_left): + """Send countdown notification.""" + 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}") + + def _schedule_countdown_notifications(self, duration_seconds): + """Schedule countdown notifications for the last 3 seconds.""" + 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, [countdown] + ) + timer.start() + + 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( + "🍅 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( + "🌟 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( + "☕ 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 + + 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( + "🍅 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 + + # Send stop notification + self._send_notification( + "⏹️ Pomodoro Stopped", + f"Timer stopped after { + self.total_cycles_completed + } completed cycles\nCurrent work period: {self.current_cycle}", + ) + + def is_active(self): + """Check if the timer is currently active.""" + return self.timer is not None + + def get_status(self): + """Get current timer status.""" + if not self.is_active(): + return "Inactive" + + remaining_seconds = max(0, int(self.end_time - time.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})" + + return f"{period_type} - {remaining_minutes:02d}:{remaining_seconds:02d} | Total: {self.total_cycles_completed}" + + +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", "pomo "]) + + def cleanup(self): + """Cleanup the Pomodoro plugin.""" + self.pomodoro.stop() + + 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 + results.append( + Result( + title="Stop Pomodoro", + subtitle="Stop the current timer", + icon_markup=icons.timer_off, + action=lambda: self.pomodoro.stop(), + 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 + 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=lambda: self.pomodoro.start( + self.DEFAULT_POMODORO_DURATION, + self.DEFAULT_BREAK_DURATION, + self.DEFAULT_LONG_BREAK_DURATION, + self.DEFAULT_POMODORO_COUNT, + ), + 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 + ) + + 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=lambda _p=p, _b=b, _lb=lb, _c=c: self.pomodoro.start( + _p, _b, _lb, _c + ), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "start_custom", "params": [p, b, lb, c]}, + ) + ) + + return results diff --git a/modules/launcher/plugins/power.py b/modules/launcher/plugins/power.py index b2fa330..17e04c2 100644 --- a/modules/launcher/plugins/power.py +++ b/modules/launcher/plugins/power.py @@ -3,21 +3,19 @@ Power management plugin for the launcher. Handles system power operations like shutdown, restart, sleep, etc. """ -import subprocess -from typing import List, Dict, Any -import os -import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GdkPixbuf, GLib -from ..plugin_base import PluginBase -from ..result import Result +from typing import List + import utils.icons as icons +from fabric.utils import exec_shell_command_async +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + class PowerPlugin(PluginBase): """ Plugin for system power management operations. """ - + def __init__(self): super().__init__() self.display_name = "Power" @@ -26,33 +24,28 @@ class PowerPlugin(PluginBase): "shutdown": { "description": "Shutdown the system", "icon": icons.shutdown, - "action": self.shutdown + "action": self.shutdown, }, "restart": { "description": "Restart the system", "icon": icons.reboot, - "action": self.restart - }, - "sleep": { - "description": "Put system to sleep", - "icon": icons.suspend, - "action": self.sleep + "action": self.restart, }, "lock": { "description": "Lock the screen", "icon": icons.lock, - "action": self.lock + "action": self.lock, }, "suspend": { "description": "Suspend the system", "icon": icons.suspend, - "action": self.suspend + "action": self.suspend, }, "logout": { "description": "Logout from current session", "icon": icons.logout, - "action": self.logout - } + "action": self.logout, + }, } def initialize(self): @@ -80,32 +73,23 @@ class PowerPlugin(PluginBase): action=info["action"], relevance=1.0 if query == cmd else 0.7, plugin_name=self.display_name, - data={"command": cmd} + data={"command": cmd}, ) results.append(result) return results - def shutdown(self) -> None: - """Shutdown the system""" - subprocess.run(["shutdown", "now"]) + def shutdown(self, *args) -> None: + exec_shell_command_async("systemctl poweroff") - def restart(self) -> None: - """Restart the system""" - subprocess.run(["shutdown", "-r", "now"]) + def restart(self, *args) -> None: + exec_shell_command_async("systemctl reboot") - def sleep(self) -> None: - """Put system to sleep""" - subprocess.run(["systemctl", "suspend"]) + def lock(self, *args) -> None: + exec_shell_command_async("loginctl lock-session") - def lock(self) -> None: - """Lock the screen using hyprlock""" - subprocess.run(["hyprlock"]) + def suspend(self, *args) -> None: + exec_shell_command_async("systemctl suspend") - def suspend(self) -> None: - """Suspend the system""" - subprocess.run(["systemctl", "suspend"]) - - def logout(self) -> None: - """Logout from current session""" - subprocess.run(["loginctl", "terminate-session", os.environ.get("XDG_SESSION_ID")]) \ No newline at end of file + def logout(self, *args) -> None: + exec_shell_command_async("hyprctl dispatch exit") diff --git a/modules/launcher/plugins/screencapture.py b/modules/launcher/plugins/screencapture.py new file mode 100644 index 0000000..8515ef7 --- /dev/null +++ b/modules/launcher/plugins/screencapture.py @@ -0,0 +1,128 @@ +""" +Screencapture plugin for the launcher. +Provides screenshot and screen recording functionality. +""" + +from typing import List + +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result +from services.screencapture import ScreenCapture +from utils.icons import screenrecord, screenshots, ssfull, ssregion, stop + + +class ScreencapturePlugin(PluginBase): + """ + Plugin for taking screenshots and screen recordings. + """ + + def __init__(self): + super().__init__() + self.display_name = "Screencapture" + self.description = "Take screenshots and screen recordings" + self.screencapture = ScreenCapture() + + def initialize(self): + """Initialize the screencapture plugin.""" + self.set_triggers(["sc", "sc "]) + + def cleanup(self): + """Cleanup the screencapture plugin.""" + pass + + def query(self, query_string: str) -> List[Result]: + """Search for screencapture actions based on query.""" + # Remove the trigger word from the query + query = query_string.replace("screencapture", "").strip().lower() + + results = [] + + # If recording is active, show stop button first + if self.screencapture.is_recording: + results.append( + Result( + title="Stop Recording", + subtitle="Stop the current screen recording", + icon_markup=stop, + action=self.screencapture.screencast_stop, + relevance=1.0, + plugin_name=self.display_name, + ) + ) + return results + + # Screenshot actions + results.extend( + [ + Result( + title="Take Screenshot", + subtitle="Capture the entire screen", + icon_markup=ssfull, + action=lambda: self.screencapture.screenshot(fullscreen=True), + relevance=1.0, + plugin_name=self.display_name, + ), + Result( + title="Take Region Screenshot", + subtitle="Capture a selected region", + icon_markup=ssregion, + action=lambda: self.screencapture.screenshot(fullscreen=False), + relevance=0.9, + plugin_name=self.display_name, + ), + Result( + title="Take Screenshot (Clipboard Only)", + subtitle="Capture and copy to clipboard", + icon_markup=screenshots, + action=lambda: self.screencapture.screenshot(save_copy=False), + relevance=0.8, + plugin_name=self.display_name, + ), + ] + ) + + # Screen recording actions + results.extend( + [ + Result( + title="Start Fullscreen Recording", + subtitle="Record the entire screen", + icon_markup=screenrecord, + action=lambda: self.screencapture.screencast_start(fullscreen=True), + relevance=0.7, + plugin_name=self.display_name, + ), + Result( + title="Start Region Recording", + subtitle="Record a selected region", + icon_markup=screenrecord, + action=lambda: self.screencapture.screencast_start( + fullscreen=False + ), + relevance=0.6, + plugin_name=self.display_name, + ), + Result( + title="Start Fullscreen Recording with Audio", + subtitle="Record entire screen with system audio", + icon_markup=screenrecord, + action=lambda: self.screencapture.screencast_start( + fullscreen=True, audio=True + ), + relevance=0.5, + plugin_name=self.display_name, + ), + Result( + title="Start Region Recording with Audio", + subtitle="Record selected region with system audio", + icon_markup=screenrecord, + action=lambda: self.screencapture.screencast_start( + fullscreen=False, audio=True + ), + relevance=0.4, + plugin_name=self.display_name, + ), + ] + ) + + return results diff --git a/modules/launcher/plugins/system.py b/modules/launcher/plugins/system.py index c398ffa..dec3906 100644 --- a/modules/launcher/plugins/system.py +++ b/modules/launcher/plugins/system.py @@ -3,13 +3,14 @@ System plugin for the launcher. Provides system commands and actions. """ -import subprocess import os import time -from typing import List, Set -from ..plugin_base import PluginBase -from ..result import Result +from typing import List + +from fabric.utils import exec_shell_command_async import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result class SystemPlugin(PluginBase): @@ -31,7 +32,6 @@ class SystemPlugin(PluginBase): """Initialize the system plugin.""" # Set up triggers for system commands self.set_triggers(["bin", "bin "]) - print("Initializing System plugin...") self._update_bin_cache() def cleanup(self): @@ -101,8 +101,4 @@ class SystemPlugin(PluginBase): def _execute_command(self, command: List[str]): """Execute a system command.""" - try: - subprocess.Popen(command) - print(f"Executed command: {' '.join(command)}") - except Exception as e: - print(f"Failed to execute command {' '.join(command)}: {e}") + exec_shell_command_async(command) diff --git a/modules/launcher/plugins/wallpaper.py b/modules/launcher/plugins/wallpaper.py new file mode 100644 index 0000000..f48d4b8 --- /dev/null +++ b/modules/launcher/plugins/wallpaper.py @@ -0,0 +1,596 @@ +""" +Wallpaper plugin for the launcher. +Provides wallpaper management with search, random selection, matugen integration, and hex color support. +""" + +import os +import random +import hashlib +import colorsys +import re +from typing import List, Dict +from PIL import Image +from gi.repository import GdkPixbuf +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result +from fabric.utils.helpers import exec_shell_command_async +import config.data as data +import utils.icons as icons + + +class WallpaperPlugin(PluginBase): + """ + Plugin for wallpaper management with search, random selection, and matugen integration. + """ + + def __init__(self): + super().__init__() + self.wallpapers = [] + self.cache_dir = f"{data.CACHE_DIR}/thumbs" + self.schemes = { + "scheme-tonal-spot": "Tonal Spot", + "scheme-content": "Content", + "scheme-expressive": "Expressive", + "scheme-fidelity": "Fidelity", + "scheme-fruit-salad": "Fruit Salad", + "scheme-monochrome": "Monochrome", + "scheme-neutral": "Neutral", + "scheme-rainbow": "Rainbow", + } + + def initialize(self): + """Initialize the wallpaper plugin.""" + self.set_triggers(["wall", "wall "]) + self._load_wallpapers() + os.makedirs(self.cache_dir, exist_ok=True) + + def cleanup(self): + """Cleanup the wallpaper plugin.""" + pass + + def _load_wallpapers(self): + """Load available wallpapers from the wallpapers directory.""" + try: + if os.path.exists(data.WALLPAPERS_DIR): + self.wallpapers = sorted( + [f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)] + ) + else: + print(f"Wallpapers directory not found: {data.WALLPAPERS_DIR}") + except Exception as e: + print(f"Error loading wallpapers: {e}") + + def _is_image(self, filename: str) -> bool: + """Check if file is a supported image format.""" + return filename.lower().endswith( + (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") + ) + + def _get_matugen_state(self) -> bool: + """Get current matugen state from file.""" + try: + if os.path.exists(data.MATUGEN_STATE_FILE): + with open(data.MATUGEN_STATE_FILE, "r") as f: + content = f.read().strip().lower() + return content == "true" + return True # Default to True + except Exception as e: + print(f"Error reading matugen state: {e}") + return True + + def _set_matugen_state(self, enabled: bool): + """Set matugen state to file.""" + try: + os.makedirs(os.path.dirname(data.MATUGEN_STATE_FILE), exist_ok=True) + with open(data.MATUGEN_STATE_FILE, "w") as f: + f.write("true" if enabled else "false") + # Show notification for immediate feedback + status = "enabled" if enabled else "disabled" + exec_shell_command_async( + f"notify-send '🎨 Matugen' 'Dynamic colors {status}' -a '{ + data.APP_NAME_CAP + }' -e" + ) + except Exception as e: + print(f"Error setting matugen state: {e}") + + def _get_cache_path(self, filename: str) -> str: + """Get cache path for wallpaper thumbnail.""" + file_hash = hashlib.md5(filename.encode("utf-8")).hexdigest() + return os.path.join(self.cache_dir, f"{file_hash}.png") + + def _create_thumbnail(self, filename: str) -> str: + """Create thumbnail for wallpaper if it doesn't exist.""" + full_path = os.path.join(data.WALLPAPERS_DIR, filename) + cache_path = self._get_cache_path(filename) + + if not os.path.exists(cache_path): + try: + with Image.open(full_path) as img: + # Use faster thumbnail creation + img.thumbnail((48, 48), Image.Resampling.LANCZOS) + img.save(cache_path, "PNG") + except Exception as e: + print(f"Error creating thumbnail for {filename}: {e}") + return None + + return cache_path + + def _set_wallpaper(self, filename: str, scheme: str = None): + """Set wallpaper with optional matugen integration.""" + full_path = os.path.join(data.WALLPAPERS_DIR, filename) + current_wall = os.path.expanduser("~/.current.wall") + + if scheme is None: + scheme = self._get_current_scheme() + + # Update current wallpaper symlink + if os.path.isfile(current_wall) or os.path.islink(current_wall): + os.remove(current_wall) + os.symlink(full_path, current_wall) + + # Apply wallpaper with or without matugen + matugen_enabled = self._get_matugen_state() + if matugen_enabled: + exec_shell_command_async(f'matugen image "{full_path}" -t {scheme}') + else: + exec_shell_command_async( + f'swww img "{ + full_path + }" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest' + ) + + def _set_random_wallpaper(self): + """Set a random wallpaper.""" + if not self.wallpapers: + return + + filename = random.choice(self.wallpapers) + self._set_wallpaper(filename) + # Show notification for immediate feedback + exec_shell_command_async( + f"notify-send '🎲 Random Wallpaper' 'Applied: {filename}' -a '{ + data.APP_NAME_CAP + }' -e" + ) + return filename + + def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str: + """Convert HSL color value to RGB HEX string.""" + # colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0 + hue = h / 360.0 + r, g, b = colorsys.hls_to_rgb(hue, l, s) # Note the order: H, L, S + r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255) + return f"#{r_int:02X}{g_int:02X}{b_int:02X}" + + def _is_valid_hex_color(self, hex_color: str) -> bool: + """Check if string is a valid hex color.""" + if not hex_color.startswith("#"): + hex_color = "#" + hex_color + return bool(re.match(r"^#[0-9A-Fa-f]{6}$", hex_color)) + + def _get_current_scheme(self) -> str: + """Get current color scheme (default to tonal-spot).""" + # You could store this in a config file, for now use default + return "scheme-tonal-spot" + + def _set_current_scheme(self, scheme: str): + """Set current color scheme.""" + # For now, just use it immediately. Could be stored in config later. + scheme_name = self.schemes.get(scheme, scheme) + matugen_enabled = self._get_matugen_state() + + # Show notification with matugen status + if matugen_enabled: + exec_shell_command_async( + f"notify-send '🎨 Color Scheme' 'Set to { + scheme_name + }\\nMatugen: Enabled' -a '{data.APP_NAME_CAP}' -e" + ) + else: + exec_shell_command_async( + f"notify-send '⚠️ Color Scheme' 'Set to { + scheme_name + }\\nMatugen: Disabled (enable for effect)' -a '{data.APP_NAME_CAP}' -e" + ) + + def _apply_hex_color(self, hex_color: str, scheme: str = None): + """Apply hex color using matugen.""" + if not hex_color.startswith("#"): + hex_color = "#" + hex_color + + if scheme is None: + scheme = self._get_current_scheme() + + exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}') + scheme_name = self.schemes.get(scheme, scheme) + exec_shell_command_async( + f"notify-send '🎨 Hex Color Applied' 'Color: {hex_color}\\nScheme: { + scheme_name + }' -a '{data.APP_NAME_CAP}' -e" + ) + + def _generate_random_hex_color(self) -> str: + """Generate a random hex color.""" + hue = random.randint(0, 360) + return self._hsl_to_rgb_hex(hue) + + def _get_status_indicators(self) -> tuple: + """Get current status indicators for display.""" + current_scheme = self._get_current_scheme() + matugen_enabled = self._get_matugen_state() + scheme_name = self.schemes.get(current_scheme, current_scheme) + + indicators = [] + if not matugen_enabled: + indicators.append("⚠ Matugen Off") + + indicator_text = " • " + " • ".join(indicators) if indicators else "" + status_text = f"Matugen: { + 'Enabled' if matugen_enabled else 'Disabled' + } • Scheme: {scheme_name}" + + return indicator_text, status_text, current_scheme, matugen_enabled + + def query(self, query_string: str) -> List[Result]: + """Search wallpapers and provide management options.""" + results = [] + query = query_string.lower().strip() + + # Get status indicators for consistent display + indicator_text, status_text, current_scheme, matugen_enabled = ( + self._get_status_indicators() + ) + + # Special commands + if query == "random" or query.startswith("random"): + # Execute immediately when this result is created/selected + self._set_random_wallpaper() + results.append( + Result( + title=f"Random Wallpaper Applied{indicator_text}", + subtitle=f"A random wallpaper has been set • {status_text}", + icon_markup=random.choice( + [ + icons.dice_1, + icons.dice_2, + icons.dice_3, + icons.dice_4, + icons.dice_5, + icons.dice_6, + ] + ), + action=lambda: None, # No action needed since already executed + relevance=1.0, + plugin_name=self.display_name, + data={ + "action": "random", + "executed": True, + "bypass_max_results": True, + }, + ) + ) + + # Hex color commands + if "color" in query or "hex" in query or query.startswith("#"): + # Check for scheme specification in the query + scheme = self._get_current_scheme() + for scheme_id, scheme_name in self.schemes.items(): + if ( + scheme_name.lower() in query.lower() + or scheme_id.lower() in query.lower() + ): + scheme = scheme_id + break + + # Check for hex color patterns + hex_match = re.search(r"#?([0-9A-Fa-f]{6})", query) + if hex_match: + hex_color = "#" + hex_match.group(1) + # Execute immediately + self._apply_hex_color(hex_color, scheme) + scheme_name = self.schemes.get(scheme, scheme) + results.append( + Result( + title=f"Hex Color Applied: {hex_color}{indicator_text}", + subtitle=f"Applied with {scheme_name} scheme • {status_text}", + icon_markup=icons.palette, + action=lambda: None, # No action needed since already executed + relevance=1.0, + plugin_name=self.display_name, + data={ + "action": "hex_color", + "color": hex_color, + "scheme": scheme, + "executed": True, + "bypass_max_results": True, + }, + ) + ) + elif "random" in query: + # Random hex color + hex_color = self._generate_random_hex_color() + self._apply_hex_color(hex_color, scheme) + scheme_name = self.schemes.get(scheme, scheme) + results.append( + Result( + title=f"Random Color Applied: {hex_color}{indicator_text}", + subtitle=f"Applied with {scheme_name} scheme • {status_text}", + icon_markup=icons.palette, + action=lambda: None, # No action needed since already executed + relevance=1.0, + plugin_name=self.display_name, + data={ + "action": "random_hex", + "color": hex_color, + "scheme": scheme, + "executed": True, + "bypass_max_results": True, + }, + ) + ) + else: + # Show hex color help + results.append( + Result( + title="Hex Color Commands", + subtitle="Use: color #FF5733, hex #00FF00, color random, or add scheme name", + icon_markup=icons.palette, + action=lambda: None, + relevance=0.8, + plugin_name=self.display_name, + data={"action": "hex_help", "bypass_max_results": True}, + ) + ) + + # Color scheme commands + if "scheme" in query: + current_scheme = self._get_current_scheme() + matugen_enabled = self._get_matugen_state() + + # Show all available schemes + for scheme_id, scheme_name in self.schemes.items(): + # Check if this scheme matches the query (for filtering) + if ( + query.strip() == "scheme" + or scheme_name.lower() in query.lower() + or scheme_id.lower() in query.lower() + ): + # Create indicators + indicators = [] + if scheme_id == current_scheme: + indicators.append("● Current") + if not matugen_enabled: + indicators.append("⚠ Matugen Off") + + indicator_text = ( + " • " + " • ".join(indicators) if indicators else "" + ) + + results.append( + Result( + title=f"{scheme_name}{indicator_text}", + subtitle=f"Set color scheme to {scheme_name}" + + ( + " (requires matugen enabled)" + if not matugen_enabled + else "" + ), + icon_markup=icons.palette, + action=lambda s=scheme_id: self._set_current_scheme(s), + relevance=1.0 if scheme_id == current_scheme else 0.8, + plugin_name=self.display_name, + data={ + "action": "scheme_select", + "scheme": scheme_id, + "bypass_max_results": True, + }, + ) + ) + + # Matugen controls + if "matugen" in query: + current_state = self._get_matugen_state() + if "on" in query or "enable" in query: + # Execute immediately + self._set_matugen_state(True) + # Get updated status after change + new_indicator_text, new_status_text, _, _ = ( + self._get_status_indicators() + ) + results.append( + Result( + title=f"Matugen Enabled{new_indicator_text}", + subtitle=f"Dynamic color generation is now enabled • { + new_status_text + }", + icon_markup=icons.palette, + action=lambda: None, # No action needed since already executed + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "matugen_on", + "executed": True, + "bypass_max_results": True, + }, + ) + ) + elif "off" in query or "disable" in query: + # Execute immediately + self._set_matugen_state(False) + # Get updated status after change + new_indicator_text, new_status_text, _, _ = ( + self._get_status_indicators() + ) + results.append( + Result( + title=f"Matugen Disabled{new_indicator_text}", + subtitle=f"Dynamic color generation is now disabled • { + new_status_text + }", + icon_markup=icons.palette, + action=lambda: None, # No action needed since already executed + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "matugen_off", + "executed": True, + "bypass_max_results": True, + }, + ) + ) + elif "toggle" in query: + # Execute toggle immediately + new_state = not current_state + self._set_matugen_state(new_state) + # Get updated status after change + new_indicator_text, new_status_text, _, _ = ( + self._get_status_indicators() + ) + results.append( + Result( + title=f"Matugen {'Enabled' if new_state else 'Disabled'}{ + new_indicator_text + }", + subtitle=f"Dynamic color generation is now { + 'enabled' if new_state else 'disabled' + } • {new_status_text}", + icon_markup=icons.palette, + action=lambda: None, # No action needed since already executed + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "matugen_toggle", + "executed": True, + "bypass_max_results": True, + }, + ) + ) + else: + # Show current state with scheme info + results.append( + Result( + title=f"Matugen: {'Enabled' if current_state else 'Disabled'}{ + indicator_text + }", + subtitle=f"Dynamic colors • {status_text}", + icon_markup=icons.palette, + action=lambda: None, + relevance=0.8, + plugin_name=self.display_name, + data={"action": "matugen_status", "bypass_max_results": True}, + ) + ) + + # Status command + if query == "status" or query == "info": + results.append( + Result( + title=f"Wallpaper System Status{indicator_text}", + subtitle=status_text, + icon_markup=icons.palette, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"action": "status", "bypass_max_results": True}, + ) + ) + + # Handle empty query (just trigger keywords: wall, wallpaper, wp) + if not query: + # Show status and quick actions when just typing trigger keywords + results.append( + Result( + title=f"Wallpaper System{indicator_text}", + subtitle=f"{ + status_text + } • Type commands: random, scheme, status, matugen", + icon_markup=icons.wallpapers, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"action": "overview", "bypass_max_results": True}, + ) + ) + + # Show quick actions + results.append( + Result( + title=f"Random Wallpaper{indicator_text}", + subtitle=f"Set a random wallpaper • {status_text}", + icon_markup=random.choice( + [ + icons.dice_1, + icons.dice_2, + icons.dice_3, + icons.dice_4, + icons.dice_5, + icons.dice_6, + ] + ), + action=lambda: self._set_random_wallpaper(), + relevance=0.9, + plugin_name=self.display_name, + data={"action": "random_quick", "bypass_max_results": True}, + ) + ) + + # Search wallpapers by filename + if not query or ( + query + and "matugen" not in query + and "random" not in query + and "scheme" not in query + and "status" not in query + and "info" not in query + and "color" not in query + and "hex" not in query + ): + matching_wallpapers = [] + for wallpaper in self.wallpapers: + if not query or query in wallpaper.lower(): + # Calculate relevance + relevance = 1.0 if query == wallpaper.lower() else 0.7 + if query and query in wallpaper.lower(): + relevance = 0.8 + matching_wallpapers.append((wallpaper, relevance)) + + # Sort by relevance - no limit since we use bypass_max_results + matching_wallpapers.sort(key=lambda x: x[1], reverse=True) + + for wallpaper, relevance in matching_wallpapers: + # Only create thumbnails for displayed results + cache_path = self._get_cache_path(wallpaper) + icon = None + + # Try to load existing thumbnail first + if os.path.exists(cache_path): + try: + icon = GdkPixbuf.Pixbuf.new_from_file_at_scale( + cache_path, 32, 32, True + ) + except Exception as e: + print(f"Error loading existing thumbnail: {e}") + + results.append( + Result( + title=f"{wallpaper}{indicator_text if not query else ''}", + subtitle=f"Set as wallpaper{ + ' • ' + status_text if not query else '' + }", + icon=icon, + icon_markup=icons.wallpapers if not icon else None, + action=lambda w=wallpaper: self._set_wallpaper(w), + relevance=relevance, + plugin_name=self.display_name, + data={ + "wallpaper": wallpaper, + "action": "set", + "bypass_max_results": True, + }, + ) + ) + + # Sort by relevance - no limit since we use bypass_max_results + results.sort(key=lambda x: x.relevance, reverse=True) + return results diff --git a/modules/launcher/plugins/websearch.py b/modules/launcher/plugins/websearch.py new file mode 100644 index 0000000..a308a80 --- /dev/null +++ b/modules/launcher/plugins/websearch.py @@ -0,0 +1,282 @@ +""" +Web Search plugin for the launcher. +Allows searching the web using various search engines. +""" + +import subprocess +import urllib.parse +from typing import Dict, List + +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class WebSearchPlugin(PluginBase): + """ + Web search plugin that supports multiple search engines. + """ + + def __init__(self): + super().__init__() + self.display_name = "Web Search" + self.description = "Search the web using various search engines" + self.current_trigger = "" # Track the current active trigger + + # Define search engines with their URLs and icons + self.search_engines = { + "google": { + "name": "Google", + "url": "https://www.google.com/search?q={}", + "icon": icons.google, + "description": "Search with Google", + }, + "duckduckgo": { + "name": "DuckDuckGo", + "url": "https://duckduckgo.com/?q={}", + "icon": icons.duckduckgo, + "description": "Search with DuckDuckGo (privacy-focused)", + }, + "youtube": { + "name": "YouTube", + "url": "https://www.youtube.com/results?search_query={}", + "icon": icons.youtube, + "description": "Search videos on YouTube", + }, + "github": { + "name": "GitHub", + "url": "https://github.com/search?q={}", + "icon": icons.github, + "description": "Search repositories on GitHub", + }, + "stackoverflow": { + "name": "Stack Overflow", + "url": "https://stackoverflow.com/search?q={}", + "icon": icons.stackoverflow, + "description": "Search programming questions on Stack Overflow", + }, + "wikipedia": { + "name": "Wikipedia", + "url": "https://en.wikipedia.org/wiki/Special:Search?search={}", + "icon": icons.wikipedia, + "description": "Search articles on Wikipedia", + }, + "reddit": { + "name": "Reddit", + "url": "https://www.reddit.com/search/?q={}", + "icon": icons.reddit, + "description": "Search discussions on Reddit", + }, + "linkedin": { + "name": "LinkedIn", + "url": "https://www.linkedin.com/search/results/all/?keywords={}", + "icon": icons.linkedin, + "description": "Search professionals and jobs on LinkedIn", + }, + } + + # Default search engine + self.default_engine = "google" + + def initialize(self): + """Initialize the web search plugin.""" + # Set up triggers for web search + triggers = ["web", "web ", "?", "? "] + + # Add search engine specific triggers + for engine in self.search_engines.keys(): + triggers.extend([engine, f"{engine} "]) + + self.set_triggers(triggers) + + def get_active_trigger(self, query_string: str) -> str: + """Override to track which trigger was activated.""" + trigger = super().get_active_trigger(query_string) + if trigger: + self.current_trigger = trigger.strip() + return trigger + + def cleanup(self): + """Cleanup the web search plugin.""" + pass + + def query(self, query_string: str) -> List[Result]: + """Process web search queries.""" + results = [] + + if not query_string.strip(): + # Show available search engines when no query + return self._get_search_engine_list() + + # Check if the query is a URL + if self._is_url(query_string): + results.append(self._create_url_result(query_string)) + return results + + # Determine which search engine to use based on the active trigger + engine_name = self._get_engine_from_trigger() + + if engine_name and engine_name in self.search_engines: + # Specific search engine triggered + search_query = query_string.strip() + if search_query: + # Search with specific engine + results.append(self._create_search_result(engine_name, search_query)) + else: + # Show specific engine info + results.append(self._create_engine_info_result(engine_name)) + else: + # General search - offer multiple engines + search_query = query_string.strip() + + # Add default search engine first + results.append( + self._create_search_result(self.default_engine, search_query) + ) + + # Add other popular search engines + popular_engines = ["duckduckgo", "youtube", "github"] + for engine in popular_engines: + if engine != self.default_engine: + results.append(self._create_search_result(engine, search_query)) + + return results + + def _parse_engine_query(self, query: str) -> tuple[str, str]: + """Parse query to extract search engine and search terms.""" + query = query.strip().lower() + + for engine in self.search_engines.keys(): + if query.startswith(f"{engine} "): + return engine, query[len(engine) :].strip() + elif query == engine: + return engine, "" + + return "", query + + def _get_engine_from_trigger(self) -> str: + """Get the search engine name from the current trigger.""" + if not self.current_trigger: + return "" + + # Clean the trigger (remove spaces) + trigger_clean = self.current_trigger.strip().lower() + + # Check if the trigger matches a search engine + if trigger_clean in self.search_engines: + return trigger_clean + + # Handle general triggers + if trigger_clean in ["web", "?"]: + return "" # Use general search (multiple engines) + + return "" + + def _is_url(self, text: str) -> bool: + """Check if the text is a URL.""" + text = text.strip().lower() + return text.startswith(("http://", "https://", "www.")) or ( + "." in text and " " not in text and len(text) > 3 + ) + + def _create_url_result(self, url: str) -> Result: + """Create a result for opening a URL directly.""" + # Add protocol if missing + if not url.startswith(("http://", "https://")): + if url.startswith("www."): + url = "https://" + url + else: + url = "https://" + url + + return Result( + title=f"Open {url}", + subtitle="Open this URL in your default browser", + icon_markup=icons.world, + action=lambda u=url: self._open_url(u), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "url", "url": url}, + ) + + def _open_url(self, url: str): + """Open a URL directly in the default browser.""" + try: + subprocess.Popen( + ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + except Exception as e: + print(f"Failed to open URL: {e}") + + def _get_search_engine_list(self) -> List[Result]: + """Get list of available search engines.""" + results = [] + + for engine_id, engine_info in self.search_engines.items(): + result = Result( + title=engine_info["name"], + subtitle=engine_info["description"], + icon_markup=engine_info["icon"], + action=lambda e=engine_id: self._show_engine_help(e), + relevance=1.0 if engine_id == self.default_engine else 0.8, + plugin_name=self.display_name, + data={"type": "engine_info", "engine": engine_id}, + ) + results.append(result) + + return results + + def _create_search_result(self, engine_id: str, query: str) -> Result: + """Create a search result for a specific engine and query.""" + engine_info = self.search_engines[engine_id] + + return Result( + title=f"Search '{query}' on {engine_info['name']}", + subtitle=f"{engine_info['description']} - {query}", + icon_markup=engine_info["icon"], + action=lambda e=engine_id, q=query: self._perform_search(e, q), + relevance=1.0 if engine_id == self.default_engine else 0.9, + plugin_name=self.display_name, + data={"type": "search", "engine": engine_id, "query": query}, + ) + + def _create_engine_info_result(self, engine_id: str) -> Result: + """Create an info result for a specific search engine.""" + engine_info = self.search_engines[engine_id] + + return Result( + title=f"{engine_info['name']} Search", + subtitle=f"{engine_info['description']} - Type your search query", + icon_markup=engine_info["icon"], + action=lambda: None, # No action for info result + relevance=1.0, + plugin_name=self.display_name, + data={"type": "engine_ready", "engine": engine_id}, + ) + + def _perform_search(self, engine_id: str, query: str): + """Perform a web search using the specified engine.""" + if not query.strip(): + return + + engine_info = self.search_engines.get(engine_id) + if not engine_info: + return + + # URL encode the search query + encoded_query = urllib.parse.quote_plus(query) + search_url = engine_info["url"].format(encoded_query) + + try: + # Open the search URL in the default browser + subprocess.Popen( + ["xdg-open", search_url], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception as e: + print(f"Failed to open search URL: {e}") + + def _show_engine_help(self, engine_id: str): + """Show help for a specific search engine.""" + engine_info = self.search_engines.get(engine_id) + engine_info = self.search_engines.get(engine_id) diff --git a/modules/launcher/result.py b/modules/launcher/result.py index c7fb81c..79f7e08 100644 --- a/modules/launcher/result.py +++ b/modules/launcher/result.py @@ -2,10 +2,10 @@ Result class representing a search result from plugins. """ -from typing import Optional, Callable, Any from dataclasses import dataclass -from gi.repository import GdkPixbuf -import utils.icons as icons +from typing import Any, Callable, Optional + +from gi.repository import GdkPixbuf, Gtk @dataclass @@ -13,7 +13,7 @@ class Result: """ Represents a search result that can be displayed and activated. """ - + # Display information title: str subtitle: str = "" @@ -24,29 +24,32 @@ class Result: # Behavior action: Optional[Callable[[], Any]] = None relevance: float = 0.0 - + + # Custom widget support + custom_widget: Optional[Gtk.Widget] = None + # Metadata plugin_name: str = "" data: Optional[dict] = None - + def activate(self): """Activate this result (execute its action).""" if self.action: return self.action() else: raise NotImplementedError("No action defined for this result") - + def __post_init__(self): """Post-initialization processing.""" # Ensure relevance is within valid range self.relevance = max(0.0, min(1.0, self.relevance)) - + # Set default data if None if self.data is None: self.data = {} - + def __str__(self): return f"Result(title='{self.title}', relevance={self.relevance})" - + def __repr__(self): return self.__str__() diff --git a/modules/launcher/result_item.py b/modules/launcher/result_item.py index 11868e0..149c416 100644 --- a/modules/launcher/result_item.py +++ b/modules/launcher/result_item.py @@ -2,16 +2,16 @@ UI component for displaying search results. """ +from gi.repository import Gtk import gi from fabric.widgets.box import Box from fabric.widgets.label import Label from fabric.widgets.image import Image from fabric.widgets.eventbox import EventBox from fabric.core.service import Signal -from .result import Result +from modules.launcher.result import Result gi.require_version("Gtk", "3.0") -from gi.repository import Gtk class ResultItem(EventBox): @@ -24,26 +24,23 @@ class ResultItem(EventBox): def clicked(self, index: int) -> None: """Emitted when result is clicked.""" pass - + def __init__(self, result: Result, selected: bool = False, **kwargs): - super().__init__( - name="launcher-result-item", - **kwargs - ) - + super().__init__(name="launcher-result-item", **kwargs) + self.result = result self._selected = selected - + # Setup UI self._setup_ui() - + # Connect signals self.connect("button-press-event", self._on_button_press) self.connect("enter-notify-event", self._on_enter) - + # Set initial selection state self.set_selected(selected) - + def _setup_ui(self): """Setup the result item UI.""" # Main container @@ -55,34 +52,28 @@ class ResultItem(EventBox): v_align="center", ) self.add(main_box) - + # Icon if self.result.icon: - icon_widget = Image( - pixbuf=self.result.icon, - name="result-item-icon" - ) + icon_widget = Image(pixbuf=self.result.icon, name="result-item-icon") elif self.result.icon_name: icon_widget = Image( - icon_name=self.result.icon_name, - icon_size=32, - name="result-item-icon" + icon_name=self.result.icon_name, icon_size=32, name="result-item-icon" ) elif self.result.icon_markup: icon_widget = Label( - markup=self.result.icon_markup, - name="launcher-icon-label" + markup=self.result.icon_markup, name="launcher-icon-label" ) else: # Default icon icon_widget = Image( icon_name="application-x-executable", icon_size=32, - name="result-item-icon" + name="result-item-icon", ) - + main_box.add(icon_widget) - + # Text container text_box = Box( name="result-item-text", @@ -92,7 +83,7 @@ class ResultItem(EventBox): v_align="center", ) main_box.add(text_box) - + # Title title_label = Label( label=self.result.title, @@ -102,7 +93,7 @@ class ResultItem(EventBox): ellipsize="end", ) text_box.add(title_label) - + # Subtitle (if present) if self.result.subtitle: subtitle_label = Label( @@ -113,7 +104,7 @@ class ResultItem(EventBox): ellipsize="end", ) text_box.add(subtitle_label) - + # Plugin name (small text) if self.result.plugin_name: plugin_label = Label( @@ -124,27 +115,27 @@ class ResultItem(EventBox): ellipsize="end", ) text_box.add(plugin_label) - + def set_selected(self, selected: bool): """Set the selection state of this result item.""" self._selected = selected - + if selected: self.add_style_class("selected") else: self.remove_style_class("selected") - + def get_selected(self) -> bool: """Get the selection state of this result item.""" return self._selected - + def _on_button_press(self, widget, event): """Handle button press events.""" if event.button == 1: # Left click self.clicked.emit(0) # Emit with dummy index, parent will handle return True return False - + def _on_enter(self, widget, event): """Handle mouse enter events.""" # Could be used for hover effects diff --git a/modules/launcher/trigger_config.py b/modules/launcher/trigger_config.py index 7b2771e..1de7451 100644 --- a/modules/launcher/trigger_config.py +++ b/modules/launcher/trigger_config.py @@ -4,81 +4,97 @@ Simple trigger configuration manager for the launcher. import json import os -from typing import Dict, List, Any -from fabric.utils import get_relative_path +from typing import Any, Dict, List + import utils.icons as icons +from fabric.utils import get_relative_path + def load_launcher_config(config_path: str) -> Dict: """Load trigger configuration from JSON file""" - config = {'launcher_config': {}, 'settings': {}} - + config = {"launcher_config": {}, "settings": {}} + if os.path.exists(config_path): try: - with open(config_path, 'r', encoding='utf-8') as f: + with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) except Exception as e: print(f"Error loading trigger config: {e}") - + return config + def save_launcher_config(config_path: str, config: Dict) -> bool: """Save trigger configuration to JSON file""" try: os.makedirs(os.path.dirname(config_path), exist_ok=True) - with open(config_path, 'w', encoding='utf-8') as f: + with open(config_path, "w", encoding="utf-8") as f: json.dump(config, f, indent=2, ensure_ascii=False) return True except Exception as e: print(f"Error saving trigger config: {e}") return False + class TriggerConfig: def __init__(self, config_path: str = None): if config_path is None: config_path = get_relative_path("../../config/launcher.json") - + self.config_path = config_path self.config = load_launcher_config(config_path) - self.launcher_config = self.config.get('launcher_config', {}) - self.settings = self.config.get('settings', {}) - + self.launcher_config = self.config.get("launcher_config", {}) + self.settings = self.config.get("settings", {}) + if not self.launcher_config: self._create_default_config() - + def save_config(self) -> bool: - self.config['launcher_config'] = self.launcher_config - self.config['settings'] = self.settings + self.config["launcher_config"] = self.launcher_config + self.config["settings"] = self.settings return save_launcher_config(self.config_path, self.config) - + def get_trigger_examples(self, trigger: str) -> List[str]: - examples = self.launcher_config.get(trigger, {}).get('examples', []) + examples = self.launcher_config.get(trigger, {}).get("examples", []) if not examples: - template = self.settings.get('fallback_example_template', '{trigger} ') + template = self.settings.get( + "fallback_example_template", "{trigger} " + ) examples = [template.format(trigger=trigger)] return examples - + def get_trigger_icon(self, trigger: str) -> str: - icon_name = self.launcher_config.get(trigger, {}).get('icon', self.settings.get('default_icon', 'apps')) + icon_name = self.launcher_config.get(trigger, {}).get( + "icon", self.settings.get("default_icon", "apps") + ) return getattr(icons, icon_name, icons.apps) - + def get_trigger_description(self, trigger: str) -> str: - return self.launcher_config.get(trigger, {}).get('description', f'{trigger} - No description available') - + return self.launcher_config.get(trigger, {}).get( + "description", f"{trigger} - No description available" + ) + def get_all_triggers(self) -> Dict[str, Dict[str, Any]]: return self.launcher_config.copy() - - def add_trigger(self, trigger: str, examples: List[str], icon: str = None, description: str = None) -> bool: + + def add_trigger( + self, + trigger: str, + examples: List[str], + icon: str = None, + description: str = None, + ) -> bool: try: self.launcher_config[trigger] = { - 'examples': examples, - 'icon': icon or self.settings.get('default_icon', 'system-search'), - 'description': description or f'{trigger} - Custom trigger' + "examples": examples, + "icon": icon or self.settings.get("default_icon", "system-search"), + "description": description or f"{trigger} - Custom trigger", } return True except Exception as e: print(f"Error adding trigger {trigger}: {e}") return False - + def remove_trigger(self, trigger: str) -> bool: try: if trigger in self.launcher_config: @@ -88,32 +104,32 @@ class TriggerConfig: except Exception as e: print(f"Error removing trigger {trigger}: {e}") return False - + def get_max_examples_shown(self) -> int: - return self.settings.get('max_examples_shown', 2) - + return self.settings.get("max_examples_shown", 2) + def _create_default_config(self): self.launcher_config = { - 'app': { - 'examples': ['app firefox', 'app chrome', 'app terminal'], - 'icon': 'apps', - 'description': 'Applications - Launch installed applications' + "app": { + "examples": ["app firefox", "app chrome", "app terminal"], + "icon": "apps", + "description": "Applications - Launch installed applications", }, - 'calc': { - 'examples': ['calc 2+2', 'calc sqrt(16)', 'calc pi*2'], - 'icon': 'calculator', - 'description': 'Calculator - Perform mathematical calculations' + "calc": { + "examples": ["calc 2+2", "calc sqrt(16)", "calc pi*2"], + "icon": "calculator", + "description": "Calculator - Perform mathematical calculations", + }, + "file": { + "examples": ["file document.pdf", "file *.py", "file config"], + "icon": "file", + "description": "Files - Search for files and documents", }, - 'file': { - 'examples': ['file document.pdf', 'file *.py', 'file config'], - 'icon': 'file', - 'description': 'Files - Search for files and documents' - } } - + self.settings = { - 'max_examples_shown': 2, - 'default_icon': 'apps', - 'fallback_example_template': '{trigger} ', - 'config_version': '1.0' + "max_examples_shown": 2, + "default_icon": "apps", + "fallback_example_template": "{trigger} ", + "config_version": "1.0", } diff --git a/modules/switcher.py b/modules/switcher.py index 76178f8..0b649cd 100644 --- a/modules/switcher.py +++ b/modules/switcher.py @@ -24,7 +24,7 @@ class ApplicationSwitcher(Window): layer="top", anchor="center", exclusivity="auto", - keyboard_mode="on-demand", + keyboard_mode="exclusive", **kwargs, ) @@ -143,7 +143,6 @@ class ApplicationSwitcher(Window): style_classes=["active"] if i == self.current_index else None, child=button_content, ) - event_box.connect("button-press-event", self.on_button_clicked, i) current_row.add(event_box) # Create new row if needed @@ -162,14 +161,6 @@ class ApplicationSwitcher(Window): except Exception as e: print(f"Failed to update windows: {e}") - def on_button_clicked(self, widget, event, index): - self.current_index = index - self.update_selection() - if event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS: - self.activate_selected() - self.hide_switcher() - return True - def on_key_press(self, widget, event): keyval = event.keyval state = event.state diff --git a/scripts/capture.sh b/scripts/capture.sh deleted file mode 100755 index 465e038..0000000 --- a/scripts/capture.sh +++ /dev/null @@ -1,201 +0,0 @@ - -#!/bin/bash - -# Print usage information -print_usage() { - cat <<"EOF" -Usage: screen-tools.sh [mode] [option] -Modes: - screenshot [option] Take a screenshot - Options: - p Print all screens - s Snip current screen - sf Snip current screen (frozen) - m Print focused monitor - [mockup] Optional: Add mockup effect to screenshot - - record Start/Stop screen recording - - ocr Perform OCR on selected area -EOF -} - -# OCR functionality using hyprshot -do_ocr() { - sleep 0.25 - - # Temporary file to store the snipped image - temp_ocr_image="$(mktemp --suffix=.png)" - hyprshot -s -z -m region -o "$(dirname "$temp_ocr_image")" -f "$(basename "$temp_ocr_image")" - - if [ ! -f "$temp_ocr_image" ]; then - notify-send -a "Modus" "OCR Failed" "No image captured" - exit 1 - fi - - # Perform OCR with tesseract - ocr_text=$(tesseract "$temp_ocr_image" - -l eng 2>/dev/null) - - rm -f "$temp_ocr_image" # Clean up temp file - - # Check if OCR was successful - if [[ -n "$ocr_text" ]]; then - echo -n "$ocr_text" | wl-copy - notify-send -a "Modus" "OCR Success" "Text copied to clipboard" - else - notify-send -a "Modus" "OCR Failed" "No text recognized or operation failed" - fi -} - -# Screen recording functionality -do_screenrecord() { - if [ -z "$XDG_VIDEOS_DIR" ]; then - XDG_VIDEOS_DIR="$HOME/Videos" - fi - local save_dir="${XDG_VIDEOS_DIR:-$HOME/Videos}/Recordings" - mkdir -p "$save_dir" - - if pgrep -f "gpu-screen-recorder" >/dev/null; then - pkill -SIGINT -f "gpu-screen-recorder" - sleep 1 - - local last_video=$(ls -t "$save_dir"/*.mp4 2>/dev/null | head -n 1) - local action=$(notify-send -a "Modus" "⬜ Recording stopped" -A "view=View" -A "open=Open folder") - - if [ "$action" = "view" ] && [ -n "$last_video" ]; then - xdg-open "$last_video" - elif [ "$action" = "open" ]; then - xdg-open "$save_dir" - fi - exit 0 - fi - - local output_file="$save_dir/$(date +%Y-%m-%d-%H-%M-%S).mp4" - notify-send -a "Modus" "🔴 Recording started" - gpu-screen-recorder -w screen -q ultra -a default_output -ac opus -cr full -f 60 -o "$output_file" -} - -# Screenshot functionality -do_screenshot() { - sleep 0.25 - - if [ -z "$XDG_PICTURES_DIR" ]; then - XDG_PICTURES_DIR="$HOME/Pictures" - fi - - local save_dir="${3:-$XDG_PICTURES_DIR/Screenshots}" - local save_file=$(date +'%y%m%d_%Hh%Mm%Ss_screenshot.png') - local full_path="$save_dir/$save_file" - mkdir -p "$save_dir" - - local mockup_mode="$2" - - case $1 in - p) hyprshot -s -m output -o "$save_dir" -f "$save_file" ;; - s) hyprshot -s -m region -o "$save_dir" -f "$save_file" ;; - sf) hyprshot -s -z -m region -o "$save_dir" -f "$save_file" ;; - m) hyprshot -s -m output -m active -o "$save_dir" -f "$save_file" ;; - *) - echo "Invalid screenshot mode" - print_usage - exit 1 - ;; - esac - - if [ -f "$full_path" ]; then - if [ "$mockup_mode" != "mockup" ]; then - if command -v wl-copy >/dev/null 2>&1; then - wl-copy <"$full_path" - elif command -v xclip >/dev/null 2>&1; then - xclip -selection clipboard -t image/png <"$full_path" - fi - fi - - # Process as mockup if requested - if [ "$mockup_mode" = "mockup" ]; then - temp_file="${full_path%.png}_temp.png" - cropped_file="${full_path%.png}_cropped.png" - mockup_file="${full_path%.png}_mockup.png" - mockup_success=true - - # Crop top pixel - convert "$full_path" -crop +0+1 +repage "$cropped_file" || mockup_success=false - - # Rounded corners and transparency - if [ "$mockup_success" = true ]; then - convert "$cropped_file" \ - \( +clone -alpha extract -draw 'fill black polygon 0,0 0,20 20,0 fill white circle 20,20 20,0' \ - \( +clone -flip \) -compose Multiply -composite \ - \( +clone -flop \) -compose Multiply -composite \ - \) -alpha off -compose CopyOpacity -composite "$temp_file" || mockup_success=false - fi - - # Add shadow - if [ "$mockup_success" = true ]; then - convert "$temp_file" \ - \( +clone -background black -shadow 60x20+0+10 -alpha set -channel A -evaluate multiply 1 +channel \) \ - +swap -background none -layers merge +repage "$mockup_file" || mockup_success=false - fi - - # Finalize - if [ "$mockup_success" = true ] && [ -f "$mockup_file" ]; then - rm "$temp_file" "$cropped_file" - mv "$mockup_file" "$full_path" - if command -v wl-copy >/dev/null 2>&1; then - wl-copy <"$full_path" - elif command -v xclip >/dev/null 2>&1; then - xclip -selection clipboard -t image/png <"$full_path" - fi - else - echo "Warning: Mockup processing failed for $full_path." >&2 - rm -f "$temp_file" "$cropped_file" "$mockup_file" - if [ "$mockup_mode" = "mockup" ]; then - if command -v wl-copy >/dev/null 2>&1; then - wl-copy <"$full_path" - elif command -v xclip >/dev/null 2>&1; then - xclip -selection clipboard -t image/png <"$full_path" - fi - fi - fi - fi - - local action=$(notify-send -a "Modus" -i "$full_path" "Screenshot saved" "in $full_path" \ - -A "view=View" -A "edit=Edit" -A "open=Open Folder") - - case "$action" in - view) xdg-open "$full_path" ;; - edit) swappy -f "$full_path" ;; - open) xdg-open "$save_dir" ;; - esac - else - notify-send -a "Modus" "Screenshot Aborted" - fi -} - -# Handle command line arguments -if [ $# -eq 0 ]; then - print_usage - exit 1 -fi - -case "$1" in -screenshot) - if [ $# -lt 2 ]; then - echo "Error: Screenshot mode requires an option" - print_usage - exit 1 - fi - do_screenshot "$2" "$3" - ;; -record) - do_screenrecord - ;; -ocr) - do_ocr - ;; -*) - echo "Error: Invalid mode '$1'" - print_usage - exit 1 - ;; -esac diff --git a/services/screencapture.py b/services/screencapture.py new file mode 100644 index 0000000..7ae596b --- /dev/null +++ b/services/screencapture.py @@ -0,0 +1,157 @@ +import datetime +import os +from fabric.core.service import Property, Service, Signal +from fabric.utils import exec_shell_command, exec_shell_command_async +from gi.repository import Gio, GLib +from loguru import logger + + +class ScreenCapture(Service): + @Signal + def recording(self, value: bool) -> None: ... + + def __init__(self, **kwargs): + self.screenshot_path = GLib.get_home_dir() + "/Pictures/Screenshots/" + self.screenrecord_path = GLib.get_home_dir() + "/Videos/Screencasting/" + os.makedirs(self.screenshot_path, exist_ok=True) + os.makedirs(self.screenrecord_path, exist_ok=True) + + super().__init__(**kwargs) + + def screenshot(self, fullscreen=False, save_copy=True): + time = datetime.datetime.today().strftime("%Y-%m-%d_%H-%M-%S") + file_path = self.screenshot_path + str(time) + ".png" + # hyprshot: --mode [window|output|region], --stdout, --clipboard, --save-path + if save_copy: + command = [ + "hyprshot", + "--mode", + "output" if fullscreen else "region", + "--clipboard", + "--save-path", + file_path, + ] + else: + command = [ + "hyprshot", + "--mode", + "output" if fullscreen else "region", + "--clipboard" + ] + try: + exec_shell_command_async( + " ".join(command), + lambda *_: self.send_screenshot_notification( + file_path=file_path if save_copy else None, + ), + ) + + except Exception as e: + logger.error(f"[SCREENSHOT] Failed to run command: {command}") + + def screencast_start(self, fullscreen=False, audio=False): + if self.is_recording: + logger.error( + "[SCREENRECORD] Another instance of wf-recorder is already running" + ) + return + time = datetime.datetime.today().strftime("%Y-%m-%d_%H-%M-%S") + file_path = self.screenrecord_path + str(time) + ".mp4" + self._current_screencast_path = file_path + area = "" if fullscreen else f"-g '{exec_shell_command('slurp')}'" + audio_flag = "--audio --no-mic" if audio else "" + command = f"wf-recorder --file={file_path} --pixel-format yuv420p {audio_flag} {area}" + exec_shell_command_async(command) + self.emit("recording", True) + + def screencast_stop(self): + exec_shell_command_async("killall -INT wf-recorder") + self.emit("recording", False) + self.send_screencast_notification(self._current_screencast_path) + + def send_screencast_notification(self, file_path): + # TODO: Generate a thumbnail + # exec_shell_command_async(f"totem-video-thumbnailer {file_path} {FABRIC_CACHE}") + + cmd = ["notify-send"] + cmd.extend( + [ + "-A", + "files=Show in Files", + "-A", + "view=View", + "-i", + "camera-video-symbolic", + "-a", + "Fabric Screenshot Utility", + "Screencast Saved", + f"Saved Screencast at {file_path}", + ] + ) + + proc: Gio.Subprocess = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.STDOUT_PIPE) + + def do_callback(process: Gio.Subprocess, task: Gio.Task): + try: + _, stdout, stderr = process.communicate_utf8_finish(task) + except Exception: + logger.error( + f"[SCREENCAST] Failed read notification action with error {stderr}" + ) + return + + match stdout.strip("\n"): + case "files": + exec_shell_command_async(f"xdg-open {self.screenrecord_path}") + case "view": + exec_shell_command_async(f"xdg-open {file_path}") + + proc.communicate_utf8_async(None, None, do_callback) + + def send_screenshot_notification(self, file_path=None): + cmd = ["notify-send"] + cmd.extend( + [ + "-A", + "files=Show in Files", + "-A", + "view=View", + "-A", + "edit=Edit", + "-i", + "camera-photo-symbolic", + "-a", + "Fabric Screenshot Utility", + "-h", + f"STRING:image-path:{file_path}", + "Screenshot Saved", + f"Saved Screenshot at {file_path}", + ] + if file_path + else ["Screenshot Sent to Clipboard"] + ) + + proc: Gio.Subprocess = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.STDOUT_PIPE) + + def do_callback(process: Gio.Subprocess, task: Gio.Task): + try: + _, stdout, stderr = process.communicate_utf8_finish(task) + except Exception: + logger.error( + f"[SCREENSHOT] Failed read notification action with error {stderr}" + ) + return + + match stdout.strip("\n"): + case "files": + exec_shell_command_async(f"xdg-open {self.screenshot_path}") + case "view": + exec_shell_command_async(f"xdg-open {file_path}") + case "edit": + exec_shell_command_async(f"swappy -f {file_path}") + + proc.communicate_utf8_async(None, None, do_callback) + + @Property(bool, "readable", default_value=False) + def is_recording(self): + return False if len(exec_shell_command("pidof wf-recorder")) == 0 else True diff --git a/styles/colors.css b/styles/colors.css index 64e41f4..8247b8d 100644 --- a/styles/colors.css +++ b/styles/colors.css @@ -1,32 +1,32 @@ :vars { - --foreground: #e0e3e8; - --background: #101417; - --cursor: #e0e3e8; - --primary: #96ccf8; - --on-primary: #00344f; - --secondary: #b7c9d9; - --on-secondary: #22323f; - --tertiary: #d0c0e8; - --on-tertiary: #362b4a; - --surface: #101417; - --surface-bright: #363a3e; + --foreground: #f0dfd6; + --background: #19120d; + --cursor: #f0dfd6; + --primary: #ffb784; + --on-primary: #502500; + --secondary: #e4bfa8; + --on-secondary: #422b1b; + --tertiary: #c8ca94; + --on-tertiary: #30330b; + --surface: #19120d; + --surface-bright: #413731; --error: #ffb4ab; --error-dim: #ff8678; --on-error: #690005; --error-container: #93000a; - --outline: #8b9198; + --outline: #9f8d83; --shadow: #000000; - --red: #ffb2b9; - --red-dim: #ff7f8b; - --green: #95d5a7; - --green-dim: #70c789; - --yellow: #b8cf84; - --yellow-dim: #a3c15f; - --blue: #afc6ff; - --blue-dim: #7ca2ff; - --magenta: #e4b7f3; - --magenta-dim: #d48bec; - --cyan: #82d3e2; - --cyan-dim: #59c4d8; + --red: #ffb598; + --red-dim: #ff9065; + --green: #b7d085; + --green-dim: #a1c260; + --yellow: #dec56e; + --yellow-dim: #d5b444; + --blue: #cebdfe; + --blue-dim: #a98bfd; + --magenta: #fcb0d5; + --magenta-dim: #fa7fbb; + --cyan: #84d5c4; + --cyan-dim: #5ec8b2; --white: #82d3e0; } diff --git a/styles/kanban.css b/styles/kanban.css new file mode 100644 index 0000000..6c889ba --- /dev/null +++ b/styles/kanban.css @@ -0,0 +1,75 @@ +#kanban { + /* background-color: var(--shadow); */ + border-radius: 20px; + border: 4px solid var(--surface); + padding: 4px; +} + +#kanban-header { + border: 2px solid var(--surface); + border-radius: 12px; + padding: 4px; +} + +#column-header { + padding: 4px; + font-weight: bold; + color: var(--primary); +} + +#kanban-note { + background-color: var(--surface); + border-radius: 4px; + padding: 8px; + margin-bottom: 4px; +} + +#kanban-row:first-child > * > * { + border-radius: 16px 16px 4px 4px; +} + +#kanban-row:last-child > * > * { + border-radius: 4px 4px 16px 16px; +} + +#inline-editor { + border: 2px solid var(--surface-bright); + border-radius: 12px; + padding: 8px; +} + +#kanban-btn, +#kanban-btn-add { + background-color: var(--surface); + border-radius: 8px; + padding: 4px; +} + +#kanban-btn { + background-color: var(--shadow); +} + +#kanban-btn:hover, +#kanban-btn-add:hover { + background-color: var(--surface-bright); +} + +#kanban-btn:active, +#kanban-btn-add:active { + background-color: var(--primary); +} + +#kanban-btn-label, +#kanban-btn-neg { + font-size: 20px; + color: var(--primary); +} + +#kanban-btn-neg { + color: var(--red-dim); +} + +#kanban-btn:active #kanban-btn-label, +#kanban-btn-add:active #kanban-btn-label { + color: var(--shadow); +} diff --git a/styles/launcher.css b/styles/launcher.css new file mode 100644 index 0000000..f878f68 --- /dev/null +++ b/styles/launcher.css @@ -0,0 +1,155 @@ +#launcher { + background: var(--shadow); + padding: 12px; + border-radius: 34px; + min-width: 550px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} +#close-button, +#config-button { + background-color: var(--surface); + border-radius: 40px; + padding: 8px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#close-button:hover, +#close-button:focus, +#config-button:hover, +#config-button:focus { + background-color: var(--surface-bright); + border-radius: 16px; +} + +#close-button:active { + background-color: var(--red-dim); + border-radius: 40px; +} + +#close-label { + color: var(--red-dim); + font-size: 24px; +} + +#close-button:active #close-label { + color: var(--shadow); +} + +#config-button:active { + background-color: var(--primary); + border-radius: 40px; +} + +#config-label { + color: var(--primary); + font-size: 24px; +} + +#config-button:active #config-label { + color: var(--shadow); +} + +#launcher-icon-label { + font-size: 32px; + padding: 8px; +} + +#launcher-search { + font-weight: bold; + background-color: var(--surface); + color: var(--foreground); + border-radius: 16px; + padding: 10px; +} + +#launcher-search selection { + color: var(--background); + background-color: var(--primary); +} + +#launcher-results-scroll { + margin-top: 10px; + border-radius: 16px; +} + +#launcher-results-scroll scrollbar { + border-radius: 10px; + background-color: var(--surface); + padding: 4px; + margin-left: 6px; +} + +#launcher-results-scroll slider { + border-radius: 8px; + min-width: 10px; + min-height: 38px; + background-color: var(--primary); +} + +#launcher-results { + background: transparent; +} + +#launcher-result-item { + border-radius: 40px; + padding: 0 16px; + animation: loadSlot 0.5s ease; + min-height: 52px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +@keyframes loadSlot { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} +#launcher-result-item:focus, +#launcher-result-item:selected, +#launcher-result-item:hover, +#launcher-result-item.selected { + border-radius: 16px; + background-color: var(--surface); + padding-left: 20px; +} + +#result-item-main { + min-height: 48px; +} + +#launcher-result-item { + min-height: 48px; +} + +#result-item-icon { + min-width: 48px; + min-height: 48px; + margin-right: 12px; +} + +#result-item-text { +} + +#result-item-title { + font-size: 14px; + font-weight: 600; + color: #ffffff; + margin-top: 8px; + margin-bottom: 2px; +} + +#result-item-subtitle { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 8px; +} + +#result-item-plugin { + font-size: 10px; + color: rgba(255, 255, 255, 0.5); + font-style: italic; + margin-bottom: 8px; +} diff --git a/styles/launcher_simple.css b/styles/launcher_simple.css index 4ea72a9..50fec8a 100644 --- a/styles/launcher_simple.css +++ b/styles/launcher_simple.css @@ -1,95 +1,96 @@ /* Simple Launcher Styles */ -#launcher-window { - background: rgba(30, 30, 30, 0.95); - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.1); -} -#launcher-icon-label{ - font-size: 32px; - padding: 8px; +/* #launcher-window { */ +/* border-radius: 12px; */ +/* border: 1px solid rgba(255, 255, 255, 0.1); */ +/* } */ + +#launcher-icon-label { + font-size: 32px; + padding: 8px; } #launcher-main { - padding: 20px; - min-width: 550px + background: var(--shadow); + padding: 20px; + min-width: 550px; } #launcher-search { - background: rgba(50, 50, 50, 0.8); - border: 2px solid rgba(100, 100, 100, 0.3); - border-radius: 8px; - padding: 12px 16px; - font-size: 16px; - color: #ffffff; - margin-bottom: 16px; + background: var(--surface); + border: 2px solid var(--outline); + border-radius: 8px; + padding: 12px 16px; + font-size: 16px; + color: #ffffff; + margin-bottom: 16px; } #launcher-search:focus { - border-color: #4a9eff; + border-color: #4a9eff; } #launcher-results-scroll { - background: transparent; - border: none; - min-height: 100px; + background: transparent; + border: none; + min-height: 100px; } #launcher-results { - background: transparent; + background: transparent; } #launcher-result-item { - background: transparent; - border-radius: 8px; - padding: 12px 16px; - margin: 2px 0; - border: 1px solid transparent; + background: transparent; + border-radius: 8px; + padding: 12px 16px; + margin: 2px 0; + border: 1px solid transparent; } #launcher-result-item:hover { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.1); } #launcher-result-item.selected { - background: rgba(74, 158, 255, 0.2); - border-color: #4a9eff; + background: rgba(74, 158, 255, 0.2); + border-color: #4a9eff; } #result-item-main { - min-height: 48px; + min-height: 48px; } #launcher-result-item { - min-height: 48px; + min-height: 48px; } #result-item-icon { - min-width: 48px; - min-height: 48px; - margin-right: 12px; + min-width: 48px; + min-height: 48px; + margin-right: 12px; } #result-item-text { } #result-item-title { - font-size: 14px; - font-weight: 600; - color: #ffffff; - margin-top: 8px; - margin-bottom: 2px; + font-size: 14px; + font-weight: 600; + color: #ffffff; + margin-top: 8px; + margin-bottom: 2px; } #result-item-subtitle { - font-size: 12px; - color: rgba(255, 255, 255, 0.7); - margin-bottom: 2px; + font-size: 12px; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 2px; } #result-item-plugin { - font-size: 10px; - color: rgba(255, 255, 255, 0.5); - font-style: italic; - margin-bottom: 8px; + font-size: 10px; + color: rgba(255, 255, 255, 0.5); + font-style: italic; + margin-bottom: 8px; } diff --git a/utils/icons.py b/utils/icons.py index dd668bb..b61ff9c 100644 --- a/utils/icons.py +++ b/utils/icons.py @@ -189,6 +189,16 @@ dice_4: str = "" dice_5: str = "" dice_6: str = "" +# search engine +reddit: str = "" +youtube: str = "" +duckduckgo: str = "" +github: str = "" +stackoverflow: str = "" +google: str = "" +linkedin: str = "" +wikipedia: str = "啕" + exceptions: list[str] = ["font_family", "font_weight", "span"] diff --git a/utils/inhibit.py b/utils/inhibit.py index a1e8127..d967e4f 100644 --- a/utils/inhibit.py +++ b/utils/inhibit.py @@ -2,8 +2,10 @@ # License: WTFPL Version 2 import sys +import os import time import argparse +import subprocess from dataclasses import dataclass from signal import SIGINT, SIGTERM, signal from threading import Event, Timer @@ -25,10 +27,10 @@ class GlobalRegistry: def parse_duration(duration_str: str) -> int: """Parse duration string into seconds. - Examples: '1h', '30m', '45s', '1.5h', '1.5m', '30.5s', 'off' + Examples: '1h', '30m', '45s', '1.5h', '1.5m', '30.5s', 'on', 'off' """ try: - if duration_str.lower() == 'off': + if duration_str.lower() in ['on', 'off']: return 0 elif duration_str.endswith('h'): return int(float(duration_str[:-1]) * 3600) @@ -39,7 +41,40 @@ def parse_duration(duration_str: str) -> int: else: return int(duration_str) except ValueError: - raise ValueError("Invalid duration format. Use '1h', '30m', '45s', 'off', etc.") + raise ValueError("Invalid duration format. Use '1h', '30m', '45s', 'on', 'off', etc.") + + +def kill_existing_inhibit_processes(): + """Kill any existing modus-inhibit processes.""" + try: + # Find all modus-inhibit processes except the current one + output = subprocess.check_output(["pgrep", "-f", "modus-inhibit"], text=True) + pids = output.strip().split('\n') + current_pid = str(os.getpid()) + + killed_count = 0 + for pid in pids: + pid = pid.strip() + if pid and pid != current_pid: + try: + subprocess.run(["kill", pid], check=True) + killed_count += 1 + except subprocess.CalledProcessError: + pass # Process might have already exited + + if killed_count > 0: + print(f"Stopped {killed_count} existing inhibit process(es)") + else: + print("No existing inhibit processes were running") + return killed_count + + except subprocess.CalledProcessError: + # No processes found + print("No existing inhibit processes were running") + return 0 + except Exception as e: + print(f"Error stopping existing processes: {e}") + return 0 def handle_registry_global( @@ -59,9 +94,14 @@ def handle_registry_global( def main() -> None: parser = argparse.ArgumentParser(description='Inhibit system idle for a specified duration') parser.add_argument('duration', nargs='?', default='0', - help='Duration to inhibit (e.g., "1h", "30m", "45s", "3600"). Use 0 for indefinite.') + help='Duration to inhibit (e.g., "1h", "30m", "45s", "on", "off"). Use "on" for indefinite, "off" to stop.') args = parser.parse_args() + # Handle "off" argument - kill existing processes and exit + if args.duration.lower() == 'off': + kill_existing_inhibit_processes() + sys.exit(0) + done = Event() signal(SIGINT, lambda _, __: done.set()) signal(SIGTERM, lambda _, __: done.set())