diff --git a/config/dock.json b/config/dock.json new file mode 100644 index 0000000..7564bbf --- /dev/null +++ b/config/dock.json @@ -0,0 +1,3 @@ +{ + "pinned_apps": [] +} \ No newline at end of file diff --git a/config/hypr/modus.conf b/config/hypr/modus.conf index 93c85c8..89ea896 100644 --- a/config/hypr/modus.conf +++ b/config/hypr/modus.conf @@ -8,9 +8,11 @@ $fabricSend = fabric-cli exec modus $axMessage = notify-send "Axenide" "FIRE IN THE HOLE‼️🗣️🔥🕳️" -i "/home/sankalp/.config/Modus/assets/ax.png" -A "🗣️" -A "🔥" -A "🕳️" -a "Source Code" # Reload Modus -bind = SHIFT ALT, W, exec, killall modus; uwsm-app $(python /home/sankalp/.config/Modus/main.py) +bind = SUPER ALT, B, exec, killall modus; uwsm-app $(python /home/sankalp/.config/Modus/main.py) # Message bind = SUPER, A, exec, $axMessage +# Application Switcher +bind = ALT, TAB, exec, $fabricSend 'switcher.show_switcher()' # Dashboard bind = SUPER, D, exec, $fabricSend 'notch.open_notch("dashboard")' # Bluetooth diff --git a/config/launcher.json b/config/launcher.json new file mode 100644 index 0000000..5cd51e4 --- /dev/null +++ b/config/launcher.json @@ -0,0 +1,53 @@ +{ + "launcher_config": { + "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": "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" + } +} diff --git a/config/settings/gui.py b/config/settings/gui.py index edc30a3..813ad0b 100644 --- a/config/settings/gui.py +++ b/config/settings/gui.py @@ -27,8 +27,8 @@ gi.require_version("Gtk", "3.0") class HyprConfGUI(Window): def __init__(self, show_lock_checkbox: bool, show_idle_checkbox: bool, **kwargs): super().__init__( - title="Ax-Shell Settings", - name="axshell-settings-window", + title="Modus Settings", + name="modus-settings-window", size=(640, 640), **kwargs, ) @@ -124,7 +124,11 @@ class HyprConfGUI(Window): bindings = [ (f"Reload {APP_NAME_CAP}", "prefix_restart", "suffix_restart"), ("Message", "prefix_axmsg", "suffix_axmsg"), - ("Application Switcher", "prefix_application_switcher", "suffix_application_switcher"), + ( + "Application Switcher", + "prefix_application_switcher", + "suffix_application_switcher", + ), ("Dashboard", "prefix_dash", "suffix_dash"), ("Bluetooth", "prefix_bluetooth", "suffix_bluetooth"), ("Pins", "prefix_pins", "suffix_pins"), @@ -342,7 +346,9 @@ class HyprConfGUI(Window): layout_grid.attach(dock_theme_combo_container, 3, 1, 1, 1) # Add icon size scale - icon_size_label = Label(label="Taskbar Icon Size", h_align="start", v_align="center") + icon_size_label = Label( + label="Taskbar Icon Size", h_align="start", v_align="center" + ) layout_grid.attach(icon_size_label, 0, 4, 1, 1) icon_size_scale_container = Gtk.Box( @@ -365,7 +371,9 @@ class HyprConfGUI(Window): layout_grid.attach(icon_size_scale_container, 1, 4, 1, 1) # Add application switcher items scale - application_switcher_label = Label(label="Application Switcher Items", h_align="start", v_align="center") + application_switcher_label = Label( + label="Application Switcher Items", h_align="start", v_align="center" + ) layout_grid.attach(application_switcher_label, 2, 4, 1, 1) application_switcher_scale_container = Gtk.Box( @@ -378,11 +386,15 @@ class HyprConfGUI(Window): self.application_switcher_scale = Gtk.Scale.new_with_range( Gtk.Orientation.HORIZONTAL, 5, 20, 1 ) - self.application_switcher_scale.set_value(bind_vars.get("window_switcher_items_per_row", 13)) + self.application_switcher_scale.set_value( + bind_vars.get("window_switcher_items_per_row", 13) + ) self.application_switcher_scale.set_size_request(150, -1) self.application_switcher_scale.set_draw_value(True) self.application_switcher_scale.set_value_pos(Gtk.PositionType.RIGHT) - self.application_switcher_scale.set_tooltip_text("Adjust the number of items per row in the application switcher") + self.application_switcher_scale.set_tooltip_text( + "Adjust the number of items per row in the application switcher" + ) application_switcher_scale_container.add(self.application_switcher_scale) layout_grid.attach(application_switcher_scale_container, 3, 4, 1, 1) @@ -491,7 +503,7 @@ class HyprConfGUI(Window): "indicators": "Indicators", "applications": "Taskbar", "language": "Language Indicator", - "music_player": "Music Player" + "music_player": "Music Player", } self.corners_switch = Gtk.Switch(active=bind_vars.get("corners_visible", True)) @@ -599,7 +611,7 @@ class HyprConfGUI(Window): valign=Gtk.Align.CENTER, ) self.lock_switch = Gtk.Switch( - tooltip_text="Replace Hyprlock configuration with Ax-Shell's custom config" + tooltip_text="Replace Hyprlock configuration with Modus's custom config" ) lock_switch_container.add(self.lock_switch) system_grid.attach(lock_switch_container, 3, row, 1, 1) @@ -616,7 +628,7 @@ class HyprConfGUI(Window): valign=Gtk.Align.CENTER, ) self.idle_switch = Gtk.Switch( - tooltip_text="Replace Hypridle configuration with Ax-Shell's custom config" + tooltip_text="Replace Hypridle configuration with Modus's custom config" ) idle_switch_container.add(self.idle_switch) system_grid.attach(idle_switch_container, 3, row, 1, 1) @@ -842,7 +854,7 @@ class HyprConfGUI(Window): self.icon_size_scale.get_value() ) current_bind_vars_snapshot["window_switcher_items_per_row"] = int( - self.window_switcher_scale.get_value() + self.application_switcher_scale.get_value() ) current_bind_vars_snapshot["terminal_command"] = self.terminal_entry.get_text() current_bind_vars_snapshot["corners_visible"] = self.corners_switch.get_active() @@ -973,7 +985,7 @@ class HyprConfGUI(Window): start_config() print(f"{time.time():.4f}: Finished start_config().") - print(f"{time.time():.4f}: Initiating Ax-Shell restart using Popen...") + print(f"{time.time():.4f}: Initiating Modus restart using Popen...") main_py = os.path.expanduser(f"~/{APP_NAME_CAP}/main.py") kill_cmd = f"killall {APP_NAME}" start_cmd = ["uwsm", "app", "--", "python", main_py] @@ -1004,7 +1016,7 @@ class HyprConfGUI(Window): except Exception as e: print(f"Error restarting {APP_NAME_CAP} via Popen: {e}") - print(f"{time.time():.4f}: Ax-Shell restart commands issued via Popen.") + print(f"{time.time():.4f}: Modus restart commands issued via Popen.") end_time = time.time() print( f"{end_time:.4f}: Background task finished (Total: { @@ -1061,7 +1073,7 @@ class HyprConfGUI(Window): self.wall_dir_chooser.set_filename(utils.bind_vars["wallpapers_dir"]) positions = ["Bottom", "Left", "Right"] - default_position = DEFAULTS.get("dock_position", "Top") + default_position = DEFAULTS.get("dock_position", "Bottom") try: self.position_combo.set_active(positions.index(default_position)) except ValueError: @@ -1069,7 +1081,7 @@ class HyprConfGUI(Window): self.dock_switch.set_active(utils.bind_vars.get("dock_enabled", True)) self.dock_auto_hide_switch.set_active( - utils.bind_vars.get("dock_auto_hide", False) + utils.bind_vars.get("dock_auto_hide", True) ) self.dock_hover_switch.set_active( utils.bind_vars.get("dock_always_occluded", False) @@ -1164,7 +1176,9 @@ class HyprConfGUI(Window): self.idle_switch.set_active(False) # Add reset for icon size scale self.icon_size_scale.set_value(utils.bind_vars.get("dock_icon_size", 20)) - self.application_switcher_scale.set_value(utils.bind_vars.get("window_switcher_items_per_row", 13)) + self.application_switcher_scale.set_value( + utils.bind_vars.get("window_switcher_items_per_row", 13) + ) print("Settings reset to defaults.") def on_close(self, widget): diff --git a/config/settings/utils.py b/config/settings/utils.py index ab3eca6..f21fea7 100644 --- a/config/settings/utils.py +++ b/config/settings/utils.py @@ -138,7 +138,7 @@ def ensure_matugen_config(): image_path = "" if not os.path.exists(current_wall): example_wallpaper_path = os.path.expanduser( - f"~/.config/{APP_NAME_CAP}/assets/wallpapers_example/example-1.jpg" + f"~/{APP_NAME_CAP}/assets/wallpapers_example/example-1.jpg" ) if os.path.exists(example_wallpaper_path): try: diff --git a/main.css b/main.css index f96e252..f37c7db 100644 --- a/main.css +++ b/main.css @@ -6,6 +6,7 @@ @import url("./styles/workspaces.css"); @import url("./styles/switcher.css"); @import url("./styles/osd.css"); +@import url("./styles/launcher_simple.css"); * { all: unset; diff --git a/main.py b/main.py index 116a7c7..b980135 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,8 @@ from config.data import APP_NAME, APP_NAME_CAP, CONFIG_FILE from modules.corners import Corners from modules.dock.main import Dock from modules.switcher import ApplicationSwitcher -from modules.osd import OSD +from modules.osd import OSD +from modules.launcher import Launcher gi.require_version("GLib", "2.0") @@ -46,6 +47,7 @@ if __name__ == "__main__": dock = Dock() switcher = ApplicationSwitcher() osd = OSD() + launcher = Launcher() # Set corners visibility based on config corners_visible = config.get("corners_visible", True) corners.set_visible(corners_visible) @@ -63,6 +65,7 @@ if __name__ == "__main__": corners, switcher, osd, + launcher, ) # Make sure corners is added to the app def set_css(): @@ -74,4 +77,23 @@ if __name__ == "__main__": app.set_css() + # Register launcher actions + @Application.action("show-launcher") + def show_launcher(): + """Show the launcher window.""" + launcher.show_launcher() + + @Application.action("hide-launcher") + def hide_launcher(): + """Hide the launcher window.""" + launcher.hide_launcher() + + @Application.action("toggle-launcher") + def toggle_launcher(): + """Toggle the launcher window visibility.""" + if launcher.visible: + launcher.hide_launcher() + else: + launcher.show_launcher() + app.run() diff --git a/modules/dock/components/applications.py b/modules/dock/components/applications.py index 24b6ad4..45fa6d5 100644 --- a/modules/dock/components/applications.py +++ b/modules/dock/components/applications.py @@ -91,7 +91,8 @@ class Applications(Box): # Connect to Hyprland events for live updates if self.conn.ready: - self.update_applications() + # Only update again if not already updated above + pass else: self.conn.connect("event::ready", lambda *args: self.update_applications()) @@ -393,8 +394,13 @@ class Applications(Box): for child in children: self.add(child) - # Show or hide based on whether there are any applications - if len(children) > 0: + # Check if the applications component is enabled in the dock configuration + # Import here to avoid circular imports + import config.data as data + applications_visible = data.DOCK_COMPONENTS_VISIBILITY.get("applications", True) + + # Show or hide based on whether there are any applications AND if component is enabled + if len(children) > 0 and applications_visible: self.show_all() else: self.hide() @@ -457,6 +463,9 @@ class Applications(Box): if self.dock_instance: self.dock_instance.prevent_hiding(True) + # Store the source widget for later use + self._dragged_widget = widget + def on_drag_data_get(self, widget, drag_context, data_obj, info, time): # Find the index of the button being dragged children = self.get_children() @@ -551,6 +560,31 @@ class Applications(Box): if self.dock_instance: self.dock_instance.prevent_hiding(False) + # Check if the drag ended outside the dock + if hasattr(self, '_dragged_widget') and self._dragged_widget: + children = self.get_children() + source_index = children.index(self._dragged_widget) + + # Find separator index + separator_index = -1 + for i, child in enumerate(children): + if child.get_name() == "dock-separator": + separator_index = i + break + + # If the dragged item was in the pinned section (before separator) + if separator_index != -1 and source_index < separator_index: + # Remove from pinned apps + if source_index < len(self.pinned): + self.pinned.pop(source_index) + self.config["pinned_apps"] = self.pinned + self.update_pinned_apps_file() + self.update_applications() + + # Clear the dragged widget reference + if hasattr(self, '_dragged_widget'): + delattr(self, '_dragged_widget') + def get_focused(self): try: return json.loads( diff --git a/modules/launcher/__init__.py b/modules/launcher/__init__.py new file mode 100644 index 0000000..547fa73 --- /dev/null +++ b/modules/launcher/__init__.py @@ -0,0 +1,8 @@ +""" +Plugin-based launcher module for Fabric. +Similar to Albert Launcher with extensible plugin system. +""" + +from .main import Launcher + +__all__ = ["Launcher"] diff --git a/modules/launcher/main.py b/modules/launcher/main.py new file mode 100644 index 0000000..afe06e1 --- /dev/null +++ b/modules/launcher/main.py @@ -0,0 +1,669 @@ +""" +Main launcher window implementation. +Provides a search interface with plugin-based results. +""" + +import gi +from typing import List, Optional +from fabric.widgets.window import Window +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 + + +class Launcher(WaylandWindow): + """ + 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", + anchor="center", + exclusivity="auto", + keyboard_mode="on-demand", + **kwargs, + ) + + # Initialize plugin manager + self.plugin_manager = PluginManager() + + # Initialize trigger configuration + self.trigger_config = TriggerConfig() + + # Current results and selection + self.results: List[Result] = [] + self.selected_index = 0 + self.max_results = 10 + + # 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", + 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, + ) + self.search_entry.connect("changed", self._on_search_changed) + self.search_entry.connect("activate", self._on_entry_activate) + main_box.add(self.search_entry) + + # 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), + ) + + self.results_box = Box( + name="launcher-results", + orientation="v", + spacing=0, + ) + self.results_scroll.add(self.results_box) + main_box.add(self.results_scroll) + + # Initially hide the results container + self.results_scroll.hide() + + + 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.grab_focus() + self.visible = True + + def hide_launcher(self): + """Hide the launcher and clear search.""" + self.hide() + self.search_entry.set_text("") + self._clear_results() + self.triggered_plugin = None + self.active_trigger = "" + self.visible = False + + def _on_search_changed(self, entry): + """Handle search text changes.""" + query = entry.get_text().strip() + self.query = query + + if 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() + + def _perform_search(self, query: str) -> bool: + """Perform search across all plugins.""" + # Only search if query hasn't changed + if query != self.query: + return False + + if not query: + # Empty query - reset trigger mode and hide results + self.triggered_plugin = None + self.active_trigger = "" + self._clear_results() + return False + + # Check if we're already in trigger mode + if self.triggered_plugin and self.active_trigger: + # 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) + all_results = self.triggered_plugin.query(remaining_query) + except Exception as e: + print(f"Error in triggered plugin {self.triggered_plugin.name}: {e}") + all_results = [] + else: + # Check for trigger activation + triggered_plugin, trigger = self._detect_trigger(query) + + if triggered_plugin: + # New trigger detected - enter trigger mode + self.triggered_plugin = triggered_plugin + self.active_trigger = trigger + + # 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 + all_results = [] + else: + # No trigger detected - try global search across all plugins + self.triggered_plugin = None + self.active_trigger = "" + + # First try trigger suggestions if query matches trigger prefixes + trigger_suggestions = self._get_trigger_suggestions(query) + + if trigger_suggestions: + # Show trigger suggestions + all_results = trigger_suggestions + else: + # No trigger suggestions - do global search across all plugins + all_results = self._global_search(query) + + # Sort results by relevance score + all_results.sort(key=lambda r: r.relevance, reverse=True) + + # 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') + 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] + else: + self.results = all_results + self.selected_index = 0 + + # Update UI + self._update_results_display() + + return False # Don't repeat timeout + + def _extract_query_after_trigger(self, query: str, trigger: str) -> str: + """ + Extract the search query after removing the trigger. + + Args: + query: The full query string + trigger: The trigger keyword + + Returns: + The remaining query after the trigger + """ + if not query or not trigger: + return "" + + query_lower = query.lower() + 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() + + # Handle trigger without space (e.g., "app") + trigger_word = trigger.strip().lower() + if query_lower.startswith(trigger_word): + # 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): + # No space after trigger word, extract rest + return query[len(trigger_word):].strip() + + return "" + + def _global_search(self, query: str): + """ + Perform global search across all plugins. + + Args: + query: The search query + + Returns: + List of Result objects from all plugins + """ + all_results = [] + + # Search in all active plugins + for plugin in self.plugin_manager.get_active_plugins(): + try: + plugin_results = plugin.query(query) + all_results.extend(plugin_results) + except Exception as e: + print(f"Error searching in plugin {plugin.name}: {e}") + + return all_results + + 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.selected_index = 0 + self._update_results_display() + + def _detect_trigger(self, query: str): + """ + Detect if query starts with a trigger keyword. + + Args: + query: The search query + + Returns: + Tuple of (plugin, trigger) if triggered, (None, "") otherwise + """ + if not query.strip(): + return None, "" + + # Check all plugins for triggers + for plugin in self.plugin_manager.get_active_plugins(): + trigger = plugin.get_active_trigger(query) + if trigger: + return plugin, trigger + + return None, "" + + def _get_trigger_suggestions(self, query: str): + """ + Get trigger suggestions based on the current query. + + Args: + query: The search query + + Returns: + List of Result objects showing available triggers + """ + from .result import Result + + suggestions = [] + query_lower = query.lower().strip() + + # Get all available triggers from plugins + all_triggers = {} + for plugin in self.plugin_manager.get_active_plugins(): + triggers = plugin.get_triggers() + for trigger in triggers: + trigger_clean = trigger.strip() + if trigger_clean not in all_triggers: + all_triggers[trigger_clean] = { + 'plugin': plugin, + 'trigger': trigger, + 'examples': [] + } + + # Get max examples to show from configuration + max_examples = self.trigger_config.get_max_examples_shown() + + # Show trigger suggestions based on query + if query_lower: + # Show triggers that match the query + for trigger_clean, info in all_triggers.items(): + 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) + + # Create result for this trigger + result = Result( + title=f"{trigger_clean}", + 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} + ) + suggestions.append(result) + else: + # Empty query - show all available triggers + for trigger_clean, info in all_triggers.items(): + 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) + + # Create result for this trigger + result = Result( + title=f"{trigger_clean}", + 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} + ) + suggestions.append(result) + + return suggestions[:self.max_results] + + def _activate_trigger(self, trigger: str): + """ + Activate a trigger by setting it in the search entry. + + Args: + trigger: The trigger keyword to activate + """ + # 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 + + # Manually set the trigger mode to avoid search processing issues + triggered_plugin, detected_trigger = self._detect_trigger(trigger_text) + if triggered_plugin: + self.triggered_plugin = triggered_plugin + self.active_trigger = detected_trigger + + # Clear results and show trigger ready state + self.results = [] + self.selected_index = 0 + self._update_results_display() + + # Focus back to the search entry for immediate typing + self.search_entry.grab_focus() + + # Don't hide the launcher - user should be able to continue typing + + def _update_results_display(self): + """Update the results display.""" + # Update input field with trigger indication (Albert-style) + self._update_input_action_text() + + # Clear existing results + for child in self.results_box.get_children(): + self.results_box.remove(child) + + # 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) + + self.results_box.show_all() + + # Show/hide scroll container based on results + if self.results: + self.results_scroll.show() + else: + self.results_scroll.hide() + + def _update_input_action_text(self): + """Update the input field with action text (Albert-style).""" + # Get the current input text + current_text = self.search_entry.get_text() + + if self.triggered_plugin: + if self.results: + # Show the first result as action text + first_result = self.results[0] + 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']) + + # 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}") + else: + 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") + else: + # Searching within trigger + 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...)") + else: + self.search_entry.set_placeholder_text("Type trigger keyword: calc, app, file, system...") + + def _clear_results(self): + """Clear all results.""" + self.results = [] + self.selected_index = 0 + 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: + # Exit trigger mode + self.triggered_plugin = None + self.active_trigger = "" + self.search_entry.set_text("") + self._clear_results() + return True + else: + # Hide launcher + self.hide_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 + if event.state & Gdk.ModifierType.SHIFT_MASK: + if self.results and 0 <= self.selected_index < len(self.results): + result = self.results[self.selected_index] + if result.data and result.data.get("pin_action"): + result.data["pin_action"]() + return True + # Normal Enter behavior + self._activate_selected() + return True + + # Tab - cycle through results + if keyval == Gdk.KEY_Tab: + if self.results: + self.selected_index = (self.selected_index + 1) % len(self.results) + self._update_selection() + 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() + selected_widget = None + + for i, child in enumerate(children): + if isinstance(child, ResultItem): + is_selected = (i == self.selected_index) + child.set_selected(is_selected) + if is_selected: + selected_widget = child + + # Scroll to make the selected item visible + if selected_widget and self.results_scroll.get_visible(): + # Use immediate scrolling for better responsiveness + self._scroll_to_widget(selected_widget) + # Also schedule a more accurate scroll after layout is complete + GLib.idle_add(self._ensure_selected_visible) + + def _scroll_to_widget(self, widget): + """Scroll the results container to make the widget visible.""" + if not widget or not self.results_scroll.get_visible(): + return + + # Debug output (can be removed in production) + # print(f"Scrolling to selected index: {self.selected_index}") + + # Get the scrolled window's vertical adjustment + vadjustment = self.results_scroll.get_vadjustment() + if not vadjustment: + return + + # Use a simpler approach: scroll to the selected item index + if self.results and 0 <= self.selected_index < len(self.results): + # Get all children to work with actual widgets + children = self.results_box.get_children() + if not children or self.selected_index >= len(children): + return + + # Get the selected child widget + selected_child = children[self.selected_index] + + # Try to get actual allocation, fallback to estimation + try: + allocation = selected_child.get_allocation() + item_height = allocation.height if allocation.height > 0 else 60 + item_y = allocation.y + except: + # Fallback to estimation + item_height = 60 # Default item height + item_y = self.selected_index * item_height + + # Get current scroll info + current_scroll = vadjustment.get_value() + page_size = vadjustment.get_page_size() + max_scroll = vadjustment.get_upper() - page_size + + # Calculate visible area + visible_top = current_scroll + visible_bottom = current_scroll + page_size + + # Check if selected item is visible + item_top = item_y + item_bottom = item_y + item_height + + # Add some padding for better visibility + padding = 10 + + # Scroll if needed + if item_top < visible_top + padding: + # Item is above visible area - scroll up + new_scroll = max(0, item_top - padding) + vadjustment.set_value(new_scroll) + elif item_bottom > visible_bottom - padding: + # Item is below visible area - scroll down + new_scroll = min(max_scroll, item_bottom - page_size + padding) + vadjustment.set_value(new_scroll) + + 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): + return False + + children = self.results_box.get_children() + if self.selected_index < len(children): + selected_child = children[self.selected_index] + + # Try to use widget's allocation for more accurate scrolling + try: + allocation = selected_child.get_allocation() + if allocation.height > 0: + vadjustment = self.results_scroll.get_vadjustment() + 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)) + vadjustment.set_value(target_pos) + except Exception as e: + print(f"Error in _ensure_selected_visible: {e}") + + return False # Don't repeat the idle callback + + def _activate_selected(self): + """Activate the currently selected result.""" + if self.results and 0 <= self.selected_index < len(self.results): + result = self.results[self.selected_index] + try: + # Check if this is a trigger suggestion + is_trigger_suggestion = ( + result.data and + result.data.get("type") == "trigger_suggestion" + ) + + # Activate the result + result.activate() + + # Only hide launcher for non-trigger suggestions + if not is_trigger_suggestion: + self.hide_launcher() + # For trigger suggestions, the launcher stays open and + # the user can continue typing after the trigger + + except Exception as e: + print(f"Error activating result: {e}") diff --git a/modules/launcher/plugin_base.py b/modules/launcher/plugin_base.py new file mode 100644 index 0000000..d63ca88 --- /dev/null +++ b/modules/launcher/plugin_base.py @@ -0,0 +1,173 @@ +""" +Base class for launcher plugins. +""" + +from abc import ABC, abstractmethod +from typing import List +from .result import Result + + +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__ + self.description = "A launcher plugin" + self.version = "1.0.0" + self.enabled = True + self._triggers = [] # List of trigger keywords + + @abstractmethod + def initialize(self): + """ + Initialize the plugin. + Called when the plugin is activated. + """ + pass + + @abstractmethod + def cleanup(self): + """ + Cleanup the plugin. + 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. + If the query starts with any of these, this plugin gets priority. + + Returns: + List of trigger strings (e.g., ["calc", "=", "math"]) + """ + return self._triggers + + def set_triggers(self, triggers: List[str]): + """ + Set the trigger keywords for this plugin. + + Args: + 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. + + Args: + query_string: The search query + + Returns: + True if this plugin should process the query + """ + if not self.enabled: + return False + + # Check triggers + triggers = self.get_triggers() + if triggers: + query_lower = query_string.lower().strip() + return any(query_lower.startswith(trigger.lower()) for trigger in triggers) + + # Default: handle all queries + return True + + def get_active_trigger(self, query_string: str) -> str: + """ + Get the active trigger for the given query. + + Args: + query_string: The search query + + Returns: + The trigger keyword if found, empty string otherwise + """ + if not self.enabled: + return "" + + triggers = self.get_triggers() + if triggers: + query_lower = query_string.lower().strip() + + # Sort triggers by length (longest first) to match more specific triggers first + sorted_triggers = sorted(triggers, key=len, reverse=True) + + for trigger in sorted_triggers: + trigger_lower = trigger.lower() + + # Exact match with trigger (including space if present) + if query_lower.startswith(trigger_lower): + return trigger + + # Match trigger word followed by space + trigger_word = trigger.strip().lower() + if (query_lower.startswith(trigger_word + " ") or + query_lower == trigger_word): + return trigger + + return "" + + def query_triggered(self, query_string: str, trigger: str) -> List[Result]: + """ + Process a triggered query (when plugin is in sticky mode). + Default implementation removes trigger and calls query(). + + Args: + query_string: The full query string including trigger + trigger: The trigger that activated this plugin + + Returns: + List of Result objects + """ + # Remove trigger from query and process remaining text + 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 + """ + return { + "name": self.name, + "display_name": self.display_name, + "description": self.description, + "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 new file mode 100644 index 0000000..837007e --- /dev/null +++ b/modules/launcher/plugin_manager.py @@ -0,0 +1,176 @@ +""" +Plugin manager for the launcher. +Handles loading, managing, and executing plugins. +""" + +import os +import importlib +import importlib.util +from typing import List, Dict, Type +from modules.launcher.plugin_base import PluginBase + + +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") + + if not os.path.exists(plugins_dir): + return + + for filename in os.listdir(plugins_dir): + 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) + + 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) + + # 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): + + 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", + "calculator", + "system", + "clipboard", + "power", + "caffeine", + ] + + 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] + + 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/__init__.py b/modules/launcher/plugins/__init__.py new file mode 100644 index 0000000..666274b --- /dev/null +++ b/modules/launcher/plugins/__init__.py @@ -0,0 +1,3 @@ +""" +Built-in plugins for the launcher. +""" diff --git a/modules/launcher/plugins/applications.py b/modules/launcher/plugins/applications.py new file mode 100644 index 0000000..2df9f11 --- /dev/null +++ b/modules/launcher/plugins/applications.py @@ -0,0 +1,195 @@ +""" +Applications plugin for the launcher. +Searches and launches desktop applications. +""" + +import re +import json +from typing import List +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 + + +class ApplicationsPlugin(PluginBase): + """ + Plugin for searching and launching desktop applications. + """ + + def __init__(self): + super().__init__() + self.display_name = "Applications" + self.description = "Search and launch desktop applications" + 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 = [] + + def _pin_application(self, app): + """Pin an application to the dock.""" + app_data = { + k: v + for k, v in { + "name": app.name, + "display_name": app.display_name, + "window_class": app.window_class, + "executable": app.executable, + "command_line": app.command_line, + "icon_name": app.icon_name, + }.items() + if v is not None + } + + config_path = get_relative_path("../../../config/dock.json") + try: + with open(config_path, "r") as file: + data = json.load(file) + except (FileNotFoundError, json.JSONDecodeError): + data = {"pinned_apps": []} + + already_pinned = False + for pinned_app in data.get("pinned_apps", []): + if ( + isinstance(pinned_app, dict) + and pinned_app.get("name") == app_data["name"] + ): + already_pinned = True + pinned_app.update(app_data) + break + elif isinstance(pinned_app, str) and pinned_app == app_data["name"]: + already_pinned = True + data["pinned_apps"].remove(pinned_app) + data["pinned_apps"].append(app_data) + break + + if not already_pinned: + data.setdefault("pinned_apps", []).append(app_data) + + with open(config_path, "w") as file: + json.dump(data, file, indent=4) + + Dock.notify_config_change() + + def query(self, query_string: str) -> List[Result]: + """Search applications based on query.""" + if not query_string.strip(): + return [] + + query = query_string.lower().strip() + results = [] + + for app in self.applications: + relevance = self._calculate_relevance(app, query) + if relevance > 0: + result = Result( + title=app.display_name or app.name, + subtitle=app.description or app.generic_name or "", + 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) + }, + ) + results.append(result) + + return results + + def _calculate_relevance(self, app, query: str) -> float: + """Calculate relevance score for an application.""" + if not query: + return 0.0 + + # Get searchable text + name = (app.name or "").lower() + display_name = (app.display_name or "").lower() + description = (app.description or "").lower() + generic_name = (app.generic_name or "").lower() + executable = (app.executable or "").lower() + + # Exact matches get highest score + if query == name or query == display_name: + return 1.0 + + # Starts with matches get high score + if ( + name.startswith(query) + or display_name.startswith(query) + or generic_name.startswith(query) + ): + return 0.9 + + # Contains matches get medium score + if ( + query in name + or query in display_name + or query in description + or query in generic_name + ): + return 0.7 + + # Fuzzy matching for partial matches + if self._fuzzy_match(query, name) or self._fuzzy_match(query, display_name): + return 0.5 + + # Executable name matching + if executable and query in executable: + return 0.4 + + return 0.0 + + def _fuzzy_match(self, query: str, text: str) -> bool: + """Simple fuzzy matching algorithm.""" + if not query or not text: + return False + + # Create regex pattern for fuzzy matching + 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}") diff --git a/modules/launcher/plugins/caffeine.py b/modules/launcher/plugins/caffeine.py new file mode 100644 index 0000000..80673f9 --- /dev/null +++ b/modules/launcher/plugins/caffeine.py @@ -0,0 +1,127 @@ +""" +Caffeine plugin for the launcher. +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 + +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") + + # Predefined durations + self.durations = { + "30m": "30 minutes", + "1h": "1 hour", + "2h": "2 hours", + "4h": "4 hours", + "8h": "8 hours", + "indefinite": "Indefinitely", + "off": "Off" + } + + def initialize(self): + """Initialize the caffeine plugin.""" + self.set_triggers(["caffeine", "caffeine "]) + + def cleanup(self): + """Cleanup the caffeine plugin.""" + pass + + 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 + subprocess.Popen( + ["python3", self.inhibit_script, duration], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + 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: + """Check if the query is a valid duration format.""" + if query in self.durations: + return True + if query.isdigit(): + return True + if query.endswith(('h', 'm', 's')) and query[:-1].replace('.', '').isdigit(): + return True + return False + + def _get_default_action(self, query: str): + """Get the default action for a direct duration input.""" + if self._is_valid_duration(query): + return self._create_inhibit_action(query) + return None + + def query(self, query_string: str) -> List[Result]: + """Search caffeine durations based on query.""" + if not query_string.strip(): + return [] + + query = query_string.lower().strip() + results = [] + + # 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 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()): + result = Result( + title=f"Custom: {query}", + subtitle="Set custom duration", + icon_markup=icons.coffee, + action=self._create_inhibit_action(query), + relevance=1.0, + plugin_name=self.display_name, + 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(): + result = Result( + title=description, + subtitle=f"Prevent idle for {description.lower()}", + 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} + ) + results.append(result) + + # Set default action for direct duration input + if results: + results[0].default_action = self._get_default_action(query) + + return results \ No newline at end of file diff --git a/modules/launcher/plugins/calculator.py b/modules/launcher/plugins/calculator.py new file mode 100644 index 0000000..e30e307 --- /dev/null +++ b/modules/launcher/plugins/calculator.py @@ -0,0 +1,156 @@ +""" +Calculator plugin for the launcher. +Evaluates mathematical expressions and converts units. +""" + +import re +import math +import time +import subprocess +from typing import List, Dict, Tuple +from ..plugin_base import PluginBase +from ..result import Result +from utils.conversion import Conversion +import utils.icons as icons + + +class CalculatorPlugin(PluginBase): + """ + Plugin for calculating mathematical expressions and converting units. + """ + + def __init__(self): + super().__init__() + self.display_name = "Calculator" + self.description = "Evaluate mathematical expressions and convert units" + + # Safe functions for evaluation + self.safe_functions = { + "abs": abs, + "round": round, + "min": min, + "max": max, + "sum": sum, + "pow": pow, + "sqrt": math.sqrt, + "sin": math.sin, + "cos": math.cos, + "tan": math.tan, + "asin": math.asin, + "acos": math.acos, + "atan": math.atan, + "log": math.log, + "log10": math.log10, + "exp": math.exp, + "pi": math.pi, + "e": math.e, + } + + # Initialize conversion utility + self.converter = Conversion() + + # Pre-compiled regex patterns + self.expression_pattern = re.compile(r"[\d+\-*/^()=]") + self.number_pattern = re.compile(r"\d") + self.conversion_pattern = re.compile( + r"(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*(?:to|in|=)\s*([a-zA-Z]+)" + ) + + # Cache for conversion results + self._conversion_cache = {} + self._last_cache_cleanup = time.time() + self._cache_cleanup_interval = 300 # 5 minutes + + def initialize(self): + """Initialize the files plugin.""" + self.set_triggers(["=", "= "]) + + def _cleanup_cache(self): + """Clean up old cache entries.""" + current_time = time.time() + if current_time - self._last_cache_cleanup > self._cache_cleanup_interval: + self._conversion_cache.clear() + self._last_cache_cleanup = current_time + + def query(self, query: str) -> List[Result]: + """Process a query and return results.""" + if not query: + return [] + + # Clean up cache periodically + self._cleanup_cache() + + # Check if it's a conversion query + conversion_match = self.conversion_pattern.match(query) + if conversion_match: + try: + value, from_unit, to_unit = conversion_match.groups() + value = float(value) + + # Check cache first + cache_key = f"{value}_{from_unit}_{to_unit}" + if cache_key in self._conversion_cache: + result = self._conversion_cache[cache_key] + else: + # Use the conversion utility + result = self.converter.convert(value, from_unit, to_unit) + # Cache the result + self._conversion_cache[cache_key] = result + + return [ + Result( + title=f"{result:.6g} {to_unit}", + subtitle=f"{value} {from_unit} = {result:.6g} {to_unit}", + icon_markup=icons.calculator, + action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r), + relevance=1.0, + plugin_name=self.display_name, + data={"from": (value, from_unit), "to": (result, to_unit)}, + ) + ] + except ValueError as e: + return [ + Result( + title="Invalid conversion", + subtitle=str(e), + icon_markup=icons.calculator, + relevance=0.0, + plugin_name=self.display_name, + ) + ] + + # Check if it's a math expression + if self.expression_pattern.search(query): + try: + # Evaluate the expression + result = eval(query, {"__builtins__": {}}, self.safe_functions) + if isinstance(result, (int, float)): + return [ + Result( + title=f"{result:.6g}", + subtitle=f"{query} = {result:.6g}", + icon_markup=icons.calculator, + action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r), + relevance=1.0, + plugin_name=self.display_name, + ) + ] + except Exception: + pass + + return [] + + def _copy_to_clipboard(self, text: str): + """Copy text to clipboard using cliphist.""" + try: + # First copy to clipboard + subprocess.run(["wl-copy"], input=text.encode(), check=True) + # Then store in cliphist + subprocess.run(["cliphist", "store"], input=text.encode(), check=True) + except subprocess.CalledProcessError: + # If cliphist fails, at least we have the text in clipboard + pass + + def cleanup(self): + """Clean up resources.""" + self._conversion_cache.clear() diff --git a/modules/launcher/plugins/clipboard.py b/modules/launcher/plugins/clipboard.py new file mode 100644 index 0000000..02b6aed --- /dev/null +++ b/modules/launcher/plugins/clipboard.py @@ -0,0 +1,207 @@ +""" +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 + + +class ClipboardPlugin(PluginBase): + def __init__(self): + super().__init__() + 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-") + self.image_cache: Dict[str, GdkPixbuf.Pixbuf] = {} + self.clipboard_items = [] + self._loading = False + self._pending_updates = False + + def initialize(self): + """Initialize the plugin.""" + try: + subprocess.run(["cliphist", "list"], capture_output=True, check=True) + except (subprocess.SubprocessError, FileNotFoundError): + raise RuntimeError("cliphist is not installed or not working properly") + + def cleanup(self): + """Cleanup the plugin.""" + try: + if os.path.exists(self.tmp_dir): + import shutil + + shutil.rmtree(self.tmp_dir) + self.image_cache.clear() + except Exception as e: + print(f"Error cleaning up temporary files: {e}", file=sys.stderr) + + def _load_clipboard_items(self) -> List[str]: + """Load clipboard items from cliphist.""" + try: + result = subprocess.run( + ["cliphist", "list"], capture_output=True, check=True + ) + stdout_str = result.stdout.decode("utf-8", errors="replace") + lines = stdout_str.strip().split("\n") + return [line for line in lines if line and " GdkPixbuf.Pixbuf: + """Create a GdkPixbuf from image bytes with size limit.""" + try: + loader = GdkPixbuf.PixbufLoader() + loader.write(image_data) + loader.close() + pixbuf = loader.get_pixbuf() + + # Scale image if needed + width, height = pixbuf.get_width(), pixbuf.get_height() + if width > height: + new_width = max_size + new_height = int(height * (max_size / width)) + else: + new_height = max_size + new_width = int(width * (max_size / height)) + + return pixbuf.scale_simple( + new_width, new_height, GdkPixbuf.InterpType.BILINEAR + ) + except GLib.Error: + return None + + def _is_image_data(self, content: str) -> bool: + """Determine if clipboard content is likely an image.""" + return "binary" in content.lower() and any( + ext in content.lower() for ext in ["jpg", "jpeg", "png", "bmp", "gif"] + ) + + def _get_text_preview(self, content: str) -> str: + """Get a text preview of the content.""" + if len(content) > 50: + return content[:37] + "..." + return content + + def _load_image_preview_async(self, item_id: str) -> GdkPixbuf.Pixbuf: + """Load image preview asynchronously.""" + if item_id in self.image_cache: + return self.image_cache[item_id] + + try: + result = subprocess.run( + ["cliphist", "decode", item_id], capture_output=True, check=True + ) + pixbuf = self._create_pixbuf_from_bytes(result.stdout) + if pixbuf: + self.image_cache[item_id] = pixbuf + return pixbuf + except Exception as e: + print(f"Error loading image preview: {e}", file=sys.stderr) + return None + + def query(self, query_string: str) -> List[Result]: + """Search clipboard history using cliphist.""" + results = [] + + # Handle query string + if query_string.lower() == "clip": + query_string = "" # Show all items + + try: + # Load clipboard items + self.clipboard_items = self._load_clipboard_items() + + # Filter items based on query + filtered_items = [] + for item in self.clipboard_items: + parts = item.split("\t", 1) + content = parts[1] if len(parts) > 1 else item + if not query_string or query_string.lower() in content.lower(): + filtered_items.append(item) + + # Process all items without batching + for item in filtered_items: + parts = item.split("\t", 1) + item_id = parts[0] if len(parts) > 1 else "0" + content = parts[1] if len(parts) > 1 else item + + # Handle image content + if self._is_image_data(content): + pixbuf = self._load_image_preview_async(item_id) + if pixbuf: + result = Result( + title="Image from clipboard", + subtitle="Click to copy image to clipboard", + description="Image content", + icon=pixbuf, + relevance=1.0, + plugin_name=self.name, + action=lambda id=item_id: self._copy_to_clipboard(id), + data={"bypass_max_results": True} + ) + results.append(result) + continue + + # Handle text content + display_text = self._get_text_preview(content) + result = Result( + title=display_text, + subtitle="Text from clipboard", + description=content, + icon_name="edit-paste", + relevance=1.0, + plugin_name=self.name, + action=lambda id=item_id: self._copy_to_clipboard(id), + data={"bypass_max_results": True} + ) + results.append(result) + + except Exception as e: + # Handle errors gracefully + results.append( + Result( + title="Error accessing clipboard history", + subtitle=str(e), + icon_name="dialog-error", + relevance=0.0, + plugin_name=self.name, + data={"bypass_max_results": True} + ) + ) + + return results + + def _copy_to_clipboard(self, entry_id: str): + """Copy entry to clipboard using cliphist.""" + try: + result = subprocess.run( + ["cliphist", "decode", entry_id], capture_output=True, check=True + ) + # Use wl-copy for Wayland or xclip for X11 + try: + subprocess.run(["wl-copy"], input=result.stdout, check=True) + except subprocess.SubprocessError: + subprocess.run( + ["xclip", "-selection", "clipboard"], + input=result.stdout, + check=True, + ) + except subprocess.SubprocessError as e: + print(f"Error copying to clipboard: {e}", file=sys.stderr) diff --git a/modules/launcher/plugins/files.py b/modules/launcher/plugins/files.py new file mode 100644 index 0000000..5dc1a11 --- /dev/null +++ b/modules/launcher/plugins/files.py @@ -0,0 +1,354 @@ +""" +Files plugin for the launcher. +High-performance file and directory search using advanced fuzzy matching. +""" + +import os +import subprocess +import threading +import time +from typing import List, Dict, Set, Tuple +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed +from thefuzz import fuzz +from ..plugin_base import PluginBase +from ..result import Result +import utils.icons as icons + + +class FilesPlugin(PluginBase): + """ + High-performance plugin for searching files and directories. + Uses advanced fuzzy matching, caching, and threading for optimal performance. + """ + + def __init__(self): + super().__init__() + self.display_name = "Files" + self.description = "Search for files and directories" + + # Performance-optimized search paths + self.search_paths = [ + os.path.expanduser("~"), + os.path.expanduser("/"), + ] + + # Performance settings + self.max_results = 15 + self.max_depth = 3 + self.search_timeout = 0.8 + self.min_query_length = 1 + + # Fuzzy search settings + self.fuzzy_thresholds = { + "ratio": 60, + "partial": 70, + "token_sort": 65, + "token_set": 65, + } + + # Enhanced caching + self.file_cache: Dict[str, List[str]] = {} + self.cache_timestamp: Dict[str, float] = {} + self.cache_ttl = 300 + self.cache_lock = threading.Lock() + + # Thread pool for concurrent searches + self.executor = ThreadPoolExecutor( + max_workers=4, thread_name_prefix="files-search" + ) + + def initialize(self): + """Initialize the files plugin.""" + self.set_triggers( + [ + "file", + "find", + "/", + "file ", + "find ", + "/ ", + ] + ) + print("Initializing Files plugin...") + self._warm_cache() + + def cleanup(self): + """Cleanup the files plugin.""" + if hasattr(self, "executor"): + self.executor.shutdown(wait=False) + with self.cache_lock: + self.file_cache.clear() + self.cache_timestamp.clear() + + def query(self, query_string: str) -> List[Result]: + """Fast search for files matching the query.""" + query = query_string.strip() + + if len(query) < self.min_query_length: + return [] + + # Handle special path queries + if query.startswith("/") or query.startswith("~"): + return self._handle_path_query(query) + + # Check if this is a file-only search + is_file_only = query.startswith("file ") + if is_file_only: + query = query[5:].strip() + + # Use cached search for better performance + results = self._fast_search(query, file_only=is_file_only) + + # Sort by relevance and limit results + results.sort(key=lambda r: r.relevance, reverse=True) + return results[: self.max_results] + + def _warm_cache(self): + """Pre-warm cache for common directories in background.""" + + def warm_directory(path): + try: + if os.path.exists(path) and os.path.isdir(path): + self._get_cached_files(path) + except: + pass + + for path in self.search_paths: + threading.Thread(target=warm_directory, args=(path,), daemon=True).start() + + def _handle_path_query(self, query: str) -> List[Result]: + """Handle path-based queries like '/home' or '~/Documents'.""" + try: + expanded_path = ( + os.path.expanduser(query) if query.startswith("~") else query + ) + + if os.path.isdir(expanded_path): + return self._list_directory_contents(expanded_path) + + parent_dir = os.path.dirname(expanded_path) + if os.path.isdir(parent_dir): + basename = os.path.basename(expanded_path) + return self._search_in_directory(parent_dir, basename, max_results=10) + + except Exception: + pass + + return [] + + def _fast_search(self, query: str, file_only: bool = False) -> List[Result]: + """Fast search using cached file lists and concurrent processing.""" + results = [] + query_lower = query.lower() + + # Search in cached directories + for search_path in self.search_paths: + if not os.path.exists(search_path): + continue + + cached_files = self._get_cached_files(search_path) + for file_path in cached_files: + if file_only and os.path.isdir(file_path): + continue + + filename = os.path.basename(file_path) + if self._simple_fuzzy_match(query_lower, filename): + relevance = self._calculate_relevance(filename, query_lower) + result = self._create_file_result(file_path, relevance) + if result: + results.append(result) + + if len(results) >= self.max_results * 2: + break + + if len(results) >= self.max_results * 2: + break + + return results + + def _get_cached_files(self, directory: str) -> List[str]: + """Get cached file list for directory, refresh if needed.""" + current_time = time.time() + + with self.cache_lock: + if ( + directory in self.file_cache + and directory in self.cache_timestamp + and current_time - self.cache_timestamp[directory] < self.cache_ttl + ): + return self.file_cache[directory] + + files = self._scan_directory_fast(directory) + self.file_cache[directory] = files + self.cache_timestamp[directory] = current_time + + return files + + def _scan_directory_fast(self, directory: str) -> List[str]: + """Fast directory scan with improved depth and fuzzy search.""" + files = [] + current_depth = 0 + + def scan_dir(path: str, depth: int): + if depth > self.max_depth: + return + + try: + with os.scandir(path) as entries: + for entry in entries: + try: + if entry.name.startswith("."): + continue + + files.append(entry.path) + + if entry.is_dir(): + scan_dir(entry.path, depth + 1) + + except (PermissionError, OSError): + continue + except (PermissionError, OSError): + pass + + scan_dir(directory, current_depth) + return files + + def _is_important_directory(self, dirname: str) -> bool: + """Check if directory is important enough to scan deeper.""" + important_dirs = { + "Documents", + "Downloads", + "Desktop", + "Pictures", + "Videos", + "Music", + "Projects", + "Code", + "Development", + "Work", + "src", + "bin", + } + return dirname in important_dirs + + def _search_in_directory( + self, directory: str, query: str, max_results: int = 10 + ) -> List[Result]: + """Search for files in a specific directory.""" + results = [] + query_lower = query.lower() + + try: + with os.scandir(directory) as entries: + for entry in entries: + if self._simple_fuzzy_match(query_lower, entry.name): + relevance = self._calculate_relevance(entry.name, query_lower) + result = self._create_file_result(entry.path, relevance) + if result: + results.append(result) + + if len(results) >= max_results: + break + except (PermissionError, OSError): + pass + + return results + + def _list_directory_contents( + self, directory: str, max_results: int = 15 + ) -> List[Result]: + """List contents of a directory.""" + results = [] + + try: + with os.scandir(directory) as entries: + for entry in entries: + if not entry.name.startswith("."): + result = self._create_file_result(entry.path, 0.8) + if result: + results.append(result) + + if len(results) >= max_results: + break + except (PermissionError, OSError): + pass + + return results + + def _create_file_result(self, file_path: str, relevance: float) -> Result: + """Create a Result object for a file or directory.""" + try: + filename = os.path.basename(file_path) + is_dir = os.path.isdir(file_path) + + return Result( + title=filename, + subtitle=file_path, + icon_markup=icons.file, + action=lambda p=file_path: self._open_path(p), + relevance=relevance, + plugin_name=self.display_name, + data={"path": file_path, "type": "directory" if is_dir else "file"}, + ) + except Exception: + return None + + def _open_path(self, path: str): + """Open a file or directory with the default application.""" + try: + subprocess.Popen( + ["xdg-open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + print(f"Opened: {path}") + except Exception as e: + print(f"Failed to open {path}: {e}") + + def _open_file(self, file_path: str): + """Open a file with the default application.""" + self._open_path(file_path) + + def _open_directory(self, dir_path: str): + """Open a directory in the file manager.""" + self._open_path(dir_path) + + def _simple_fuzzy_match(self, query: str, text: str) -> bool: + """Advanced fuzzy matching using multiple methods.""" + text_lower = text.lower() + query_lower = query.lower() + + if query_lower in text_lower: + return True + + return ( + fuzz.ratio(query_lower, text_lower) >= self.fuzzy_thresholds["ratio"] + or fuzz.partial_ratio(query_lower, text_lower) + >= self.fuzzy_thresholds["partial"] + or fuzz.token_sort_ratio(query_lower, text_lower) + >= self.fuzzy_thresholds["token_sort"] + or fuzz.token_set_ratio(query_lower, text_lower) + >= self.fuzzy_thresholds["token_set"] + ) + + def _calculate_relevance(self, filename: str, query: str) -> float: + """Calculate relevance score using multiple fuzzy matching methods.""" + filename_lower = filename.lower() + query_lower = query.lower() + + ratio = fuzz.ratio(query_lower, filename_lower) + partial = fuzz.partial_ratio(query_lower, filename_lower) + token_sort = fuzz.token_sort_ratio(query_lower, filename_lower) + token_set = fuzz.token_set_ratio(query_lower, filename_lower) + + relevance = ( + ratio * 0.2 + partial * 0.4 + token_sort * 0.2 + token_set * 0.2 + ) / 100.0 + + if query_lower == filename_lower: + relevance = 1.0 + elif filename_lower.startswith(query_lower): + relevance = max(relevance, 0.9) + elif query_lower in filename_lower: + relevance = max(relevance, 0.8) + + return relevance diff --git a/modules/launcher/plugins/power.py b/modules/launcher/plugins/power.py new file mode 100644 index 0000000..b2fa330 --- /dev/null +++ b/modules/launcher/plugins/power.py @@ -0,0 +1,111 @@ +""" +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 +import utils.icons as icons + +class PowerPlugin(PluginBase): + """ + Plugin for system power management operations. + """ + + def __init__(self): + super().__init__() + self.display_name = "Power" + self.description = "System power management and control" + self.commands = { + "shutdown": { + "description": "Shutdown the system", + "icon": icons.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 + }, + "lock": { + "description": "Lock the screen", + "icon": icons.lock, + "action": self.lock + }, + "suspend": { + "description": "Suspend the system", + "icon": icons.suspend, + "action": self.suspend + }, + "logout": { + "description": "Logout from current session", + "icon": icons.logout, + "action": self.logout + } + } + + def initialize(self): + """Initialize the power plugin.""" + self.set_triggers(["power", "power "]) + + def cleanup(self): + """Cleanup the power plugin.""" + pass + + def query(self, query_string: str) -> List[Result]: + """Search power commands based on query.""" + if not query_string.strip(): + return [] + + query = query_string.lower().strip() + results = [] + + for cmd, info in self.commands.items(): + if query in cmd.lower() or query in info["description"].lower(): + result = Result( + title=cmd.capitalize(), + subtitle=info["description"], + icon_markup=info["icon"], + action=info["action"], + relevance=1.0 if query == cmd else 0.7, + plugin_name=self.display_name, + data={"command": cmd} + ) + results.append(result) + + return results + + def shutdown(self) -> None: + """Shutdown the system""" + subprocess.run(["shutdown", "now"]) + + def restart(self) -> None: + """Restart the system""" + subprocess.run(["shutdown", "-r", "now"]) + + def sleep(self) -> None: + """Put system to sleep""" + subprocess.run(["systemctl", "suspend"]) + + def lock(self) -> None: + """Lock the screen using hyprlock""" + subprocess.run(["hyprlock"]) + + 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 diff --git a/modules/launcher/plugins/system.py b/modules/launcher/plugins/system.py new file mode 100644 index 0000000..c398ffa --- /dev/null +++ b/modules/launcher/plugins/system.py @@ -0,0 +1,108 @@ +""" +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 +import utils.icons as icons + + +class SystemPlugin(PluginBase): + """ + Plugin for system commands and actions. + """ + + def __init__(self): + super().__init__() + self.display_name = "System" + self.description = "System commands and actions" + + # Cache for system binaries + self._bin_cache = set() + self._last_bin_update = 0 + self._bin_update_interval = 300 # 5 minutes + + def initialize(self): + """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): + """Cleanup the system plugin.""" + self._bin_cache.clear() + + def _update_bin_cache(self): + """Update the cache of available system binaries.""" + current_time = time.time() + if current_time - self._last_bin_update > self._bin_update_interval: + self._bin_cache.clear() + for path in os.environ["PATH"].split(":"): + if os.path.exists(path): + try: + for file in os.listdir(path): + if os.access(os.path.join(path, file), os.X_OK): + self._bin_cache.add(file) + except (PermissionError, FileNotFoundError): + continue + self._last_bin_update = current_time + + def query(self, query_string: str) -> List[Result]: + """Search for system commands matching the query.""" + query = query_string.lower().strip() + + if not query: + return [] + + results = [] + + # Check system binaries + self._update_bin_cache() + for binary in self._bin_cache: + if query in binary.lower(): + relevance = self._calculate_binary_relevance(binary, query) + if relevance > 0: + result = Result( + title=binary, + subtitle=f"Execute {binary}", + icon_markup=icons.terminal, + action=lambda b=binary: self._execute_command([b]), + relevance=relevance, + plugin_name=self.display_name, + data={"command": [binary], "id": binary}, + ) + results.append(result) + + return sorted(results, key=lambda x: x.relevance, reverse=True) + + def _calculate_binary_relevance(self, binary: str, query: str) -> float: + """Calculate relevance score for a binary.""" + binary_lower = binary.lower() + + # Exact match + if query == binary_lower: + return 1.0 + + # Starts with query + if binary_lower.startswith(query): + return 0.9 + + # Contains query + if query in binary_lower: + return 0.7 + + return 0.0 + + 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}") diff --git a/modules/launcher/result.py b/modules/launcher/result.py new file mode 100644 index 0000000..c7fb81c --- /dev/null +++ b/modules/launcher/result.py @@ -0,0 +1,52 @@ +""" +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 + + +@dataclass +class Result: + """ + Represents a search result that can be displayed and activated. + """ + + # Display information + title: str + subtitle: str = "" + description: str = "" + icon: Optional[GdkPixbuf.Pixbuf] = None + icon_name: Optional[str] = None + icon_markup: Optional[str] = None + # Behavior + action: Optional[Callable[[], Any]] = None + relevance: float = 0.0 + + # 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 new file mode 100644 index 0000000..11868e0 --- /dev/null +++ b/modules/launcher/result_item.py @@ -0,0 +1,151 @@ +""" +UI component for displaying search results. +""" + +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 + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +class ResultItem(EventBox): + """ + Widget for displaying a single search result. + """ + + # Signals + @Signal + 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 + ) + + 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 + main_box = Box( + name="result-item-main", + orientation="h", + spacing=12, + h_align="fill", + v_align="center", + ) + self.add(main_box) + + # Icon + if self.result.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" + ) + elif self.result.icon_markup: + icon_widget = 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" + ) + + main_box.add(icon_widget) + + # Text container + text_box = Box( + name="result-item-text", + orientation="v", + spacing=2, + h_expand=True, + v_align="center", + ) + main_box.add(text_box) + + # Title + title_label = Label( + label=self.result.title, + name="result-item-title", + h_align="start", + v_align="center", + ellipsize="end", + ) + text_box.add(title_label) + + # Subtitle (if present) + if self.result.subtitle: + subtitle_label = Label( + label=self.result.subtitle, + name="result-item-subtitle", + h_align="start", + v_align="center", + ellipsize="end", + ) + text_box.add(subtitle_label) + + # Plugin name (small text) + if self.result.plugin_name: + plugin_label = Label( + label=f"via {self.result.plugin_name}", + name="result-item-plugin", + h_align="start", + v_align="center", + 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 + return False diff --git a/modules/launcher/trigger_config.py b/modules/launcher/trigger_config.py new file mode 100644 index 0000000..7b2771e --- /dev/null +++ b/modules/launcher/trigger_config.py @@ -0,0 +1,119 @@ +""" +Simple trigger configuration manager for the launcher. +""" + +import json +import os +from typing import Dict, List, Any +from fabric.utils import get_relative_path +import utils.icons as icons + +def load_launcher_config(config_path: str) -> Dict: + """Load trigger configuration from JSON file""" + config = {'launcher_config': {}, 'settings': {}} + + if os.path.exists(config_path): + try: + 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: + 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', {}) + + 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 + 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', []) + if not examples: + 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')) + 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') + + 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: + try: + self.launcher_config[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: + del self.launcher_config[trigger] + return True + return False + 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) + + def _create_default_config(self): + self.launcher_config = { + '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' + }, + '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' + } diff --git a/modules/switcher.py b/modules/switcher.py index 86d2897..76178f8 100644 --- a/modules/switcher.py +++ b/modules/switcher.py @@ -23,7 +23,7 @@ class ApplicationSwitcher(Window): name="application-switcher", layer="top", anchor="center", - exclusivity="normal", + exclusivity="auto", keyboard_mode="on-demand", **kwargs, ) diff --git a/scripts/systemupdates.sh b/scripts/systemupdates.sh deleted file mode 100755 index a2677ff..0000000 --- a/scripts/systemupdates.sh +++ /dev/null @@ -1,182 +0,0 @@ -#!/bin/bash - -# Word of caution: Changing this may break the update module, be careful! - -# Function to check updates for Arch-based distros -check_arch_updates() { - official_updates=0 - aur_updates=0 - flatpak_updates=0 - tooltip="" - - # Get the number of official updates - official_updates=$(checkupdates 2>/dev/null | wc -l) - - # Determine the AUR helper - if command -v paru &>/dev/null; then - aur_helper="paru" - else - aur_helper="yay" - fi - - # Get the number of AUR updates - aur_updates=$($aur_helper -Qum 2>/dev/null | wc -l) - - # Check for Flatpak updates - if command -v flatpak &>/dev/null; then - flatpak_updates=$(flatpak remote-ls --updates | wc -l) - fi - - # Calculate total updates - total_updates=$((official_updates + aur_updates + flatpak_updates)) - - # Build the tooltip - tooltip="󰣇 Official: $official_updates\n󰮯 AUR: $aur_updates\n Flatpak: $flatpak_updates" - - # Output JSON - echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}" -} - -# Function to check updates for Ubuntu-based distros -check_ubuntu_updates() { - official_updates=0 - flatpak_updates=0 - tooltip="" - - # Get the number of official updates - official_updates=$(apt-get -s -o Debug::NoLocking=true upgrade | grep -c ^Inst) - - # Check for Flatpak updates - if command -v flatpak &>/dev/null; then - flatpak_updates=$(flatpak remote-ls --updates | wc -l) - fi - - # Calculate total updates - total_updates=$((official_updates + flatpak_updates)) - - # Build the tooltip - tooltip="󰕈 Official: $official_updates\n Flatpak: $flatpak_updates" - - # Output JSON - echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}" -} - -# Function to check updates for Fedora-based distros -check_fedora_updates() { - official_updates=0 - flatpak_updates=0 - tooltip="" - - # Get the number of official updates - official_updates=$(dnf list updates -q | tail -n +2 | wc -l) - - # Check for Flatpak updates - if command -v flatpak &>/dev/null; then - flatpak_updates=$(flatpak remote-ls --updates | wc -l) - fi - - # Calculate total updates - total_updates=$((official_updates + flatpak_updates)) - - # Build the tooltip - tooltip="󰣛 Official: $official_updates\n Flatpak: $flatpak_updates" - - # Output JSON - echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}" -} - -# Function to check updates for openSUSE-based distros -check_opensuse_updates() { - official_updates=0 - flatpak_updates=0 - tooltip="" - - # Get the number of official updates (skip header lines) - official_updates=$(zypper lu | tail -n +3 | wc -l) - - # Check for Flatpak updates - if command -v flatpak &>/dev/null; then - flatpak_updates=$(flatpak remote-ls --updates | wc -l) - fi - - # Calculate total updates - total_updates=$((official_updates + flatpak_updates)) - - # Build the tooltip - tooltip=" Official: $official_updates\n Flatpak: $flatpak_updates" - - # Output JSON - echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}" -} - -# Function to update Arch-based distros -update_arch() { - kitty --title "System Update" sh -c " - fastfetch - yay -Syu - read -n 1 -p 'Press any key to continue...' - " -} - -# Function to update Ubuntu-based distros -update_ubuntu() { - kitty --title "System Update" sh -c " - neofetch - sudo apt update && sudo apt upgrade -y - read -n 1 -p 'Press any key to continue...' - " -} - -# Function to update Fedora-based distros -update_fedora() { - kitty --title "System Update" sh -c " - neofetch - sudo dnf upgrade -y - read -n 1 -p 'Press any key to continue...' - " -} - -# Function to update openSUSE-based distros -update_opensuse() { - kitty --title "System Update" sh -c " - neofetch - sudo zypper up -y - read -n 1 -p 'Press any key to continue...' - " -} - -# Main case handling for different distributions -case "$1" in --arch) - if [ -z "$2" ]; then - check_arch_updates - else - update_arch - fi - ;; --ubuntu) - if [ -z "$2" ]; then - check_ubuntu_updates - else - update_ubuntu - fi - ;; --fedora) - if [ -z "$2" ]; then - check_fedora_updates - else - update_fedora - fi - ;; --suse) - if [ -z "$2" ]; then - check_opensuse_updates - else - update_opensuse - fi - ;; -*) - echo "Usage: $0 [-arch|-ubuntu|-fedora|-suse] [up (optional)]" - exit 1 - ;; -esac diff --git a/styles/launcher_simple.css b/styles/launcher_simple.css new file mode 100644 index 0000000..4ea72a9 --- /dev/null +++ b/styles/launcher_simple.css @@ -0,0 +1,95 @@ +/* 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-main { + 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; +} + +#launcher-search:focus { + border-color: #4a9eff; +} + +#launcher-results-scroll { + background: transparent; + border: none; + min-height: 100px; +} + +#launcher-results { + background: transparent; +} + +#launcher-result-item { + 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); +} + +#launcher-result-item.selected { + background: rgba(74, 158, 255, 0.2); + border-color: #4a9eff; +} + +#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: 2px; +} + +#result-item-plugin { + font-size: 10px; + color: rgba(255, 255, 255, 0.5); + font-style: italic; + margin-bottom: 8px; +} diff --git a/styles/switcher.css b/styles/switcher.css index d2f9849..6c36ad7 100644 --- a/styles/switcher.css +++ b/styles/switcher.css @@ -1,5 +1,9 @@ -#application-switcher { +/* #application-switcher { background-color: var(--shadow); +} */ + +#application-switcher-container{ + background: var(--shadow); } #application-switcher-view { diff --git a/utils/conversion.py b/utils/conversion.py new file mode 100644 index 0000000..d50230b --- /dev/null +++ b/utils/conversion.py @@ -0,0 +1,449 @@ +import requests + + +class Units(): + def __init__(self): + self.WEIGHT_CHART: dict[str, tuple[float, float]] = { + "kilogram": (1, 1), + "kg": (1, 1), + "tonne": (1000, 0.001), + "ton": (1000, 0.001), + "gram": (1e-3, 1e3), + "g": (1e-3, 1e3), + "milligram": (1e-6, 1e6), + "mg": (1e-6, 1e6), + "metric-ton": (1000, 0.001), + "metric-tonne": (1000, 0.001), + "long-ton": (1016.04608, 0.0009842073), + "short-ton": (907.184, 0.0011023122), + "pound": (0.453592, 2.2046244202), + "lb": (0.453592, 2.2046244202), + "stone": (6.35029, 0.1574731728), + "st": (6.35029, 0.1574731728), + "ounce": (0.0283495, 35.273990723), + "oz": (0.0283495, 35.273990723), + "carrat": (0.0002, 5000), + "ct": (0.0002, 5000), + "atomic-mass-unit": (1.660540199e-27, 6.022136652e26), + } + + self.LENGTH_CHART: dict[str, float] = { + # meter + "m": 1, + "M": 1, + "meter": 1, + # kilometer + "km": 1e3, + "KM": 1e3, + "kilometer": 1e3, + # centimeter + "cm": 1e-2, + "CM": 1e-2, + "centimeter": 1e-2, + # millimeter + "mm": 1e-3, + "MM": 1e-3, + "millimeter": 1e-3, + # micrometer + "um": 1e-6, + "UM": 1e-6, + "micrometer": 1e-6, + # nanometer + "nm": 1e-9, + "NM": 1e-9, + "nanometer": 1e-9, + # mile + "mi": 1609.344, + "MI": 1609.344, + "mile": 1609.344, + # yard + "yd": 0.9144, + "YD": 0.9144, + "yard": 0.9144, + # foot + "ft": 0.3048, + "FT": 0.3048, + "foot": 0.3048, + "feet": 0.3048, + # inch + "in": 0.0254, + "IN": 0.0254, + "inch": 0.0254, + "inches": 0.0254, + # nautical mile + "nmi": 1852, + "NMI": 1852, + "nautical-mile": 1852, + } + + self.STORAGE_TYPE_CHART: dict[str, float] = { + "bit": 1, + "byte": 8, + "B": 8, + "kilobyte": 8192, + "KB": 8192, + "megabyte": 8388608, + "MB": 8388608, + "gigabyte": 8589934592, + "GB": 8589934592, + "terabyte": 8796093022208, + "TB": 8796093022208, + "petabyte": 9007199254740992, + "PB": 9007199254740992, + "exabyte": 9223372036854775808, + "EB": 9223372036854775808, + } + + self.TEMPERATURE_CHART = { + "celsius": (lambda v: v + 273.15, lambda v: v - 273.15), + "c": (lambda v: v + 273.15, lambda v: v - 273.15), + "fahrenheit": (lambda v: (v - 32) * 5/9 + 273.15, lambda v: (v - 273.15) * 9/5 + 32), + "f": (lambda v: (v - 32) * 5/9 + 273.15, lambda v: (v - 273.15) * 9/5 + 32), + "kelvin": (lambda v: v, lambda v: v), + "k": (lambda v: v, lambda v: v), + "rankine": (lambda v: v * 5/9, lambda v: v * 9/5), + "reaumur": (lambda v: v * 5/4 + 273.15, lambda v: (v - 273.15) * 4/5), + } + + self.TIME_CHART: dict[str, float] = { + "second": 1, + "s": 1, + "minute": 60, + "min": 60, + "m": 60, + "hour": 3600, + "h": 3600, + "milisecond": 1e-3, + "ms": 1e-3, + "day": 86400, + "d": 86400, + "week": 604800, + "w": 604800, + "fortnight": 1209600, + "month": 2628000, # Approximation (30.44 days) + "mo": 2628000, # Approximation (30.44 days) + "year": 31536000, # Approximation (365 days) + "yr": 31536000, # Approximation (365 days) + "decade": 315360000, # Approximation (10 years) + "dec": 315360000, # Approximation (10 years) + "century": 3153600000, # Approximation (100 years) + "cent": 3153600000, # Approximation (100 years) + "millennium": 31536000000, # Approximation (1000 years) + "millenia": 31536000000, # Approximation (1000 years) + } + + self.LIQUID_VOLUME_CHART: dict[str, float] = { + "liter": 1, + "l": 1, + "milliliter": 1e-3, + "ml": 1e-3, + "gallon": 3.78541, + "quart": 0.946353, + "pint": 0.473176, + "fluid-ounce": 0.0295735, + "fl-oz": 0.0295735, + "oz": 0.0295735, + "ounce": 0.0295735, + "cup": 0.236588, + "tablespoon": 0.0147868, + "tbsp": 0.0147868, + "teaspoon": 0.00492892, + "tsp": 0.00492892, + } + + self.ANGLE_CHART: dict[str, float] = { + "degree": 1, + "deg": 1, + "radian": 57.2958, + "rad": 57.2958, + "gradian": 0.9, + "gon": 0.9, + } + + self.ENERGY_CHART: dict[str, float] = { + "joule": 1, + "j": 1, + "kilojoule": 1000, + "kj": 1000, + "calorie": 4.184, + "cal": 4.184, + "kilocalorie": 4184, + "kcal": 4184, + "watt-hour": 3600, + "wh": 3600, + "kilowatt-hour": 3.6e6, + "kwh": 3.6e6, + } + + self.SPEED_CHART: dict[str, float] = { + "mps": 1, + "kmph": 0.277778, + "mph": 0.44704, + "fps": 0.3048, + "knot": 0.514444, + } + + self.PRESSURE_CHART: dict[str, float] = { + "pascal": 1, + "Pa": 1, + "bar": 100000, + "atm": 101325, + "torr": 133.322, + "mmHg": 133.322, + "psi": 6894.76, + } + + self.FORCE_CHART: dict[str, float] = { + "newton": 1, + "N": 1, + "kilonewton": 1000, + "kN": 1000, + "pound-force": 4.44822, + "lbf": 4.44822, + "dyne": 1e-5, + } + + self.POWER_CHART: dict[str, float] = { + "watt": 1, + "W": 1, + "kilowatt": 1000, + "kW": 1000, + "horsepower": 745.7, + "hp": 745.7, + "megawatt": 1e6, + "MW": 1e6, + } + + self.VOLTAGE_CHART: dict[str, float] = { + "volt": 1, + "V": 1, + "millivolt": 1e-3, + "mV": 1e-3, + "kilovolt": 1000, + "kV": 1000, + "megavolt": 1e6, + "MV": 1e6, + } + + self.CURRENT_CHART: dict[str, float] = { + "ampere": 1, + "A": 1, + "milliampere": 1e-3, + "mA": 1e-3, + "microampere": 1e-6, + "μA": 1e-6, + } + + self.RESISTANCE_CHART: dict[str, float] = { + "ohm": 1, + "Ω": 1, + "kilohm": 1000, + "kΩ": 1000, + "megohm": 1e6, + "MΩ": 1e6, + } + + self.CAPACITANCE_CHART: dict[str, float] = { + "farad": 1, + "F": 1, + "millifarad": 1e-3, + "mF": 1e-3, + "microfarad": 1e-6, + "μF": 1e-6, + "nanofarad": 1e-9, + "nF": 1e-9, + } + + self.INDUCTANCE_CHART: dict[str, float] = { + "henry": 1, + "H": 1, + "millihenry": 1e-3, + "mH": 1e-3, + "microhenry": 1e-6, + "μH": 1e-6, + "nanohenry": 1e-9, + "nH": 1e-9, + } + + self.FREQUENCY_CHART: dict[str, float] = { + "hertz": 1, + "Hz": 1, + "kilohertz": 1e3, + "kHz": 1e3, + "megahertz": 1e6, + "MHz": 1e6, + "gigahertz": 1e9, + "GHz": 1e9, + } + + self.LUMINANCE_CHART: dict[str, float] = { + "candela": 1, + "cd": 1, + "lumen": 1, + "lm": 1, + "lux": 1, + "lx": 1, + } + + self.AREA_CHART: dict[str, float] = { + "square-meter": 1, + "m2": 1, + "square-kilometer": 1e6, + "km2": 1e6, + "hectare": 1e4, + "ha": 1e4, + "are": 1e2, + "a": 1e2, + "square-centimeter": 1e-4, + "cm2": 1e-4, + "square-millimeter": 1e-6, + "mm2": 1e-6, + } + + # Ya no usamos currency_converter aquí. + + +class Conversion(): + def __init__(self): + self.units = Units() + + def convert(self, value: float, from_type: str, to_type: str): + """ + Generalized conversion function que funciona con todas las categorías, + incluyendo moneda via floatrates.com. + """ + # Colección de todos los charts no-monedas + charts = { + "WEIGHT_CHART": self.units.WEIGHT_CHART, + "LENGTH_CHART": self.units.LENGTH_CHART, + "TEMPERATURE_CHART": self.units.TEMPERATURE_CHART, + "TIME_CHART": self.units.TIME_CHART, + "LIQUID_VOLUME_CHART": self.units.LIQUID_VOLUME_CHART, + "STORAGE_TYPE_CHART": self.units.STORAGE_TYPE_CHART, + "ANGLE_CHART": self.units.ANGLE_CHART, + "ENERGY_CHART": self.units.ENERGY_CHART, + "SPEED_CHART": self.units.SPEED_CHART, + "PRESSURE_CHART": self.units.PRESSURE_CHART, + "FORCE_CHART": self.units.FORCE_CHART, + "POWER_CHART": self.units.POWER_CHART, + "VOLTAGE_CHART": self.units.VOLTAGE_CHART, + "CURRENT_CHART": self.units.CURRENT_CHART, + "RESISTANCE_CHART": self.units.RESISTANCE_CHART, + "CAPACITANCE_CHART": self.units.CAPACITANCE_CHART, + "INDUCTANCE_CHART": self.units.INDUCTANCE_CHART, + "FREQUENCY_CHART": self.units.FREQUENCY_CHART, + "LUMINANCE_CHART": self.units.LUMINANCE_CHART, + "AREA_CHART": self.units.AREA_CHART, + } + + # 1) Revisar si está en alguno de los charts (no monedas) + for chart_name, chart in charts.items(): + if from_type in chart and to_type in chart: + # Temperaturas usan lambdas + if chart_name == "TEMPERATURE_CHART": + if from_type == to_type: + return value + to_kelvin = chart[from_type][0] + from_kelvin = chart[to_type][1] + return from_kelvin(to_kelvin(value)) + + # Handle WEIGHT_CHART separately (tuple values) + if chart_name == "WEIGHT_CHART": + if from_type == to_type: + return value + to_kg = chart[from_type][0] + from_kg = chart[to_type][1] + return value * to_kg * from_kg + + # Cualquier otro chart numérico + if from_type == to_type: + return value + return value * (chart[from_type] / chart[to_type]) + + # 2) Si ambos son códigos de moneda (p. ej. “USD”, “ARS”) + # asumimos que están en mayúsculas y tienen 3 letras. + if len(from_type) == 3 and len(to_type) == 3 and from_type.isalpha() and to_type.isalpha(): + return self._convert_currency_via_floatrates(value, from_type, to_type) + + # 3) Si no cae en ningún caso, error. + raise ValueError(f"Unsupported conversion: {from_type} to {to_type}") + + def _convert_currency_via_floatrates(self, value: float, from_code: str, to_code: str) -> float: + """ + Convierte usando el JSON de floatrates.com: + - Hace GET a https://www.floatrates.com/daily/{from_lower}.json + - Toma el rate de la clave to_lower y multiplica. + """ + from_lower = from_code.lower() + to_lower = to_code.lower() + + if from_lower == to_lower: + return value + + url = f"https://www.floatrates.com/daily/{from_lower}.json" + resp = requests.get(url, timeout=5) + if resp.status_code != 200: + raise ValueError(f"Error al obtener datos de floatrates para {from_code}") + + data = resp.json() + if to_lower not in data: + raise ValueError(f"Moneda destino '{to_code}' no encontrada en la respuesta de floatrates para '{from_code}'") + + rate = data[to_lower]["rate"] + return value * rate + + def parse_input_and_convert(self, input: str): + parts = input.split() + addition = "s" if parts[-1].endswith("s") else "" + + if "and" in parts: # valor unidad1 and valor2 unidad2 _ a unidad_destino + parts.remove("and") + if len(parts) != 6: + raise ValueError("Formato inválido. Esperado: 'value from_type and value2 from_type2 _ to_type'") + + value1, from_type1, value2, from_type2, _, to_type = parts + value1, value2 = float(value1), float(value2) + from_type1 = self.clean_type(from_type1) + from_type2 = self.clean_type(from_type2) + to_type = self.clean_type(to_type) + + if from_type1 == from_type2: + return self.convert(value1 + value2, from_type1, to_type), to_type + addition + else: + res = 0 + res += self.convert(value1, from_type1, to_type) + res += self.convert(value2, from_type2, to_type) + return res, to_type + addition + else: + if len(parts) != 4: + raise ValueError("Formato inválido. Esperado: 'value from_type _ to_type'") + value, from_type, _, to_type = parts + value = float(value) + from_type = self.clean_type(from_type) + to_type = self.clean_type(to_type) + return self.convert(value, from_type, to_type), to_type + addition + + def clean_type(self, type: str) -> str: + """ + Si es moneda (3 letras), lo pasa a mayúsculas. + Si termina en 's' (y no es 'celsius'), le quita la 's' para + las otras unidades. """ + if len(type) == 3 and type.isalpha(): + return type.upper() + if type.endswith("s") and type.lower() != "celsius": + # Para las tablas que tienen singular/plural + singular = type[:-1].lower() + # Si existe en STORAGE_TYPE_CHART, lo usamos; + # si no, devolvemos singular en minúsculas para otros charts. + if singular in self.units.STORAGE_TYPE_CHART: + return singular + return singular.lower() + return type + + +# Ejemplo rápido de uso: +if __name__ == "__main__": + conv = Conversion() + # Convierte 10 USD a ARS: + result, suffix = conv.parse_input_and_convert("10 USD _ ARS") + print(f"{result:.2f} {suffix}") # Ej: "10 USD _ ARS" -> "38754.23 ARS" diff --git a/utils/icons.py b/utils/icons.py index 451d116..dd668bb 100644 --- a/utils/icons.py +++ b/utils/icons.py @@ -1,10 +1,10 @@ # Parameters -font_family: str = 'tabler-icons' -font_weight: str = 'normal' +font_family: str = "tabler-icons" +font_weight: str = "normal" span: str = f"" -#Panels +# Panels apps: str = "" dashboard: str = "" chat: str = "" @@ -14,7 +14,7 @@ windows: str = "" colorpicker: str = "" media: str = "" -#Toolbox +# Toolbox toolbox: str = "" ssfull: str = "" @@ -26,7 +26,7 @@ recordings: str = "" ocr: str = "ﳃ" gamemode: str = "" gamemode_off: str = "" -close : str = "" +close: str = "" # Circles temp: str = "" @@ -90,7 +90,7 @@ bluetooth_off: str = "" night_off: str = "" notifications_off: str = "" -notifications_clear: str = ""; +notifications_clear: str = "" download: str = "" upload: str = "" @@ -177,6 +177,9 @@ terminal: str = "" timer_off: str = "" timer_on: str = "" spy: str = "" +calculator: str = "" +file: str = "" + # Dice dice_1: str = "" @@ -186,12 +189,14 @@ dice_4: str = "" dice_5: str = "" dice_6: str = "" -exceptions: list[str] = ['font_family', 'font_weight', 'span'] +exceptions: list[str] = ["font_family", "font_weight", "span"] + def apply_span() -> None: global_dict = globals() for key in global_dict: - if key not in exceptions and not key.startswith('__'): + if key not in exceptions and not key.startswith("__"): global_dict[key] = f"{span}{global_dict[key]}" + apply_span() diff --git a/utils/inhibit.py b/utils/inhibit.py new file mode 100644 index 0000000..a1e8127 --- /dev/null +++ b/utils/inhibit.py @@ -0,0 +1,136 @@ +# From https://github.com/stwa/wayland-idle-inhibitor +# License: WTFPL Version 2 + +import sys +import time +import argparse +from dataclasses import dataclass +from signal import SIGINT, SIGTERM, signal +from threading import Event, Timer + +import setproctitle +from pywayland.client.display import Display +from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import \ + ZwpIdleInhibitManagerV1 +from pywayland.protocol.wayland.wl_compositor import WlCompositor +from pywayland.protocol.wayland.wl_registry import WlRegistryProxy +from pywayland.protocol.wayland.wl_surface import WlSurface + + +@dataclass +class GlobalRegistry: + surface: WlSurface | None = None + inhibit_manager: ZwpIdleInhibitManagerV1 | None = None + + +def parse_duration(duration_str: str) -> int: + """Parse duration string into seconds. + Examples: '1h', '30m', '45s', '1.5h', '1.5m', '30.5s', 'off' + """ + try: + if duration_str.lower() == 'off': + return 0 + elif duration_str.endswith('h'): + return int(float(duration_str[:-1]) * 3600) + elif duration_str.endswith('m'): + return int(float(duration_str[:-1]) * 60) + elif duration_str.endswith('s'): + return int(float(duration_str[:-1])) + else: + return int(duration_str) + except ValueError: + raise ValueError("Invalid duration format. Use '1h', '30m', '45s', 'off', etc.") + + +def handle_registry_global( + wl_registry: WlRegistryProxy, id_num: int, iface_name: str, version: int +) -> None: + global_registry: GlobalRegistry = wl_registry.user_data or GlobalRegistry() + + if iface_name == "wl_compositor": + compositor = wl_registry.bind(id_num, WlCompositor, version) + global_registry.surface = compositor.create_surface() # type: ignore + elif iface_name == "zwp_idle_inhibit_manager_v1": + global_registry.inhibit_manager = wl_registry.bind( + id_num, ZwpIdleInhibitManagerV1, version + ) + + +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.') + args = parser.parse_args() + + done = Event() + signal(SIGINT, lambda _, __: done.set()) + signal(SIGTERM, lambda _, __: done.set()) + + global_registry = GlobalRegistry() + + try: + display = Display() + display.connect() + + registry = display.get_registry() # type: ignore + registry.user_data = global_registry + registry.dispatcher["global"] = handle_registry_global + + def shutdown() -> None: + display.dispatch() + display.roundtrip() + display.disconnect() + + display.dispatch() + display.roundtrip() + + if global_registry.surface is None: + print("Error: Failed to create Wayland surface.") + shutdown() + sys.exit(1) + + if global_registry.inhibit_manager is None: + print("Error: Your Wayland compositor does not support idle inhibition.") + print("This usually means either:") + print("1. Your compositor (like Hyprland) doesn't support the idle-inhibit protocol") + print("2. The protocol is not enabled in your compositor") + print("\nFor Hyprland, you can enable it by adding to your config:") + print("misc:disable_autoreload = true") + print("misc:enable_wayland_protocols = true") + shutdown() + sys.exit(1) + + inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore + global_registry.surface + ) + + display.dispatch() + display.roundtrip() + + duration = parse_duration(args.duration) + if duration > 0: + print(f"Inhibiting idle for {args.duration}...") + # Set up timer to release inhibition + timer = Timer(duration, lambda: done.set()) + timer.start() + else: + print("Inhibiting idle indefinitely...") + + done.wait() + print("Shutting down...") + + inhibitor.destroy() + if duration > 0: + timer.cancel() + + shutdown() + + except Exception as e: + print(f"Error: {str(e)}") + print("Make sure you're running this under a Wayland session.") + sys.exit(1) + + +if __name__ == "__main__": + setproctitle.setproctitle("modus-inhibit") + main()