diff --git a/README.md b/README.md index 495cbc6..36ad5df 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ 1. Dependencies: ```sh -fabric fabric-cli gnome-bluetooth-3.0 wf-recorder cliphist wl-clipboard grimblast power-profile-daemon adw-gtk-theme brightnessctl swww hypridle hyprlock imagemagick libnotify swappy libadwaita +fabric fabric-cli gnome-bluetooth-3.0 wf-recorder cliphist wl-clipboard grimblast power-profile-daemon adw-gtk-theme brightnessctl swww hypridle hyprlock imagemagick libnotify swappy libadwaita wlinhibit ``` ```sh @@ -95,12 +95,13 @@ cd Tela-icon-theme - [x] Update Screenshot - [x] Installation script - [x] Network Manager -- [ ] Calendar with events -- [ ] Power Manager +- [x] Calendar +- [x] Power Manager +- [x] Dock +- [x] Web search in Launcher - [ ] Notification Panel - [ ] Improve CSS Styling -- [ ] Window Switcher -- [ ] Web search in Launcher +- [ ] Update Screenshot ## Special Thanks diff --git a/config/assets/config.json b/config/assets/config.json index 2f0a891..a93e26d 100644 --- a/config/assets/config.json +++ b/config/assets/config.json @@ -1 +1 @@ -{"prefix_restart": "SHIFT ALT", "suffix_restart": "T", "prefix_bluetooth": "ALT", "suffix_bluetooth": "B", "prefix_emoji": "SUPER", "suffix_emoji": "E", "prefix_cliphist": "SUPER", "suffix_cliphist": "V", "prefix_todo": "SUPER", "suffix_todo": "T", "prefix_sh": "SUPER", "suffix_sh": "S", "prefix_walls": "SUPER", "suffix_walls": "W", "prefix_screenshotregion": "SUPER", "suffix_screenshotregion": "Z", "prefix_screenshot": "", "suffix_screenshot": "Print", "prefix_screencast": "SUPER", "suffix_screencast": "R", "prefix_launcher": "SUPER", "suffix_launcher": "D", "prefix_power": "SUPER", "suffix_power": "X", "prefix_toggle": "SUPER", "suffix_toggle": "H", "prefix_randomwallpaper": "ALT SHIFT", "suffix_randomwallpaper": "W", "wallpapers_dir": "/home/sankalp/Pictures/wallpapers"} \ No newline at end of file +{"prefix_restart": "SHIFT ALT", "suffix_restart": "T", "prefix_bluetooth": "ALT", "suffix_bluetooth": "B", "prefix_emoji": "SUPER", "suffix_emoji": "E", "prefix_cliphist": "SUPER", "suffix_cliphist": "V", "prefix_todo": "SUPER", "suffix_todo": "T", "prefix_sh": "SUPER", "suffix_sh": "S", "prefix_walls": "SUPER", "suffix_walls": "W", "prefix_screenshotregion": "SUPER", "suffix_screenshotregion": "Z", "prefix_screenshot": "", "suffix_screenshot": "Print", "prefix_screencast": "SUPER", "suffix_screencast": "R", "prefix_launcher": "SUPER", "suffix_launcher": "D", "prefix_power": "SUPER", "suffix_power": "X", "prefix_wifi": "SUPER", "suffix_wifi": "N", "prefix_toggle": "SUPER", "suffix_toggle": "H", "prefix_randomwallpaper": "ALT SHIFT", "suffix_randomwallpaper": "W", "wallpapers_dir": "/home/sankalp/Pictures/wallpapers"} \ No newline at end of file diff --git a/config/assets/launcher.json b/config/assets/modus.json similarity index 54% rename from config/assets/launcher.json rename to config/assets/modus.json index a153df6..12e8657 100644 --- a/config/assets/launcher.json +++ b/config/assets/modus.json @@ -7,6 +7,18 @@ ":bt": "launcher.open(\"bluetooth\")", ":sh": "launcher.open(\"sh\")", ":pw": "launcher.open(\"power\")", - ":nw": "launcher.open(\"wifi\")" - } + ":nw": "launcher.open(\"wifi\")", + ":cl": "launcher.open(\"calendar\")" + }, + "pinned_apps": [ + "kitty", + "code", + "materialgram", + "vesktop", + "spotify", + "libreoffice", + "wasistlos", + "pavucontrol", + "zen-browser" + ] } diff --git a/config/assets/settings.json b/config/assets/settings.json index 65e8d2b..d11f7c8 100644 --- a/config/assets/settings.json +++ b/config/assets/settings.json @@ -2,4 +2,4 @@ "color-scheme": "dark", "custom-color": "none", "generation-scheme": "tonalSpot" -} +} \ No newline at end of file diff --git a/config/config.py b/config/config.py index 46cd4c7..c280ec1 100644 --- a/config/config.py +++ b/config/config.py @@ -117,7 +117,7 @@ def copy_wallpapers(): subprocess.run( [ "python", - os.path.expanduser("~/Modus/config/wallpaper.py"), + os.path.expanduser("~/Modus/config/scripts/wallpaper.py"), "-I", os.path.join(dest_wallpaper_dir, "example-1.jpg"), ] @@ -150,7 +150,7 @@ exec-once = wl-paste --type text --watch cliphist store exec-once = wl-paste --type image --watch cliphist store $fabricSend = fabric-cli exec modus -$scriptsDir = $HOME/Modus/config +$scriptsDir = $HOME/Modus/config/scripts/ bind = {bind_vars["prefix_restart"]}, {bind_vars["suffix_restart"]}, exec, killall modus; python {home}/Modus/main.py # Reload Modus | Default: SUPER ALT + T bind = {bind_vars["prefix_bluetooth"]}, {bind_vars["suffix_bluetooth"]}, exec, $fabricSend 'launcher.open("bluetooth")' # Bluetooth | Default: ALT + B diff --git a/config/hypr/modus.conf b/config/hypr/modus.conf index cd7dc0d..2eee02b 100644 --- a/config/hypr/modus.conf +++ b/config/hypr/modus.conf @@ -5,10 +5,11 @@ exec-once = wl-paste --type text --watch cliphist store exec-once = wl-paste --type image --watch cliphist store $fabricSend = fabric-cli exec modus -$scriptsDir = $HOME/Modus/config +$scriptsDir = $HOME/Modus/config/scripts/ bind = SHIFT ALT, T, exec, killall modus; python /home/sankalp/Modus/main.py # Reload Modus | Default: SUPER ALT + T bind = ALT, B, exec, $fabricSend 'launcher.open("bluetooth")' # Bluetooth | Default: ALT + B +bind = SUPER, N, exec, $fabricSend 'launcher.open("wifi")' # Wifi | Default: SUPER + N bind = SUPER, E, exec, $fabricSend 'launcher.open("emoji")' # Emoji | Default: SUPER + E bind = SUPER, V, exec, $fabricSend 'launcher.open("cliphist")' # Cliphist | Default: SUPER + V bind = SUPER, T, exec, $fabricSend 'launcher.open("todo")' # Todo | Default: SUPER + T @@ -20,7 +21,7 @@ bind = SUPER, X, exec, $fabricSend 'launcher.open("power")' # Power Menu | Defau bind = SUPER, H, exec, $fabricSend 'bar.toggle_hidden()' # Toggle Bar | Default: SUPER + H bind = SUPER, Z, exec, $fabricSend 'sc.screenshot()' # Screenshot Region | Default: SUPER + Z bind = , Print, exec, $fabricSend 'sc.screenshot(True)' # ScreenshotFullScreen | Default: Print -bind = SUPER, R, exec, $fabricSend 'sc.screencast()' # Screencast | Default: SUPER + R +bind = SUPER, R, exec, $fabricSend 'sc.screencast_start()' # Screencast | Default: SUPER + R # Wallpapers directory: /home/sankalp/Pictures/wallpapers diff --git a/config/material-colors/generate.py b/config/material-colors/generate.py index 32504e0..6fa3446 100644 --- a/config/material-colors/generate.py +++ b/config/material-colors/generate.py @@ -7,23 +7,22 @@ import argparse from materialyoucolor.quantize import QuantizeCelebi # type: ignore from materialyoucolor.score.score import Score # type: ignore from materialyoucolor.hct import Hct # type: ignore -from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors # type: ignore # noqa -from materialyoucolor.scheme.dynamic_scheme import DynamicScheme # type: ignore # noqa -from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot # type: ignore # noqa -from materialyoucolor.scheme.scheme_expressive import SchemeExpressive # type: ignore # noqa -from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad # type: ignore # noqa -from materialyoucolor.scheme.scheme_monochrome import SchemeMonochrome # type: ignore # noqa -from materialyoucolor.scheme.scheme_rainbow import SchemeRainbow # type: ignore # noqa -from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant # type: ignore # noqa -from materialyoucolor.scheme.scheme_neutral import SchemeNeutral # type: ignore # noqa -from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity # type: ignore # noqa -from materialyoucolor.scheme.scheme_content import SchemeContent # type: ignore # noqa +from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors # type: ignore # noqa +from materialyoucolor.scheme.dynamic_scheme import DynamicScheme # type: ignore # noqa +from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot # type: ignore # noqa +from materialyoucolor.scheme.scheme_expressive import SchemeExpressive # type: ignore # noqa +from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad # type: ignore # noqa +from materialyoucolor.scheme.scheme_monochrome import SchemeMonochrome # type: ignore # noqa +from materialyoucolor.scheme.scheme_rainbow import SchemeRainbow # type: ignore # noqa +from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant # type: ignore # noqa +from materialyoucolor.scheme.scheme_neutral import SchemeNeutral # type: ignore # noqa +from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity # type: ignore # noqa +from materialyoucolor.scheme.scheme_content import SchemeContent # type: ignore # noqa import hashlib import pickle import numpy as np import re - schemes = { "tonalSpot": SchemeTonalSpot, "expressive": SchemeExpressive, @@ -41,22 +40,21 @@ def join(*args): return os.path.join(*args) -home = os.path.expanduser('~') - +home = os.path.expanduser("~") script_dir = os.path.dirname(os.path.abspath(__file__)) cache_path = f"{home}/.cache/material/" os.makedirs(cache_path, exist_ok=True) def rgb_to_hex(rgb): - return '#{:02x}{:02x}{:02x}'.format(*rgb[:3]) + return "#{:02x}{:02x}{:02x}".format(*rgb[:3]) def rgba_to_rgb(rgba): - return f'{rgba[0]}, {rgba[1]}, {rgba[2]}' + return f"{rgba[0]}, {rgba[1]}, {rgba[2]}" -class Color(): +class Color: def __init__(self, scheme) -> None: self.scheme = scheme @@ -70,8 +68,12 @@ class Color(): class ColorsCache: def __init__( - self, colors: DynamicScheme | dict, wallpaper: str, - original_color: int, contrast_level: int, scheme_name: str + self, + colors: DynamicScheme | dict, + wallpaper: str, + original_color: int, + contrast_level: int, + scheme_name: str, ) -> None: self.colors: dict[str, str] = {} self.wallpaper = wallpaper @@ -90,14 +92,13 @@ class ColorsCache: def colors_dict(cache: ColorsCache): - dict = { + return { "wallpaper": cache.wallpaper, "colors": cache.colors, "original_color": cache.original_color, "contrast_level": cache.contrast_level, - "scheme_name": cache.scheme_name + "scheme_name": cache.scheme_name, } - return dict def get_cache_object(object: dict | str): @@ -108,10 +109,7 @@ def get_cache_object(object: dict | str): original_color = object["original_color"] contrast_level = object.get("contrast_level", 0) scheme_name = object.get("scheme_name", "tonalSpot") - return ColorsCache( - colors, wallpaper, original_color, - contrast_level, scheme_name - ) + return ColorsCache(colors, wallpaper, original_color, contrast_level, scheme_name) def get_file_list(folder_path): @@ -124,21 +122,18 @@ def get_file_list(folder_path): ready_templates = { "colors.css": "@define-color {name} {hex};\n", - "colors.scss": "${name}: {hex};\n" -} -additional = { - "onBackground": "foreground" + "colors.scss": "${name}: {hex};\n", } +additional = {"onBackground": "foreground"} -def generate_color_map(scheme: DynamicScheme): - color_map: dict[str, str] = {} - for color in vars(MaterialDynamicColors).keys(): - color_name = getattr(MaterialDynamicColors, color) - if hasattr(color_name, "get_hct"): - rgba = color_name.get_hct(scheme).to_rgba() - color_map[color.strip()] = rgb_to_hex(rgba) - return color_map +def generate_color_map(scheme): + colors = {} + for color_name in vars(MaterialDynamicColors).keys(): + color_obj = getattr(MaterialDynamicColors, color_name) + if hasattr(color_obj, "get_hct"): + colors[color_name] = rgb_to_hex(color_obj.get_hct(scheme).to_rgba()) + return colors class ColorFormatter: @@ -147,25 +142,23 @@ class ColorFormatter: def apply_transformations(self, value, transformations): intermediate_transforms = [ - t for t in transformations - if not (t.startswith("strip") or t == "rgb") + t for t in transformations if not (t.startswith("strip") or t == "rgb") ] final_transforms = [ - t for t in transformations - if t.startswith("strip") or t == "rgb" + t for t in transformations if t.startswith("strip") or t == "rgb" ] for transform in intermediate_transforms: if transform.startswith("lighten"): - percent = int(re.search(r'\d+', transform).group()) + percent = int(re.search(r"\d+", transform).group()) value = self.adjust_brightness(value, percent) elif transform.startswith("darken"): - percent = int(re.search(r'\d+', transform).group()) + percent = int(re.search(r"\d+", transform).group()) value = self.adjust_brightness(value, -percent) for transform in final_transforms: if transform.startswith("strip"): - value = value.lstrip('#') + value = value.lstrip("#") elif transform == "rgb": value = self.hex_to_rgb(value) @@ -175,24 +168,20 @@ class ColorFormatter: def min_max(v): return min(255, max(0, v)) - hex_color = hex_color.lstrip('#') - r, g, b = (int(hex_color[i:i+2], 16) for i in (0, 2, 4)) - + hex_color = hex_color.lstrip("#") + r, g, b = (int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) r = min_max(int(r * (1 + factor / 100))) g = min_max(int(g * (1 + factor / 100))) b = min_max(int(b * (1 + factor / 100))) - - return f'#{r:02X}{g:02X}{b:02X}'.lower() + return f"#{r:02X}{g:02X}{b:02X}".lower() def hex_to_rgb(self, hex_color): - hex_color = hex_color.lstrip('#') - r, g, b = (int(hex_color[i:i+2], 16) for i in (0, 2, 4)) - return f'{r},{g},{b}' + hex_color = hex_color.lstrip("#") + r, g, b = (int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + return f"{r},{g},{b}" def parse_transformations(self, transformations_str): - transformations = re.findall( - r'(\w+)(?:\((\d*)\))?', transformations_str - ) + transformations = re.findall(r"(\w+)(?:\((\d*)\))?", transformations_str) result = [] for command, arg in transformations: if command: @@ -203,7 +192,7 @@ class ColorFormatter: return result def format(self, text): - pattern = r'(<(\w+)(?:\.(.+))?>)' + pattern = r"(<(\w+)(?:\.(.+))?>)" matches = re.findall(pattern, text) result = [] last_end = 0 @@ -211,27 +200,28 @@ class ColorFormatter: for match in matches: full_match, key, transformations_str = match if key in self.color_map: - result.append(text[last_end:text.find(full_match, last_end)]) + result.append(text[last_end : text.find(full_match, last_end)]) value = self.color_map[key] if transformations_str: - transformations = self.parse_transformations( - transformations_str - ) + transformations = self.parse_transformations(transformations_str) value = self.apply_transformations(value, transformations) result.append(value) last_end = text.find(full_match, last_end) + len(full_match) result.append(text[last_end:]) - result = ''.join(result) - pattern = r'<\\\\([^>]+)>' - - result = re.sub(pattern, r'<\1>', result) - + result = "".join(result) + pattern = r"<\\\\([^>]+)>" + result = re.sub(pattern, r"<\1>", result) return result -def generate_templates(folder: str, output_folder: str, scheme: DynamicScheme, - color_scheme: str, wallpaper: str = ''): +def generate_templates( + folder: str, + output_folder: str, + scheme: DynamicScheme, + color_scheme: str, + wallpaper: str = "", +): if not os.path.exists(output_folder): os.makedirs(output_folder) if not os.path.exists(folder): @@ -244,13 +234,12 @@ def generate_templates(folder: str, output_folder: str, scheme: DynamicScheme, formatter = ColorFormatter(scheme) template = formatter.format(template) template = ( - template - .replace("", color_scheme) + template.replace("", color_scheme) .replace("", output_folder) .replace("", wallpaper) ) new_path = join(output_folder, os.path.basename(file_path)) - with open(new_path, 'w') as f: + with open(new_path, "w") as f: f.write(template) for file in ready_templates: @@ -262,21 +251,17 @@ def generate_templates(folder: str, output_folder: str, scheme: DynamicScheme, hex_color = rgb_to_hex(rgba) rgb_color = rgba_to_rgb(rgba) new_line = ready_templates[file].format( - name=color, - hex=hex_color, - rgb=rgb_color + name=color, hex=hex_color, rgb=rgb_color ) _template += new_line if color in additional: new_line = ready_templates[file].format( - name=additional[color], - hex=hex_color, - rgb=rgb_color + name=additional[color], hex=hex_color, rgb=rgb_color ) _template += new_line new_path = join(output_folder, os.path.basename(file)) - with open(new_path, 'w') as f: + with open(new_path, "w") as f: f.write(_template) @@ -286,10 +271,10 @@ def run_hooks(folder: str): file_list = get_file_list(folder) for file_path in file_list: file_name = os.path.basename(file_path) - if str(file_name).endswith(".py"): - subprocess.run(['python', '-O', file_path]) - elif str(file_name).endswith(".sh"): - subprocess.run(['sh', file_path]) + if file_name.endswith(".py"): + subprocess.run(["python", "-O", file_path]) + elif file_name.endswith(".sh"): + subprocess.run(["sh", file_path]) def process_image(image_path, quality=2, num_colors=128): @@ -297,81 +282,69 @@ def process_image(image_path, quality=2, num_colors=128): global cache_path _cache_path = join(cache_path, "cached_colors") hash_object = hashlib.md5(image_path.encode()) - cache_filename = hash_object.hexdigest() + '.pkl' + cache_filename = hash_object.hexdigest() + ".pkl" os.makedirs(_cache_path, exist_ok=True) return join(_cache_path, cache_filename) def load_from_cache(cache_path): if os.path.exists(cache_path): - with open(cache_path, 'rb') as f: + with open(cache_path, "rb") as f: return pickle.load(f) return None def save_to_cache(cache_path, data): - with open(cache_path, 'wb') as f: + with open(cache_path, "wb") as f: pickle.dump(data, f) - cache_path = get_cache_path(image_path) - - cached_result = load_from_cache(cache_path) + cache_file = get_cache_path(image_path) + cached_result = load_from_cache(cache_file) if cached_result is not None: return cached_result - image = Image.open(image_path).convert('RGB') - + image = Image.open(image_path).convert("RGB") image_data = np.array(image) pixel_array = image_data[::quality, ::quality].reshape(-1, 3) - result = QuantizeCelebi(pixel_array, num_colors) - color = Score.score(result)[0] - - save_to_cache(cache_path, color) - + save_to_cache(cache_file, color) return color def main( - color_scheme: str, image_path: str, use_color: int | None = None, - scheme_name: str | None = None, contrast_level: int = 0 + color_scheme: str, + image_path: str, + use_color: int | None = None, + scheme_name: str | None = None, + contrast_level: int = 0, ): scheme_name = scheme_name or "tonalSpot" scheme = schemes[scheme_name] is_dark = True if color_scheme == "dark" else False if use_color is None: color = process_image(image_path, 4, 1024) - else: color = use_color - scheme = scheme( - Hct.from_int(color), - is_dark, - contrast_level - ) + scheme = scheme(Hct.from_int(color), is_dark, contrast_level) # for color in vars(MaterialDynamicColors).keys(): # color_name = getattr(MaterialDynamicColors, color) # if hasattr(color_name, "get_hct"): # print(color, rgb_to_hex(color_name.get_hct(scheme).to_rgba())) - with open(join(cache_path, "colors.json"), 'w') as f: - object = ColorsCache( - scheme, image_path, color, contrast_level, scheme_name + with open(join(cache_path, "colors.json"), "w") as f: + cache_obj = ColorsCache( + generate_color_map(scheme), image_path, color, contrast_level, scheme_name ) - json.dump(colors_dict(object), f, indent=2) + json.dump(colors_dict(cache_obj), f, indent=2) generate_templates( f"{script_dir}/templates", cache_path, scheme, color_scheme, image_path ) - generate_templates( - f"{script_dir}/templates/svg", join(cache_path, "svg/"), scheme, - color_scheme, image_path - ) if is_dark: - subprocess.run(['sh', join(script_dir, 'gtk-material.sh')]) + subprocess.run(["sh", join(script_dir, "gtk-material.sh")]) else: - subprocess.run(['sh', join(script_dir, 'gtk-material.sh'), '--light']) + subprocess.run(["sh", join(script_dir, "gtk-material.sh"), "--light"]) run_hooks(join(script_dir, "hooks")) @@ -379,30 +352,29 @@ def main( def _argparse() -> tuple[bool, bool, str, str, str, str]: parser = argparse.ArgumentParser() group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("-R", help="Restore previous colorscheme.", action="store_true") group.add_argument( - "-R", help="Restore previous colorscheme.", action='store_true' - ) - group.add_argument( - "-w", help="Use last used wallpaper for color generation.", - action='store_true' - ) - group.add_argument( - "--image", type=str, help="Image for color scheme generation" + "-w", help="Use last used wallpaper for color generation.", action="store_true" ) + group.add_argument("--image", type=str, help="Image for color scheme generation") group.add_argument( "--color", type=str, help="Main color for color scheme generation" ) parser.add_argument( - '--color-scheme', type=str, default='dark', choices=['dark', 'light'], - help="Color scheme variant" + "--color-scheme", + type=str, + default="dark", + choices=["dark", "light"], + help="Color scheme variant", ) parser.add_argument( - '--scheme', type=str, default="", - choices=list(schemes.keys()), help="Scheme for color generation" + "--scheme", + type=str, + default="", + choices=list(schemes.keys()), + help="Scheme for color generation", ) - args = parser.parse_args() - restore_palette = args.R use_last_used_wallpaper = args.w image = args.image @@ -410,16 +382,19 @@ def _argparse() -> tuple[bool, bool, str, str, str, str]: use_color = args.color scheme = args.scheme return ( - restore_palette, use_last_used_wallpaper, image, - color_scheme, use_color, scheme + restore_palette, + use_last_used_wallpaper, + image, + color_scheme, + use_color, + scheme, ) def _main(): - ( - restore_palette, use_last_used_wallpaper, image, - color_scheme, use_color, scheme - ) = _argparse() + restore_palette, use_last_used_wallpaper, image, color_scheme, use_color, scheme = ( + _argparse() + ) if not scheme: scheme = None if use_color is not None: @@ -427,15 +402,18 @@ def _main(): with open(join(cache_path, "colors.json")) as f: content = get_cache_object(f.read()) main( - color_scheme, image_path=content.wallpaper, - use_color=int(use_color.lstrip('#'), 16), scheme_name=scheme + color_scheme, + image_path=content.wallpaper, + use_color=int(use_color.lstrip("#"), 16), + scheme_name=scheme, ) except Exception: main( - color_scheme, image_path="/dev/null", - use_color=int(use_color.lstrip('#'), 16), scheme_name=scheme + color_scheme, + image_path="/dev/null", + use_color=int(use_color.lstrip("#"), 16), + scheme_name=scheme, ) - elif restore_palette: with open(join(cache_path, "colors.json")) as f: content = get_cache_object(f.read()) @@ -444,7 +422,7 @@ def _main(): image_path=content.wallpaper, use_color=content.original_color, scheme_name=scheme or content.scheme_name, - contrast_level=content.contrast_level + contrast_level=content.contrast_level, ) elif use_last_used_wallpaper: with open(join(cache_path, "colors.json")) as f: @@ -453,13 +431,12 @@ def _main(): color_scheme, image_path=content.wallpaper, scheme_name=scheme or content.scheme_name, - contrast_level=content.contrast_level + contrast_level=content.contrast_level, ) else: if os.path.isdir(image): files = [ - f for f in os.listdir(image) - if f.endswith(('.png', '.jpg', '.jpeg')) + f for f in os.listdir(image) if f.endswith((".png", ".jpg", ".jpeg")) ] if files: image = join(image, random.choice(files)) diff --git a/config/material-colors/hooks/kitty.sh b/config/material-colors/hooks/kitty.sh new file mode 100755 index 0000000..d6c3dfd --- /dev/null +++ b/config/material-colors/hooks/kitty.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Define the cache path where your colors-kitty.conf file is stored. +CACHE_PATH="${HOME}/.cache/material" +COLORS_FILE="${CACHE_PATH}/colors-kitty.conf" + +if [ -f "$COLORS_FILE" ]; then + # Reload kitty colors using kitty's remote control. + kitty @ set-colors --all "$COLORS_FILE" + # Send the USR1 signal to kitty to complete the reload process. + pkill -USR1 kitty + echo "Kitty colors reloaded successfully." +else + echo "Colors file not found at $COLORS_FILE." +fi diff --git a/config/material-colors/templates/colors.tdesktop-theme b/config/material-colors/templates/colors.tdesktop-theme index 1dc6a05..7b4db51 100644 --- a/config/material-colors/templates/colors.tdesktop-theme +++ b/config/material-colors/templates/colors.tdesktop-theme @@ -392,7 +392,7 @@ msgFileOutBgSelected: ACCENT; // Outgoing selected audio file msgFile1Bg: #72b1df; // Blue shared links / files without image square thumbnail msgFile1BgDark: #5c9ece; // Blue shared files without image download circle background msgFile1BgOver: #5294c4; // Blue shared files without image download circle background with mouse over -msgFile1BgSelected: #5099d0; // Blue shared files without image download circle background if file is selected +msgFile1BgSelected: #50100d0; // Blue shared files without image download circle background if file is selected msgFile2Bg: #61b96e; // Green shared links / shared files without image square thumbnail msgFile2BgDark: #4da859; // Green shared files without image download circle background msgFile2BgOver: #44a050; // Green shared files without image download circle background with mouse over @@ -514,7 +514,7 @@ mediaviewFileSizeFg: ON_BACKGROUND_DARKER; // File size text in file rectan mediaviewFileRedCornerFg: #d55959; // Red file thumbnail placeholder corner in file rectangle (for a file without thumbnail, like .pdf) mediaviewFileYellowCornerFg: #e8a659; // Yellow file thumbnail placeholder corner in file rectangle (for a file without thumbnail, like .zip) mediaviewFileGreenCornerFg: #49a957; // Green file thumbnail placeholder corner in file rectangle (for a file without thumbnail, like .exe) -mediaviewFileBlueCornerFg: #599dcf; // Blue file thumbnail placeholder corner in file rectangle (for a file without thumbnail, like .dmg) +mediaviewFileBlueCornerFg: #5100dcf; // Blue file thumbnail placeholder corner in file rectangle (for a file without thumbnail, like .dmg) mediaviewFileExtFg: activeButtonFg; // File extension text in file thumbnail placeholder in file rectangle mediaviewMenuBg: BACKGROUND; // Context menu in Media Viewer background diff --git a/config/material-colors/templates/material-discord.scss b/config/material-colors/templates/material-discord.scss index 607ee99..f302ab7 100644 --- a/config/material-colors/templates/material-discord.scss +++ b/config/material-colors/templates/material-discord.scss @@ -1,14 +1,14 @@ @use "sass:color"; +@use "sass:math"; +@use "colors.scss" as *; @import url(https://capnkitten.github.io/BetterDiscord/Themes/Material-Discord/css/source.css); -@import "/colors.scss"; - $color-scheme: ; -$hue: round(color.channel($primary, "hue", $space: hsl)); -$saturation: round(color.channel($primary, "saturation", $space: hsl)); -$lightness: round(color.channel($primary, "lightness", $space: hsl)); +$hue: math.round(color.channel($primary, "hue", $space: hsl)); +$saturation: math.round(color.channel($primary, "saturation", $space: hsl)); +$lightness: math.round(color.channel($primary, "lightness", $space: hsl)); * { transition: color 0.2s ease-in-out; diff --git a/config/scripts/systemupdates.sh b/config/scripts/systemupdates.sh new file mode 100755 index 0000000..e8044eb --- /dev/null +++ b/config/scripts/systemupdates.sh @@ -0,0 +1,164 @@ +#!/bin/bash + +# Word of caution: changing this can break update module, be vigilant + +check_arch_updates() { + official_updates=0 + aur_updates=0 + flatpak_updates=0 + tooltip="" # Initialize tooltip variable + + # Get the number of official updates using 'checkupdates' + official_updates=$(checkupdates 2>/dev/null | wc -l) + + if command -v paru &>/dev/null; then + aur_helper="paru" + else + aur_helper="yay" + fi + + # Get the number of AUR updates using 'yay' + aur_updates=$($aur_helper -Qum 2>/dev/null | wc -l) + + # Calculate total updates + total_updates=$((official_updates + aur_updates + flatpak_updates)) + + # Build the tooltip for official updates + tooltip="󰣇 Official $official_updates\n󰮯 AUR $aur_updates" + + # Check if flatpak is installed + if command -v flatpak &>/dev/null; then + # Get the number of Flatpak updates if flatpak is installed + flatpak_updates=$(flatpak remote-ls --updates | wc -l) + + # Append Flatpak updates to the tooltip + tooltip="$tooltip\n Flatpak $flatpak_updates" + + # Recalculate total updates + total_updates=$((official_updates + aur_updates + flatpak_updates)) + fi + + # Output the result as a JSON object + echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}" +} + +check_ubuntu_updates() { + official_updates=0 + flatpak_updates=0 + + # Check if flatpak is installed and get Flatpak updates if it is + if command -v flatpak &>/dev/null; then + flatpak_updates=$(flatpak remote-ls --updates | wc -l) + # Always show Flatpak updates in the tooltip, even if there are none + tooltip="󰕈 Official $official_updates\n Flatpak $flatpak_updates" + else + # If flatpak is not installed, only show official updates + tooltip="󰕈 Official $official_updates" + fi + + # Get the number of official updates using 'apt-get' + official_updates=$(apt-get -s -o Debug::NoLocking=true upgrade | grep -c ^Inst) + + # Calculate total updates + total_updates=$((official_updates + flatpak_updates)) + + # Output the result as a JSON object + echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}" +} + +check_fedora_updates() { + official_updates=0 + flatpak_updates=0 + + # Get the number of official updates using 'dnf' + official_updates=$(dnf check-update -q | grep -v '^Loaded plugins' | grep -v '^No match for' | wc -l) + + # Calculate total updates + total_updates=$((official_updates + flatpak_updates)) + + # Check if flatpak is installed and get Flatpak updates if it is + if command -v flatpak &>/dev/null; then + flatpak_updates=$(flatpak remote-ls --updates | wc -l) + # Always show Flatpak updates in the tooltip, even if there are none + tooltip="󰣛 Official $official_updates\n Flatpak $flatpak_updates" + else + # If flatpak is not installed, only show official updates + tooltip="󰣛 Official $official_updates" + fi + + # Output the result as a JSON object + echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}" +} + +check_opensuse_updates() { + official_updates=0 + flatpak_updates=0 + + # Check if flatpak is installed and get Flatpak updates if it is + if command -v flatpak &>/dev/null; then + flatpak_updates=$(flatpak remote-ls --updates | wc -l) + fi + + # Get the number of official updates using 'dnf' + official_updates=$(zypper lu | wc -l) + + # Calculate total updates + total_updates=$((official_updates + flatpak_updates)) + + # Check if flatpak is installed and get Flatpak updates if it is + if command -v flatpak &>/dev/null; then + flatpak_updates=$(flatpak remote-ls --updates | wc -l) + # Always show Flatpak updates in the tooltip, even if there are none + tooltip=" Official $official_updates\n Flatpak $flatpak_updates" + else + # If flatpak is not installed, only show official updates + tooltip=" Official $official_updates" + fi + + # Output the result as a JSON object + echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}" +} + +update_arch() { + command=" + fastfetch + yay -Syu + read -n 1 -p 'Press any key to continue...' + " + kitty --title systemupdate sh -c "${command}" +} + +case "$1" in +-arch) + if [ -z "$2" ]; then # If second argument is null (not provided) + check_arch_updates + else + update_arch + fi + ;; +-ubuntu) + if [ -z "$2" ]; then # If second argument is null (not provided) + check_ubuntu_updates + else + update_ubuntu + fi + ;; +-fedora) + if [ -z "$2" ]; then # If second argument is null (not provided) + check_fedora_updates + else + update_fedora + fi + ;; +-suse) + if [ -z "$2" ]; then # If second argument is null (not provided) + check_opensuse_updates + else + update_arch + fi + ;; +*) + echo "Usage: $0 [-arch|-ubuntu|-fedora|-suse] [up (optional)]" + exit 1 + ;; +esac diff --git a/config/scripts/wayland-idle-inhibitor.py b/config/scripts/wayland-idle-inhibitor.py deleted file mode 100755 index c731f67..0000000 --- a/config/scripts/wayland-idle-inhibitor.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python - -import sys -from dataclasses import dataclass -from signal import SIGINT, SIGTERM, signal -from threading import Event -import setproctitle - -from pywayland.client.display import Display # type: ignore -from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import ( # type: ignore - ZwpIdleInhibitManagerV1, -) -from pywayland.protocol.wayland.wl_compositor import WlCompositor # type: ignore -from pywayland.protocol.wayland.wl_registry import WlRegistryProxy # type: ignore -from pywayland.protocol.wayland.wl_surface import WlSurface # type: ignore - - -@dataclass -class GlobalRegistry: - surface: WlSurface | None = None - inhibit_manager: ZwpIdleInhibitManagerV1 | None = None - - -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: - done = Event() - signal(SIGINT, lambda _, __: done.set()) - signal(SIGTERM, lambda _, __: done.set()) - - global_registry = GlobalRegistry() - - 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 or global_registry.inhibit_manager is None: - print("Wayland seems not to support idle_inhibit_unstable_v1 protocol.") - shutdown() - sys.exit(1) - - inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore - global_registry.surface - ) - - display.dispatch() - display.roundtrip() - - print("Inhibiting idle...") - done.wait() - print("Shutting down...") - - inhibitor.destroy() - - shutdown() - - -if __name__ == "__main__": - setproctitle.setproctitle("wayland-idle-inhibitor.py") - main() diff --git a/install.sh b/install.sh index 40b0d1d..bbc8f65 100644 --- a/install.sh +++ b/install.sh @@ -33,6 +33,7 @@ PACKAGES=( libadwaita adw-gtk-theme brightnessctl + wlinhibit power-profile-daemon ttf-font-awesome otf-font-awesome @@ -42,62 +43,96 @@ PACKAGES=( ttf-robot ) +# Ensure running on Arch Linux if ! grep -q "arch" /etc/os-release; then - echo ":: This script is designed to run on Arch Linux." + echo "This script is designed to run on Arch Linux." exit 1 fi -# Optional: Prevent running as root +# Prevent running as root if [ "$(id -u)" -eq 0 ]; then - echo ":: Please do not run this script as root." + echo "Please do not run this script as root." exit 1 fi -# Install paru if not installed -if ! command -v yay &>/dev/null; then - echo ":: Installing paru..." +# Check for AUR helper (yay or paru) +aur_helper="" +if command -v yay &>/dev/null; then + aur_helper="yay" +elif command -v paru &>/dev/null; then + aur_helper="paru" +else + echo "Installing paru..." tmpdir=$(mktemp -d) git clone https://aur.archlinux.org/paru.git "$tmpdir/paru" cd "$tmpdir/paru" makepkg -si --noconfirm cd - >/dev/null rm -rf "$tmpdir" + aur_helper="paru" fi -# Clone or update the repository +# Clone or update the Modus repository if [ -d "$INSTALL_DIR" ]; then - echo ":: Updating Modus..." + echo "Updating Modus..." git -C "$INSTALL_DIR" pull else - echo ":: Cloning Modus..." + echo "Cloning Modus..." git clone "$REPO_URL" "$INSTALL_DIR" fi -# Install required packages using paru -echo ":: Installing required packages..." -paru -S --needed --noconfirm "${PACKAGES[@]}" || true +# Install required packages using the AUR helper +echo "Installing required packages..." +$aur_helper -Syy --needed --noconfirm "${PACKAGES[@]}" || true -# Backup and replace GTK configs -echo ":: Updating GTK configurations..." +# Update outdated packages +echo "Checking for outdated packages..." +outdated=$($aur_helper -Qu | awk '{print $1}' || true) +to_update=() +for pkg in "${PACKAGES[@]}"; do + if echo "$outdated" | grep -q "^$pkg\$"; then + to_update+=("$pkg") + fi +done + +if [ ${#to_update[@]} -gt 0 ]; then + echo "Updating outdated packages..." + $aur_helper -S --noconfirm "${to_update[@]}" || true +else + echo "All required packages are up-to-date." +fi + +# Backup and replace GTK configurations +echo "Updating GTK configurations..." mkdir -p "$HOME/.config" -[[ -d "$HOME/.config/gtk-3.0" ]] && mv "$HOME/.config/gtk-3.0" "$HOME/.config/gtk-3.0-bk" -[[ -d "$HOME/.config/gtk-4.0" ]] && mv "$HOME/.config/gtk-4.0" "$HOME/.config/gtk-4.0-bk" +[[ -d "$HOME/.config/gtk-3.0" ]] && mv "$HOME/.config/gtk-3.0" "$HOME/.config/gtk-3.0-bk-$(date +%s)" +[[ -d "$HOME/.config/gtk-4.0" ]] && mv "$HOME/.config/gtk-4.0" "$HOME/.config/gtk-4.0-bk-$(date +%s)" -cp -r "$INSTALL_DIR/assets/gtk-3.0" "$HOME/.config/" -cp -r "$INSTALL_DIR/assets/gtk-4.0" "$HOME/.config/" +cp -r "$INSTALL_DIR/assets/gtk-3.0" "$HOME/.config/" || true +cp -r "$INSTALL_DIR/assets/gtk-4.0" "$HOME/.config/" || true -# Install Icon -echo ":: Installing icon theme..." +# Install Icon Theme +echo "Installing Tela icon theme..." tmpdir=$(mktemp -d) -cd /tmp/install -git clone https://github.com/vinceliuice/Tela-icon-theme +git clone https://github.com/vinceliuice/Tela-icon-theme "$tmpdir/Tela-icon-theme" cd "$tmpdir/Tela-icon-theme" ./install.sh nord +rm -rf "$tmpdir" -# Launch Modus without terminal output -echo ":: Starting Modus..." +# Install Vencord +echo "Installing Vencord..." +if command -v curl &>/dev/null; then + sh -c "$(curl -sS https://raw.githubusercontent.com/Vendicated/VencordInstaller/main/install.sh)" || true + mkdir -p "$HOME/.config/Vencord/settings/" + ln -sf "$HOME/.cache/material/material-discord.css" "$HOME/.config/Vencord/settings/quickCss.css" +else + echo "Skipping Vencord installation (curl not found)." +fi + +# Launch Modus +echo "Starting Modus..." killall modus 2>/dev/null || true python "$INSTALL_DIR/main.py" >/dev/null 2>&1 & disown -echo ":: Installation complete." +echo "Installation complete." diff --git a/main.py b/main.py index 56baf28..ad71f77 100644 --- a/main.py +++ b/main.py @@ -5,12 +5,15 @@ from fabric import Application from fabric.utils import get_relative_path, monitor_file from loguru import logger -from modules.bar.bar import Bar +from modules.bar.bar import Bar, ScreenCorners from modules.launcher.launcher import Launcher +from modules.dock import Dock from modules.notification_popup import NotificationPopup -from modules.osd import OSDContainer -from modules.corners import Corners + +from modules.osd import OSD + from services import sc +from config.config import open_config for log in [ "fabric.hyprland.widgets", @@ -19,6 +22,8 @@ for log in [ ]: logger.disable(log) +config_path = os.path.expanduser("~/Modus/config/assets/config.json") + def update_main_css(): colors_css_path = f"/home/{os.getlogin()}/.cache/material/colors.css" @@ -42,10 +47,13 @@ def apply_style(app: Application): if __name__ == "__main__": update_main_css() + if not os.path.isfile(config_path): + open_config() sc = sc bar = Bar() - corners = Corners() - osd = OSDContainer() + dock = Dock() + corners = ScreenCorners() + osd = OSD() notif = NotificationPopup() launcher = Launcher() @@ -54,6 +62,7 @@ if __name__ == "__main__": bar, launcher, osd, + dock, ) setproctitle.setproctitle("modus") diff --git a/modules/bar/bar.py b/modules/bar/bar.py index 74ee2d4..278e228 100644 --- a/modules/bar/bar.py +++ b/modules/bar/bar.py @@ -1,27 +1,81 @@ +from typing import Literal from fabric.hyprland.widgets import Language from fabric.system_tray.widgets import SystemTray -from fabric.utils import FormattedString, bulk_replace +from fabric.utils import ( + FormattedString, + bulk_replace, + exec_shell_command_async, + get_relative_path, + bulk_connect, +) +from fabric.widgets.shapes import Corner from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.datetime import DateTime from fabric.widgets.label import Label +from fabric.widgets.revealer import Revealer from fabric.widgets.wayland import WaylandWindow as Window from gi.repository import GLib from modules.bar.components import ( - BatteryLabel, - BluetoothIndicator, - IdleIndicator, - MicrophoneIndicator, - NetworkIndicator, - PowerProfileIndicator, - SystemInfo, - TaskBar, - VolumeIndicator, + Battery, + Metrics, workspace, + SystemIndicators, + UpdatesWidget, ) -from snippets import MaterialIcon from services import sc +import utils.icons as icons + + +class StatusBarCorner(Box): + def __init__(self, corner: Literal["top-right", "top-left"]): + super().__init__( + style="margin-bottom: 15px;", + name="bar-corner", + children=Corner( + orientation=corner, + size=15, + ), + ) + + +class Tray(Box): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.tray = Box( + name="left-tray", + children=[SystemTray(name="tray", icon_size=16, spacing=4)], + ) + self.profile = Button( + child=Label(name="tray-revealer", markup=icons.chevron_left) + ) + self.revealer = Revealer(transition_type="slide-left", transition_duration=1000) + self.revealer.add(self.tray) + + bulk_connect( + self.profile, + { + "enter-notify-event": self._on_enter, + "leave-notify-event": self._on_leave, + "button-press-event": self.toggle_revealer, + }, + ) + + self.add(self.profile) + self.add(self.revealer) + + def _on_enter(self, *args): + self.profile.set_cursor("pointer") + + def _on_leave(self, *args): + self.profile.set_cursor("default") + + def toggle_revealer(self, *args): + new_state = not self.revealer.get_reveal_child() + self.revealer.set_reveal_child(new_state) + new_icon = icons.chevron_right if new_state else icons.chevron_left + self.profile.get_child().set_markup(new_icon) class Bar(Window): @@ -38,30 +92,18 @@ class Bar(Window): ), ), ) - + self.bar_content = CenterBox(name="center-bar") self.recording_indicator = Button( name="recording-indicator", - child=MaterialIcon("stop_circle", 16), + child=Label(name="recorder", markup=icons.record), visible=False, on_clicked=lambda *_: sc.screencast_stop(), ) - sc.connect( - "recording", lambda _, status: self.on_recording_status_change(status) - ) + sc.connect("recording", self.on_recording_status_change) self.date_time = DateTime(formatters=["%-I:%M 󰧞 %a %d %b"], name="datetime") - - self.battery = BatteryLabel(name="battery") - self.volume = VolumeIndicator() - self.bluetooth = BluetoothIndicator() - self.network = NetworkIndicator() - self.microphone = MicrophoneIndicator() - self.idle = IdleIndicator() - self.taskbar = TaskBar() - self.info = SystemInfo() - self.powerindicator = PowerProfileIndicator() - self.tray = SystemTray(name="tray", icon_size=16, spacing=4) + self.battery = Battery() self.launcher = Button( name="logo", child=Label(name="logo-name", label="󰣇 󰫿󰫰󰫵"), @@ -69,70 +111,110 @@ class Bar(Window): "fabric-cli exec modus 'launcher.open(\"launcher\")'" ), ) - + self.button_config = Button( + name="button-bar", + on_clicked=lambda *_: exec_shell_command_async( + f"python {get_relative_path('../../config/config.py')}" + ), + child=Label(name="button-bar-label", markup=icons.config), + ) + self.stats = Metrics() + self.updates = UpdatesWidget() + self.tray = Tray() + self.indicators = SystemIndicators() self.applets = Box( - name="applets", + name="system-indicators", spacing=4, orientation="h", - children=[ - self.language, - self.powerindicator, - self.bluetooth, - self.network, - self.volume, - self.microphone, - self.idle, - ], + children=[self.language, self.indicators], ) - self.bar = CenterBox( - name="bar", - start_children=Box( - name="start-container", - spacing=8, - orientation="h", - children=[ - self.launcher, - self.workspaces, - self.info, - ], - ), - center_children=Box( - name="center-container", - spacing=8, - orientation="h", - children=self.taskbar, - ), - end_children=Box( - name="end-container", - spacing=8, - orientation="h", + self.bar_content.end_children = [ + StatusBarCorner("top-right"), + Box( + name="bar", + spacing=4, children=[ self.recording_indicator, self.tray, self.battery, self.applets, self.date_time, + self.button_config, ], + style_classes="end-container", ), - ) + ] + + self.bar_content.start_children = [ + Box( + name="bar", + spacing=4, + children=[self.launcher, self.workspaces, self.stats, self.updates], + style_classes="start-container", + ), + StatusBarCorner("top-left"), + ] + super().__init__( layer="top", - anchor="left bottom right", + anchor="left top right", exclusivity="auto", visible=True, - child=self.bar, + child=self.bar_content, ) - self.hidden = False - def on_recording_status_change(self, status): - print(f"Recording status changed: {status}") + def on_recording_status_change(self, _, status): self.recording_indicator.set_visible(status) def toggle_hidden(self): self.hidden = not self.hidden if self.hidden: - self.bar.add_style_class("hidden") + self.bar_content.add_style_class("hidden") + self.bar_content.remove_style_class("visible") + self.set_property("visible", False) else: - self.bar.remove_style_class("hidden") + self.bar_content.add_style_class("visible") + self.bar_content.remove_style_class("hidden") + self.set_property("visible", True) + + +class ScreenCorners(Window): + def __init__(self): + super().__init__( + layer="top", + anchor="top left bottom right", + pass_through=True, + child=Box( + orientation="vertical", + children=[ + Box( + children=[ + self.make_corner("top-left"), + Box(h_expand=True), + self.make_corner("top-right"), + ] + ), + Box(v_expand=True), + Box( + children=[ + self.make_corner("bottom-left"), + Box(h_expand=True), + self.make_corner("bottom-right"), + ] + ), + ], + ), + ) + + def make_corner(self, orientation) -> Box: + return Box( + h_expand=False, + v_expand=False, + name="bar-corner", + children=Corner( + orientation=orientation, + size=15, + ), + ) diff --git a/modules/bar/components/__init__.py b/modules/bar/components/__init__.py index 278c35f..4cba446 100644 --- a/modules/bar/components/__init__.py +++ b/modules/bar/components/__init__.py @@ -1,10 +1,5 @@ from .battery import * -from .bluetooth import * -from .idle import * -from .stats import * -from .microphone import * -from .network import * -from .powerprofile import * -from .taskbar import * -from .volume import * +from .metric import * from .workspace import * +from .system_indicators import * +from .updates import * diff --git a/modules/bar/components/battery.py b/modules/bar/components/battery.py index f6ddcac..5707911 100644 --- a/modules/bar/components/battery.py +++ b/modules/bar/components/battery.py @@ -1,70 +1,237 @@ -import psutil -from fabric import Fabricator +import subprocess +import re from fabric.widgets.box import Box -from fabric.widgets.overlay import Overlay +from fabric.widgets.eventbox import EventBox +from fabric.widgets.button import Button from fabric.widgets.label import Label from fabric.widgets.circularprogressbar import CircularProgressBar -from snippets import MaterialIcon +from fabric.widgets.overlay import Overlay +from fabric.widgets.revealer import Revealer +from fabric.core.fabricator import Fabricator +from fabric.utils.helpers import exec_shell_command_async +from gi.repository import GLib +import utils.icons as icons -class BatteryLabel(Box): - ICONS_CHARGING = [ - "battery_charging_20", - "battery_charging_20", - "battery_charging_20", - "battery_charging_30", - "battery_charging_30", - "battery_charging_50", - "battery_charging_60", - "battery_charging_80", - "battery_charging_80", - "battery_charging_90", - "battery_charging_full", - ] - ICONS_NOT_CHARGING = [ - "battery_alert", - "battery_1_bar", - "battery_1_bar", - "battery_2_bar", - "battery_2_bar", - "battery_3_bar", - "battery_4_bar", - "battery_4_bar", - "battery_5_bar", - "battery_6_bar", - "battery_full", - ] - +class Battery(Box): def __init__(self, **kwargs): - super().__init__(**kwargs) - Fabricator(interval=1000, poll_from=self.update_battery_status) - - def update_battery_status(self, *_): - battery = psutil.sensors_battery() - if battery is None: - self.hide() - return - is_charging = battery.power_plugged if battery else False - icons = self.ICONS_CHARGING if is_charging else self.ICONS_NOT_CHARGING - battery_percent = round(battery.percent) - index = min(max(battery_percent // 10, 0), 10) - battery_icon = MaterialIcon(icons[index], size=16) - - self.progress_bar = CircularProgressBar( - name="progress", line_style="round", line_width=2, size=24 + super().__init__( + name="battery", + orientation="h", + spacing=0, ) - self.battery_overlay = Overlay(child=self.progress_bar, overlays=battery_icon) - self.battery_label = Label(label=f"{battery_percent}%", name="battery-label") - - self.children = self.battery_label, self.battery_overlay - - battery_percent = round(battery.percent) - self.progress_bar.value = battery_percent / 100 - self.show() if battery_percent < 100 else self.hide() - - self.set_tooltip_text( - f"Battery: {battery_percent}%\nStatus: {'Charging' if is_charging else 'Discharging'}" + self.bat_save = Button( + name="battery-save", + child=Label(name="battery-save-label", markup=icons.power_saving), + on_clicked=lambda *_: self.set_power_mode("powersave"), + ) + self.bat_balanced = Button( + name="battery-balanced", + child=Label(name="battery-balanced-label", markup=icons.power_balanced), + on_clicked=lambda *_: self.set_power_mode("balanced"), + ) + self.bat_perf = Button( + name="battery-performance", + child=Label( + name="battery-performance-label", markup=icons.power_performance + ), + on_clicked=lambda *_: self.set_power_mode("performance"), ) - return True + # Attach mouse enter/leave events to buttons as well + for btn in [self.bat_save, self.bat_balanced, self.bat_perf]: + btn.connect("enter-notify-event", self.on_mouse_enter) + btn.connect("leave-notify-event", self.on_mouse_leave) + + # Group the mode buttons into a container. + self.mode_switcher = Box( + name="power-mode-switcher", + orientation="h", + spacing=4, + children=[self.bat_save, self.bat_balanced, self.bat_perf], + ) + + self.bat_icon = Label(name="battery-icon", markup=icons.battery) + + self.bat_circle = CircularProgressBar( + name="battery-circle", + value=0, + size=28, + line_width=2, + start_angle=150, + end_angle=390, + ) + + self.bat_overlay = Overlay( + name="battery-overlay", + visible=False, + child=self.bat_circle, + overlays=[self.bat_icon], + ) + + self.bat_level = Label( + name="battery-level", + label="100%", + ) + + self.bat_revealer = Revealer( + name="battery-revealer", + transition_duration=250, + transition_type="slide-left", + child=self.bat_level, + child_revealed=False, + ) + + # Create an inner container to hold the battery elements. + inner_container = Box(orientation="h", spacing=3) + inner_container.add(self.bat_overlay) + inner_container.add(self.bat_revealer) + inner_container.add(self.mode_switcher) + + # Wrap the inner container in an EventBox. + self.event_box = EventBox( + events=["enter-notify-event", "leave-notify-event"], + name="battery-eventbox", + ) + self.event_box.connect("enter-notify-event", self.on_mouse_enter) + self.event_box.connect("leave-notify-event", self.on_mouse_leave) + self.event_box.add(inner_container) + + # Add the EventBox to the Battery widget. + self.add(self.event_box) + + self.current_mode = None + # Initialize hide_timer for delayed hiding. + self.hide_timer = None + # Initialize counter to track hover status across all related widgets. + self.hover_counter = 0 + + # Initialize Fabricator for battery polling every 5 seconds. + self.batt_fabricator = Fabricator( + lambda *args, **kwargs: self.poll_battery(), + interval=1000, + stream=False, + default_value=0, + ) + self.batt_fabricator.changed.connect(self.update_battery) + + # Run update_battery immediately at startup. + GLib.idle_add(self.update_battery, None, self.poll_battery()) + self.set_power_mode("balanced") + + def on_mouse_enter(self, widget, event): + """Reveal battery level on hover.""" + self.hover_counter += 1 + if self.hide_timer is not None: + GLib.source_remove(self.hide_timer) + self.hide_timer = None + self.bat_revealer.set_reveal_child(True) + return False + + def on_mouse_leave(self, widget, event): + """Schedule hiding the battery level after a 0.5s delay only if not hovering any element.""" + if self.hover_counter > 0: + self.hover_counter -= 1 + if self.hover_counter == 0: + if self.hide_timer is not None: + GLib.source_remove(self.hide_timer) + self.hide_timer = GLib.timeout_add(500, self.hide_revealer) + return False + + def hide_revealer(self): + self.bat_revealer.set_reveal_child(False) + self.hide_timer = None + return False + + def poll_battery(self): + """ + Polls the battery status by running the "acpi -b" command. + If no battery information is found, returns (0, None). + Otherwise, returns a tuple: (battery percentage as a float between 0 and 1, battery status string) + """ + try: + output = subprocess.check_output(["acpi", "-b"]).decode("utf-8").strip() + if "Battery" not in output: + return (0, None) + match_percent = re.search(r"(\d+)%", output) + match_status = re.search(r"Battery \d+: (\w+)", output) + if match_percent: + percent = int(match_percent.group(1)) + status = match_status.group(1) if match_status else None + return (percent / 100.0, status) + except Exception: + pass + return (0, None) + + def update_battery(self, sender, battery_data): + """ + Updates the battery widget... + """ + value, status = battery_data + if value == 0: + self.bat_overlay.set_visible(False) + self.bat_revealer.set_visible(False) + else: + self.bat_overlay.set_visible(True) + self.bat_revealer.set_visible(True) + self.bat_circle.set_value(value) + + percentage = int(value * 100) + self.bat_level.set_label(f"{percentage}%") + + # Actualizar el icono de la batería basado en el nivel y el estado de carga. + if percentage <= 15: + self.bat_icon.set_markup(icons.alert) + self.bat_icon.add_style_class("alert") + self.bat_circle.add_style_class("alert") + else: + self.bat_icon.remove_style_class("alert") + self.bat_circle.remove_style_class("alert") + if ( + status == "Discharging" + ): # Muestra discharging en cualquier nivel, incluso al 100%. + self.bat_icon.set_markup(icons.discharging) + elif percentage == 100: + self.bat_icon.set_markup(icons.battery) + elif status == "Charging": + self.bat_icon.set_markup(icons.charging) + else: + self.bat_icon.set_markup(icons.battery) + + def set_power_mode(self, mode): + """ + Switches power mode by running the corresponding auto-cpufreq command. + mode: one of 'powersave', 'balanced', or 'performance' + """ + commands = { + "powersave": "sudo auto-cpufreq --force powersave", + "balanced": "sudo auto-cpufreq --force reset", + "performance": "sudo auto-cpufreq --force performance", + } + if mode in commands: + try: + exec_shell_command_async(commands[mode]) + self.current_mode = mode + self.update_button_styles() + except Exception as err: + # Optionally, handle errors or display a notification. + print(f"Error setting power mode: {err}") + + def update_button_styles(self): + """ + Optionally updates button styles to reflect the current mode. + Adjust the styling method based on your toolkit's capabilities. + """ + if self.current_mode == "powersave": + self.bat_save.add_style_class("active") + self.bat_balanced.remove_style_class("active") + self.bat_perf.remove_style_class("active") + elif self.current_mode == "balanced": + self.bat_save.remove_style_class("active") + self.bat_balanced.add_style_class("active") + self.bat_perf.remove_style_class("active") + elif self.current_mode == "performance": + self.bat_save.remove_style_class("active") + self.bat_balanced.remove_style_class("active") + self.bat_perf.add_style_class("active") diff --git a/modules/bar/components/bluetooth.py b/modules/bar/components/bluetooth.py deleted file mode 100644 index ffc4fec..0000000 --- a/modules/bar/components/bluetooth.py +++ /dev/null @@ -1,34 +0,0 @@ -from fabric.bluetooth import BluetoothClient -from fabric.widgets.box import Box -from snippets import MaterialIcon - - -class BluetoothIndicator(Box): - ICON_ENABLED = "bluetooth" - ICON_DISABLED = "bluetooth_disabled" - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.bluetooth_client = BluetoothClient() - - self.bluetooth_icon = None - - self.bluetooth_client.connect("changed", self.update_bluetooth_status) - - self.update_bluetooth_status() - - def update_bluetooth_status(self, *_): - icon_label = ( - self.ICON_ENABLED if self.bluetooth_client.enabled else self.ICON_DISABLED - ) - - new_icon = MaterialIcon(icon_label, size=16) - - if self.bluetooth_icon: - self.remove(self.bluetooth_icon) - - self.bluetooth_icon = new_icon - self.add(self.bluetooth_icon) - - def on_destroy(self): - self.bluetooth_client.disconnect("changed", self.update_bluetooth_status) diff --git a/modules/bar/components/idle.py b/modules/bar/components/idle.py deleted file mode 100644 index 6ad444a..0000000 --- a/modules/bar/components/idle.py +++ /dev/null @@ -1,27 +0,0 @@ -from fabric import Fabricator -from fabric.utils import exec_shell_command -from fabric.widgets.box import Box -from snippets import MaterialIcon - - -class IdleIndicator(Box): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.idle_icon = self.create_idle_icon() - self.fabricator = Fabricator(interval=1000, poll_from=self.check_idle_status) - - self.children = [self.idle_icon] - - def create_idle_icon(self): - return MaterialIcon("coffee", 16) - - def check_idle_status(self, *_): - output = exec_shell_command("pidof wayland-idle-inhibitor.py") - is_idle = bool(str(output).strip()) - self.update_idle_status(is_idle) - - def update_idle_status(self, is_idle): - if is_idle: - self.idle_icon.set_visible(True) - else: - self.idle_icon.set_visible(False) diff --git a/modules/bar/components/metric.py b/modules/bar/components/metric.py new file mode 100644 index 0000000..d0e026a --- /dev/null +++ b/modules/bar/components/metric.py @@ -0,0 +1,83 @@ +import psutil +from gi.repository import GLib + +from fabric.widgets.label import Label +from fabric.widgets.box import Box +from fabric.widgets.overlay import Overlay +from fabric.widgets.circularprogressbar import CircularProgressBar +import utils.icons as icons + + +class Metrics(Box): + ICONS = { + "CPU": icons.cpu, + "RAM": icons.memory, + "Swap": icons.swap, + "Disk": icons.disk, + "Temp": icons.temp, + } + + def __init__(self, **kwargs): + super().__init__( + name="metrics", + spacing=8, + h_align="center", + v_align="fill", + visible=True, + all_visible=True, + ) + + self.progress_bars = { + system: CircularProgressBar( + name="metric-circle", + line_style="round", + line_width=2, + size=28, + start_angle=150, + end_angle=390, + ) + for system in self.ICONS + } + + for system, icon_name in self.ICONS.items(): + overlay = Overlay( + child=self.progress_bars[system], + overlays=[Label(name="metric-icon", markup=icon_name)], + ) + self.add(overlay) + + GLib.timeout_add_seconds(1, self._update_system_info) + + def _update_system_info(self): + ram_usage = psutil.virtual_memory().percent + swap_usage = psutil.swap_memory().percent + disk_usage = psutil.disk_usage("/").percent + cpu_usage = psutil.cpu_percent(interval=0) + + temp = self._get_device_temperature() + temp_usage = temp if temp is not None else 0 + + usages = { + "CPU": cpu_usage, + "RAM": ram_usage, + "Swap": swap_usage, + "Disk": disk_usage, + "Temp": temp_usage, + } + + for system, usage in usages.items(): + self.progress_bars[system].value = usage / 100.0 + self.progress_bars[system].set_tooltip_text(f"{system} {usage:.1f}%") + + return True + + @staticmethod + def _get_device_temperature(): + try: + temps = psutil.sensors_temperatures() + for key in ("coretemp", "cpu_thermal"): + if key in temps and temps[key]: + return round(temps[key][0].current, 1) + except Exception: + pass + return None diff --git a/modules/bar/components/microphone.py b/modules/bar/components/microphone.py deleted file mode 100644 index a89baf7..0000000 --- a/modules/bar/components/microphone.py +++ /dev/null @@ -1,33 +0,0 @@ -from fabric.audio.service import Audio -from fabric.widgets.box import Box -from snippets import MaterialIcon - - -class MicrophoneIndicator(Box): - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.audio_service = Audio() - self.microphone_icon = self.create_microphone_icon() - - self.audio_service.connect("microphone_changed", self.update_microphone_status) - self.update_microphone_status() - - self.children = (self.microphone_icon,) - - def create_microphone_icon(self): - return MaterialIcon("mic_off", size=16) - - def update_microphone_status(self, *args): - current_microphone = self.audio_service.microphone - - if current_microphone: - is_muted = current_microphone.muted - icon_name = "mic_off" if is_muted else "mic" - - self.microphone_icon.set_label(icon_name) - self.microphone_icon.set_visible(is_muted) - else: - self.microphone_icon.set_visible(False) - - return True diff --git a/modules/bar/components/network.py b/modules/bar/components/network.py deleted file mode 100644 index 3407134..0000000 --- a/modules/bar/components/network.py +++ /dev/null @@ -1,84 +0,0 @@ -import subprocess - -import psutil -from fabric import Fabricator -from fabric.widgets.box import Box -from snippets import MaterialIcon - - -class NetworkIndicator(Box): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.update_interval = 5000 # Increased interval to 5 seconds - self.last_status = None - self.wifi_strength_cache = None - self.fabricator = Fabricator( - interval=self.update_interval, - poll_from=self.update_network_status, - ) - - def update_network_status(self, *_): - current_status = self.get_network_status() - if current_status != self.last_status: - self.update_icon(current_status) - self.last_status = current_status - return True - - def update_icon(self, current_status): - self.icon_label = MaterialIcon(current_status, size=16) - self.children = (self.icon_label,) - - def get_network_status(self): - if self.is_ethernet_connected(): - return "lan" - if self.is_wifi_connected(): - return self.get_wifi_strength_icon() - return "signal_wifi_off" - - def get_wifi_strength_icon(self): - wifi_strength = self.check_wifi_strength() - if wifi_strength is not None: - self.wifi_strength_cache = wifi_strength # Cache the result - else: - wifi_strength = self.wifi_strength_cache # Use cached value if available - - return self.wifi_signal_icon(wifi_strength) - - def wifi_signal_icon(self, strength): - if strength is None or strength <= -100: - return "signal_wifi_0_bar" - if strength > -50: - return "signal_wifi_4_bar" - if strength > -60: - return "network_wifi_3_bar" - if strength > -70: - return "network_wifi_2_bar" - return "network_wifi_1_bar" - - def check_wifi_strength(self): - try: - result = subprocess.run(["iwconfig"], capture_output=True, text=True) - for line in result.stdout.splitlines(): - if "Signal level" in line: - signal_level = line.split("Signal level=")[1].split(" ")[0] - return int(signal_level.strip().replace("dBm", "")) - return None - except Exception: - return None - - def is_wifi_connected(self): - return any( - self._is_interface_up(interface, ["wlan", "wifi"]) - for interface in psutil.net_if_addrs() - ) - - def is_ethernet_connected(self): - return any( - self._is_interface_up(interface, ["enp", "eth"]) - for interface in psutil.net_if_addrs() - ) - - def _is_interface_up(self, interface, keywords): - if any(substring in interface.lower() for substring in keywords): - return psutil.net_if_stats().get(interface, {}).isup - return False diff --git a/modules/bar/components/powerprofile.py b/modules/bar/components/powerprofile.py deleted file mode 100644 index a1817cc..0000000 --- a/modules/bar/components/powerprofile.py +++ /dev/null @@ -1,44 +0,0 @@ -from fabric import Fabricator -from fabric.utils import exec_shell_command -from fabric.widgets.box import Box -from snippets import MaterialIcon - -PROFILE_ICONS = { - "power-saver": "data_saver_on", - "balanced": None, - "performance": "mode_heat", -} - - -class PowerProfileIndicator(Box): - def __init__(self, **kwargs): - super().__init__(**kwargs) - - self.profile_icon = None - - self.fabricator = Fabricator(interval=1000, poll_from=self.update_power_profile) - - def update_power_profile(self, *_): - profile = self.get_power_profile() - self.update_icon(profile) - return True - - def get_power_profile(self): - result = exec_shell_command("powerprofilesctl get") - return result.strip() or "power-saver" - - def update_icon(self, profile): - icon_name = PROFILE_ICONS.get(profile) - - new_icon = MaterialIcon(icon_name, 16) if icon_name else None - - if self.profile_icon: - self.remove(self.profile_icon) - - if new_icon: - self.profile_icon = new_icon - self.add(self.profile_icon) - else: - if self.profile_icon: - self.profile_icon.hide() - self.profile_icon = None diff --git a/modules/bar/components/stats.py b/modules/bar/components/stats.py deleted file mode 100644 index 2b54fa5..0000000 --- a/modules/bar/components/stats.py +++ /dev/null @@ -1,88 +0,0 @@ -from time import sleep -import psutil -from fabric import Fabricator -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.circularprogressbar import CircularProgressBar -from fabric.widgets.label import Label -from fabric.widgets.overlay import Overlay -from snippets import MaterialIcon - - -class SystemInfo(Button): - ICONS = [ - ("CPU", "memory"), - ("Swap", "swap_horiz"), - ("RAM", "memory_alt"), - ("Temp", "thermometer"), - ] - - def __init__(self): - super().__init__(child=self._initialize_content()) - - Fabricator( - poll_from=self._refresh_system_info, - stream=True, - on_changed=lambda fab, data: self._update_ui(data), - ) - - def _initialize_content(self): - self.box = Box(orientation="h", name="tray") - self.progress_bars = {} - self.icons = {} - - for system, icon_name in self.ICONS: - self._create_system_item(system, icon_name) - - return self.box - - def _create_system_item(self, system, icon_name): - self.progress_bars[system] = self._create_progress_bar() - self.icons[system] = self._create_icon(icon_name) - - overlay = Overlay( - child=self.progress_bars[system], overlays=[self.icons[system]] - ) - self.box.pack_start(overlay, False, False, 4) - - def _create_progress_bar(self): - return CircularProgressBar( - name="progress", - line_style="round", - line_width=3, - size=28, - start_at=0, - end_at=0.5, - # start_angle=180, - end_angle=180, - ) - - def _create_icon(self, icon_name): - return MaterialIcon(icon_name, 16) - - def _refresh_system_info(self, fab: Fabricator): - while True: - yield { - "CPU": round(psutil.cpu_percent()), - "RAM": round(psutil.virtual_memory().percent), - "Swap": round(psutil.swap_memory().percent), - "Temp": self._get_device_temperature(), - } - sleep(1) - - @staticmethod - def _get_device_temperature(): - try: - temps = psutil.sensors_temperatures() - if "coretemp" in temps: - return round(temps["coretemp"][0].current, 1) - elif "cpu_thermal" in temps: - return round(temps["cpu_thermal"][0].current, 1) - return None - except Exception: - return None - - def _update_ui(self, usages): - for system, usage in usages.items(): - self.progress_bars[system].value = usage / 100 - self.progress_bars[system].set_tooltip_text(f"{system} {usage}%") diff --git a/modules/bar/components/system_indicators.py b/modules/bar/components/system_indicators.py new file mode 100644 index 0000000..0f49299 --- /dev/null +++ b/modules/bar/components/system_indicators.py @@ -0,0 +1,134 @@ +import subprocess +from fabric import Fabricator +from fabric.widgets.label import Label +from fabric.widgets.box import Box +from fabric.bluetooth import BluetoothClient +from services import NetworkClient, audio +import utils.icons as icons + + +class SystemIndicators(Box): + def __init__(self, **kwargs): + super().__init__(orientation="h", spacing=2, **kwargs) + + self.bluetooth_icon = Label(name="system-indicator-icon") + self.wifi_icon = Label(name="system-indicator-icon") + self.volume_icon_button = Label(name="system-indicator-icon") + self.microphone_icon = Label(name="system-indicator-icon") + self.idle_label = Label(name="system-indicator-icon") + self.night_label = Label(name="system-indicator-icon") + + for widget in [ + self.bluetooth_icon, + self.wifi_icon, + self.volume_icon_button, + self.microphone_icon, + self.idle_label, + self.night_label, + ]: + self.add(widget) + + self.audio_service = audio + self.bluetooth_client = BluetoothClient() + self.network_client = NetworkClient() + + # Connect signals + self.bluetooth_client.connect("changed", self.update_bluetooth_status) + self.audio_service.connect("microphone_changed", self.update_mic_status) + self.audio_service.connect("changed", self.update_volume_status) + self.network_client.connect("device_ready", self.update_network_status) + + if self.network_client.wifi_device: + self.network_client.wifi_device.connect( + "changed", self.update_network_status + ) + + if self.network_client.ethernet_device: + self.network_client.ethernet_device.connect( + "changed", self.update_network_status + ) + + Fabricator(interval=1000, poll_from=self.update_all_statuses) + + self.update_bluetooth_status() + + def update_all_statuses(self, *_args): + self.update_idle_night_status() + self.update_network_status() + + def update_idle_night_status(self, *_): + """Updates the idle and night mode status icons.""" + self.idle_label.set_markup( + icons.coffee + if subprocess.run( + ["pgrep", "-x", "wlinhibit"], capture_output=True + ).returncode + == 0 + else "" + ) + + self.night_label.set_markup( + icons.night + if subprocess.run( + ["pgrep", "-x", "hyprsunset"], capture_output=True + ).returncode + == 0 + else "" + ) + + def update_volume_status(self, *_): + """Updates the volume icon based on speaker status.""" + stream = self.audio_service.speaker + if stream: + volume_level = stream.volume + self.volume_icon_button.set_markup( + icons.vol_off + if stream.muted + else ( + icons.vol_mute + if volume_level < 1 + else (icons.vol_medium if volume_level >= 1 else icons.vol_high) + ) + ) + + def update_mic_status(self, *_): + """Updates the microphone icon based on mute status.""" + mic = self.audio_service.microphone + self.microphone_icon.set_markup(icons.mic_off if mic and mic.muted else "") + self.microphone_icon.set_visible(bool(mic)) + + def update_bluetooth_status(self, *_): + """Updates the Bluetooth icon based on enabled status.""" + self.bluetooth_icon.set_markup( + icons.bluetooth if self.bluetooth_client.enabled else icons.bluetooth_off + ) + + def update_network_status(self, *_): + primary_device = self.network_client.primary_device + + if primary_device == "wifi" and self.network_client.wifi_device: + wifi_device = self.network_client.wifi_device + self.set_tooltip_text(self.network_client.wifi_device.ssid) + + if wifi_device.enabled: + wifi_strength = wifi_device.strength + + if wifi_strength == -1: # No active connection + icon_label = icons.wifi_off + else: + icon_label = { + 80: icons.wifi, + 60: icons.good_signal, + 40: icons.moderate_signal, + 20: icons.weak_signal, + 0: icons.no_signal, + }.get(min(80, 20 * round(wifi_strength / 20)), icons.wifi_off) + else: + icon_label = icons.wifi_off + + elif primary_device == "wired": + icon_label = icons.lan + else: + icon_label = icons.wifi_off + + self.wifi_icon.set_markup(icon_label) diff --git a/modules/bar/components/taskbar.py b/modules/bar/components/taskbar.py deleted file mode 100644 index cc8af45..0000000 --- a/modules/bar/components/taskbar.py +++ /dev/null @@ -1,154 +0,0 @@ -import json -import os -from typing import TypedDict - -from fabric.hyprland.widgets import get_hyprland_connection -from fabric.utils import exec_shell_command_async -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.image import Image -from gi.repository import GdkPixbuf, GLib, Gtk -from loguru import logger - - -class PagerClient(TypedDict): - title: str - initialClass: str - mapped: bool - hidden: bool - address: str - - -class TaskBar(Box): - def __init__(self, icon_size: int = 20, **kwargs): - super().__init__( - orientation="h", - spacing=7, - name="taskbar", - **kwargs, - ) - self.connection = get_hyprland_connection() - self.icon_size = icon_size - self.icon_theme = Gtk.IconTheme.get_default() - - self.set_visible(False) - - if self.connection.ready: - self.render_with_delay() - else: - self.connection.connect("event::ready", lambda *_: self.render_with_delay()) - - for event in ( - "activewindow", - "openwindow", - "closewindow", - "changefloatingmode", - ): - self.connection.connect("event::" + event, lambda *_: self.render(None)) - - def render_with_delay(self, *_): - GLib.timeout_add(101, self.render) - - def render(self, *_): - self.children = [] - - clients = self.fetch_clients() - active_window_address = self.get_active_window_address() - - visible_clients = [ - client for client in clients if client["mapped"] and not client["hidden"] - ] - - if visible_clients: - for client in visible_clients: - window_class = client["initialClass"].lower() - icon = self.bake_window_icon(window_class) - - button = Button(image=icon, tooltip_text=client["title"]) - if client["address"] == active_window_address: - button.add_style_class("activated") - button.connect( - "button-press-event", - self.on_icon_click, - client["address"], - ) - - self.add(button) - self.set_visible(True) - self.show_all() - else: - self.set_visible(False) - - self.set_visible(len(self.children) > 0) - if self.children: - self.show_all() - - def get_active_window_address(self) -> str: - active_window_info = json.loads( - self.connection.send_command("j/activewindow").reply.decode() - ) - return active_window_info.get("address", "") - - def on_icon_click(self, widget, event, address): - exec_shell_command_async( - f"hyprctl dispatch focuswindow address:{address}", lambda *_: None - ) - - def fetch_clients(self) -> list[PagerClient]: - return json.loads(self.connection.send_command("j/clients").reply.decode()) - - def bake_window_icon( - self, - window_class: str, - fallback_icon: str = "image-missing", - ) -> Image: - icon_name = self.get_icon_from_desktop_entry(window_class) - - if icon_name: - pixbuf = self.load_icon(icon_name) - else: - pixbuf = self.load_icon(window_class, fallback_icon) - - return Image(pixbuf=pixbuf, size=self.icon_size) - - def get_icon_from_desktop_entry(self, window_class: str) -> str: - for data_dir in GLib.get_system_data_dirs(): - applications_dir = os.path.join(data_dir, "applications") - - if os.path.isdir(applications_dir): - for desktop_file in os.listdir(applications_dir): - if desktop_file.endswith(".desktop"): - file_path = os.path.join(applications_dir, desktop_file) - try: - with open(file_path) as f: - app_name = None - icon_name = None - for line in f: - if line.startswith("Name="): - app_name = line.split("=", 2)[1].strip().lower() - elif line.startswith("Icon=") and app_name: - icon_name = line.split("=", 2)[1].strip() - if window_class in app_name: - return icon_name - except Exception as e: - logger.error(f"Error reading {file_path}: {e}") - return None - - def load_icon( - self, - icon_name: str, - fallback_icon: str = "image-missing", - ) -> GdkPixbuf.Pixbuf: - try: - pixbuf = self.icon_theme.load_icon( - icon_name, - self.icon_size, - Gtk.IconLookupFlags.FORCE_SIZE, - ) - except Exception: - pixbuf = self.icon_theme.load_icon( - fallback_icon, - self.icon_size, - Gtk.IconLookupFlags.FORCE_SIZE, - ) - return pixbuf diff --git a/modules/bar/components/updates.py b/modules/bar/components/updates.py new file mode 100644 index 0000000..a285479 --- /dev/null +++ b/modules/bar/components/updates.py @@ -0,0 +1,103 @@ +import json +from fabric.utils import exec_shell_command_async, get_relative_path, invoke_repeater +from fabric.widgets.box import Box +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label +from fabric.widgets.button import Button +from fabric.widgets.stack import Stack +from loguru import logger +from gi.repository import Gtk +import utils.icons as icons + + +class UpdatesWidget(Button): + """A widget to display the number of available updates.""" + + def __init__( + self, + **kwargs, + ): + # Initialize the button with specific name and style + super().__init__(name="button-bar", **kwargs) + + self.script_file = get_relative_path("../../../config/scripts/systemupdates.sh") + + self.update_level_label = Label( + name="update-label", + label="0", + visible=False, # Initially hidden + ) + + self.update_icon = Label(name="update-icon", markup=icons.update) + self.updated_icon = Label(name="updated-icon", markup=icons.updated) + + self.update_box = CenterBox( + center_children=[self.update_icon, self.update_level_label], + ) + + self.updated_box = CenterBox( + center_children=[self.updated_icon], + ) + + self.stack = Stack() + self.stack.add_named(self.update_box, "update_box") + self.stack.add_named(self.updated_box, "updated_box") + self.stack.set_visible_child_name("update_box") + + self.children = Box( + children=self.stack, + ) + + # Ensure the stack resizes to the current visible child + self.stack.set_homogeneous(False) + self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + + # Connect the button press event + self.connect("button-press-event", self.on_button_press) + + # Set up a repeater to call the update method at specified intervals + invoke_repeater(60000, self.update, initial_call=True) + + def update_values(self, value: str): + logger.info(f"[Updates] Received update JSON: {value}") + + try: + value = json.loads(value) + except json.JSONDecodeError: + logger.error("[Updates] Failed to parse update JSON") + return False + + update_count = str(value["total"]) + self.update_level_label.set_label(update_count) + + if update_count == "0": + self.stack.set_visible_child_name("updated_box") + self.update_level_label.set_visible(False) + else: + self.stack.set_visible_child_name("update_box") + self.update_level_label.set_visible(True) + + self.set_tooltip_text(value.get("tooltip", "")) + return True + + def on_button_press(self, _, event): + if event.button == 1: + exec_shell_command_async( + f"{self.script_file} -arch -up", + lambda _: None, + ) + self.update() + return True + else: + self.update() + + def update(self): + logger.info("[Updates] Checking for updates...") + + # Execute the update script asynchronously and update values + exec_shell_command_async( + f"{self.script_file} -arch", + lambda output: self.update_values(output), + ) + + return True diff --git a/modules/bar/components/volume.py b/modules/bar/components/volume.py deleted file mode 100644 index 0fed57c..0000000 --- a/modules/bar/components/volume.py +++ /dev/null @@ -1,81 +0,0 @@ -from fabric import Fabricator -from fabric.utils import exec_shell_command -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from snippets import MaterialIcon -from services import audio - -VOLUME_ICONS = { - "muted": "volume_off", - "low": "volume_mute", - "medium-low": "volume_down", - "medium-high": "volume_up", - "high": "volume_up", -} - - -class VolumeIndicator(Box): - def __init__(self, **kwargs): - super().__init__(**kwargs) - - self.audio_service = audio - self.volume_icon_button = self.create_volume_icon() - - self.volume_icon_button.connect("button-press-event", self.on_button_press) - self.audio_service.connect("changed", self.update_volume_status) - - Fabricator(interval=1000, poll_from=self.update_volume_status) - self.children = (self.volume_icon_button,) - - def create_volume_icon(self): - icon = MaterialIcon(VOLUME_ICONS["muted"], size=16) - button = Button(child=icon) - button.icon_widget = icon - return button - - def on_button_press(self, widget, event): - if event.button == 1: - self.toggle_mute() - elif event.button == 3: - self.open_audio_settings() - - def toggle_mute(self): - current_stream = self.audio_service.speaker - if current_stream: - current_stream.muted = not current_stream.muted - # self.update_volume_status() - - def open_audio_settings(self): - exec_shell_command("pavucontrol") - - def update_volume_status(self, *_): - current_stream = self.audio_service.speaker - if current_stream: - self.update_icon(current_stream) - - return True - - def update_icon(self, stream): - if stream.muted: - icon_name = VOLUME_ICONS["muted"] - volume_percentage = 0 - tooltip_text = "Volume Muted" - else: - volume_level = stream.volume - icon_name = self.get_volume_icon(volume_level) - volume_percentage = int(volume_level) - tooltip_text = f"Volume: {volume_percentage}%" - - self.volume_icon_button.icon_widget.set_label(icon_name) - - self.volume_icon_button.set_tooltip_text(tooltip_text) - - def get_volume_icon(self, volume_level): - if volume_level == 0: - return VOLUME_ICONS["low"] - elif volume_level <= 33: - return VOLUME_ICONS["medium-low"] - elif volume_level <= 66: - return VOLUME_ICONS["medium-high"] - else: - return VOLUME_ICONS["high"] diff --git a/modules/corners.py b/modules/corners.py deleted file mode 100644 index 10ce26d..0000000 --- a/modules/corners.py +++ /dev/null @@ -1,64 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.wayland import WaylandWindow as Window -from fabric.widgets.shapes import Corner - - -class MyCorner(Box): - def __init__(self, corner): - super().__init__( - name="corner-container", - children=Corner( - name="corner", - orientation=corner, - size=20, - ), - ) - - -class Corners(Window): - def __init__(self): - super().__init__( - name="corners", - layer="top", - anchor="top bottom left right", - exclusivity="normal", - pass_through=True, - visible=False, - all_visible=False, - ) - - self.all_corners = Box( - name="all-corners", - orientation="v", - h_expand=True, - v_expand=True, - h_align="fill", - v_align="fill", - children=[ - Box( - name="top-corners", - orientation="h", - h_align="fill", - children=[ - MyCorner("top-left"), - Box(h_expand=True), - MyCorner("top-right"), - ], - ), - Box(v_expand=True), - Box( - name="bottom-corners", - orientation="h", - h_align="fill", - children=[ - MyCorner("bottom-left"), - Box(h_expand=True), - MyCorner("bottom-right"), - ], - ), - ], - ) - - self.add(self.all_corners) - - self.show_all() diff --git a/modules/dock.py b/modules/dock.py new file mode 100644 index 0000000..593b2c6 --- /dev/null +++ b/modules/dock.py @@ -0,0 +1,196 @@ +import json +import threading +from gi.repository import GLib +from utils import IconResolver, read_config +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.image import Image +from fabric.widgets.eventbox import EventBox +from fabric.widgets.wayland import WaylandWindow as Window +from fabric.hyprland.widgets import get_hyprland_connection +from fabric.utils import exec_shell_command, idle_add, remove_handler + + +class Dock(Window): + def __init__(self, **kwargs): + super().__init__( + layer="top", + anchor="bottom center", + exclusivity="none", + **kwargs, + ) + self.config = read_config() + self.conn = get_hyprland_connection() + self.icon = IconResolver() + self.pinned = self.config.get("pinned_apps", []) + self.is_hidden = False + self.hide_id = None + self._arranger_handler = None + + # Set up UI containers + self.view = Box(name="viewport", orientation="h") + self.wrapper = Box(name="dock", orientation="v", children=[self.view]) + self.hover = EventBox() + self.hover.set_size_request(-1, 1) + self.hover.connect("enter-notify-event", self._on_hover_enter) + self.hover.connect("leave-notify-event", self._on_hover_leave) + self.view.connect("enter-notify-event", self._on_hover_enter) + self.view.connect("leave-notify-event", self._on_hover_leave) + self.main_box = Box(orientation="v", children=[self.wrapper, self.hover]) + self.add(self.main_box) + + if self.conn.ready: + self.update_dock() + GLib.timeout_add( + 500, self.check_hide + ) # Delay to ensure Hyprland loads clients + else: + self.conn.connect("event::ready", self.update_dock) + self.conn.connect("event::ready", self.check_hide) + + for ev in ("activewindow", "openwindow", "closewindow", "changefloatingmode"): + self.conn.connect(f"event::{ev}", self.update_dock) + self.conn.connect("event::workspace", self.check_hide) + + def _on_hover_enter(self, *args): + self.toggle_dock(show=True) + + def _on_hover_leave(self, *args): + self.delay_hide() + + def toggle_dock(self, show): + """Show or hide the dock immediately.""" + if show: + if self.is_hidden: + self.is_hidden = False + self.wrapper.add_style_class("show-dock") + self.wrapper.remove_style_class("hide-dock") + if self.hide_id: + GLib.source_remove(self.hide_id) + self.hide_id = None + else: + if not self.is_hidden: + self.is_hidden = True + self.wrapper.add_style_class("hide-dock") + self.wrapper.remove_style_class("show-dock") + + def delay_hide(self): + """Schedule hiding the dock after a short delay.""" + if self.hide_id: + GLib.source_remove(self.hide_id) + self.hide_id = GLib.timeout_add(1000, self.hide_dock) + + def hide_dock(self): + self.toggle_dock(show=False) + self.hide_id = None + return False + + def check_hide(self, *args): + """Show the dock immediately in an empty workspace; otherwise, hide if needed.""" + clients = self.get_clients() + current_ws = self.get_workspace() + ws_clients = [w for w in clients if w["workspace"]["id"] == current_ws] + + if not ws_clients: + self.toggle_dock(show=True) + elif any(not w.get("floating") and not w.get("fullscreen") for w in ws_clients): + self.delay_hide() + else: + self.toggle_dock(show=True) + + def update_dock(self, *args): + """Refresh the dock icons based on running and pinned apps.""" + arranger_handler = getattr(self, "_arranger_handler", None) + + if arranger_handler: + remove_handler(arranger_handler) + clients = self.get_clients() + running = {} + for c in clients: + key = c["initialClass"].lower() + running.setdefault(key, []).append(c) + pinned_buttons = [ + self.create_button(app, running.get(app.lower(), [])) for app in self.pinned + ] + open_buttons = [ + self.create_button( + c["initialClass"], running.get(c["initialClass"].lower(), []) + ) + for group in running.values() + for c in group + if c["initialClass"].lower() not in self.pinned + ] + children = pinned_buttons + if pinned_buttons and open_buttons: + children += [Box(orientation="v", v_expand=True, name="dock-separator")] + children += open_buttons + self.view.children = children + idle_add(self._update_size) + + def _update_size(self): + width, _ = self.view.get_preferred_width() + self.set_size_request(width, -1) + return False + + def create_button(self, app, instances): + """Create a button for an app with an icon and an optional indicator.""" + icon_img = self.icon.get_icon_pixbuf( + app.lower(), 30 + ) or self.icon.get_icon_pixbuf("image-missing", 30) + items = [Image(pixbuf=icon_img)] + if instances: + items.append( + Image( + pixbuf=self.icon.get_icon_pixbuf("filename-dot-amarok", 11), + name="dock-app-indicator", + ) + ) + return Button( + child=Box( + name="dock-icon", + orientation="v", + h_align="center", + children=items, + ), + on_clicked=lambda *a: self.handle_app(app, instances), + tooltip_text=instances[0]["title"] if instances else app, + name="dock-app-button", + ) + + def handle_app(self, app, instances): + if not instances: + threading.Thread( + target=exec_shell_command, args=(f"nohup {app}",), daemon=True + ).start() + else: + focused = self.get_focused() + idx = next( + (i for i, inst in enumerate(instances) if inst["address"] == focused), + -1, + ) + next_inst = instances[(idx + 1) % len(instances)] + exec_shell_command( + f"hyprctl dispatch focuswindow address:{next_inst['address']}" + ) + + def get_clients(self): + try: + return json.loads(self.conn.send_command("j/clients").reply.decode()) + except json.JSONDecodeError: + return [] + + def get_focused(self): + try: + return json.loads( + self.conn.send_command("j/activewindow").reply.decode() + ).get("address", "") + except json.JSONDecodeError: + return "" + + def get_workspace(self): + try: + return json.loads( + self.conn.send_command("j/activeworkspace").reply.decode() + ).get("id", 0) + except json.JSONDecodeError: + return 0 diff --git a/modules/launcher/components/__init__.py b/modules/launcher/components/__init__.py index e5f7be3..8ea1e96 100644 --- a/modules/launcher/components/__init__.py +++ b/modules/launcher/components/__init__.py @@ -8,3 +8,5 @@ from .sh import * from .todo import * from .wallpapers import * from .wifi import * +from .calendar import * +from .dashboard import * diff --git a/modules/launcher/components/applauncher.py b/modules/launcher/components/applauncher.py index 4e755ae..fb9ce05 100644 --- a/modules/launcher/components/applauncher.py +++ b/modules/launcher/components/applauncher.py @@ -1,228 +1,240 @@ from collections.abc import Iterator -import operator from fabric.utils import DesktopApp, get_desktop_applications, idle_add, remove_handler from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.entry import Entry from fabric.widgets.label import Label from fabric.widgets.scrolledwindow import ScrolledWindow -from gi.repository import GLib, Gdk -from snippets import read_config from fabric.widgets.image import Image +from gi.repository import Gdk, GLib +import json +import os +import math +import re +import subprocess +import webbrowser +from functools import partial +from utils import read_config class AppLauncher(Box): def __init__(self, **kwargs): super().__init__( - name="app-launcher", - visible=False, - all_visible=False, - **kwargs, + name="app-launcher", visible=False, all_visible=False, **kwargs ) + self.scrolled_window = None self.launcher = kwargs["launcher"] self.selected_index = -1 - - self._arranger_handler: int = 0 + self._arranger_handler = 0 self._all_apps = get_desktop_applications() self.config = read_config() + self.calc_history_path = os.path.expanduser("~/.cache/modus/calc.json") + self.calc_history = self._load_calc_history() + self.viewport = Box(name="viewport", spacing=4, orientation="v") self.search_entry = Entry( name="search-entry", h_expand=True, - notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()), - on_activate=lambda entry, *_: self.on_search_entry_activate( - entry.get_text() - ), - on_key_press_event=self.on_search_entry_key_press, # Handle key presses - ) - self.scrolled_window = ScrolledWindow( - name="scrolled-window", - spacing=10, - h_scrollbar_policy="never", - v_scrollbar_policy="never", - child=self.viewport, + notify_text=self._on_notify_text, + on_activate=self._on_activate, + on_key_press_event=self.on_search_entry_key_press, ) self.header_box = Box( - name="header_box", - orientation="h", - children=[self.search_entry], + name="header-box", orientation="h", children=[self.search_entry] ) self.launcher_box = Box( name="launcher-box", h_expand=True, orientation="v", - children=[ - self.header_box, - self.scrolled_window, - ], + children=[self.header_box], ) - self.resize_viewport() - self.add(self.launcher_box) self.show_all() + def _load_calc_history(self): + if os.path.exists(self.calc_history_path): + with open(self.calc_history_path, "r") as f: + return json.load(f) + return [] + + def _on_notify_text(self, entry, *args): + self.arrange_viewport(entry.get_text()) + + def _on_activate(self, entry, *args): + self.on_search_entry_activate(entry.get_text()) + def close_launcher(self, *_): self.viewport.children = [] - self.selected_index = -1 # Reset selection + self.selected_index = -1 self.launcher.close() + self.hide_scrolled_window() def open_launcher(self): self._all_apps = get_desktop_applications() - self.arrange_viewport() - self.show_all() + self.arrange_viewport("") def arrange_viewport(self, query: str = ""): - remove_handler(self._arranger_handler) if self._arranger_handler else None + if query.startswith(":"): + self.hide_scrolled_window() + self.launcher.dashboard.hide() + return + + if query.startswith("="): + self.show_scrolled_window() + self.update_calculator_viewport() + self.launcher.dashboard.hide() + return + + if self._arranger_handler: + remove_handler(self._arranger_handler) + self._arranger_handler = 0 self.viewport.children = [] self.selected_index = -1 - if not query.strip(): + if query.strip(): + self.launcher.dashboard.hide() + self.show_scrolled_window() + else: + self.launcher.dashboard.show() + self.hide_scrolled_window() return False - filtered_apps = iter( - sorted( - [ - app - for app in self._all_apps - if query.casefold() - in ( - (app.display_name or "") - + (" " + app.name + " ") - + (app.generic_name or "") - ).casefold() - ][:6], - key=lambda app: (app.display_name or "").casefold(), - ) + filtered_apps = [ + app + for app in self._all_apps + if query.casefold() + in ( + (app.display_name or "") + + " " + + app.name + + " " + + (app.generic_name or "") + ).casefold() + ] + filtered_apps_iter = iter( + sorted(filtered_apps, key=lambda app: (app.display_name or "").casefold()) ) - should_resize = operator.length_hint(filtered_apps) == len(self._all_apps) self._arranger_handler = idle_add( - lambda apps_iter: self.add_next_application(apps_iter) - or self.handle_arrange_complete(should_resize, query), - filtered_apps, - pin=True, + partial(self._arranger_callback, query=query), filtered_apps_iter, pin=True ) - def handle_arrange_complete(self, should_resize, query): - if should_resize: - self.resize_viewport() - # Only auto-select first item if query exists - if query.strip() != "" and self.viewport.get_children(): + def _arranger_callback(self, apps_iter: Iterator[DesktopApp], query: str): + if self.add_next_application(apps_iter): + return True + return self.handle_arrange_complete(query) + + def show_scrolled_window(self): + if self.scrolled_window is None: + self.scrolled_window = ScrolledWindow( + name="scrolled-window", + style_classes="applauncher-scrolled-window", + h_scrollbar_policy="never", + child=self.viewport, + ) + self.launcher_box.add(self.scrolled_window) + + self.scrolled_window.show_all() + GLib.idle_add(self.update_scrolled_window_size) + + def hide_scrolled_window(self): + if self.scrolled_window: + self.scrolled_window.hide() + + def handle_arrange_complete(self, query): + self.update_scrolled_window_size() + if query.strip() and self.viewport.get_children(): self.update_selection(0) return False def add_next_application(self, apps_iter: Iterator[DesktopApp]): - if not (app := next(apps_iter, None)): + app = next(apps_iter, None) + if app is None: return False - self.viewport.add(self.bake_application_slot(app)) return True - def resize_viewport(self): - self.scrolled_window.set_min_content_width( - self.viewport.get_allocation().width # type: ignore - ) - return False - - def bake_application_slot(self, app: DesktopApp, **kwargs) -> Button: - button = Button( + def bake_application_slot(self, app: DesktopApp) -> Button: + return Button( name="launcher-app", child=Box( orientation="h", children=[ - Image( - pixbuf=app.get_icon_pixbuf(size=32), - h_align="start", - name="launcher-app-icon", - ), + Image(pixbuf=app.get_icon_pixbuf(size=32), h_align="start"), Label( label=app.display_name or "Unknown", ellipsization="end", v_align="center", - h_align="center", ), ], ), tooltip_text=app.description, - on_clicked=lambda *_: (app.launch(), self.close_launcher()), - **kwargs, + on_clicked=partial(self._on_app_clicked, app), ) - return button + + def _on_app_clicked(self, app, *args): + app.launch() + self.close_launcher() def update_selection(self, new_index: int): - # Unselect current - if self.selected_index != -1 and self.selected_index < len( - self.viewport.get_children() - ): - current_button = self.viewport.get_children()[self.selected_index] - current_button.get_style_context().remove_class("selected") - # Select new - if new_index != -1 and new_index < len(self.viewport.get_children()): - new_button = self.viewport.get_children()[new_index] - new_button.get_style_context().add_class("selected") + children = self.viewport.get_children() + if self.selected_index >= 0 < len(children): + children[self.selected_index].get_style_context().remove_class("selected") + + if 0 <= new_index < len(children): + children[new_index].get_style_context().add_class("selected") self.selected_index = new_index - self.scroll_to_selected(new_button) else: self.selected_index = -1 - def scroll_to_selected(self, button): - def scroll(): - adj = self.scrolled_window.get_vadjustment() - alloc = button.get_allocation() - if alloc.height == 0: - return False # Retry if allocation isn't ready + def update_scrolled_window_size(self): + if self.scrolled_window is None: + return - y = alloc.y - height = alloc.height - page_size = adj.get_page_size() - current_value = adj.get_value() + children = self.viewport.get_children() + item_count = len(children) - # Calculate visible boundaries - visible_top = current_value - visible_bottom = current_value + page_size + if item_count == 0: + self.scrolled_window.set_size_request(-1, 0) + return - if y < visible_top: - # Item above viewport - align to top - adj.set_value(y) - elif y + height > visible_bottom: - # Item below viewport - align to bottom - new_value = y + height - page_size - adj.set_value(new_value) - # No action if already fully visible - return False + first_child = children[0] + first_child_height = first_child.get_allocation().height - GLib.idle_add(scroll) + if first_child_height == 0: + GLib.idle_add(self.update_scrolled_window_size) + return + + spacing = self.viewport.props.spacing + total_height = (item_count * first_child_height) + ((item_count - 1) * spacing) + + max_height = 400 + self.scrolled_window.set_size_request(-1, min(total_height, max_height)) def on_search_entry_activate(self, text): - commands = self.config.get("commands", {}) + if text.startswith(":i "): + webbrowser.open(f"https://www.google.com/search?q={text[3:].strip()}") + self.close_launcher() + return - if text in commands: - command = commands[text] - eval(command, {"launcher": self.launcher}) # Dynamically execute command - else: - children = self.viewport.get_children() - if children: - # Only activate if we have selection or non-empty query - if text.strip() == "" and self.selected_index == -1: - return # Prevent accidental activation when empty - selected_index = self.selected_index if self.selected_index != -1 else 0 - if 0 <= selected_index < len(children): - children[selected_index].clicked() + if text.startswith("="): + self.evaluate_calculator_expression(text) + return + + children = self.viewport.get_children() + if children: + (children[self.selected_index or 0]).clicked() def on_search_entry_key_press(self, widget, event): - keyval = event.keyval - if keyval == Gdk.KEY_Down: - self.move_selection(1) + if event.keyval in (Gdk.KEY_Down, Gdk.KEY_Up): + self.move_selection(1 if event.keyval == Gdk.KEY_Down else -1) return True - elif keyval == Gdk.KEY_Up: - self.move_selection(-1) - return True - elif keyval == Gdk.KEY_Escape: + if event.keyval == Gdk.KEY_Escape: self.close_launcher() return True return False @@ -231,10 +243,32 @@ class AppLauncher(Box): children = self.viewport.get_children() if not children: return - # Allow starting selection from nothing when empty - if self.selected_index == -1 and delta == 1: - new_index = 0 - else: - new_index = self.selected_index + delta - new_index = max(0, min(new_index, len(children) - 1)) + new_index = max(0, min(self.selected_index + delta, len(children) - 1)) self.update_selection(new_index) + + def evaluate_calculator_expression(self, text: str): + expr = text.lstrip("=").replace("^", "**").replace("×", "*") + expr = re.sub(r"(\d+)!", r"math.factorial(\1)", expr) + result = eval(expr, {"__builtins__": None, "math": math}) + self.calc_history.insert(0, f"{text} => {result}") + self.save_calc_history() + self.update_calculator_viewport() + + def update_calculator_viewport(self): + self.viewport.children = [ + Button( + child=Label(label=item), + on_clicked=partial(self.copy_text_to_clipboard, item), + ) + for item in self.calc_history + ] + self.update_scrolled_window_size() + + def save_calc_history(self): + with open(self.calc_history_path, "w") as f: + json.dump(self.calc_history, f) + + def copy_text_to_clipboard(self, text: str, *args): + subprocess.run( + ["wl-copy"], input=text.split("=>", 1)[-1].strip().encode(), check=True + ) diff --git a/modules/launcher/components/bluetooth.py b/modules/launcher/components/bluetooth.py index 68fb90a..69bf2a8 100644 --- a/modules/launcher/components/bluetooth.py +++ b/modules/launcher/components/bluetooth.py @@ -5,7 +5,7 @@ from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.scrolledwindow import ScrolledWindow from fabric.bluetooth import BluetoothClient, BluetoothDevice -from snippets import MaterialIcon +import utils.icons as icons class BluetoothDeviceSlot(CenterBox): @@ -17,7 +17,9 @@ class BluetoothDeviceSlot(CenterBox): "notify::closed", lambda *_: self.device.closed and self.destroy() ) - self.connection_icon = MaterialIcon("bluetooth_disabled", 12) + self.connection_label = Label( + name="bluetooth-connection", markup=icons.bluetooth_disconnected + ) self.connect_button = Button( name="bluetooth-connect", label="Connect", @@ -28,9 +30,9 @@ class BluetoothDeviceSlot(CenterBox): Box( spacing=8, children=[ - Image(icon_name=device.icon_name + "-symbolic", size=32), + Image(icon_name=f"{device.icon_name}-symbolic", size=32), Label(label=device.name), - self.connection_icon, + self.connection_label, ], ) ] @@ -39,10 +41,11 @@ class BluetoothDeviceSlot(CenterBox): self.device.emit("changed") # to update display status def on_changed(self, *_): - self.connection_icon.set_label( - "bluetooth" if self.device.connected else "bluetooth_disabled" + self.connection_label.set_markup( + icons.bluetooth_connected + if self.device.connected + else icons.bluetooth_disconnected ) - if self.device.connecting: self.connect_button.set_label( "Connecting..." if not self.device.connecting else "Disconnecting..." @@ -63,34 +66,36 @@ class BluetoothConnections(Box): **kwargs, ) - self.client = BluetoothClient(on_device_added=self.on_device_added) - self.toggle_icon = MaterialIcon("bluetooth_disabled") - self.scan_icon = MaterialIcon("stop") + self.launcher = kwargs["launcher"] + self.buttons = self.launcher.dashboard.bluetooth_button + self.bt_status_text = self.buttons.bluetooth_status_text + self.bt_status_button = self.buttons.bluetooth_status_button + self.bt_icon = self.buttons.bluetooth_icon + self.bt_label = self.buttons.bluetooth_label + self.bt_menu_button = self.buttons.bluetooth_menu_button + self.bt_menu_label = self.buttons.bluetooth_menu_label + + self.client = BluetoothClient(on_device_added=self.on_device_added) self.scan_button = Button( name="bluetooth-scan", - child=self.scan_icon, - tooltip_text="scan", + child=Label(name="bluetooth-scan-icon", markup=icons.scan_stop), on_clicked=lambda *_: self.client.toggle_scan(), ) self.toggle_button = Button( name="bluetooth-toggle", - tooltip_text="bluetooth-toggle", - child=self.toggle_icon, + child=Label(name="bluetooth-toggle-icon", markup=icons.bluetooth_off), on_clicked=lambda *_: self.client.toggle_power(), ) - self.client.notify("scanning") - self.client.notify("enabled") + self.client.connect( "notify::enabled", - lambda *_: self.toggle_icon.set_label( - "bluetooth" if self.client.enabled else "bluetooth_disabled" - ), + lambda *_: self.status_label(), ) self.client.connect( "notify::scanning", - lambda *_: self.scan_icon.set_label( - "stop" if self.client.scanning else "bluetooth_searching" + lambda *_: self.scan_button.get_child().set_markup( + icons.scan_stop if self.client.scanning else icons.scan ), ) @@ -107,19 +112,52 @@ class BluetoothConnections(Box): Label(name="bluetooth-section", label="Paired"), ScrolledWindow( name="bluetooth-paired", - v_expand=True, + min_content_size=(-1, -1), child=self.paired_box, + v_expand=True, ), Label(name="bluetooth-section", label="Available"), ScrolledWindow( - name="bluetooth-paired", - v_expand=True, + name="bluetooth-available", + min_content_size=(-1, -1), child=self.available_box, + v_expand=True, ), ] # to run notify closures thus display the status # without having to wait until an actual change + self.client.notify("scanning") + self.client.notify("enabled") + + def status_label(self): + print(self.client.enabled) + if self.client.enabled: + self.toggle_button.get_child().set_markup(icons.bluetooth) + self.bt_status_text.set_label("Enabled") + for i in [ + self.bt_status_button, + self.bt_status_text, + self.bt_icon, + self.bt_label, + self.bt_menu_button, + self.bt_menu_label, + ]: + i.remove_style_class("disabled") + self.bt_icon.set_markup(icons.bluetooth) + else: + self.toggle_button.get_child().set_markup(icons.bluetooth_off) + self.bt_status_text.set_label("Disabled") + for i in [ + self.bt_status_button, + self.bt_status_text, + self.bt_icon, + self.bt_label, + self.bt_menu_button, + self.bt_menu_label, + ]: + i.add_style_class("disabled") + self.bt_icon.set_markup(icons.bluetooth_off) def on_device_added(self, client: BluetoothClient, address: str): if not (device := client.get_device(address)): @@ -127,5 +165,6 @@ class BluetoothConnections(Box): slot = BluetoothDeviceSlot(device) if device.paired: - return self.paired_box.add(slot) - return self.available_box.add(slot) + self.paired_box.add(slot) + else: + self.available_box.add(slot) diff --git a/modules/launcher/components/calendar.py b/modules/launcher/components/calendar.py new file mode 100644 index 0000000..76bd139 --- /dev/null +++ b/modules/launcher/components/calendar.py @@ -0,0 +1,199 @@ +import calendar +from datetime import datetime +import gi +from fabric.widgets.label import Label + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib +import utils.icons as icons + + +class Calendar(Gtk.Box): + def __init__(self): + super().__init__( + orientation=Gtk.Orientation.VERTICAL, spacing=8, name="calendar" + ) + + self.current_year = datetime.now().year + self.current_month = datetime.now().month + self.current_day = datetime.now().day + self.previous_key = (self.current_year, self.current_month) + + self.cache_threshold = 1 # Allow current month +/- 1 month + self.month_views = {} + + self.header = Gtk.Box(spacing=4, name="header") + self.pack_start(self.header, False, False, 0) + + self.prev_month_button = Gtk.Button( + name="prev-month-button", + child=Label(name="month-button-label", markup=icons.chevron_left), + ) + self.prev_month_button.connect("clicked", self.on_prev_month_clicked) + self.header.pack_start(self.prev_month_button, False, False, 0) + + self.month_label = Gtk.Label(name="month-label") + self.header.pack_start(self.month_label, True, True, 0) + + self.next_month_button = Gtk.Button( + name="next-month-button", + child=Label(name="month-button-label", markup=icons.chevron_right), + ) + self.next_month_button.connect("clicked", self.on_next_month_clicked) + self.header.pack_start(self.next_month_button, False, False, 0) + + self.weekday_row = Gtk.Box(spacing=4, name="weekday-row") + self.pack_start(self.weekday_row, False, False, 0) + + self.stack = Gtk.Stack(name="calendar-stack") + self.stack.set_transition_duration(500) + self.pack_start(self.stack, True, True, 0) + + self.update_header() + self.update_calendar() + GLib.timeout_add_seconds(60, self.check_date_change) + + def update_header(self): + self.month_label.set_text( + datetime(self.current_year, self.current_month, 1) + .strftime("%B %Y") + .capitalize() + ) + for child in self.weekday_row.get_children(): + self.weekday_row.remove(child) + + days = self.get_weekday_initials() + for day in days: + label = Gtk.Label(label=day.upper(), name="weekday-label") + self.weekday_row.pack_start(label, True, True, 0) + self.weekday_row.show_all() + + def update_calendar(self): + new_key = (self.current_year, self.current_month) + + if new_key > self.previous_key: + self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT) + elif new_key < self.previous_key: + self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_RIGHT) + + self.previous_key = new_key + + if new_key not in self.month_views: + month_view = self.create_month_view(self.current_year, self.current_month) + self.month_views[new_key] = month_view + self.stack.add_titled( + month_view, + f"{self.current_year}_{self.current_month}", + f"{self.current_year}_{self.current_month}", + ) + + self.stack.set_visible_child_name(f"{self.current_year}_{self.current_month}") + self.update_header() + self.stack.show_all() + self.prune_cache() + + def prune_cache(self): + def month_index(key): + year, month = key + return year * 12 + (month - 1) + + current_index = month_index((self.current_year, self.current_month)) + keys_to_remove = [ + key + for key in self.month_views + if abs(month_index(key) - current_index) > self.cache_threshold + ] + + for key in keys_to_remove: + widget = self.month_views.pop(key) + self.stack.remove(widget) + + def create_month_view(self, year, month): + grid = Gtk.Grid( + column_homogeneous=True, row_homogeneous=False, name="calendar-grid" + ) + + cal = calendar.Calendar(firstweekday=6) + month_days = cal.monthdayscalendar(year, month) + + prev_month = month - 1 if month > 1 else 12 + next_month = month + 1 if month < 12 else 1 + prev_year = year if month > 1 else year - 1 + next_year = year if month < 12 else year + 1 + + prev_month_days_count = calendar.monthrange(prev_year, prev_month)[1] + next_month_day = 1 + + for row, week in enumerate(month_days): + for col, day in enumerate(week): + day_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="day-box") + top_spacer = Gtk.Box(hexpand=True, vexpand=True) + middle_box = Gtk.Box(hexpand=True, vexpand=True) + bottom_spacer = Gtk.Box(hexpand=True, vexpand=True) + + if day == 0: + if row == 0: + day_num = prev_month_days_count - week[:col].count(0) + label = Gtk.Label(label=str(day_num), name="prev-month-day") + else: + label = Gtk.Label( + label=str(next_month_day), name="next-month-day" + ) + next_month_day += 1 + else: + label = Gtk.Label(label=str(day), name="day-label") + if ( + day == self.current_day + and month == datetime.now().month + and year == datetime.now().year + ): + label.get_style_context().add_class("current-day") + + middle_box.pack_start( + Gtk.Box(hexpand=True, vexpand=True), True, True, 0 + ) + middle_box.pack_start(label, False, False, 0) + middle_box.pack_start( + Gtk.Box(hexpand=True, vexpand=True), True, True, 0 + ) + + day_box.pack_start(top_spacer, True, True, 0) + day_box.pack_start(middle_box, True, True, 0) + day_box.pack_start(bottom_spacer, True, True, 0) + + grid.attach(day_box, col, row, 1, 1) + + grid.show_all() + return grid + + def get_weekday_initials(self): + return [datetime(2023, 1, i + 1).strftime("%a")[:1] for i in range(7)] + + def on_prev_month_clicked(self, widget): + if self.current_month == 1: + self.current_month = 12 + self.current_year -= 1 + else: + self.current_month -= 1 + self.update_calendar() + + def on_next_month_clicked(self, widget): + if self.current_month == 12: + self.current_month = 1 + self.current_year += 1 + else: + self.current_month += 1 + self.update_calendar() + + def check_date_change(self): + now = datetime.now() + if ( + now.day != self.current_day + or now.month != self.current_month + or now.year != self.current_year + ): + self.current_day = now.day + self.current_month = now.month + self.current_year = now.year + self.update_calendar() + return True diff --git a/modules/launcher/components/cliphist.py b/modules/launcher/components/cliphist.py index 6153fb1..f2e66fd 100644 --- a/modules/launcher/components/cliphist.py +++ b/modules/launcher/components/cliphist.py @@ -1,16 +1,15 @@ import os import subprocess from typing import List - +from functools import partial from fabric import Fabricator -from fabric.utils import remove_handler from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.entry import Entry from fabric.widgets.label import Label from fabric.widgets.scrolledwindow import ScrolledWindow from gi.repository import GLib -from snippets import MaterialIcon +import utils.icons as icons class Cliphist(Box): @@ -21,7 +20,6 @@ class Cliphist(Box): visible=False, **kwargs, ) - self._arranger_handler = 0 self.cliphist_manager = CliphistManager(self) self.viewport = None @@ -30,25 +28,26 @@ class Cliphist(Box): self.search_entry = Entry( name="search-entry", h_expand=True, - notify_text=lambda entry, *_: self.handle_search_input(entry.get_text()), - on_activate=lambda entry, *_: self.handle_search_input(entry.get_text()), - ) - - self.toggle_button = Button( - child=MaterialIcon("delete_forever"), - name="clear-launcher-button", - on_clicked=self.on_button_clicked, + on_activate=self._on_search_activate, ) self.header_box = Box( name="header-box", spacing=10, orientation="h", - children=[self.search_entry, self.toggle_button], + children=[ + self.search_entry, + Button( + name="clear-button", + child=Label(name="cliphist-clear-button", markup=icons.clear), + tooltip_text="Clear Clipboard History", + on_clicked=self.handle_clear_history, + ), + ], ) self.launcher_box = Box( - name="cliphist-history", + name="cliphist-launcher-box", spacing=10, orientation="v", h_expand=True, @@ -57,9 +56,8 @@ class Cliphist(Box): self.add(self.launcher_box) - def on_button_clicked(self, *_): - GLib.spawn_command_line_async("cliphist wipe") - self.cliphist_manager.update_cliphist_history() + def _on_search_activate(self, entry, *args): + self.handle_search(entry.get_text()) def open_launcher(self): if not self.viewport: @@ -67,22 +65,24 @@ class Cliphist(Box): self.scrolled_window = ScrolledWindow( name="scrolled-window", spacing=10, + min_content_size=(-1, -1), h_scrollbar_policy="never", child=self.viewport, ) self.launcher_box.add(self.scrolled_window) self.viewport.children = [] - self.cliphist_manager.arrange_viewport() + self.cliphist_manager.update_cliphist_history() self.viewport.show() self.search_entry.grab_focus() - def close_launcher(self): - self.launcher.close() + def handle_search(self, text: str): + self.cliphist_manager.arrange_viewport(query=text) - def handle_search_input(self, text: str): - self.cliphist_manager.arrange_viewport(text) + def handle_clear_history(self, *_): + GLib.spawn_command_line_async("cliphist wipe") + self.cliphist_manager.update_cliphist_history() class CliphistManager: @@ -92,9 +92,10 @@ class CliphistManager: self.wl_paste_watcher = Fabricator( poll_from="wl-paste --watch echo", stream=True, interval=-1 ) - self.wl_paste_watcher.connect( - "changed", lambda *_: self.update_cliphist_history() - ) + self.wl_paste_watcher.connect("changed", self._on_wl_paste_changed) + + def _on_wl_paste_changed(self, *args): + self.update_cliphist_history() def get_clip_history(self) -> List[dict]: try: @@ -121,34 +122,39 @@ class CliphistManager: ["cliphist", "decode", clip_id], capture_output=True, check=True ) subprocess.run(["wl-copy"], input=decode_result.stdout, check=True) - self.launcher.close_launcher() + self.launcher.close() except subprocess.CalledProcessError as e: print(f"Error copying clip {clip_id}: {e}") def save_image_file(self, clip_id: str) -> str: output_file = f"/tmp/cliphist-{clip_id}.png" os.makedirs("/tmp", exist_ok=True) - try: decode_result = subprocess.run( ["cliphist", "decode", clip_id], capture_output=True, check=True ) - with open(output_file, "wb") as f: f.write(decode_result.stdout) - return output_file except (subprocess.CalledProcessError, IOError) as e: print(f"Error saving image file for clip {clip_id}: {e}") return "" + def update_cliphist_history(self): + self.cliphist_history = self.get_clip_history() + self.arrange_viewport() + def query_clips(self, query: str = "") -> List[dict]: - history = self.get_clip_history() - if not query.strip(): - return history + return self.cliphist_history + return [ + clip + for clip in self.cliphist_history + if query.lower() in clip["content"].lower() + ] - return [item for item in history if query.lower() in item["content"].lower()] + def _on_clip_clicked(self, clip_id: str, *args): + self.copy_clip(clip_id) def bake_clip_slot(self, item: dict) -> Button: if "[[ binary data" in item["content"].lower(): @@ -161,44 +167,32 @@ class CliphistManager: button = Button( h_align="start", name="clip-img-item", - on_clicked=lambda _, clip_id=item["id"]: self.copy_clip(clip_id), + on_clicked=partial(self._on_clip_clicked, item["id"]), ) - button.set_style( f"background-image: url('{output_file}'); background-size: cover; background-position: center;" ) return button - return None def _create_text_button(self, item: dict) -> Button: return Button( child=Label( - label=item["content"][:55] - + ("..." if len(item["content"]) > 55 else ""), + label=item["content"][:35] + + ("..." if len(item["content"]) > 35 else ""), h_expand=True, v_align="center", h_align="start", ), - on_clicked=lambda _, clip_id=item["id"]: self.copy_clip(clip_id), + on_clicked=partial(self._on_clip_clicked, item["id"]), name="clip-item", ) - def update_cliphist_history(self): - self.cliphist_history = self.get_clip_history() - if self.launcher.viewport: - GLib.idle_add(self.arrange_viewport) - def arrange_viewport(self, query: str = ""): if not self.launcher.viewport: return - - if self.launcher._arranger_handler: - remove_handler(self.launcher._arranger_handler) - self.launcher.viewport.children = [] filtered_clips = self.query_clips(query) - for clip in filtered_clips: button = self.bake_clip_slot(clip) if button: diff --git a/modules/launcher/components/dashboard.py b/modules/launcher/components/dashboard.py index d266e0e..2dbe112 100644 --- a/modules/launcher/components/dashboard.py +++ b/modules/launcher/components/dashboard.py @@ -1,242 +1,430 @@ +import subprocess from fabric.widgets.box import Box -from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox from fabric.widgets.label import Label -from snippets import MaterialIcon +from fabric.widgets.button import Button +from fabric.utils.helpers import exec_shell_command_async, exec_shell_command +import gi +from gi.repository import Gtk, Gdk, GLib # Added Gdk import +from fabric.utils import get_relative_path + +gi.require_version("Gtk", "3.0") +import utils.icons as icons -class Buttons(Box): - def __init__(self, **kwargs): - super().__init__( - name="buttons", - spacing=4, - h_align="center", - v_align="start", - h_expand=True, - v_expand=True, - visible=True, - all_visible=True, +def add_hover_cursor(widget): + # Add enter/leave events to change the cursor + widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK) + widget.connect( + "enter-notify-event", + lambda w, e: w.get_window().set_cursor( + Gdk.Cursor.new_from_name(w.get_display(), "pointer") ) + if w.get_window() + else None, + ) + widget.connect( + "leave-notify-event", + lambda w, e: w.get_window().set_cursor(None) if w.get_window() else None, + ) + +class NetworkButton(Box): + def __init__(self): + self.network_icon = Label( + name="network-icon", + markup=icons.wifi, + ) + self.network_label = Label( + name="network-label", + label="Wi-Fi", + justification="left", + ) + self.network_label_box = Box(children=[self.network_label, Box(h_expand=True)]) + self.network_ssid = Label( + name="network-ssid", + label="Hello_World!!", + justification="left", + ) + self.network_ssid_box = Box(children=[self.network_ssid, Box(h_expand=True)]) + self.network_text = Box( + name="network-text", + orientation="v", + h_align="start", + v_align="center", + children=[self.network_label_box, self.network_ssid_box], + ) + self.network_status_box = Box( + h_align="start", + v_align="center", + spacing=10, + children=[self.network_icon, self.network_text], + ) self.network_status_button = Button( name="network-status-button", h_expand=True, - child=Box( - h_align="start", - v_align="center", - spacing=10, - children=[ - MaterialIcon("wifi"), - Box( - name="network-text", - orientation="v", - h_align="start", - v_align="center", - children=[ - Box( - children=[ - Label( - name="network-label", - label="Wi-Fi", - justification="left", - ), - Box(h_expand=True), - ] - ), - Box( - children=[ - Label( - name="network-ssid", - label="ARGOS_5GHz", - justification="left", - ), - Box(h_expand=True), - ] - ), - ], - ), - ], - ), + child=self.network_status_box, ) + add_hover_cursor(self.network_status_button) # <-- Added hover + self.network_menu_label = Label( + name="network-menu-label", + markup=icons.chevron_right, + ) self.network_menu_button = Button( name="network-menu-button", - child=MaterialIcon("chevron_right"), + child=self.network_menu_label, ) + add_hover_cursor(self.network_menu_button) # <-- Added hover - self.network_button = Box( + super().__init__( name="network-button", orientation="h", h_align="fill", - v_align="center", + v_align="fill", h_expand=True, v_expand=True, - children=[ - self.network_status_button, - self.network_menu_button, - ], + spacing=0, + children=[self.network_status_button, self.network_menu_button], ) - self.bluetooth_status_button = Button( - name="bluetooth-status-button", - h_expand=True, - child=Box( - h_align="start", - v_align="center", - spacing=10, - children=[ - MaterialIcon("bluetooth"), - Box( - name="bluetooth-text", - orientation="v", - h_align="start", - v_align="center", - children=[ - Box( - children=[ - Label( - name="bluetooth-label", - label="Bluetooth", - justification="left", - ), - Box(h_expand=True), - ] - ), - Box( - children=[ - Label( - name="bluetooth-status", - label="Connected", - justification="left", - ), - Box(h_expand=True), - ] - ), - ], - ), - ], - ), - ) - self.bluetooth_menu_button = Button( - name="bluetooth-menu-button", - child=MaterialIcon("chevron_right"), - ) - - self.bluetooth_button = Box( +class BluetoothButton(Box): + def __init__(self, launcher): + super().__init__( name="bluetooth-button", orientation="h", h_align="fill", + v_align="fill", + h_expand=True, + v_expand=True, + ) + self.launcher = launcher + + self.bluetooth_icon = Label( + name="bluetooth-icon", + markup=icons.bluetooth, + ) + self.bluetooth_label = Label( + name="bluetooth-label", + label="Bluetooth", + justification="left", + ) + self.bluetooth_label_box = Box( + children=[self.bluetooth_label, Box(h_expand=True)] + ) + self.bluetooth_status_text = Label( + name="bluetooth-status", + label="Disabled", + justification="left", + ) + self.bluetooth_status_box = Box( + children=[self.bluetooth_status_text, Box(h_expand=True)] + ) + self.bluetooth_text = Box( + orientation="v", + h_align="start", v_align="center", + children=[self.bluetooth_label_box, self.bluetooth_status_box], + ) + self.bluetooth_status_container = Box( + h_align="start", + v_align="center", + spacing=10, + children=[self.bluetooth_icon, self.bluetooth_text], + ) + self.bluetooth_status_button = Button( + name="bluetooth-status-button", h_expand=True, - v_expand=True, - children=[ - self.bluetooth_status_button, - self.bluetooth_menu_button, - ], + child=self.bluetooth_status_container, + on_clicked=lambda *_: self.launcher.bluetooth.client.toggle_power(), + ) + add_hover_cursor(self.bluetooth_status_button) # <-- Added hover + self.bluetooth_menu_label = Label( + name="bluetooth-menu-label", + markup=icons.chevron_right, + ) + self.bluetooth_menu_button = Button( + name="bluetooth-menu-button", + on_clicked=lambda *_: self.launcher.open("bluetooth"), + child=self.bluetooth_menu_label, + ) + add_hover_cursor(self.bluetooth_menu_button) # <-- Added hover + + self.add(self.bluetooth_status_button) + self.add(self.bluetooth_menu_button) + + +class NightModeButton(Button): + def __init__(self): + self.night_mode_icon = Label( + name="night-mode-icon", + markup=icons.night, + ) + self.night_mode_label = Label( + name="night-mode-label", + label="Night Mode", + justification="left", + ) + self.night_mode_label_box = Box( + children=[self.night_mode_label, Box(h_expand=True)] + ) + self.night_mode_status = Label( + name="night-mode-status", + label="Enabled", + justification="left", + ) + self.night_mode_status_box = Box( + children=[self.night_mode_status, Box(h_expand=True)] + ) + self.night_mode_text = Box( + name="night-mode-text", + orientation="v", + h_align="start", + v_align="center", + children=[self.night_mode_label_box, self.night_mode_status_box], + ) + self.night_mode_box = Box( + h_align="start", + v_align="center", + spacing=10, + children=[self.night_mode_icon, self.night_mode_text], ) - self.night_mode_button = Button( - name="night-mode-button", - child=Box( - h_align="start", - v_align="center", - spacing=10, - children=[ - MaterialIcon("nightlight"), - Box( - name="night-mode-text", - orientation="v", - h_align="start", - v_align="center", - children=[ - Box( - children=[ - Label( - name="night-mode-label", - label="Night Mode", - justification="left", - ), - Box(h_expand=True), - ] - ), - Box( - children=[ - Label( - name="night-mode-status", - label="Enabled", - justification="left", - ), - Box(h_expand=True), - ] - ), - ], - ), - ], - ), - ) - - self.caffeine_button = Button( - name="caffeine-button", - child=Box( - h_align="start", - v_align="center", - spacing=10, - children=[ - MaterialIcon("coffee"), - Box( - name="caffeine-text", - orientation="v", - h_align="start", - v_align="center", - children=[ - Box( - children=[ - Label( - name="caffeine-label", - label="Caffeine", - justification="left", - ), - Box(h_expand=True), - ] - ), - Box( - children=[ - Label( - name="caffeine-status", - label="Enabled", - justification="left", - ), - Box(h_expand=True), - ] - ), - ], - ), - ], - ), - ) - - self.add(self.network_button) - self.add(self.bluetooth_button) - self.add(self.night_mode_button) - self.add(self.caffeine_button) - - self.show_all() - - -class Dashboard(Box): - def __init__(self, **kwargs): super().__init__( - name="dashboard", - h_align="center", - v_align="start", + name="night-mode-button", h_expand=True, - v_expand=True, - visible=True, - all_visible=True, + child=self.night_mode_box, + on_clicked=self.toggle_hyprsunset, + ) + add_hover_cursor(self) # <-- Added hover + + self.widgets = [ + self, + self.night_mode_label, + self.night_mode_status, + self.night_mode_icon, + ] + self.check_hyprsunset() + + def toggle_hyprsunset(self, *args): + """ + Toggle the 'hyprsunset' process: + - If running, kill it and mark as 'Disabled'. + - If not running, start it and mark as 'Enabled'. + """ + try: + subprocess.check_output(["pgrep", "hyprsunset"]) + exec_shell_command_async("pkill hyprsunset") + self.night_mode_status.set_label("Disabled") + for widget in self.widgets: + widget.add_style_class("disabled") + except subprocess.CalledProcessError: + exec_shell_command_async("hyprsunset -t 3500") + self.night_mode_status.set_label("Enabled") + for widget in self.widgets: + widget.remove_style_class("disabled") + + def check_hyprsunset(self, *args): + """ + Update the button state based on whether hyprsunset is running. + """ + try: + subprocess.check_output(["pgrep", "hyprsunset"]) + self.night_mode_status.set_label("Enabled") + for widget in self.widgets: + widget.remove_style_class("disabled") + except subprocess.CalledProcessError: + self.night_mode_status.set_label("Disabled") + for widget in self.widgets: + widget.add_style_class("disabled") + + +class CaffeineButton(Button): + def __init__(self): + self.caffeine_icon = Label( + name="caffeine-icon", + markup=icons.coffee, + ) + self.caffeine_label = Label( + name="caffeine-label", + label="Caffeine", + justification="left", + ) + self.caffeine_label_box = Box( + children=[self.caffeine_label, Box(h_expand=True)] + ) + self.caffeine_status = Label( + name="caffeine-status", + label="Enabled", + justification="left", + ) + self.caffeine_status_box = Box( + children=[self.caffeine_status, Box(h_expand=True)] + ) + self.caffeine_text = Box( + name="caffeine-text", + orientation="v", + h_align="start", + v_align="center", + children=[self.caffeine_label_box, self.caffeine_status_box], + ) + self.caffeine_box = Box( + h_align="start", + v_align="center", + spacing=10, + children=[self.caffeine_icon, self.caffeine_text], + ) + super().__init__( + name="caffeine-button", + h_expand=True, + child=self.caffeine_box, + on_clicked=self.toggle_wlinhibit, + ) + add_hover_cursor(self) # <-- Added hover + + self.widgets = [ + self, + self.caffeine_label, + self.caffeine_status, + self.caffeine_icon, + ] + self.check_wlinhibit() + + def toggle_wlinhibit(self, *args): + """ + Toggle the 'wlinhibit' process: + - If running, kill it and mark as 'Disabled' (add 'disabled' class). + - If not running, start it and mark as 'Enabled' (remove 'disabled' class). + """ + try: + subprocess.check_output(["pgrep", "wlinhibit"]) + exec_shell_command_async("pkill wlinhibit") + self.caffeine_status.set_label("Disabled") + for i in self.widgets: + i.add_style_class("disabled") + except subprocess.CalledProcessError: + exec_shell_command_async("wlinhibit") + self.caffeine_status.set_label("Enabled") + for i in self.widgets: + i.remove_style_class("disabled") + + def check_wlinhibit(self, *args): + try: + subprocess.check_output(["pgrep", "wlinhibit"]) + self.caffeine_status.set_label("Enabled") + for i in self.widgets: + i.remove_style_class("disabled") + except subprocess.CalledProcessError: + self.caffeine_status.set_label("Disabled") + for i in self.widgets: + i.add_style_class("disabled") + + +class DarkModeButton(Button): + def __init__(self): + self.dark_mode_icon = Label( + name="dark-mode-icon", + markup=icons.darkmode, + ) + self.dark_mode_label = Label( + name="dark-mode-label", label="Dark Mode", justification="left" ) - self.buttons = Buttons() + self.dark_mode_label_box = Box( + children=[self.dark_mode_label, Box(h_expand=True)] + ) + self.dark_mode_status = Label( + name="dark-mode-status", label="Enabled", justification="left" + ) + self.dark_mode_status_box = Box( + children=[self.dark_mode_status, Box(h_expand=True)] + ) + self.dark_mode_text = Box( + name="dark-mode-text", + orientation="v", + h_align="start", + v_align="center", + children=[self.dark_mode_label_box, self.dark_mode_status_box], + ) + self.dark_mode_box = Box( + h_align="start", + v_align="center", + spacing=10, + children=[self.dark_mode_icon, self.dark_mode_text], + ) - self.add(self.buttons) + super().__init__( + name="dark-mode-button", + h_expand=True, + child=self.dark_mode_box, + on_clicked=self.toggle_darkmode, + ) + add_hover_cursor(self) + + self.widgets = [ + self, + self.dark_mode_label, + self.dark_mode_status, + self.dark_mode_icon, + ] + self.check_darkmode() + + def toggle_darkmode(self, *_): + current_state = self.check_darkmode() + new_mode = "dark" if not current_state else "light" + command = f"bash {get_relative_path('../../../config/scripts/dark-theme.sh')} --set {new_mode}" + GLib.spawn_command_line_async(command) + + self.dark_mode_status.set_label("Enabled" if new_mode == "dark" else "Disabled") + + # Apply or remove 'disabled' class + if new_mode == "light": + for widget in self.widgets: + widget.add_style_class("disabled") + else: + for widget in self.widgets: + widget.remove_style_class("disabled") + + def check_darkmode(self): + result = exec_shell_command( + "gsettings get org.gnome.desktop.interface color-scheme" + ) + is_dark_mode = result.strip().replace("'", "") == "prefer-dark" + + if not is_dark_mode: + for widget in self.widgets: + widget.add_style_class("disabled") + else: + for widget in self.widgets: + widget.remove_style_class("disabled") + + return is_dark_mode + + +class Dashboard(Gtk.Grid): + def __init__(self, **kwargs): + super().__init__(name="buttons-grid") + self.set_row_homogeneous(True) + self.set_column_homogeneous(True) + self.set_row_spacing(8) + self.set_column_spacing(8) + self.set_vexpand(True) # Prevent vertical expansion + + self.launcher = kwargs["launcher"] + + # Instantiate each button + self.network_button = NetworkButton() + self.bluetooth_button = BluetoothButton(self.launcher) + self.night_mode_button = NightModeButton() + self.caffeine_button = CaffeineButton() + self.dark_mode_button = DarkModeButton() + + # Attach buttons into the grid (one row, four columns) + self.attach(self.network_button, 0, 0, 1, 1) + self.attach(self.bluetooth_button, 1, 0, 1, 1) + self.attach(self.dark_mode_button, 2, 0, 1, 1) + self.attach(self.night_mode_button, 0, 1, 1, 1) + self.attach(self.caffeine_button, 1, 1, 1, 1) self.show_all() diff --git a/modules/launcher/components/emoji.py b/modules/launcher/components/emoji.py index 731c108..b3d688b 100644 --- a/modules/launcher/components/emoji.py +++ b/modules/launcher/components/emoji.py @@ -1,7 +1,7 @@ import subprocess from typing import Generator, List - import ijson +from functools import partial from fabric.utils import remove_handler, get_relative_path from fabric.widgets.box import Box from fabric.widgets.button import Button @@ -18,26 +18,23 @@ class Emoji(Box): visible=False, **kwargs, ) - self.launcher = kwargs["launcher"] - self._arranger_handler: int = 0 + self._arranger_handler = 0 self.emoji_manager = EmojiManager(self) self.viewport = None self.search_entry = Entry( name="search-entry", h_expand=True, - notify_text=lambda entry, *_: self.handle_search_input(entry.get_text()), - on_activate=lambda entry, *_: self.handle_search_input(entry.get_text()), + notify_text=self._on_search_input, + on_activate=self._on_search_input, ) self.header_box = Box( name="header-box", spacing=10, orientation="h", - children=[ - self.search_entry, - ], + children=[self.search_entry], ) self.launcher_box = Box( @@ -49,6 +46,9 @@ class Emoji(Box): self.add(self.launcher_box) + def _on_search_input(self, entry, *args): + self.handle_search_input(entry.get_text()) + def open_launcher(self): if not self.viewport: self.viewport = Box(name="viewport", spacing=4, orientation="v") @@ -63,12 +63,11 @@ class Emoji(Box): self.viewport.children = [] self.emoji_manager.arrange_viewport() - self.viewport.show() self.search_entry.grab_focus() def close_launcher(self): - self.launcher.close() + self.launcher.close_launcher() def handle_search_input(self, text: str): self.emoji_manager.arrange_viewport(text) @@ -105,7 +104,7 @@ class EmojiManager: name="emoji-item", child=Label(label=emoji[0], h_align="center"), tooltip_text=emoji[1], - on_clicked=lambda *_: self.copy_emoji(emoji), + on_clicked=partial(self.copy_emoji, emoji), **kwargs, ) @@ -113,11 +112,8 @@ class EmojiManager: if not self.launcher.viewport: return - ( + if self.launcher._arranger_handler: remove_handler(self.launcher._arranger_handler) - if self.launcher._arranger_handler - else None - ) self.launcher.viewport.children = [] filtered_emojis = self.query_emojis(query) diff --git a/modules/launcher/components/power.py b/modules/launcher/components/power.py index e00efa1..b441e25 100644 --- a/modules/launcher/components/power.py +++ b/modules/launcher/components/power.py @@ -1,14 +1,14 @@ from fabric.widgets.box import Box +from fabric.widgets.label import Label from fabric.widgets.button import Button -from gi.repository import GLib - -from snippets import MaterialIcon +from fabric.utils.helpers import exec_shell_command_async +import utils.icons as icons class PowerMenu(Box): def __init__(self, **kwargs): super().__init__( - name="powermenu", + name="power-menu", orientation="h", spacing=10, v_align="center", @@ -22,32 +22,32 @@ class PowerMenu(Box): self.launcher = kwargs["launcher"] self.btn_lock = Button( - name="powermenu-button", - child=MaterialIcon("lock", 45), + name="power-menu-button", + child=Label(name="button-label", markup=icons.lock), on_clicked=self.lock, ) self.btn_suspend = Button( - name="powermenu-button", - child=MaterialIcon("sleep", 45), + name="power-menu-button", + child=Label(name="button-label", markup=icons.suspend), on_clicked=self.suspend, ) self.btn_logout = Button( - name="powermenu-button", - child=MaterialIcon("logout", 45), + name="power-menu-button", + child=Label(name="button-label", markup=icons.logout), on_clicked=self.logout, ) self.btn_reboot = Button( - name="powermenu-button", - child=MaterialIcon("restart_alt", 45), + name="power-menu-button", + child=Label(name="button-label", markup=icons.reboot), on_clicked=self.reboot, ) self.btn_shutdown = Button( - name="powermenu-button", - child=MaterialIcon("power_settings_new", 45), + name="power-menu-button", + child=Label(name="button-label", markup=icons.shutdown), on_clicked=self.poweroff, ) @@ -67,22 +67,22 @@ class PowerMenu(Box): def close_menu(self): self.launcher.close() - def lock(self, *_): - GLib.spawn_command_line_async("hyprlock --immediate") + def lock(self, *args): + exec_shell_command_async("hyprlock --immediate") self.close_menu() - def suspend(self, *_): - GLib.spawn_command_line_async("systemctl suspend") + def suspend(self, *args): + exec_shell_command_async("systemctl suspend") self.close_menu() - def logout(self, *_): - GLib.spawn_command_line_async("hyprctl dispatch exit") + def logout(self, *args): + exec_shell_command_async("hyprctl dispatch exit") self.close_menu() - def reboot(self, *_): - GLib.spawn_command_line_async("systemctl reboot") + def reboot(self, *args): + exec_shell_command_async("systemctl reboot") self.close_menu() - def poweroff(self, *_): - GLib.spawn_command_line_async("systemctl poweroff") + def poweroff(self, *args): + exec_shell_command_async("systemctl poweroff") self.close_menu() diff --git a/modules/launcher/components/sh.py b/modules/launcher/components/sh.py index 5992ec8..194b75b 100644 --- a/modules/launcher/components/sh.py +++ b/modules/launcher/components/sh.py @@ -6,10 +6,10 @@ from fabric.widgets.button import Button from fabric.widgets.entry import Entry from fabric.widgets.label import Label from fabric.widgets.scrolledwindow import ScrolledWindow -from snippets import MaterialIcon + CACHE_DIR = os.getenv( - "XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache", "fabric") + "XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache", "modus") ) BINS = os.path.join(CACHE_DIR, "binaries") @@ -37,7 +37,7 @@ class Sh(Box): self.header_box = Box( spacing=10, orientation="h", - children=[self.search_entry, MaterialIcon("terminal")], + children=[self.search_entry], ) self.launcher_box = Box( @@ -53,9 +53,11 @@ class Sh(Box): if not self.viewport: self.viewport = Box(name="viewport", spacing=10, orientation="v") self.scrolled_window = ScrolledWindow( + name="scrolled-window", spacing=10, h_scrollbar_policy="never", v_scrollbar_policy="never", + # min_content_size=(-1, -1), child=self.viewport, ) self.launcher_box.add(self.scrolled_window) diff --git a/modules/launcher/components/todo.py b/modules/launcher/components/todo.py index 7cc569b..b34bc94 100644 --- a/modules/launcher/components/todo.py +++ b/modules/launcher/components/todo.py @@ -6,7 +6,7 @@ from fabric.widgets.button import Button from fabric.widgets.entry import Entry from fabric.widgets.label import Label from fabric.widgets.scrolledwindow import ScrolledWindow -from snippets import MaterialIcon +import utils.icons as icons class TodoManager(Box): @@ -36,7 +36,7 @@ class TodoManager(Box): self.todo_entry, Button( name="add-button", - child=MaterialIcon("add"), + child=Label(name="todo-add-button", markup=icons.add), tooltip_text="Add Todo", on_clicked=lambda *_: self.handle_add_todo( self.todo_entry.get_text() @@ -59,8 +59,9 @@ class TodoManager(Box): if not self.viewport: self.viewport = Box(name="viewport", spacing=4, orientation="v") self.scrolled_window = ScrolledWindow( - name="todo-content", + name="scrolled-window", spacing=10, + min_content_size=(-1, -1), h_scrollbar_policy="never", child=self.viewport, ) @@ -82,7 +83,7 @@ class TodoListManager: def __init__(self, launcher): self.launcher = launcher self.todos = [] - self.todo_file = os.path.expanduser("~/.local/share/fabric/todos.json") + self.todo_file = os.path.expanduser("~/.local/state/modus/todos.json") self.load_todos() def load_todos(self): @@ -130,9 +131,10 @@ class TodoListManager: ) checkbox = Button( - name="todo-checkbox", - child=MaterialIcon( - "check_box" if todo["completed"] else "check_box_outline_blank" + name="checkbox", + child=Label( + name="todo-checkbox", + markup=icons.check_box if todo["completed"] else icons.box, ), on_clicked=lambda _, todo_id=todo["id"]: self.toggle_todo(todo_id), ) @@ -147,7 +149,7 @@ class TodoListManager: delete_button = Button( name="delete-button", - child=MaterialIcon("delete"), + child=Label(name="todo-delete-button", markup=icons.trash), on_clicked=lambda _, todo_id=todo["id"]: self.delete_todo(todo_id), ) diff --git a/modules/launcher/components/wallpapers.py b/modules/launcher/components/wallpapers.py index 2a3aa9c..b3eb956 100644 --- a/modules/launcher/components/wallpapers.py +++ b/modules/launcher/components/wallpapers.py @@ -1,22 +1,21 @@ -import hashlib -import json -import os +import hashlib, json, os, colorsys from PIL import Image from fabric.widgets.box import Box -from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox from fabric.widgets.entry import Entry +from fabric.widgets.label import Label from fabric.widgets.scrolledwindow import ScrolledWindow -from gi.repository import GdkPixbuf, GLib, Gtk, Gio -from snippets import MaterialIcon -from fabric.utils import exec_shell_command, get_relative_path -import concurrent.futures -from concurrent.futures import ThreadPoolExecutor -from snippets import WALLPAPERS_DIR +from gi.repository import GdkPixbuf, GLib, Gtk, Gio, Gdk +from fabric.utils import get_relative_path, exec_shell_command_async +from concurrent.futures import ThreadPoolExecutor, wait +from utils import WALLPAPERS_DIR +import utils.icons as icons class WallpaperSelector(Box): CACHE_DIR = os.path.expanduser("~/.cache/modus/wallpapers") SETTINGS_FILE = get_relative_path("../../../config/assets/settings.json") + CURRENT_WALLPAPER_FILE = os.path.expanduser("~/.cache/current_wallpaper") SCHEMES = { "TonalSpot": "tonalSpot", "Expressive": "expressive", @@ -28,26 +27,44 @@ class WallpaperSelector(Box): "Fidelity": "fidelity", "Content": "content", } + ITEM_WIDTH = 108 def __init__(self, **kwargs): - super().__init__(name="wallpapers", spacing=4, orientation="v", **kwargs) + super().__init__( + name="wallpapers", + spacing=4, + orientation="v", + h_expand=False, + v_expand=False, + **kwargs, + ) self.launcher = kwargs["launcher"] - - # self.wallpapers_dir = os.path.expanduser("~/Pictures/wallpapers") os.makedirs(self.CACHE_DIR, exist_ok=True) - self.files = [f for f in os.listdir(WALLPAPERS_DIR) if self._is_image(f)] - self.thumbnails = [] - self.thumbnail_queue = [] - self.executor = ThreadPoolExecutor(max_workers=4) # Shared executor + self.thumbnails, self.thumbnail_queue = [], [] + self.executor = ThreadPoolExecutor(max_workers=4) + self.selected_index, self.current_wallpaper = -1, None + self._init_widgets() + self._start_thumbnail_thread() + self.setup_file_monitor() + if os.path.exists(self.CURRENT_WALLPAPER_FILE): + gfile = Gio.File.new_for_path(self.CURRENT_WALLPAPER_FILE) + self.current_wp_monitor = gfile.monitor_file( + Gio.FileMonitorFlags.NONE, None + ) + self.current_wp_monitor.connect( + "changed", self.on_current_wallpaper_changed + ) + self.show_all() + self.search_entry.grab_focus() - self.viewport = Gtk.IconView() + def _init_widgets(self): + self.viewport = Gtk.IconView(name="wallpaper-icons") self.viewport.set_model(Gtk.ListStore(GdkPixbuf.Pixbuf, str)) self.viewport.set_pixbuf_column(0) - self.viewport.set_text_column(1) - self.viewport.set_item_width(0) + self.viewport.set_text_column(-1) + self.viewport.set_item_width(self.ITEM_WIDTH) self.viewport.connect("item-activated", self.on_wallpaper_selected) - self.scrolled_window = ScrolledWindow( name="scrolled-window", spacing=10, @@ -55,174 +72,209 @@ class WallpaperSelector(Box): v_expand=True, child=self.viewport, ) - self.search_entry = Entry( - name="search-entry", + name="search-entry-walls", h_expand=True, notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()), + on_key_press_event=self.on_search_entry_key_press, ) - self.scheme_dropdown = Gtk.ComboBoxText() self.scheme_dropdown.set_name("scheme-dropdown") self.scheme_dropdown.set_tooltip_text("Select color scheme") - for display_name, scheme_id in sorted(self.SCHEMES.items()): self.scheme_dropdown.append(scheme_id, display_name) - self.scheme_dropdown.set_active_id("tonalSpot") self.scheme_dropdown.connect("changed", self.on_scheme_changed) - - # Set the initial icon based on dark mode state - initial_icon = "light_mode" if not self.check_dark_mode_state() else "dark_mode" - self.toggle_button = Button( - child=MaterialIcon(initial_icon), - name="toggle-launcher-button", - on_clicked=self.toggle_dark_mode, + self.materialyoucolor_switcher = Gtk.Switch(name="materialyoucolor-switcher") + self.materialyoucolor_switcher.set_vexpand(False) + self.materialyoucolor_switcher.set_hexpand(False) + self.materialyoucolor_switcher.set_valign(Gtk.Align.CENTER) + self.materialyoucolor_switcher.set_halign(Gtk.Align.CENTER) + self.materialyoucolor_switcher.set_active(True) + self.mat_icon = Label(name="mat-label", markup=icons.palette) + self.dropdown_box = Box(name="dropdown-box", children=[self.scheme_dropdown]) + self.custom_color_entry = Entry( + name="entry-custom-color", + h_expand=True, + notify_text=lambda entry, *_: self.on_custom_color_submitted(entry), + on_key_press_event=self.on_custom_color_key_press, ) - - self.dropdown_box = Box( - name="dropdown-box", - orientation="h", - children=[ - self.scheme_dropdown, - MaterialIcon("keyboard_arrow_down"), - ], - ) - - self.header_box = Box( + self.header_box = CenterBox( name="header-box", spacing=10, orientation="h", - children=[self.search_entry, self.dropdown_box, self.toggle_button], + start_children=[self.materialyoucolor_switcher, self.mat_icon], + center_children=[ + self.search_entry, + Label(label="Custom Color:"), + self.custom_color_entry, + ], + end_children=[self.dropdown_box], ) - self.add(self.header_box) self.add(self.scrolled_window) - self._start_thumbnail_thread() - self.setup_file_monitor() - self.show_all() def setup_file_monitor(self): gfile = Gio.File.new_for_path(WALLPAPERS_DIR) self.file_monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None) self.file_monitor.connect("changed", self.on_directory_changed) + def on_current_wallpaper_changed(self, monitor, file, other_file, event_type): + if event_type in (Gio.FileMonitorEvent.CHANGED, Gio.FileMonitorEvent.CREATED): + try: + with open(self.CURRENT_WALLPAPER_FILE, "r") as f: + new_wp = f.read().strip() + if new_wp and new_wp != self.current_wallpaper: + self.current_wallpaper = new_wp + if self.custom_color_entry.get_text().strip().lower() in [ + "", + "none", + ]: + scheme = self.scheme_dropdown.get_active_id() + color_generator = get_relative_path( + "../../../config/material-colors/generate.py" + ) + command = f'python -O {color_generator} --scheme "{scheme}" --image "{self.current_wallpaper}"' + self._run_command(command) + except Exception as e: + print(f"Error updating current wallpaper: {e}") + def on_directory_changed(self, monitor, file, other_file, event_type): file_name = file.get_basename() if event_type == Gio.FileMonitorEvent.DELETED: if file_name in self.files: self.files.remove(file_name) - cache_path = self._get_cache_path(file_name) - if os.path.exists(cache_path): - try: - os.remove(cache_path) - except Exception as e: - print(f"Error deleting cache {cache_path}: {e}") + self._delete_cache(file_name) self.thumbnails = [(p, n) for p, n in self.thumbnails if n != file_name] GLib.idle_add(self.arrange_viewport, self.search_entry.get_text()) - elif event_type == Gio.FileMonitorEvent.CREATED: - if self._is_image(file_name) and file_name not in self.files: + return + if event_type in ( + Gio.FileMonitorEvent.CREATED, + Gio.FileMonitorEvent.CHANGED, + ) and self._is_image(file_name): + if ( + event_type == Gio.FileMonitorEvent.CREATED + and file_name not in self.files + ): self.files.append(file_name) self.files.sort() - self.executor.submit(self._process_file, file_name) - elif event_type == Gio.FileMonitorEvent.CHANGED: - if self._is_image(file_name) and file_name in self.files: - cache_path = self._get_cache_path(file_name) - if os.path.exists(cache_path): - try: - os.remove(cache_path) - except Exception as e: - print(f"Error deleting cache for changed file {file_name}: {e}") - self.executor.submit(self._process_file, file_name) + elif event_type == Gio.FileMonitorEvent.CHANGED and file_name in self.files: + self._delete_cache(file_name) + self.executor.submit(self._process_file, file_name) - def close_selector(self): - self.launcher.close() + def _delete_cache(self, file_name: str): + cache_path = self._get_cache_path(file_name) + if os.path.exists(cache_path): + try: + os.remove(cache_path) + except Exception: + pass def arrange_viewport(self, query: str = ""): - self.viewport.get_model().clear() - filtered_thumbnails = [ - (thumb, name) - for thumb, name in self.thumbnails - if query.casefold() in name.casefold() - ] - - filtered_thumbnails.sort(key=lambda x: x[1].lower()) - - for pixbuf, file_name in filtered_thumbnails: - self.viewport.get_model().append([pixbuf, file_name]) - - def toggle_dark_mode(self, *_): - current_state = self.check_dark_mode_state() - new_mode = "dark" if not current_state else "light" - - # Call the shell script with the --set argument - command = f"bash {get_relative_path('../../../config/scripts/dark-theme.sh')} --set {new_mode}" - GLib.spawn_command_line_async(command) - - # Set the appropriate icon based on the mode - icon_name = "dark_mode" if new_mode == "dark" else "light_mode" - - # Update button icon - if self.toggle_button.get_child(): - self.toggle_button.remove(self.toggle_button.get_child()) - self.toggle_button.add(MaterialIcon(icon_name)) - self.toggle_button.show_all() - - def check_dark_mode_state(self): - result = exec_shell_command( - "gsettings get org.gnome.desktop.interface color-scheme" - ) - return result.strip().replace("'", "") == "prefer-dark" + model = self.viewport.get_model() + model.clear() + for pixbuf, file_name in sorted( + [ + (thumb, name) + for thumb, name in self.thumbnails + if query.casefold() in name.casefold() + ], + key=lambda x: x[1].lower(), + ): + model.append([pixbuf, file_name]) + if not query.strip(): + self.viewport.unselect_all() + self.selected_index = -1 + elif len(model) > 0: + self.update_selection(0) def on_wallpaper_selected(self, iconview, path): model = iconview.get_model() file_name = model[path][1] full_path = os.path.join(WALLPAPERS_DIR, file_name) selected_scheme = self.scheme_dropdown.get_active_id() - wallpaper = get_relative_path("../../../config/scripts/wallpaper.py") - command = f"python -O {wallpaper} -I {full_path}" - GLib.spawn_command_line_async(command) - self.update_scheme(selected_scheme) - - def on_scheme_changed(self, combo): - scheme_id = combo.get_active_id() - display_name = next( - name for name, id in self.SCHEMES.items() if id == scheme_id - ) - print(f"Color scheme selected: {display_name} ({scheme_id})") - self.update_scheme(scheme_id) - - color_generator = get_relative_path( - "../../../config/material-colors/generate.py" - ) - try: - command = f'python -O {color_generator} -R --scheme "{scheme_id}"' + wallpaper_script = get_relative_path("../../../config/scripts/wallpaper.py") + if self.materialyoucolor_switcher.get_active(): + command = f"python -O {wallpaper_script} -I {full_path}" GLib.spawn_command_line_async(command) - print(f"Applied color scheme: {display_name}") - except Exception as e: - print(f"Failed to apply color scheme: {e}") + self.update_scheme(selected_scheme) + else: + cmd = f"swww img {full_path} -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest" + exec_shell_command_async(cmd) - def update_scheme(self, scheme: str): - try: - with open(self.SETTINGS_FILE, "r") as f: - settings = json.loads(f.read()) + def on_search_entry_key_press(self, widget, event): + if event.state & Gdk.ModifierType.SHIFT_MASK: + if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down): + scheme_list = [ + scheme_id for _, scheme_id in sorted(self.SCHEMES.items()) + ] + try: + current_index = scheme_list.index( + self.scheme_dropdown.get_active_id() + ) + except ValueError: + current_index = 0 + new_index = ( + (current_index - 1) % len(scheme_list) + if event.keyval == Gdk.KEY_Up + else (current_index + 1) % len(scheme_list) + ) + self.scheme_dropdown.set_active(new_index) + return True + elif event.keyval == Gdk.KEY_Right: + self.scheme_dropdown.popup() + return True + if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right): + self.move_selection_2d(event.keyval) + return True + elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + if self.selected_index != -1: + path = Gtk.TreePath.new_from_indices([self.selected_index]) + self.on_wallpaper_selected(self.viewport, path) + return True + return False - settings["generation-scheme"] = scheme + def move_selection_2d(self, keyval): + model = self.viewport.get_model() + total_items = len(model) + if total_items == 0: + return + if self.selected_index == -1: + new_index = ( + 0 if keyval in (Gdk.KEY_Down, Gdk.KEY_Right) else total_items - 1 + ) + else: + current_index = self.selected_index + allocation = self.viewport.get_allocation() + columns = max(1, allocation.width // self.ITEM_WIDTH) + new_index = current_index + ( + 1 + if keyval == Gdk.KEY_Right + else -1 + if keyval == Gdk.KEY_Left + else columns + if keyval == Gdk.KEY_Down + else -columns + ) + new_index = max(0, min(new_index, total_items - 1)) + self.update_selection(new_index) - with open(self.SETTINGS_FILE, "w") as f: - json.dump(settings, f, indent=2) - except Exception as error: - print(f"Failed to update generation-scheme in settings.json: {error}") + def update_selection(self, new_index: int): + self.viewport.unselect_all() + path = Gtk.TreePath.new_from_indices([new_index]) + self.viewport.select_path(path) + self.viewport.scroll_to_path(path, False, 0.5, 0.5) + self.selected_index = new_index def _start_thumbnail_thread(self): - thread = GLib.Thread.new("thumbnail-loader", self._preload_thumbnails, None) + GLib.Thread.new("thumbnail-loader", self._preload_thumbnails, None) def _preload_thumbnails(self, _data): futures = [ self.executor.submit(self._process_file, file_name) for file_name in self.files ] - concurrent.futures.wait(futures) + wait(futures) GLib.idle_add(self._process_batch) def _process_file(self, file_name): @@ -231,10 +283,14 @@ class WallpaperSelector(Box): if not os.path.exists(cache_path): try: with Image.open(full_path) as img: - img.thumbnail((96, 96), Image.Resampling.BILINEAR) - img.save(cache_path, "PNG") - except Exception as e: - print(f"Error processing {file_name}: {e}") + width, height = img.size + side = min(width, height) + left = (width - side) // 2 + top = (height - side) // 2 + img_cropped = img.crop((left, top, left + side, top + side)) + img_cropped.thumbnail((96, 96), Image.Resampling.LANCZOS) + img_cropped.save(cache_path, "PNG") + except Exception: return self.thumbnail_queue.append((cache_path, file_name)) GLib.idle_add(self._process_batch) @@ -257,8 +313,110 @@ class WallpaperSelector(Box): file_hash = hashlib.md5(file_name.encode("utf-8")).hexdigest() return os.path.join(self.CACHE_DIR, f"{file_hash}.png") + def on_scheme_changed(self, combo): + scheme_id = combo.get_active_id() + display_name = next( + name for name, id in self.SCHEMES.items() if id == scheme_id + ) + self.update_scheme(scheme_id) + color_generator = get_relative_path( + "../../../config/material-colors/generate.py" + ) + command = f'python -O {color_generator} -R --scheme "{scheme_id}"' + self._run_command( + command, + success_message=f"Applied color scheme: {display_name}", + failure_message="Failed to apply color scheme:", + ) + + def update_scheme(self, scheme: str): + self._update_settings_field("generation-scheme", scheme) + + def on_custom_color_submitted(self, entry: Entry): + color = entry.get_text().strip() + if not color: + self.update_custom_color("") + return + if self.is_valid_hex_color(color): + color_value = color + elif self.is_valid_hue(color): + color_value = self.hue_to_hex(float(color)) + else: + return + self.update_custom_color(color_value) + + def on_custom_color_key_press(self, widget, event): + if event.keyval == Gdk.KEY_Return: + self.on_custom_color_submitted(widget) + return True + return False + + def _fetch_current_wallpaper(self): + if not self.current_wallpaper or not os.path.exists(self.current_wallpaper): + if os.path.exists(self.CURRENT_WALLPAPER_FILE): + try: + with open(self.CURRENT_WALLPAPER_FILE, "r") as f: + self.current_wallpaper = f.read().strip() + except Exception as e: + print(f"Error reading current wallpaper: {e}") + return self.current_wallpaper + + def update_custom_color(self, color: str): + color = color if color and color.lower() != "none" else "none" + self._update_settings_field("custom-color", color) + color_generator = get_relative_path( + "../../../config/material-colors/generate.py" + ) + if color == "none": + scheme = self.scheme_dropdown.get_active_id() + current_wp = self._fetch_current_wallpaper() + command = f'python -O {color_generator} --scheme "{scheme}" --image "{current_wp}"' + else: + command = f'python -O {color_generator} --color "{color}"' + self._run_command(command) + @staticmethod def _is_image(file_name: str) -> bool: return file_name.lower().endswith( (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") ) + + @staticmethod + def is_valid_hex_color(value: str) -> bool: + return ( + value.startswith("#") + and len(value) == 7 + and all(c in "0123456789ABCDEFabcdef" for c in value[1:]) + ) + + @staticmethod + def is_valid_hue(value: str) -> bool: + try: + return 0 <= float(value) <= 360 + except ValueError: + return False + + def hue_to_hex(self, hue: float) -> str: + r, g, b = colorsys.hls_to_rgb(hue / 360.0, 0.5, 1.0) + return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}" + + def _update_settings_field(self, field: str, value): + try: + with open(self.SETTINGS_FILE, "r") as f: + settings = json.load(f) + settings[field] = value + with open(self.SETTINGS_FILE, "w") as f: + json.dump(settings, f, indent=2) + except Exception: + pass + + def _run_command( + self, command: str, success_message: str = None, failure_message: str = None + ): + try: + GLib.spawn_command_line_async(command) + if success_message: + print(success_message) + except Exception: + if failure_message: + print(failure_message) diff --git a/modules/launcher/components/wifi.py b/modules/launcher/components/wifi.py index 675704f..657d56c 100644 --- a/modules/launcher/components/wifi.py +++ b/modules/launcher/components/wifi.py @@ -4,8 +4,8 @@ from fabric.widgets.image import Image from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.scrolledwindow import ScrolledWindow -from snippets import MaterialIcon from services import NetworkClient +import utils.icons as icons class WifiNetworkSlot(CenterBox): @@ -15,7 +15,7 @@ class WifiNetworkSlot(CenterBox): self.client = client self.connecting = False # Tracks if we are connecting/disconnecting - self.connection_icon = MaterialIcon("signal_wifi_off", 12) + self.connection_icon = Label(name="wifi-connection", markup=icons.wifi_off) self.connect_button = Button( name="wifi-connect", label="Connect", @@ -68,9 +68,7 @@ class WifiNetworkSlot(CenterBox): self.connect_button.sensitive = True if not self.connecting: - self.connection_icon.set_label( - "signal_wifi_4_bar" if connected else "signal_wifi_off" - ) + self.connection_icon.set_markup(icons.wifi if connected else icons.wifi_off) self.connect_button.set_label("Disconnect" if connected else "Connect") @@ -84,8 +82,8 @@ class WifiManager(Box): ) self.client = NetworkClient() - self.scan_icon = MaterialIcon("wifi_find") - self.toggle_icon = MaterialIcon("signal_wifi_off") + self.scan_icon = Label(name="wifi-scan-icon", markup=icons.scan) + self.toggle_icon = Label(name="wifi-toggle-icon", markup=icons.wifi_off) self.scan_button = Button( name="wifi-scan", @@ -113,8 +111,10 @@ class WifiManager(Box): end_children=self.toggle_button, ), ScrolledWindow( - name="scrolled-window", + name="wifi-available", v_expand=True, + h_scrollbar_policy="never", + min_content_size=(-1, -1), child=self.available_box, ), ] @@ -135,10 +135,8 @@ class WifiManager(Box): self.build_wifi_options() def update_toggle_icon(self): - self.toggle_icon.set_label( - "signal_wifi_4_bar" - if self.client.wifi_device.enabled - else "signal_wifi_off" + self.toggle_icon.set_markup( + icons.wifi if self.client.wifi_device.enabled else icons.wifi_off ) def build_wifi_options(self): diff --git a/modules/launcher/launcher.py b/modules/launcher/launcher.py index 0287a61..ca99e66 100644 --- a/modules/launcher/launcher.py +++ b/modules/launcher/launcher.py @@ -1,8 +1,7 @@ from fabric.widgets.centerbox import CenterBox from fabric.widgets.stack import Stack -from fabric.widgets.box import Box from fabric.widgets.wayland import WaylandWindow as Window -from gi.repository import Gtk +from fabric.widgets.box import Box from modules.launcher.components import ( AppLauncher, BluetoothConnections, @@ -13,6 +12,8 @@ from modules.launcher.components import ( TodoManager, WallpaperSelector, WifiManager, + Calendar, + Dashboard, ) @@ -26,15 +27,25 @@ class Launcher(Window): **kwargs, ) - self.launcher = AppLauncher(launcher=self) + self.dashboard = Dashboard(launcher=self) self.wallpapers = WallpaperSelector(launcher=self) self.power = PowerMenu(launcher=self) self.emoji = Emoji(launcher=self) self.cliphist = Cliphist(launcher=self) self.todo = TodoManager() - self.bluetooth = BluetoothConnections() + self.bluetooth = BluetoothConnections(launcher=self) self.sh = Sh(launcher=self) self.wifi = WifiManager() + self.calendar = Calendar() + + # Wrap the dashboard in a Box container + self.dashboard = Box( + name="dashboard", + orientation="h", + spacing=10, + children=[self.dashboard], + ) + self.launcher = AppLauncher(launcher=self) self.stack = Stack( name="launcher-content", @@ -52,6 +63,7 @@ class Launcher(Window): self.bluetooth, self.sh, self.wifi, + self.calendar, ], ) @@ -59,13 +71,16 @@ class Launcher(Window): name="launcher", orientation="v", start_children=self.stack, + end_children=self.dashboard, ) self.add(self.launcher_box) self.show_all() self.hide() - self.wallpapers.viewport.hide() - self.add_keybinding("Escape", lambda *_: self.close()) + self.add_keybinding("Escape", self._on_escape) + + def _on_escape(self, *args): + return self.close() def close(self): self.set_keyboard_mode("none") @@ -81,11 +96,8 @@ class Launcher(Window): self.bluetooth, self.sh, self.wifi, + self.calendar, ]: - if widget == self.wallpapers: - self.wallpapers.viewport.hide() - self.wallpapers.viewport.set_property("name", None) - if hasattr(widget, "viewport") and widget.viewport: widget.viewport.hide() @@ -99,6 +111,7 @@ class Launcher(Window): "bluetooth", "sh", "wifi", + "calendar", ]: self.stack.remove_style_class(style) @@ -118,10 +131,12 @@ class Launcher(Window): "bluetooth": self.bluetooth, "sh": self.sh, "wifi": self.wifi, + "calendar": self.calendar, } for w in widgets.values(): w.hide() + self.dashboard.hide() for style in widgets.keys(): self.stack.remove_style_class(style) @@ -130,23 +145,16 @@ class Launcher(Window): self.stack.set_visible_child(widgets[widget]) widgets[widget].show() - # if widget != "launcher": - # self.launcher.hide() - - if widget != "wallpapers": - self.wallpapers.viewport.hide() - self.wallpapers.viewport.set_property("name", None) - if widget == "launcher": self.launcher.open_launcher() self.launcher.search_entry.set_text("") self.launcher.search_entry.grab_focus() + self.dashboard.show() elif widget == "wallpapers": self.wallpapers.search_entry.set_text("") self.wallpapers.search_entry.grab_focus() self.wallpapers.viewport.show() - self.wallpapers.viewport.set_property("name", "wallpaper-icons") elif widget == "emoji": self.emoji.open_launcher() diff --git a/modules/notification_popup.py b/modules/notification_popup.py index 8835778..35982ff 100644 --- a/modules/notification_popup.py +++ b/modules/notification_popup.py @@ -1,70 +1,49 @@ -import time - -import gi - -gi.require_version("GdkPixbuf", "2.0") - +import os +from gi.repository import GdkPixbuf, GLib, Gdk from fabric.notifications import ( Notification, NotificationAction, NotificationCloseReason, Notifications, ) -from fabric.utils import invoke_repeater +from loguru import logger from fabric.widgets.box import Box from fabric.widgets.button import Button -from fabric.widgets.circularprogressbar import CircularProgressBar from fabric.widgets.image import Image +from fabric.widgets.centerbox import CenterBox from fabric.widgets.label import Label -from fabric.widgets.overlay import Overlay from fabric.widgets.revealer import Revealer from fabric.widgets.wayland import WaylandWindow as Window -from gi.repository import GdkPixbuf, GLib, GObject -from snippets import Animator, CustomImage - - -class AnimatedCircularProgressBar(CircularProgressBar): - def __init__(self, duration=0.6, bezier_curve=(0.34, 1.56, 0.64, 1.0), **kwargs): - super().__init__(**kwargs) - - self.animator = ( - Animator( - bezier_curve=bezier_curve, - duration=duration, - min_value=self.min_value, - max_value=self.value, - tick_widget=self, - notify_value=lambda p, *_: self.set_value(p.value), - ) - .build() - .play() - .unwrap() - ) - - def animate_value(self, value: float): - self.animator.pause() - self.animator.min_value = self.value - self.animator.max_value = value - self.animator.play() - return +from utils import CustomImage +import utils.icons as icons class ActionButton(Button): def __init__( - self, action: NotificationAction, action_number: int, total_actions: int + self, action: NotificationAction, index: int, total: int, notification_box ): - self.action = action super().__init__( - label=action.label, + name="action-button", h_expand=True, on_clicked=self.on_clicked, + child=Label(name="button-label", label=action.label), + ) + self.action = action + self.notification_box = notification_box + style_class = ( + "start-action" + if index == 0 + else "end-action" + if index == total - 1 + else "middle-action" + ) + self.add_style_class(style_class) + self.connect( + "enter-notify-event", lambda *_: notification_box.hover_button(self) + ) + self.connect( + "leave-notify-event", lambda *_: notification_box.unhover_button(self) ) - if action_number == 0: - self.add_style_class("start-action") - elif action_number == total_actions - 1: - self.add_style_class("end-action") - else: - self.add_style_class("middle-action") def on_clicked(self, *_): self.action.invoke() @@ -72,180 +51,191 @@ class ActionButton(Button): class NotificationWidget(Box): - def __init__(self, notification: Notification, **kwargs): + def __init__(self, notification: Notification, timeout_ms=5000, **kwargs): super().__init__( - name="notification", - spacing=8, + name="notification-box", orientation="v", - **kwargs, - ) - - self._notification = notification - self._timer = None - self.anim_parts = 20 - self.anim_interval = self.get_timeout() / self.anim_parts - self.timeout_in_sec = self.get_timeout() / 1000 - self.time = GLib.DateTime.new_from_unix_local(time.time()).format("%m/%d") - self.connect("button-press-event", self.on_button_press) - - header_container = Box(spacing=8, orientation="h") - self.progress_timeout = AnimatedCircularProgressBar( - name="notification-circular-progress-bar", - size=30, - min_value=0, - max_value=1, - radius_color=True, - value=0, - ) - - header_container.children = ( - self.get_icon(notification.app_icon, notification.app_name, 25), - Label( - markup=str( - self._notification.summary - if self._notification.summary - else notification.app_name - ), - h_align="start", - style="font-weight: 900;", - ellipsization="end", - ), - ) - - header_container.pack_end( - Box( - v_align="start", - children=( - Overlay( - child=self.progress_timeout, - overlays=Button( - image=Image( - icon_name="window-close-symbolic", - icon_size=16, - ), - style_classes="close-button", - on_clicked=lambda *_: self._notification.close( - "dismissed-by-user" - ), - ), - ), - ), - ), - False, - False, - 0, - ) - - body_container = Box(spacing=4, orientation="h") - - if image_pixbuf := self._notification.image_pixbuf: - body_container.add( - Box( - v_expand=True, - v_align="center", - children=CustomImage( - pixbuf=image_pixbuf.scale_simple( - 64, 64, GdkPixbuf.InterpType.BILINEAR - ), - style_classes="image", - ), - ) - ) - - body_container.add( - Label( - markup=self._notification.body, - line_wrap="word-char", - v_align="start", - ellipsization="end", - max_chars_width=40, - h_align="start", - ) - ) - - actions_container = Box( - spacing=4, - orientation="h", - name="notification-action-buttons", - children=[ - ActionButton(action, i, len(self._notification.actions)) - for i, action in enumerate(self._notification.actions) - ], + h_align="fill", h_expand=True, + children=[ + self.create_content(notification), + self.create_action_buttons(notification), + ], + ) + self.notification = notification + self.timeout_ms = timeout_ms + self._timeout_id = None + self.start_timeout() + + def create_header(self, notification): + app_icon = ( + Image( + name="notification-icon", + image_file=notification.app_icon[7:], + size=24, + ) + if "file://" in notification.app_icon + else Image( + name="notification-icon", + icon_name="dialog-information-symbolic" or notification.app_icon, + icon_size=24, + ) ) - self.add(header_container) - self.add(body_container) - self.add(actions_container) - - # Destroy this widget once the notification is closed - self._notification.connect( - "closed", - lambda *_: ( - parent.remove(self) if (parent := self.get_parent()) else None, # type: ignore - self.destroy(), - ), - invoke_repeater( - 5 * 1000, - lambda: self._notification.close("expired"), - initial_call=False, - ), + return CenterBox( + name="notification-title", + start_children=[ + Box( + spacing=4, + children=[ + app_icon, + Label( + notification.app_name, + name="notification-app-name", + h_align="start", + ), + ], + ) + ], + end_children=[self.create_close_button()], ) - self.start_timer() - - def start_timer(self): - self._timer = GObject.timeout_add(200, self.update_progress) - - def update_progress(self): - self.progress_timeout.animate_value( - self.progress_timeout.value + 1 / self.anim_parts + def create_content(self, notification): + return Box( + name="notification-content", + spacing=8, + children=[ + Box( + name="notification-image", + children=CustomImage( + pixbuf=notification.image_pixbuf.scale_simple( + 48, 48, GdkPixbuf.InterpType.BILINEAR + ) + if notification.image_pixbuf + else self.get_pixbuf(notification.app_icon, 48, 48) + ), + ), + Box( + name="notification-text", + orientation="v", + v_align="center", + children=[ + Box( + name="notification-summary-box", + orientation="h", + children=[ + Label( + name="notification-summary", + markup=notification.summary.replace("\n", " "), + h_align="start", + ellipsization="end", + ), + Label( + name="notification-app-name", + markup=" | " + notification.app_name, + h_align="start", + ellipsization="end", + ), + ], + ), + Label( + markup=notification.body.replace("\n", " "), + h_align="start", + ellipsization="end", + ) + if notification.body + else Box(), + ], + ), + Box(h_expand=True), + Box( + orientation="v", + children=[ + self.create_close_button(), + Box(v_expand=True), + ], + ), + ], ) - if self.progress_timeout.value >= self.timeout_in_sec: - self.progress_timeout.value = self.timeout_in_sec - self.stop_timer() - return True # Continue the timer - def stop_timer(self): - if self._timer: - GObject.source_remove(self._timer) - self._timer = None + def get_pixbuf(self, icon_path, width, height): + if icon_path.startswith("file://"): + icon_path = icon_path[7:] - def get_icon(self, app_icon, app_name, size) -> Image: - app_name = ( - app_name.lower().replace(" ", "-") - if isinstance(app_name, str) - else app_name + if not os.path.exists(icon_path): + logger.warning(f"Icon path does not exist: {icon_path}") + return None + + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path) + return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR) + except Exception as e: + logger.error(f"Failed to load or scale icon: {e}") + return None + + def create_action_buttons(self, notification): + return Box( + name="notification-action-buttons", + spacing=4, + h_expand=True, + children=[ + ActionButton(action, i, len(notification.actions), self) + for i, action in enumerate(notification.actions) + ], ) - if isinstance(app_icon, str) and app_icon.strip(): - if app_icon.startswith("file://") or app_icon.startswith("/"): - file_path = app_icon[7:] if app_icon.startswith("file://") else app_icon - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file(file_path) - scaled_pixbuf = pixbuf.scale_simple( - size, size, GdkPixbuf.InterpType.BILINEAR - ) - return Image(name="notification-icon", pixbuf=scaled_pixbuf) - except Exception as e: - print(f"DEBUG: Failed to load image from {file_path}: {e}") - if app_icon and not app_icon.startswith(("file://", "/")): - icon_name = app_icon - elif app_name and app_name.strip() not in ["notify-send", ""]: - icon_name = app_name.strip() - else: - icon_name = "dialog-information-symbolic" - - return Image(name="notification-icon", icon_name=icon_name, icon_size=size) - - def on_button_press(self, _, event): - if event.button != 1: - (self._notification.close("dismissed-by-user"),) - - def get_timeout(self): - return ( - self._notification.timeout if self._notification.timeout != -1 else 5 * 1000 + def create_close_button(self): + close_button = Button( + name="notif-close-button", + child=Label(name="notif-close-label", markup=icons.cancel), + on_clicked=lambda *_: self.notification.close("dismissed-by-user"), ) + close_button.connect( + "enter-notify-event", lambda *_: self.hover_button(close_button) + ) + close_button.connect( + "leave-notify-event", lambda *_: self.unhover_button(close_button) + ) + return close_button + + def start_timeout(self): + self.stop_timeout() + self._timeout_id = GLib.timeout_add(self.timeout_ms, self.close_notification) + + def stop_timeout(self): + if self._timeout_id is not None: + GLib.source_remove(self._timeout_id) + self._timeout_id = None + + def close_notification(self): + self.notification.close("expired") + self.stop_timeout() + return False + + def pause_timeout(self): + self.stop_timeout() + + def resume_timeout(self): + self.start_timeout() + + def destroy(self): + self.stop_timeout() + super().destroy() + + # @staticmethod + def set_pointer_cursor(self, widget, cursor_name): + window = widget.get_window() + if window: + cursor = Gdk.Cursor.new_from_name(widget.get_display(), cursor_name) + window.set_cursor(cursor) + + def hover_button(self, button): + self.pause_timeout() + self.set_pointer_cursor(button, "hand2") + + def unhover_button(self, button): + self.resume_timeout() + self.set_pointer_cursor(button, "arrow") class NotificationRevealer(Revealer): @@ -254,11 +244,10 @@ class NotificationRevealer(Revealer): self._notification = notification super().__init__( child=Box( - style="margin: 12px;", children=[self.notif_box], ), - transition_duration=500, - transition_type="crossfade", + transition_duration=250, + transition_type="slide-down", ) self.connect( @@ -289,7 +278,7 @@ class NotificationPopup(Window): self._server.connect("notification-added", self.on_new_notification) super().__init__( - anchor="bottom", + anchor="top right", child=self.notifications, layer="overlay", all_visible=True, diff --git a/modules/osd.py b/modules/osd.py index c2764cb..d941046 100644 --- a/modules/osd.py +++ b/modules/osd.py @@ -1,228 +1,111 @@ -import time -from typing import ClassVar, Literal -from fabric.utils import invoke_repeater -from fabric.widgets.box import Box -from fabric.widgets.revealer import Revealer -from fabric.widgets.scale import Scale, ScaleMark from fabric.widgets.wayland import WaylandWindow as Window -from gi.repository import GObject -from snippets import Animator, MaterialIcon +from fabric.widgets.label import Label +from fabric.widgets.box import Box +from gi.repository import GLib +from fabric.widgets.centerbox import CenterBox from services import brightness, audio -class AnimatedScale(Scale): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.animator = None # Lazily initialized - - def animate_value(self, value: float): - if not self.animator: - self.animator = Animator( - bezier_curve=(0.34, 1.56, 0.64, 1.0), - duration=0.8, - min_value=self.min_value, - max_value=self.value, - tick_widget=self, - notify_value=lambda p, *_: self.set_value(p.value), - ) - self.animator.pause() - self.animator.min_value = self.value - self.animator.max_value = value - self.animator.play() +def create_progress_bar(percentage, width=150): + container = Box(orientation="h", name="progress-container") + progress = Box( + name="progress-fill", style=f"min-width: {percentage * width / 100}px;" + ) + background = Box(name="progress-background", style=f"min-width: {width}px;") + background.children = [progress] + container.children = [background] + return container -class BrightnessOSDContainer(Box): - def __init__(self, **kwargs): - super().__init__(**kwargs, orientation="h", spacing=12, name="osd") - self.brightness_service = brightness - self.icon = MaterialIcon(icon_name="brightness_7", size=20) - self.scale = AnimatedScale( - marks=(ScaleMark(value=i) for i in range(0, 101, 10)), - value=70, - min_value=0, - max_value=100, - increments=(1, 1), - orientation="h", - ) - - self.add(self.icon) - self.add(self.scale) - self.update_brightness() - - self.scale.connect("value-changed", lambda *_: self.update_brightness()) - self.brightness_service.connect("screen", self.on_brightness_changed) - - def update_brightness(self) -> None: - current_brightness = self.brightness_service.screen_brightness - if current_brightness != 0: - normalized_brightness = self._normalize_brightness(current_brightness) - self.scale.animate_value(normalized_brightness) - self.update_icon(normalized_brightness) - - def update_icon(self, brightness_percentage: float) -> None: - icon_name = self.get_brightness_icon(brightness_percentage) - self.icon.set_label(icon_name) - - def on_brightness_changed(self, _sender: any, value: float, *_args) -> None: - normalized_brightness = self._normalize_brightness(value) - self.scale.animate_value(normalized_brightness) - self.update_icon(normalized_brightness) - - def _normalize_brightness(self, brightness: float) -> float: - return (brightness / self.brightness_service.max_screen) * 100 - - def get_brightness_icon(self, brightness_percentage: float) -> str: - brightness_icons = [ - "brightness_1", - "brightness_2", - "brightness_3", - "brightness_4", - "brightness_5", - "brightness_6", - "brightness_7", - ] - thresholds = [ - 100 / (1.6 ** (len(brightness_icons) - i - 1)) - for i in range(len(brightness_icons)) - ] - - for i, threshold in enumerate(thresholds): - if brightness_percentage < threshold: - return brightness_icons[i] - - return brightness_icons[-1] +def update_progress_bar(progress_bar, percentage, width=150): + progress_fill = progress_bar.children[0].children[0] + progress_fill.set_style(f"min-width: {percentage * width / 100}px;") -class AudioOSDContainer(Box): - __gsignals__: ClassVar[dict] = { - "volume-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()), - } - - def __init__(self, **kwargs): - super().__init__(**kwargs, orientation="h", spacing=13, name="osd") - self.audio = audio - self.icon = MaterialIcon(icon_name="volume_up", size=20) - self.scale = AnimatedScale( - marks=(ScaleMark(value=i) for i in range(1, 100, 10)), - value=70, - min_value=0, - max_value=100, - increments=(1, 1), - orientation="h", - ) - - self.add(self.icon) - self.add(self.scale) - self.sync_with_audio() - - self.scale.connect("value-changed", self.on_volume_changed) - self.audio.connect("notify::speaker", self.on_audio_speaker_changed) - - def sync_with_audio(self): - if self.audio.speaker: - volume = round(self.audio.speaker.volume) - self.scale.set_value(volume) - self.update_icon(volume) - - def on_volume_changed(self, *_): - if self.audio.speaker: - volume = self.scale.value - if 0 <= volume <= 100: - self.audio.speaker.set_volume(volume) - self.update_icon(volume) - self.emit("volume-changed") - - def on_audio_speaker_changed(self, *_): - if self.audio.speaker: - self.audio.speaker.connect("notify::volume", self.update_volume) - self.update_volume() - - def update_volume(self, *_): - if self.audio.speaker and not self.is_hovered(): - volume = round(self.audio.speaker.volume) - self.scale.set_value(volume) - self.update_icon(volume) - - def update_icon(self, volume): - icon_name = self._get_audio_icon_name(volume) - self.icon.set_label(icon_name) - - def _get_audio_icon_name(self, volume: int) -> str: - if volume == 101: - return "sound_detection_loud_sound" - elif volume >= 67: - return "volume_up" - elif volume >= 34: - return "volume_down" - elif volume >= 1: - return "volume_mute" - else: - return "volume_off" +def create_labeled_progress(label_text, value_text, percentage): + label = Label(name="osd-label", markup=label_text) + value = Label(name="osd-value", markup=value_text) + header = CenterBox(orientation="h", start_children=[label], end_children=[value]) + progress = create_progress_bar(percentage) + return Box( + name="control-section", orientation="v", spacing=5, children=[header, progress] + ) -class OSDContainer(Window): - def __init__(self, **kwargs): - self.audio_container = AudioOSDContainer() - self.brightness_container = BrightnessOSDContainer() +def get_brightness(): + return ( + round((brightness.screen_brightness / brightness.max_screen) * 100) + if brightness.screen_brightness + else 0 + ) - self.timeout = 1000 - self.revealer = Revealer( - transition_type="slide-right", - transition_duration=200, - child_revealed=False, - ) +def get_volume(): + return round(audio.speaker.volume) if audio.speaker else 0 - self.main_box = Box( - orientation="v", - spacing=13, - children=[self.revealer], - ) +class OSD(Window): + def __init__(self): super().__init__( + name="osd-menu", layer="overlay", - anchor="bottom", - child=self.main_box, - visible=False, - pass_through=True, + anchor="top", + margin="10px 0 0 0", keyboard_mode="on-demand", - **kwargs, + visible=False, + style_classes="osd-panel", ) - - self.last_activity_time = time.time() - - self.audio_container.audio.connect("notify::speaker", self.show_audio) - self.brightness_container.brightness_service.connect( - "screen", self.show_brightness + self.last_volume = get_volume() + self.last_brightness = get_brightness() + self.volume_container = create_labeled_progress( + "Speaker", str(self.last_volume), self.last_volume ) - self.audio_container.connect("volume-changed", self.show_audio) + self.brightness_container = create_labeled_progress( + "Brightness", str(self.last_brightness), self.last_brightness + ) + self.children = Box( + orientation="h", + spacing=10, + children=[self.brightness_container, self.volume_container], + ) + self.hide_timeout_id = None + self._connect_signals() + GLib.timeout_add(100, self._check_changes) - invoke_repeater(100, self.check_inactivity, initial_call=True) + def _connect_signals(self): + audio.connect("notify::speaker", self._update_volume) + brightness.connect("screen", self._update_brightness) - def show_audio(self, *_): - self.show_box(box_to_show="audio") - self.reset_inactivity_timer() + def _update_volume(self, *_): + volume = get_volume() + self.volume_container.children[0].end_children[0].set_markup(str(volume)) + update_progress_bar(self.volume_container.children[1], volume) + self._reset_timeout() - def show_brightness(self, *_): - self.show_box(box_to_show="brightness") - self.reset_inactivity_timer() + def _update_brightness(self, *_): + brightness_level = get_brightness() + self.brightness_container.children[0].end_children[0].set_markup( + str(brightness_level) + ) + update_progress_bar(self.brightness_container.children[1], brightness_level) + self._reset_timeout() - def show_box(self, box_to_show: Literal["audio", "brightness"]): - self.set_visible(True) - if box_to_show == "audio": - self.revealer.children = self.audio_container - elif box_to_show == "brightness": - self.revealer.children = self.brightness_container - self.revealer.set_reveal_child(True) - self.reset_inactivity_timer() + def _reset_timeout(self): + if self.hide_timeout_id: + GLib.source_remove(self.hide_timeout_id) + self.hide_timeout_id = GLib.timeout_add(1500, self._hide_osd) - def start_hide_timer(self): - self.set_visible(False) + def _hide_osd(self): + self.hide() + self.hide_timeout_id = None + return False - def reset_inactivity_timer(self): - self.last_activity_time = time.time() - - def check_inactivity(self): - if time.time() - self.last_activity_time >= (self.timeout / 1000): - self.start_hide_timer() + def _check_changes(self): + volume = get_volume() + brightness_level = get_brightness() + if volume != self.last_volume or brightness_level != self.last_brightness: + self.last_volume, self.last_brightness = volume, brightness_level + self.show_all() + self._update_volume() + self._update_brightness() return True diff --git a/services/__init__.py b/services/__init__.py index b464ce6..3d7d12d 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -1,7 +1,7 @@ from fabric.audio import Audio from .brightness import Brightness from .screen_record import ScreenRecorder -from .wifi import * +from .network import * brightness = Brightness() diff --git a/services/wifi.py b/services/network.py similarity index 100% rename from services/wifi.py rename to services/network.py diff --git a/snippets/animator.py b/snippets/animator.py deleted file mode 100644 index 1799ae6..0000000 --- a/snippets/animator.py +++ /dev/null @@ -1,179 +0,0 @@ -from typing import cast - -from fabric import Property, Service, Signal -from gi.repository import GLib, Gtk - - -class Animator(Service): - @Signal - def finished(self) -> None: ... - - @Property(tuple[float, float, float, float], "read-write") - def bezier_curve(self) -> tuple[float, float, float, float]: - return self._bezier_curve - - @bezier_curve.setter - def bezier_curve(self, value: tuple[float, float, float, float]): - self._bezier_curve = value - return - - @Property(float, "read-write") - def value(self): - return self._value - - @value.setter - def value(self, value: float): - self._value = value - return - - @Property(float, "read-write") - def max_value(self): - return self._max_value - - @max_value.setter - def max_value(self, value: float): - self._max_value = value - return - - @Property(float, "read-write") - def min_value(self): - return self._min_value - - @min_value.setter - def min_value(self, value: float): - self._min_value = value - return - - @Property(bool, "read-write", default_value=False) - def playing(self): - return self._playing - - @playing.setter - def playing(self, value: bool): - self._playing = value - return - - @Property(bool, "read-write", default_value=False) - def repeat(self): - return self._repeat - - @repeat.setter - def repeat(self, value: bool): - self._repeat = value - return - - def __init__( - self, - bezier_curve: tuple[float, float, float, float], - duration: float, - min_value: float = 0.0, - max_value: float = 1.0, - repeat: bool = False, - tick_widget: Gtk.Widget | None = None, - **kwargs, - ): - super().__init__(**kwargs) - self._bezier_curve = (1, 0, 1, 1) - self._duration = 5 - self._value = 0.0 - self._min_value = 0.0 - self._max_value = 1.0 - self._repeat = False - - self.bezier_curve = bezier_curve - self.duration = duration - self.value = min_value - self.min_value = min_value - self.max_value = max_value - self.repeat = repeat - - self.playing = False - self._start_time = None - self._tick_handler = None - self._timeline_pos = 0 - self._tick_widget = tick_widget - - def do_get_time_now(self): - return GLib.get_monotonic_time() / 1_000_000 - - def do_lerp(self, start: float, end: float, time: float) -> float: - return start + (end - start) * time - - def do_interpolate_cubic_bezier(self, time: float) -> float: - y_points = (0, self.bezier_curve[1], self.bezier_curve[3], 1) - return ( - (1 - time) ** 3 * y_points[0] - + 3 * (1 - time) ** 2 * time * y_points[1] - + 3 * (1 - time) * time**2 * y_points[2] - + time**3 * y_points[3] - ) - - def do_ease(self, time: float) -> float: - return self.do_lerp( - self.min_value, self.max_value, self.do_interpolate_cubic_bezier(time) - ) - - def do_update_value(self, delta_time: float): - if not self.playing: - return - - elapsed_time = delta_time - cast(float, self._start_time) - - self._timeline_pos = min(1, elapsed_time / self.duration) - - self.value = self.do_ease(self._timeline_pos) - - if not self._timeline_pos >= 1: - return - - if not self.repeat: - self.value = self.max_value - self.finished() - self.pause() - return - - self._start_time = delta_time - self._timeline_pos = 0 - return - - def do_handle_tick(self, *_): - current_time = self.do_get_time_now() - self.do_update_value(current_time) - return True - - def do_remove_tick_handlers(self): - if self._tick_handler: - if self._tick_widget: - self._tick_widget.remove_tick_callback(self._tick_handler) - else: - GLib.source_remove(self._tick_handler) - self._tick_handler = None - return - - def play(self): - if self.playing: - return - - self._start_time = self.do_get_time_now() - - if not self._tick_handler: - if self._tick_widget: - self._tick_handler = self._tick_widget.add_tick_callback( - self.do_handle_tick - ) - else: - self._tick_handler = GLib.timeout_add(16, self.do_handle_tick) - - self.playing = True - return - - def pause(self): - self.playing = False - return self.do_remove_tick_handlers() - - def stop(self): - if not self._tick_handler: - self._timeline_pos = 0 - self.playing = False - return - return self.do_remove_tick_handlers() diff --git a/snippets/icon.py b/snippets/icon.py deleted file mode 100644 index 86e77c0..0000000 --- a/snippets/icon.py +++ /dev/null @@ -1,16 +0,0 @@ -from fabric.widgets.label import Label - - -def MaterialIcon(icon_name: str, size: int = 24, props=None): - label_props = { - "label": str(icon_name), # Directly use the provided icon name - "name": "material_icon", - "style": f"font-size: {size}px;", # Set font family for Material Icons - "h_align": "center", # Align horizontally - "v_align": "center", # Align vertically - } - - if props: - label_props.update(props) - - return Label(**label_props) diff --git a/styles/bar.css b/styles/bar.css index 95edb20..5cca825 100644 --- a/styles/bar.css +++ b/styles/bar.css @@ -1,30 +1,68 @@ #bar { - background-color: @surface; - padding: 3px; + padding: 0px 7px 0px 7px; } -#bar label { - font-weight: bold; +#bar.start-container { + padding-top: 2px; + padding-bottom: 2px; + border-bottom-right-radius: 15px; +} + +#bar.end-container { + padding-top: 2px; + padding-bottom: 2px; + border-bottom-left-radius: 15px; +} + +#center-bar { + background-color: transparent; +} + +#bar, +#bar-corner * { + background-color: lighter(@background); } #bar.hidden { opacity: 0; - padding: 3px; - margin-bottom: -52px; + transition: all 0.5s cubic-bezier(0.5, 0.25, 0, 1); +} + +#bar.visible { + opacity: 1; transition: all 0.5s cubic-bezier(0.5, 0.25, 0, 1); } /* - * ── bar modules ─────────────────────────────────────────────────────── + * ── button bar ──────────────────────────────────────────────────────── */ -#start-container, -#center-container, -#end-container { - padding-top: 6px; - padding-bottom: 6px; - margin-left: 12px; - margin-right: 12px; +#button-bar { + background-color: @surfaceContainer; + margin-top: 5px; + margin-bottom: 5px; + padding: 0px 1px 0px 1px; + border-radius: 8px; + min-width: 24px; + min-height: 24px; + transition: all 0.1s ease; +} + +#button-bar-label { + color: @primary; + font-size: 20px; + border-radius: 8px; + transition: all 0.1s ease; +} + +#button-bar:hover #button-bar-label { + background-color: @surfaceBright; + box-shadow: 0px 3px 3px -2px rgba(0, 0, 0, 0.2); +} + +#button-bar:active #button-bar-label { + color: @shadow; + background-color: @primary; } /* @@ -32,10 +70,44 @@ */ #workspaces { - border-radius: 100px; font-style: normal; background: @surfaceContainer; - padding: 0px 10px 0px 10px; + margin-top: 5px; + margin-bottom: 5px; + padding: 0px 8px 0px; + border-radius: 8px; + min-width: 24px; + min-height: 24px; +} + +/* + * ── update ──────────────────────────────────────────────────────────── +*/ + +#update-icon { + color: @primary; + font-size: 22px; + padding: 1px; + border-radius: 11px; + transition: all 0.1s ease; +} + +#updated-icon { + color: @primary; + font-size: 20px; + padding: 4px; + border-radius: 99px; + transition: all 0.1s ease; +} + +#update-label { + color: @primary; + font-size: 10px; + font-weight: bold; + padding: 0px; + margin-right: 4px; + border-radius: 11px; + transition: all 0.1s ease; } /* @@ -44,35 +116,139 @@ #datetime { background-color: @surfaceContainer; - border-radius: 100px; - padding: 8px; + margin-top: 5px; + margin-bottom: 5px; + padding: 0px 8px 0px; + border-radius: 8px; + min-width: 24px; + min-height: 24px; } /* * ── battery ─────────────────────────────────────────────────────────── */ + #battery { background-color: @surfaceContainer; + margin-top: 5px; + margin-bottom: 5px; + padding: 0px 8px 0px; + border-radius: 8px; + min-width: 24px; + min-height: 24px; +} + +#battery-circle { + color: @surfaceBright; + border: 3px solid @primary; +} + +#battery-circle.alert { + border: 2px solid @error; +} + +#battery-icon { + font-size: 20px; +} + +#battery-icon.alert { + color: @onError; +} + +#battery-save, +#battery-balanced, +#battery-performance { + min-width: 30px; + min-height: 28px; + margin: 2px 0px; border-radius: 100px; - padding: 2px 10px 2px 10px; } -#battery label { - font-weight: 600; +#battery-save-label, +#battery-balanced-label, +#battery-performance-label { + color: @outline; + font-size: 20px; } -#battery-label { - margin-right: 5px; +#battery-save:hover, +#battery-balanced:hover, +#battery-performance:hover { + background-color: @surfaceBright; +} + +#battery-save:hover #battery-save-label, +#battery-balanced:hover #battery-balanced-label, +#battery-performance:hover #battery-performance-label { + color: @primary; +} + +#battery-save.active, +#battery-balanced.active, +#battery-performance.active { + background-color: @primary; +} + +#battery-save.active #battery-save-label, +#battery-balanced.active #battery-balanced-label, +#battery-performance.active #battery-performance-label { + color: @shadow; +} + +#battery-level { + color: @primary; + font-weight: bold; + margin: 0 4px; + font-size: 11px; +} + +/* + * ── metric ──────────────────────────────────────────────────────── +*/ + +#metrics { + background-color: @surfaceContainer; + margin-top: 5px; + margin-bottom: 5px; + padding: 0px 8px 0px; + border-radius: 8px; + min-width: 24px; + min-height: 24px; +} + +#metric-icon { + font-size: 20px; +} + +#metric-circle { + color: @surfaceBright; + border: 3px solid @primary; } /* * ── tray ────────────────────────────────────────────────────────────── */ +#tray-revealer { + background-color: @surfaceContainer; + margin-top: 5px; + margin-bottom: 5px; + padding: 0px 2px 0px; + border-radius: 8px; + min-width: 24px; + min-height: 24px; + font-size: 20px; +} + #tray { background-color: @surfaceContainer; - border-radius: 100px; - padding: 2px 10px 0px 10px; + margin-top: 5px; + margin-left: 4px; + margin-bottom: 5px; + padding: 0px 8px 0px; + border-radius: 8px; + min-width: 24px; + min-height: 24px; } #tray > button { @@ -105,8 +281,12 @@ menu > menuitem:hover > label { */ #logo { background-color: @surfaceContainer; - border-radius: 100px; - padding: 0px 10px 0px 10px; + margin-top: 5px; + margin-bottom: 5px; + padding: 0px 8px 0px; + border-radius: 8px; + min-width: 24px; + min-height: 24px; } #logo-name { @@ -119,25 +299,21 @@ menu > menuitem:hover > label { } /* - * ── applets ─────────────────────────────────────────────────────────── + * ── status indicator ─────────────────────────────────────────────────────────── */ -#applets { +#system-indicators { background-color: @surfaceContainer; - border-radius: 100px; - padding: 2px 10px 2px 10px; - margin: 0px; + margin-top: 5px; + margin-bottom: 5px; + padding: 0px 8px 0px; + border-radius: 8px; + min-width: 24px; + min-height: 24px; } -#applets .icon { - color: @onSurface; - font-weight: 900; -} - -#applets > button { - color: @onSurface; - margin: 0px; - padding: 0px; +#system-indicator-icon { + font-size: 20px; } /* @@ -172,18 +348,17 @@ menu > menuitem:hover > label { margin-right: 0px; } -#progress { - color: lighter(@outlineVariant); - border: 1px solid @primary; -} +/* + * ── recording indicator ─────────────────────────────────────────────── +*/ -#recording-indicator { - padding: 6px 12px 6px 12px; - border-radius: 99px; -} - -#recording-indicator { - color: @primary; +#recorder { + color: @error; + margin-top: 5px; + margin-bottom: 5px; + padding: 0px 6px 0px; + border-radius: 8px; + font-size: 20px; animation: error 2s infinite; } diff --git a/styles/dashboard.css b/styles/dashboard.css new file mode 100644 index 0000000..8792c72 --- /dev/null +++ b/styles/dashboard.css @@ -0,0 +1,152 @@ +#buttons-grid { + min-height: 52px; +} + +#network-button, +#bluetooth-button, +#night-mode-button, +#dark-mode-button, +#caffeine-button { + border-radius: 20px; +} + +#network-button, +#bluetooth-button { + background-color: unset; + padding: 0; +} + +#night-mode-button, +#dark-mode-button, +#caffeine-button { + background-color: @primary; + padding: 0 10px; +} + +#night-mode-button.disabled, +#dark-mode-button.disabled, +#caffeine-button.disabled { + background-color: @surface; +} + +#network-status-button, +#network-menu-button, +#bluetooth-status-button, +#bluetooth-menu-button { + background-color: @primary; + padding: 0 8px; +} + +#network-menu-button, +#bluetooth-menu-button { + border-left: 1px solid @outline; + min-width: 24px; +} + +#network-status-button:hover, +#network-menu-button:hover, +#bluetooth-status-button:hover, +#bluetooth-menu-button:hover, +#bluetooth-button:hover, +#night-mode-button:hover, +#dark-mode-button:hover, +#caffeine-button:hover { + background-color: @foreground; +} + +/* Estados Disabled */ +#network-status-button.disabled, +#network-menu-button.disabled, +#bluetooth-status-button.disabled, +#bluetooth-menu-button.disabled { + background-color: @surface; +} + +#network-menu-button.disabled, +#bluetooth-menu-button.disabled { + border-left: 1px solid @surfaceBright; +} + +#network-status-button.disabled:hover, +#network-menu-button.disabled:hover, +#bluetooth-status-button.disabled:hover, +#bluetooth-menu-button.disabled:hover, +#night-mode-button.disabled:hover, +#dark-mode-button.disabled:hover, +#caffeine-button.disabled:hover { + background-color: @surfaceBright; +} + +#network-status-button, +#bluetooth-status-button { + border-radius: 20px 0 0 20px; +} + +#network-menu-button, +#bluetooth-menu-button { + border-radius: 0 20px 20px 0; +} + +#network-icon, +#bluetooth-icon, +#night-mode-icon, +#dark-mode-icon, +#caffeine-icon { + color: @shadow; + font-size: 24px; +} + +#network-icon.disabled, +#bluetooth-icon.disabled, +#night-mode-icon.disabled, +#dark-mode-icon.disabled, +#caffeine-icon.disabled { + color: @outline; +} + +#network-menu-label, +#bluetooth-menu-label { + color: @shadow; + font-size: 16px; +} + +#network-menu-label.disabled, +#bluetooth-menu-label.disabled { + color: @outline; +} + +#network-label, +#bluetooth-label, +#night-mode-label, +#caffeine-label, +#dark-mode-label { + color: @shadow; + font-size: 14px; + font-weight: bold; + margin-bottom: -4px; +} + +#network-label.disabled, +#bluetooth-label.disabled, +#night-mode-label.disabled, +#dark-mode-label.disabled, +#caffeine-label.disabled { + color: @outline; +} + +#network-ssid, +#bluetooth-status, +#night-mode-status, +#dark-mode-status, +#caffeine-status { + color: @surfaceBright; + font-size: 12px; +} + +#network-ssid.disabled, +#bluetooth-status.disabled, +#night-mode-status.disabled, +#dark-mode-status.disabled, +#caffeine-status.disabled { + color: @outline; +} diff --git a/styles/launcher.css b/styles/launcher.css index 7f0ebdc..7c30a14 100644 --- a/styles/launcher.css +++ b/styles/launcher.css @@ -1,11 +1,14 @@ /* * ── launcher ────────────────────────────────────────────────────────── */ +#dashboard { + min-height: 100px; +} #launcher { margin: 18px; border: 1px solid @outlineVariant; - background-color: @surface; + background-color: lighter(@background); border-radius: 24px; padding: 18px; transition: all 0.5s cubic-bezier(0.5, 0.25, 0, 1); @@ -25,26 +28,35 @@ min-width: 620px; } -#launcer-content.cliphist { - min-height: 420px; +.cliphist { + min-height: 464px; + min-width: 300px; +} + +.applauncher-scrolled-window { + transition: min-height 0.2s ease; } #launcher-content.bluetooth { min-height: 464px; - min-width: 464px; } #launcher-content.wifi { min-height: 464px; - min-width: 464px; } -#launcher-content.power { - min-height: 52px; +#launcher-content.todo { + min-width: 460px; + min-height: 300px; +} + +#launcher-content.calendar { + min-height: 420px; + min-width: 420px; } #launcher-content { - min-width: 420px; + min-width: 485px; } #app-launcher, @@ -54,35 +66,15 @@ #sh, #cliphist, #emoji, -#powermenu { +#power { transition: all 0.25s cubic-bezier(0.5, 0.25, 0, 1); } -#toggle-launcher-button { - background-color: @secondaryContainer; - color: @onSecondaryContainer; - margin: 11px; - margin-left: 0px; - min-width: 54px; - min-height: 44px; - border-radius: 100%; -} - -#clear-launcher-button { - background-color: @secondaryContainer; - color: @onSecondaryContainer; - margin: 11px; - margin-left: 0px; - min-width: 44px; - min-height: 44px; - border-radius: 100%; -} - #scrolled-window scrollbar { border-radius: 10px; - background-color: @surface; - /*padding: 4px;*/ - /*margin-left: 6px;*/ + background-color: lighter(@background); + padding: 4px; + margin-left: 6px; } #scrolled-window slider { @@ -99,6 +91,7 @@ #launcher-app { border-radius: 16px; padding: 16px; + min-height: 0; transition: padding 0.25s cubic-bezier(0.5, 0.25, 0, 1); } @@ -125,6 +118,13 @@ /* * ── wallpaper ───────────────────────────────────────────────────────── */ +#search-entry-walls { + min-width: 300px; +} + +#entry-custom-color { + min-width: 10px; +} #wallpaper-icons:selected { background-color: @surfaceBright; @@ -137,7 +137,7 @@ } #dropdown-box { - background-color: @surface; + background-color: lighter(@background); box-shadow: inset 0 0 0 1px @outlineVariant; border-radius: 11px; padding: 10px; @@ -148,6 +148,42 @@ font-weight: bold; } +/* Customize the overall switch appearance */ +#materialyoucolor-switcher { + min-width: 40px; + min-height: 20px; + background-color: @surface; + border-radius: 15px; + padding: 2px; + transition: background-color 0.3s ease; +} + +/* Style the switch's slider */ +#materialyoucolor-switcher slider { + background-color: var(--primary); + border-radius: 16px; + min-width: 20px; + min-height: 20px; + transition: + background-color 0.1s cubic-bezier(0.5, 0.25, 0, 1.25), + transform 0.25s cubic-bezier(0.5, 0.25, 0, 1.25); +} + +/* When the switch is active (checked) */ +#materialyoucolor-switcher:checked { + background-color: @primary; +} + +/* Optional: additional styling for the slider when active */ +#materialyoucolor-switcher:checked slider { + background-color: @shadow; +} + +#mat-label { + font-size: 24px; + margin-left: 4px; +} + /* * ── emoji ───────────────────────────────────────────────────────────── */ @@ -173,28 +209,41 @@ * ── powermenu ───────────────────────────────────────────────────────── */ -#powermenu-button { +#power-menu-button { border-radius: 20px; + min-width: 52px; + min-height: 52px; padding: 20px; } -#powermenu-button:hover, -#powermenu-button:focus { +#power-menu-button label { + font-size: 49px; + color: @foreground; +} + +#power-menu-button:hover, +#power-menu-button:focus { background-color: @surfaceBright; } -#powermenu-button:active { +#power-menu-button:hover label, +#power-menu-button:focus label { + color: @primary; +} + +#power-menu-button:active { background-color: @primary; } +#power-menu-button:active label { + color: @shadow; +} + /* * ── cliphist ────────────────────────────────────────────────────────── */ -#cliphist-history { - min-height: 520px; -} -#launcher button#clip-img-item { +button#clip-img-item { background-size: cover; background-position: center; background-repeat: no-repeat; @@ -203,12 +252,12 @@ min-height: 70px; } -#launcher button#clip-img-item:hover, -#launcher button#clip-img-item:focus { +button#clip-img-item:hover, +button#clip-img-item:focus { box-shadow: 2px 2px 2px @primary; } -#launcher button#clip-item { +button#clip-item { border-radius: 11px; background-color: transparent; background-image: none; @@ -216,24 +265,35 @@ padding: 6px; } -#launcher button#clip-item { +button#clip-item { font-size: 30px; } -#launcher button#clip-item:hover, -#launcher button#clip-item:focus { +button#clip-item:hover, +button#clip-item:focus { text-shadow: 2px 2px 2px @primary; } +#clear-button { + margin: 11px; + margin-left: 0px; + min-width: 44px; + min-height: 44px; + border-radius: 100%; +} + +#clear-button:hover { + background-color: mix(@onSurface, @surface, 0.92); +} + +#cliphist-clear-button { + font-size: 24px; +} + /* * ── todo ────────────────────────────────────────────────────────────── */ -#todo-content { - min-height: 250px; -} - #todo-launcher { - background-color: @surface; padding: 10px; border-radius: 8px; } @@ -241,20 +301,46 @@ #todo-item { padding: 8px; border-radius: 4px; - background-color: alpha(@surface, 0.8); + background-color: alpha(@background, 0.3); } #todo-label { - color: @primary; + font-size: 16px; } #todo-label-completed { - color: alpha(@outlineVariant, 0.5); + color: alpha(@outlineVariant, 1); text-decoration: line-through; } -#delete-button { - color: @error; +#add-button { + margin: 11px; + margin-left: 0px; + margin-right: 0px; + min-width: 44px; + min-height: 44px; + border-radius: 100%; +} + +#delete-button, +#checkbox { + margin-left: 0px; + margin-right: 0px; + min-width: 44px; + min-height: 44px; + border-radius: 100%; +} + +#add-button:hover, +#delete-button:hover, +#checkbox:hover { + background-color: mix(@onSurface, @surface, 0.92); +} + +#todo-add-button, +#todo-checkbox, +#todo-delete-button { + font-size: 24px; } /* @@ -295,6 +381,11 @@ margin-bottom: 4px; } +#bluetooth-scan-button, +#bluetooth-toggle-button { + font-size: 24px; +} + #bluetooth-scan, #bluetooth-toggle { background-color: @secondaryContainer; @@ -328,7 +419,7 @@ } #bluetooth-connect { - background-color: @surface; + background-color: lighter(@background); border-radius: 16px; padding: 8px 16px; } @@ -342,7 +433,6 @@ background-color: @surfaceContainer; padding: 4px; border-radius: 20px; - min-height: 200px; } /* @@ -362,6 +452,10 @@ margin-bottom: 4px; } +#wifi-toggle-icon { + font-size: 24px; +} + #wifi-scan, #wifi-toggle { background-color: @secondaryContainer; @@ -395,7 +489,7 @@ } #wifi-connect { - background-color: @surface; + background-color: lighter(@background); border-radius: 16px; padding: 8px 16px; } @@ -408,5 +502,89 @@ background-color: @surfaceContainer; padding: 4px; border-radius: 20px; - min-height: 200px; +} + +/* + * ── calendar ────────────────────────────────────────────────────────── +*/ + +#header { + border-radius: 16px; + padding: 4px; +} + +#weekday-row { + border-radius: 8px; + padding: 4px; +} + +#month-label, +#weekday-label, +#day-label { + font-weight: bold; +} + +#day-label, +#day-empty { + min-width: 42px; + min-height: 36px; +} + +#day-empty { + color: @surfaceBright; +} + +#weekday-label { + color: @primary; +} + +#calendar-grid { + border-radius: 16px; + padding: 4px; +} + +#day-label.current-day { + background-color: @primary; + color: @onPrimary; + min-height: 36px; + min-width: 42px; + border-radius: 99px; +} + +#prev-month-button, +#next-month-button { + border-radius: 8px; + padding: 4px; +} + +#prev-month-button:hover, +#next-month-button:hover { + background-color: @surfaceBright; +} + +#prev-month-button:active, +#next-month-button:active { + background-color: @primary; +} + +#month-button-label { + color: @primary; + font-size: 20px; +} + +#prev-month-button:active #month-button-label, +#next-month-button:active #month-button-label { + color: @surface; +} + +#day-box { + padding: 5px; + border-radius: 5px; + min-width: 30px; + min-height: 30px; +} + +#prev-month-day, +#next-month-day { + color: mix(@onSurfaceVariant, transparent, 0.6); } diff --git a/styles/main.css b/styles/main.css index a971d02..c97593e 100644 --- a/styles/main.css +++ b/styles/main.css @@ -3,27 +3,13 @@ @import "./notification.css"; @import "./osd.css"; @import "./launcher.css"; +@import "./dashboard.css"; * { all: unset; color: @foreground; font-size: 16px; - font-family: "Product Sans", Roboto, "Font Awesome 6 Free", Helvetica, Arial, - sans-serif; -} - -/* - * ── corner ──────────────────────────────────────────────────────────── -*/ - -#corner { - background-color: @surface; - border-radius: 0; -} - -#corner-container { - min-width: 20px; - min-height: 20px; + font-family: "Product Sans", sans-serif; } /* @@ -31,23 +17,13 @@ */ tooltip { - background-color: @surfaceContainer; + border: solid 1px; border-radius: 10px; - padding: 6px 10px; - outline: none; - border: none; - box-shadow: none; + border-color: @surface; + background-color: @shadow; animation: tooltipShow 0.25s cubic-bezier(0.5, 0.25, 0, 1); } -tooltip label { - color: @onSurface; - outline: none; - border: none; - box-shadow: none; - font-weight: normal; -} - @keyframes tooltipShow { from { opacity: 0; @@ -58,12 +34,43 @@ tooltip label { } } -/* - * ── icon ────────────────────────────────────────────────────────────── -*/ - -#material_icon, -#material_icon * { - all: unset; - font-family: "Material Symbols Outlined", "Material Symbols Sharp"; +tooltip > * { + padding: 6px 10px; + border-radius: 10px; +} + +/* + * ── dock ────────────────────────────────────────────────────────────── +*/ +#dock-icon { + padding: 0px 2px 0px 2px; +} + +#dock { + padding: 10px; + background-color: alpha(@shadow, 0.5); + border: 0.5px solid alpha(@outlineVariant, 0.4); + border-radius: 12px; + margin-bottom: 2px; +} + +#dock-separator { + border-right: 1px solid alpha(@foreground, 0.5); +} + +#dock-app-button { + padding: 2px; + margin-left: 2px; + margin-right: 2px; + border-radius: 12px; +} + +.show-dock { + opacity: 1; + margin: 0; +} + +.hide-dock { + opacity: 1; + margin: -80px; } diff --git a/styles/notification.css b/styles/notification.css index b1ba83c..7b8ea88 100644 --- a/styles/notification.css +++ b/styles/notification.css @@ -1,60 +1,80 @@ -#notification { - padding: 16px; - border: solid 1px @outlineVariant; +#notification-action-buttons button.start-action { border-radius: 20px; - margin-bottom: 12px; - background-color: @surfaceContainerLow; - min-width: 350px; -} - -.notification-header .summary { - font-size: 0.92rem; - font-weight: bold; -} - -.notification-body .body { - color: darker(@foreground); - font-weight: 600; - font-size: 0.92rem; -} - -#notification .close-button { - border-radius: 100px; -} - -#notification .close-button:hover { - background: alpha(@foreground, 0.2); - transition: background 0.15s ease-in-out; - border-radius: 100px; -} - -#notification-action-button button.start-action { - margin-left: 0; -} - -#notification-action-button button.end-action { - margin-right: 0; } #notification-action-buttons button { - padding: 0.6rem; - font-weight: 600; - border-radius: 100px; - background-color: transparent; + background-color: @surfaceContainer; + padding: 8px; + border-radius: 20px; } + #notification-action-buttons button:hover { - background-color: lighter(@outlineVariant); + background-color: @surfaceBright; } -#notification-icon { - margin-right: 5px; +#notification-action-buttons button:active { + background-color: @primary; } -.image { - border-radius: 30px; +#notification-action-buttons button:active #button-label { + color: @shadow; } -#notification-circular-progress-bar { - border: solid 0px @outlineVariant; - color: transparent; +#notification-image image { + border-radius: 16px; + color: @onSurface; +} + +#notification-summary, +#button-label { + font-weight: bold; +} + +#notification-summary { + font-weight: bold; + color: @primary; +} + +#notification-app-name { + color: @outline; +} + +#action-button { + margin-top: 8px; +} + +#notification-box { + border-radius: 20px; + padding: 16px; + background-color: @surfaceContainerLow; + margin: 4px; + min-width: 336px; +} + +#boxed-notification-revealer { + margin-top: -14px; +} + +#notif-close-button { + background-color: @surface; + border-radius: 16px; + padding: 4px; +} + +#notif-close-button:hover, +#notif-close-button:focus { + background-color: @surfaceBright; +} + +#notif-close-button:active { + background-color: @error; +} + +#notif-close-label { + color: @error; + font-size: 20px; +} + +#notif-close-button:active #notif-close-label { + color: @shadow; } diff --git a/styles/osd.css b/styles/osd.css index f86f4e2..b1f4818 100644 --- a/styles/osd.css +++ b/styles/osd.css @@ -1,42 +1,44 @@ -#osd { - background: @surfaceContainer; - padding: 10px 4px 10px 4px; - border-radius: 14px; - margin-bottom: 16px; - min-width: 190px; +.osd-panel { + background-color: transparent; } -#osd scale { +#osd-label { + font-size: 17px; + font-weight: bold; +} + +#osd-value { + font-weight: bold; + font-size: 25px; +} + +#progress-container { + border-radius: 6px; + min-height: 12px; min-width: 150px; } -#osd slider { - color: @onPrimary; +#progress-fill { background-color: @primary; - background-size: 20px 20px; - min-width: 5px; - min-height: 14px; - border-radius: 100px; - padding: 0px; - background-repeat: no-repeat; - background-position: center; - margin-left: 0px; - margin-right: 0px; - margin-top: -16px; - margin-bottom: -16px; - box-shadow: - -4px 0px 0px 1px @surfaceContainer, - 4px 0px 0px 1px @surfaceContainer; + border-radius: 6px; + min-height: 8px; + transition: min-width 0.3s ease-in-out; } -#osd trough { - background: @secondaryContainer; - min-height: 10px; - margin-right: 4px; - border-radius: 100px; +#progress-background { + background-color: @surfaceContainer; + border-radius: 6px; + min-height: 8px; + min-width: 150px; } -#osd highlight { - border-radius: 100px 0px 0px 100px; - background: @primary; +#control-section { + padding: 10px; + border-radius: 10px; + background-color: lighter(@background); +} + +.osd-hidden { + opacity: 0; + transition: opacity 0.3s ease-in-out; } diff --git a/snippets/__init__.py b/utils/__init__.py similarity index 84% rename from snippets/__init__.py rename to utils/__init__.py index d4d3f96..587fe40 100644 --- a/snippets/__init__.py +++ b/utils/__init__.py @@ -1,13 +1,12 @@ -from .animator import * from .custom_image import * -from .icon import * +from .icon_resolver import * import json import os from fabric.utils import get_relative_path def read_config(): - config_path = get_relative_path("../config/assets/launcher.json") + config_path = get_relative_path("../config/assets/modus.json") with open(config_path, "r") as file: # Load JSON data into a Python dictionary diff --git a/snippets/custom_image.py b/utils/custom_image.py similarity index 100% rename from snippets/custom_image.py rename to utils/custom_image.py diff --git a/utils/icon_resolver.py b/utils/icon_resolver.py new file mode 100644 index 0000000..cd814c7 --- /dev/null +++ b/utils/icon_resolver.py @@ -0,0 +1,103 @@ +import json +import os +import re + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import GLib, Gtk +from loguru import logger + + +# TODO WIP +# Idea: nearest string matching algorithm +# if already exists: retrieve from json +# if found: stor in json: app_id -> icon_name +# if not found: store in json -> misisng-icon + + +CACHE_DIR = str(GLib.get_user_cache_dir()) + "/modus" +ICON_CACHE_FILE = CACHE_DIR + "/icons.json" +if not os.path.exists(CACHE_DIR): + os.makedirs(CACHE_DIR) + + +class IconResolver: + def __init__( + self, default_applicaiton_icon: str = "application-x-executable-symbolic" + ): + if os.path.exists(ICON_CACHE_FILE): + f = open(ICON_CACHE_FILE) + try: + self._icon_dict = json.load(f) + except json.JSONDecodeError: + logger.info("[ICONS] Cache file does not exist or is corrupted") + f.close() + else: + self._icon_dict = {} + + self.default_applicaiton_icon = default_applicaiton_icon + + def get_icon_name(self, app_id: str): + if app_id in self._icon_dict: + return self._icon_dict[app_id] + new_icon = self._compositor_find_icon(app_id) + logger.info( + f"[ICONS] found new icon: '{new_icon}' for app id: '{app_id}', storing..." + ) + self._store_new_icon(app_id, new_icon) + return new_icon + + def get_icon_pixbuf(self, app_id: str, size: int = 16): + return Gtk.IconTheme.get_default().load_icon( + self.get_icon_name(app_id), + size, + Gtk.IconLookupFlags.FORCE_SIZE, + ) + + def _store_new_icon(self, app_id: str, icon: str): + self._icon_dict[app_id] = icon + with open(ICON_CACHE_FILE, "w") as f: + json.dump(self._icon_dict, f) + f.close() + + def _get_icon_from_desktop_file(self, desktop_file_path: str): + # TODO: get icon in the [Desktop Entry] section only + with open(desktop_file_path) as f: + for line in f.readlines(): + if "Icon=" in line: + return "".join(line[5:].split()) + return self.default_applicaiton_icon + + def _get_desktop_file(self, app_id: str) -> str | None: + data_dirs = GLib.get_system_data_dirs() + for data_dir in data_dirs: + data_dir = data_dir + "/applications/" + if os.path.exists(data_dir): + # Do name resolving here + + files = os.listdir(data_dir) + matching = [ + s for s in files if "".join(app_id.lower().split()) in s.lower() + ] + if matching: + return data_dir + matching[0] + + for word in list(filter(None, re.split(r"-|\.|_|\s", app_id))): + matching = [s for s in files if word.lower() in s.lower()] + if matching: + return data_dir + matching[0] + + return None + + def _compositor_find_icon(self, app_id: str): + if Gtk.IconTheme.get_default().has_icon(app_id): + return app_id + if Gtk.IconTheme.get_default().has_icon(app_id + "-desktop"): + return app_id + "-desktop" + desktop_file = self._get_desktop_file(app_id) + return ( + self._get_icon_from_desktop_file(desktop_file) + if desktop_file + else self.default_applicaiton_icon + ) diff --git a/utils/icons.py b/utils/icons.py new file mode 100644 index 0000000..79c2407 --- /dev/null +++ b/utils/icons.py @@ -0,0 +1,144 @@ +# Parameters +font_family: str = "tabler-icons" +font_weight: str = "normal" + +span: str = f"" + +# Panels +apps: str = "" +dashboard: str = "" +chat: str = "" +wallpapers: str = "" +windows: str = "" + +# Bar +colorpicker: str = "" +media: str = "" + +# Circles +temp: str = "" +disk: str = "" +battery: str = "" +memory: str = "流" +cpu: str = "" +swap: str = "" + + +# Wallpapers +add: str = "" +sort: str = "" +circle: str = "" +check_box: str = "" +box: str = "" +scan: str = "" +scan_stop: str = "" +record: str = "" +update: str = "見" +updated: str = "" + +# Chevrons +chevron_up: str = "" +chevron_down: str = "" +chevron_left: str = "" +chevron_right: str = "" + +# Power +lock: str = "" +suspend: str = "" +logout: str = "" +reboot: str = "" +shutdown: str = "" + +# Power Manager +power_saving: str = "" +power_balanced: str = "勺" +power_performance: str = "" +charging: str = "" +discharging: str = "" +alert: str = "" + +# Applets +wifi: str = "" +bluetooth: str = "" +night: str = "" +coffee: str = "" +dnd: str = "" +darkmode: str = "﹖" +world: str = "" + +wifi_off: str = "" +bluetooth_off: str = "" +night_off: str = "" +dnd_off: str = "" +mic_off: str = "" + +# Bluetooth +bluetooth_connected: str = "" +bluetooth_disconnected: str = "" + + +# Player +pause: str = "" +play: str = "" +stop: str = "" +skip_back: str = "" +skip_forward: str = "" +prev: str = "" +next: str = "" +shuffle: str = "" +repeat: str = "" +music: str = "" +rewind_backward_5: str = "謹" +rewind_forward_5: str = "難" + +# Volume +vol_off: str = "" +vol_mute: str = "" +vol_medium: str = "" +vol_high: str = "" + +# Network +lan: str = "" +moderate_signal: str = "" +good_signal: str = "" +no_signal: str = "" +weak_signal: str = "" + +# Overview +circle_plus: str = "" + +# Pins +copy_plus: str = "ﶮ" +paperclip: str = "" + +# Confirm +accept: str = "" +cancel: str = "" +trash: str = "" +clear: str = "" + +# Config +config: str = "" + +# Icons +firefox: str = "" +chromium: str = "" +spotify: str = "ﺆ" +disc: str = "𐀾" +disc_off: str = "" + +# Misc +dot: str = "" +palette: str = "" + +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("__"): + global_dict[key] = f"{span}{global_dict[key]}" + + +apply_span()