diff --git a/README.md b/README.md index 6fb4959..ebdfa27 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ curl -fsSL https://raw.githubusercontent.com/Axenide/Ax-Shell/main/install.sh | - `vte3` - `webp-pixbuf-loader` - `wl-clipboard` + - `wlinhibit` + - `python-currencyconvertor` - Python dependencies: - ijson - numpy diff --git a/modules/launcher.py b/modules/launcher.py index d342191..fb252c0 100644 --- a/modules/launcher.py +++ b/modules/launcher.py @@ -19,6 +19,13 @@ from gi.repository import Gdk, GLib import config.data as data +import json +import os +import re +import math +import numpy as np +import subprocess +from utils.conversion import Conversion import modules.icons as icons from modules.dock import Dock @@ -38,12 +45,21 @@ def __init__(self, **kwargs): self._arranger_handler: int = 0 self._all_apps = get_desktop_applications() + + self.converter = Conversion() self.calc_history_path = f"{data.CACHE_DIR}/calc.json" if os.path.exists(self.calc_history_path): with open(self.calc_history_path, "r") as f: self.calc_history = json.load(f) else: self.calc_history = [] + + self.conversion_history_path = f"{data.CACHE_DIR}/conversion.json" + if os.path.exists(self.conversion_history_path): + with open(self.conversion_history_path, "r") as f: + self.conversion_history = json.load(f) + else: + self.conversion_history = [] self.viewport = Box(name="viewport", spacing=4, orientation="v") self.search_entry = Entry( @@ -140,6 +156,10 @@ def arrange_viewport(self, query: str = ""): self.update_calculator_viewport() return + if query.startswith(";"): + # In conversion mode, update history view once (not per keystroke) + self.update_conversion_viewport() + return remove_handler(self._arranger_handler) if self._arranger_handler else None self.viewport.children = [] self.selected_index = -1 @@ -262,6 +282,11 @@ def scroll(): def on_search_entry_activate(self, text): if text.startswith("="): + if self.selected_index == -1: + self.evaluate_calculator_expression(text) + return + if text.startswith(";"): + # If in calculator mode and no history item is selected, evaluate new expression. if self.selected_index == -1: self.evaluate_calculator_expression(text) return @@ -315,6 +340,35 @@ def on_search_entry_key_press(self, widget, event): self.close_launcher() return True return False + if text.startswith(";"): + if event.keyval == Gdk.KEY_Down: + self.move_selection(1) + return True + elif event.keyval == Gdk.KEY_Up: + self.move_selection(-1) + return True + elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + # In conversion mode, if a history item is highlighted: + if self.selected_index != -1 and self.selected_index < len(self.conversion_history): + if event.state & Gdk.ModifierType.SHIFT_MASK: + # Shift+Enter deletes the selected calculator history item + self.delete_selected_conversion_history() + else: + # Normal Enter copies the result + selected_text = self.conversion_history[self.selected_index] + self.copy_text_to_clipboard(selected_text) + # Clear selection so new expressions are evaluated on further Return presses + self.selected_index = -1 + else: + # Force reset selection index + self.selected_index = -1 + # No item selected, evaluate the expression + self.evaluate_conversion_expression(text) + return True + elif event.keyval == Gdk.KEY_Escape: + self.close_launcher() + return True + return False else: if event.keyval == Gdk.KEY_Down: @@ -338,6 +392,10 @@ def notify_text(self, entry, *_): if text.startswith("="): self.update_calculator_viewport() + self.selected_index = -1 + elif text.startswith(";"): + self.update_conversion_viewport() + # Always reset selection when typing a new expression self.selected_index = -1 else: self.arrange_viewport(text) @@ -410,6 +468,10 @@ def save_calc_history(self): with open(self.calc_history_path, "w") as f: json.dump(self.calc_history, f) + def save_conversion_history(self): + with open(self.conversion_history_path, "w") as f: + json.dump(self.conversion_history, f) + def evaluate_calculator_expression(self, text: str): print(f"Evaluating calculator expression: {text}") @@ -484,6 +546,27 @@ def evaluate_calculator_expression(self, text: str): self.save_calc_history() self.update_calculator_viewport() + def evaluate_conversion_expression(self, text: str): + print(f"Evaluating conversion expression: {text}") + expr = text.lstrip(";").strip() + if not expr: + return + + try: + result_value, result_type = self.converter.parse_input_and_convert(expr) + if result_type is None: + result_str = f"{result_value:.2f}" + else: + result_str = f"{result_value:.2f} {result_type}" + except: + result_str = "Error: Invalid conversion expression" + + # Format the result based on its type + + self.conversion_history.insert(0, f"{text} => {result_str}") + self.save_conversion_history() + self.update_conversion_viewport() + def update_calculator_viewport(self): self.viewport.children = [] for item in self.calc_history: @@ -492,6 +575,16 @@ def update_calculator_viewport(self): if self.selected_index >= len(self.calc_history): self.selected_index = -1 + + def update_conversion_viewport(self): + self.viewport.children = [] + for item in self.conversion_history: + btn = self.create_conversion_history_button(item) + self.viewport.add(btn) + # Don't reset selection index here automatically + # Ensure selection state stays valid + if self.selected_index >= len(self.conversion_history): + self.selected_index = -1 def create_calc_history_button(self, text: str) -> Button: @@ -547,6 +640,60 @@ def create_calc_history_button(self, text: str) -> Button: ) return btn + def create_conversion_history_button(self, text: str) -> Button: + # Parse the result to create a more readable display + if "=>" in text: + parts = text.split("=>") + expression = parts[0].strip() + result = parts[1].strip() + + # For very long results, truncate for display but keep full in tooltip + display_text = text + if len(result) > 50: # Truncate long results + display_text = f"{expression} => {result[:47]}..." + + btn = Button( + name="slot-button", # reuse existing CSS styling + child=Box( + name="calc-slot-box", + orientation="h", + spacing=10, + children=[ + Label( + name="calc-label", + label=display_text, + ellipsization="end", + v_align="center", + h_align="center", + ), + ], + ), + tooltip_text=text, + on_clicked=lambda *_: self.copy_text_to_clipboard(text), + ) + else: + # Fallback for non-calculation entries + btn = Button( + name="slot-button", + child=Box( + name="calc-slot-box", + orientation="h", + spacing=10, + children=[ + Label( + name="calc-label", + label=text, + ellipsization="end", + v_align="center", + h_align="center", + ), + ], + ), + tooltip_text=text, + on_clicked=lambda *_: self.copy_text_to_clipboard(text), + ) + return btn + def copy_text_to_clipboard(self, text: str): parts = text.split("=>", 1) @@ -577,3 +724,27 @@ def delete_selected_calc_history(self): if len(self.calc_history) > 0: self.update_selection(min(new_index, len(self.calc_history) - 1)) + + def delete_selected_conversion_history(self): + if self.selected_index != -1 and self.selected_index < len(self.conversion_history): + # Store the current index before deletion + current_index = self.selected_index + + # Delete the item + del self.conversion_history[current_index] + self.save_conversion_history() + + # Determine the new selection index + # If we deleted the first item, stay at index 0 + # Otherwise, move to the previous item + new_index = 0 if current_index == 0 else current_index - 1 + + # Reset selection before updating viewport + self.selected_index = -1 + + # Update the viewport + self.update_conversion_viewport() + + # If we still have items, select the determined index + if len(self.conversion_history) > 0: + self.update_selection(min(new_index, len(self.conversion_history) - 1)) \ No newline at end of file diff --git a/utils/conversion.py b/utils/conversion.py new file mode 100644 index 0000000..de45359 --- /dev/null +++ b/utils/conversion.py @@ -0,0 +1,398 @@ +from currency_converter import CurrencyConverter + +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, + "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) + "m": 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, + } + + self.currency_converter = CurrencyConverter() + + +class Conversion(): + def __init__(self): + self.units = Units() + + def convert(self, value: float, from_type: str, to_type: str): + """ + Generalized conversion function that works with all types. + """ + # List of all available charts + 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, + } + + # Check if the types exist in any chart + for chart_name, chart in charts.items(): + if from_type in chart and to_type in chart: + # Handle temperature conversions separately (uses lambda functions) + 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 other conversions (uses direct multiplication/division) + if from_type == to_type: + return value + return value * (chart[from_type] / chart[to_type]) + + # Handle currency conversion separately + if from_type in self.units.currency_converter.currencies and to_type in self.units.currency_converter.currencies: + return self.units.currency_converter.convert(value, from_type, to_type) + + # Raise an error if no valid conversion is found + raise ValueError(f"Unsupported conversion: {from_type} to {to_type}") + + def parse_input_and_convert(self, input:str): + parts = input.split() + addition = "s" if parts[-1].endswith("s") else "" + + if "and" in parts: # value from_type and value2 from_type2 _ to_type + parts.remove("and") + if len(parts) != 6: + raise ValueError("Invalid input format. Expected: '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, from_type2, to_type = self.clean_type(from_type1), self.clean_type(from_type2), 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("Invalid input format. Expected: 'value from_type _ to_type'") + value, from_type, _, to_type = parts + value = float(value) + from_type, to_type = self.clean_type(from_type), self.clean_type(to_type) + return self.convert(value, from_type, to_type), to_type + addition + + def clean_type(self, type:str): + """ + Strips the 's' from the end of the type if it exists. + """ + if type.upper() in self.units.currency_converter.currencies: + return type.upper() + if type.endswith("s") and type is not "celsius": + if type[:-1] in self.units.STORAGE_TYPE_CHART: + return type[:-1] + return type[:-1].lower() + return type \ No newline at end of file