diff --git a/README.md b/README.md index ba723d3..92c8644 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,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 @@ -92,7 +92,7 @@ cd Tela-icon-theme - [x] Update Screenshot - [x] Installation script - [x] Network Manager -- [ ] Calendar with events +- [x] Calendar - [ ] Power Manager - [ ] Notification Panel - [ ] Improve CSS Styling diff --git a/install.sh b/install.sh index 40b0d1d..59be581 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 @@ -75,7 +76,7 @@ fi # Install required packages using paru echo ":: Installing required packages..." -paru -S --needed --noconfirm "${PACKAGES[@]}" || true +paru -Syy --needed --noconfirm "${PACKAGES[@]}" || true # Backup and replace GTK configs echo ":: Updating GTK configurations..." diff --git a/main.py b/main.py index 56baf28..dba0ee4 100644 --- a/main.py +++ b/main.py @@ -8,9 +8,11 @@ from loguru import logger from modules.bar.bar import Bar from modules.launcher.launcher import Launcher from modules.notification_popup import NotificationPopup -from modules.osd import OSDContainer + +from modules.osd import OSD from modules.corners import Corners from services import sc +from config.config import open_config for log in [ "fabric.hyprland.widgets", @@ -19,6 +21,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 +46,12 @@ 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() + osd = OSD() notif = NotificationPopup() launcher = Launcher() diff --git a/modules/bar/bar.py b/modules/bar/bar.py index 74ee2d4..62b6132 100644 --- a/modules/bar/bar.py +++ b/modules/bar/bar.py @@ -11,10 +11,8 @@ from gi.repository import GLib from modules.bar.components import ( BatteryLabel, BluetoothIndicator, - IdleIndicator, MicrophoneIndicator, NetworkIndicator, - PowerProfileIndicator, SystemInfo, TaskBar, VolumeIndicator, @@ -57,10 +55,8 @@ class Bar(Window): 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.launcher = Button( name="logo", @@ -76,12 +72,10 @@ class Bar(Window): orientation="h", children=[ self.language, - self.powerindicator, self.bluetooth, self.network, self.volume, self.microphone, - self.idle, ], ) 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/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/launcher/components/__init__.py b/modules/launcher/components/__init__.py index e5f7be3..e542a03 100644 --- a/modules/launcher/components/__init__.py +++ b/modules/launcher/components/__init__.py @@ -8,3 +8,4 @@ from .sh import * from .todo import * from .wallpapers import * from .wifi import * +from .calendar import * diff --git a/modules/launcher/components/calendar.py b/modules/launcher/components/calendar.py new file mode 100644 index 0000000..fdf712e --- /dev/null +++ b/modules/launcher/components/calendar.py @@ -0,0 +1,199 @@ +import calendar +from datetime import datetime +import gi +from snippets import MaterialIcon +from fabric.widgets.label import Label + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib + + +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=MaterialIcon("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=MaterialIcon("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/dashboard.py b/modules/launcher/components/dashboard.py deleted file mode 100644 index d266e0e..0000000 --- a/modules/launcher/components/dashboard.py +++ /dev/null @@ -1,242 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.label import Label -from snippets import MaterialIcon - - -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, - ) - - 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), - ] - ), - ], - ), - ], - ), - ) - - self.network_menu_button = Button( - name="network-menu-button", - child=MaterialIcon("chevron_right"), - ) - - self.network_button = Box( - name="network-button", - orientation="h", - h_align="fill", - v_align="center", - h_expand=True, - v_expand=True, - 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( - name="bluetooth-button", - orientation="h", - h_align="fill", - v_align="center", - h_expand=True, - v_expand=True, - children=[ - self.bluetooth_status_button, - self.bluetooth_menu_button, - ], - ) - - 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", - h_expand=True, - v_expand=True, - visible=True, - all_visible=True, - ) - - self.buttons = Buttons() - - self.add(self.buttons) - - self.show_all() diff --git a/modules/launcher/components/wallpapers.py b/modules/launcher/components/wallpapers.py index 2a3aa9c..0085c97 100644 --- a/modules/launcher/components/wallpapers.py +++ b/modules/launcher/components/wallpapers.py @@ -3,10 +3,11 @@ import json import os from PIL import Image from fabric.widgets.box import Box +from fabric.widgets.centerbox import CenterBox from fabric.widgets.button import Button from fabric.widgets.entry import Entry from fabric.widgets.scrolledwindow import ScrolledWindow -from gi.repository import GdkPixbuf, GLib, Gtk, Gio +from gi.repository import GdkPixbuf, GLib, Gtk, Gio, Gdk from snippets import MaterialIcon from fabric.utils import exec_shell_command, get_relative_path import concurrent.futures @@ -30,10 +31,16 @@ class WallpaperSelector(Box): } 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)] @@ -41,10 +48,13 @@ class WallpaperSelector(Box): self.thumbnail_queue = [] self.executor = ThreadPoolExecutor(max_workers=4) # Shared executor - self.viewport = Gtk.IconView() + self.selected_index = -1 + + # IconView for displaying thumbnails. + 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_text_column(-1) self.viewport.set_item_width(0) self.viewport.connect("item-activated", self.on_wallpaper_selected) @@ -57,10 +67,12 @@ class WallpaperSelector(Box): ) 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.search_entry.connect("focus-out-event", self.on_search_entry_focus_out) self.scheme_dropdown = Gtk.ComboBoxText() self.scheme_dropdown.set_name("scheme-dropdown") @@ -72,7 +84,6 @@ class WallpaperSelector(Box): 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), @@ -82,18 +93,17 @@ class WallpaperSelector(Box): 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.search_entry], + end_children=[self.dropdown_box, self.toggle_button], ) self.add(self.header_box) @@ -101,6 +111,7 @@ class WallpaperSelector(Box): self._start_thumbnail_thread() self.setup_file_monitor() self.show_all() + self.search_entry.grab_focus() def setup_file_monitor(self): gfile = Gio.File.new_for_path(WALLPAPERS_DIR) @@ -135,11 +146,9 @@ class WallpaperSelector(Box): print(f"Error deleting cache for changed file {file_name}: {e}") self.executor.submit(self._process_file, file_name) - def close_selector(self): - self.launcher.close() - def arrange_viewport(self, query: str = ""): - self.viewport.get_model().clear() + model = self.viewport.get_model() + model.clear() filtered_thumbnails = [ (thumb, name) for thumb, name in self.thumbnails @@ -147,32 +156,14 @@ class WallpaperSelector(Box): ] filtered_thumbnails.sort(key=lambda x: x[1].lower()) - for pixbuf, file_name in filtered_thumbnails: - self.viewport.get_model().append([pixbuf, file_name]) + 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" + if 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() @@ -214,6 +205,76 @@ class WallpaperSelector(Box): except Exception as error: print(f"Failed to update generation-scheme in settings.json: {error}") + 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): + schemes_list = list(self.SCHEMES.keys()) + current_id = self.scheme_dropdown.get_active_id() + current_index = ( + schemes_list.index(current_id) if current_id in schemes_list else 0 + ) + if event.keyval == Gdk.KEY_Up: + new_index = (current_index - 1) % len(schemes_list) + else: + new_index = (current_index + 1) % len(schemes_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 + + 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 + # Calculate columns based on the allocation width. + allocation = self.viewport.get_allocation() + item_width = 108 + columns = max(1, allocation.width // item_width) + + if keyval == Gdk.KEY_Right: + new_index = current_index + 1 + elif keyval == Gdk.KEY_Left: + new_index = current_index - 1 + elif keyval == Gdk.KEY_Down: + new_index = current_index + columns + elif keyval == Gdk.KEY_Up: + new_index = current_index - columns + + # Ensure new_index remains within limits. + if new_index < 0: + new_index = 0 + if new_index >= total_items: + new_index = total_items - 1 + + self.update_selection(new_index) + + 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) @@ -231,8 +292,15 @@ 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") + width, height = img.size + side = min(width, height) + left = (width - side) // 2 + top = (height - side) // 2 + right = left + side + bottom = top + side + img_cropped = img.crop((left, top, right, bottom)) + img_cropped.thumbnail((96, 96), Image.Resampling.LANCZOS) + img_cropped.save(cache_path, "PNG") except Exception as e: print(f"Error processing {file_name}: {e}") return @@ -262,3 +330,25 @@ class WallpaperSelector(Box): return file_name.lower().endswith( (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") ) + + def on_search_entry_focus_out(self, widget, event): + if self.get_mapped(): + widget.grab_focus() + return False + + def toggle_dark_mode(self, *_): + current_state = self.check_dark_mode_state() + 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) + icon_name = "dark_mode" if new_mode == "dark" else "light_mode" + 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" diff --git a/modules/launcher/launcher.py b/modules/launcher/launcher.py index 0287a61..d672865 100644 --- a/modules/launcher/launcher.py +++ b/modules/launcher/launcher.py @@ -1,8 +1,6 @@ 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 modules.launcher.components import ( AppLauncher, BluetoothConnections, @@ -13,6 +11,7 @@ from modules.launcher.components import ( TodoManager, WallpaperSelector, WifiManager, + Calendar, ) @@ -35,6 +34,7 @@ class Launcher(Window): self.bluetooth = BluetoothConnections() self.sh = Sh(launcher=self) self.wifi = WifiManager() + self.calendar = Calendar() self.stack = Stack( name="launcher-content", @@ -52,6 +52,7 @@ class Launcher(Window): self.bluetooth, self.sh, self.wifi, + self.calendar, ], ) @@ -64,7 +65,6 @@ class Launcher(Window): self.add(self.launcher_box) self.show_all() self.hide() - self.wallpapers.viewport.hide() self.add_keybinding("Escape", lambda *_: self.close()) def close(self): @@ -81,11 +81,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 +96,7 @@ class Launcher(Window): "bluetooth", "sh", "wifi", + "calendar", ]: self.stack.remove_style_class(style) @@ -118,6 +116,7 @@ class Launcher(Window): "bluetooth": self.bluetooth, "sh": self.sh, "wifi": self.wifi, + "calendar": self.calendar, } for w in widgets.values(): @@ -130,13 +129,6 @@ 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("") @@ -146,7 +138,6 @@ class Launcher(Window): 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/osd.py b/modules/osd.py index c2764cb..8092959 100644 --- a/modules/osd.py +++ b/modules/osd.py @@ -1,228 +1,113 @@ 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, GObject +from fabric.widgets.centerbox import CenterBox from services import brightness, audio +from snippets import Animator, MaterialIcon -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, + margin="0 0 10px 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/styles/launcher.css b/styles/launcher.css index 7f0ebdc..db2e522 100644 --- a/styles/launcher.css +++ b/styles/launcher.css @@ -43,6 +43,11 @@ min-height: 52px; } +#launcher-content.calendar { + min-height: 420px; + min-width: 420px; +} + #launcher-content { min-width: 420px; } @@ -63,7 +68,7 @@ color: @onSecondaryContainer; margin: 11px; margin-left: 0px; - min-width: 54px; + min-width: 48px; min-height: 44px; border-radius: 100%; } @@ -125,6 +130,9 @@ /* * ── wallpaper ───────────────────────────────────────────────────────── */ +#search-entry-walls { + min-width: 300px; +} #wallpaper-icons:selected { background-color: @surfaceBright; @@ -410,3 +418,80 @@ 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: 41px; + min-height: 30px; +} + +#day-empty { + color: @surfaceBright; +} + +#weekday-label { + color: @primary; +} + +#calendar-grid { + border-radius: 16px; + padding: 4px; +} + +#day-label.current-day { + background-color: @primary; + color: @onPrimary; + border-radius: 20px; +} + +#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; +} + +#prev-month-day, +#next-month-day { + /*color: lighter(@surfaceBright);*/ + color: mix(@onSurfaceVariant, transparent, 0.6); +} diff --git a/styles/osd.css b/styles/osd.css index f86f4e2..8664d65 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: @surface; +} + +.osd-hidden { + opacity: 0; + transition: opacity 0.3s ease-in-out; }