diff --git a/.gitignore b/.gitignore index 189e9a7a..514f9428 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ MangoHud.conf modules/cava/files/themes/ modules/spotify/files/Themes/ +axctl.* TODO retro-settings.pyz diff --git a/cmds/system/setup/run.sh b/cmds/system/setup/run.sh index f467847f..566b1ae2 100755 --- a/cmds/system/setup/run.sh +++ b/cmds/system/setup/run.sh @@ -141,6 +141,12 @@ context.modules = [ ] EOF + # Add user to input group for typing sounds (libinput/evtest access) + if ! id -nG "$USER" | tr ' ' '\n' | grep -qx input; then + sudo usermod -aG input "$USER" 2>/dev/null || true + rx_log "info" "Added user to input group" + fi + retro polkit setup --needed -y if [[ ${FIREWALL_ENABLED:-true} == "true" ]]; then sudo systemctl enable nftables 2>/dev/null || true diff --git a/cmds/tools/power.sh b/cmds/tools/power.sh index ba6703a7..f4d4499e 100755 --- a/cmds/tools/power.sh +++ b/cmds/tools/power.sh @@ -232,12 +232,12 @@ cmd_power() { use_bat_s="$sg_bats"; use_bat_b="$sg_batb"; use_bat_p="$sg_batp" else rx_log "info" "Configure custom wattage limits:" - use_ac_s=$(rx_input_numeric "AC Saver (W)" "$sg_ac" 1 200) - use_ac_b=$(rx_input_numeric "AC Balanced (W)" "$sg_bal" 1 200) - use_ac_p=$(rx_input_numeric "AC Performance (W)" "$sg_perf" 1 200) - use_bat_s=$(rx_input_numeric "BAT Saver (W)" "$sg_bats" 1 200) - use_bat_b=$(rx_input_numeric "BAT Balanced (W)" "$sg_batb" 1 200) - use_bat_p=$(rx_input_numeric "BAT Performance (W)" "$sg_batp" 1 200) + use_ac_s=$(rx_input_numeric "AC Saver (W)" "$sg_ac" 1 250) + use_ac_b=$(rx_input_numeric "AC Balanced (W)" "$sg_bal" 1 250) + use_ac_p=$(rx_input_numeric "AC Performance (W)" "$sg_perf" 1 250) + use_bat_s=$(rx_input_numeric "BAT Saver (W)" "$sg_bats" 1 250) + use_bat_b=$(rx_input_numeric "BAT Balanced (W)" "$sg_batb" 1 250) + use_bat_p=$(rx_input_numeric "BAT Performance (W)" "$sg_batp" 1 250) fi rx_setup_summary "󰐋" "Power Setup Summary" \ diff --git a/cmds/tools/settings/core/config.py b/cmds/tools/settings/core/config.py index d6838f3b..95ddbe6e 100644 --- a/cmds/tools/settings/core/config.py +++ b/cmds/tools/settings/core/config.py @@ -246,6 +246,7 @@ class ConfigSections: layer_rules: list[str] | None = None window_rules_nodes: list["Rule"] | None = None layer_rules_nodes: list["Rule"] | None = None + theme_layer_rules: list[str] | None = None gestures: list[str] | None = None @@ -708,6 +709,8 @@ def _build_document(values: dict[str, str], sections: ConfigSections) -> Documen doc.lines.extend(sections.layer_rules_nodes) elif sections.layer_rules: _add_section(doc, "Layer rules", sections.layer_rules) + if sections.theme_layer_rules: + _add_section(doc, "Theme blur rules", sections.theme_layer_rules) # Autostart last: ``exec`` re-runs on every reload, so config later in # the file that affects the exec'd process (env vars, monitor layout) # is already in effect by the time the commands run. diff --git a/cmds/tools/settings/core/shell_config.py b/cmds/tools/settings/core/shell_config.py index ae9f6f0b..efcdb1b4 100644 --- a/cmds/tools/settings/core/shell_config.py +++ b/cmds/tools/settings/core/shell_config.py @@ -90,13 +90,14 @@ def save_shell_json(name: str, data: dict) -> None: "lens": ("Google Lens", "Reverse image search", "system-search-symbolic", None), "shazam": ("Shazam", "Recognize playing music", "shazam-symbolic", "songrec"), "webcam": ("Webcam Overlay", "Floating webcam preview", "camera-video-symbolic", None), + "docker": ("Docker Manager", "Monitor and manage Docker containers", "utilities-system-monitor-symbolic", "docker"), } # Default toolbox order mirrors the current hardcoded layout in ToolsMenu.qml. TOOLBOX_DEFAULT_ORDER: list = [ "screenshot", "screenshots", "separator", "recorder", "recordings", "separator", - "colorpicker", "ocr", "qr", "lens", "shazam", "webcam", + "colorpicker", "ocr", "qr", "lens", "shazam", "webcam", "docker", ] TOOLBOX_MIN_ITEMS = 5 @@ -136,6 +137,8 @@ def save_shell_json(name: str, data: dict) -> None: "battery": ("Battery", "Show battery level", "battery-symbolic"), "clock": ("Time, Weather & Calendar", "Time, date, calendar and weather", "clock-symbolic"), "power": ("Power Button", "Open the power menu", "system-shutdown-symbolic"), + "typingSounds": ("Typing Sounds", "Mechanical keyboard typing sounds", "input-keyboard-symbolic"), + "docker": ("Docker", "Monitor and manage Docker containers", "utilities-system-monitor-symbolic"), } # Unified catalog of every reorderable bar item (left + right), so an item keeps @@ -330,6 +333,7 @@ def _linear_variant(label: str, gradient, *, dot: str = "surface", "fontSize": 14, "monoFont": "Iosevka Nerd Font Mono", "monoFontSize": 14, + "emojiFont": "Noto Color Emoji", "tintIcons": False, "enableCorners": True, "animDuration": 300, @@ -503,11 +507,85 @@ def save_dock(data: dict) -> None: # Mirrors ``modules/retroshell/files/config/defaults/desktop.js``. DESKTOP_DEFAULTS: dict = { "enabled": False, + "showIcons": True, "iconSize": 40, "spacingVertical": 16, "textColor": "overBackground", + "editMode": False, + "perMonitor": False, + "widgetOrder": [], + "widgets": [], } +# Catalog of desktop widgets the user can add/remove. +# ``id`` -> (label, description, icon). Extend this list to add more widgets. +DESKTOP_WIDGET_CATALOG: dict = { + "calendar": ("Calendar 4x4", "Large square month calendar", "x-office-calendar-symbolic"), + "calendar2x4": ("Calendar 2x4", "Wide compact month calendar", "x-office-calendar-symbolic"), + "weather": ("Weather", "Animated weather scene with 7-day forecast", "weather-clear-symbolic"), + "weather2x4": ("Weather 2x4", "Wide weather card with condition disc and forecast", "weather-clear-symbolic"), + "weather2x2": ("Weather 2x2", "Simple square animated weather card", "weather-clear-symbolic"), + "music2x2": ("Music Player 2x2", "Square music player with artwork and controls", "multimedia-player-symbolic"), + "music2x4": ("Music Player 2x4", "Tall music player with circular seek disc and controls", "multimedia-player-symbolic"), + "clockdigital": ("Digital Clock", "Big time and date", "appointment-new-symbolic"), + "clockanalog": ("Analog Clock", "Circular clock face with hands", "appointment-new-symbolic"), + "worldclock": ("World Clock", "Up to 4 timezones side by side", "appointment-new-symbolic"), + "storage": ("Device Storage", "Apple-style storage breakdown card", "drive-harddisk-symbolic"), + "storage2x4": ("Device Storage 2x4", "Wide storage card with per-category sizes", "drive-harddisk-symbolic"), + "network": ("Network Monitor", "Live bandwidth, sparkline and IPs", "network-wireless-symbolic"), + "network2x4": ("Network Monitor 2x4", "Wide bandwidth card with big sparkline", "network-wireless-symbolic"), + "network1x4": ("Network Monitor Slim", "Slim horizontal bandwidth strip", "network-wireless-symbolic"), + "network1x3": ("Network Monitor 1x3", "Compact strip with speeds and toggle", "network-wireless-symbolic"), + "power": ("Power & Performance", "Power draw, thermals and profile switcher", "battery-level-80-symbolic"), + "power1x3": ("Power & Performance 1x3", "Slim strip with profile switcher", "battery-level-80-symbolic"), + "bluetooth": ("Bluetooth", "Connected devices, battery and quick pair", "bluetooth-symbolic"), + "note": ("Note", "Pinned editable note on the desktop", "text-x-generic-symbolic"), + "batteryring": ("Battery Rings", "Apple-style circular battery gauge", "battery-symbolic"), + "batteryring2x4": ("Battery Rings 2x4", "Battery rings for up to 4 devices", "battery-symbolic"), + "feed": ("Dev Feed", "Full-cover article feed (DEV.to / Hacker News / daily.dev)", "globe-symbolic"), + "sysmonitor": ("System Monitor", "CPU, RAM and disk usage", "utilities-system-monitor-symbolic"), + "sysmonitor2x4": ("System Monitor 2x4", "CPU/GPU/RAM sparkline graph", "utilities-system-monitor-symbolic"), + "battery": ("Battery", "Battery level and charging state", "battery-symbolic"), + "photo": ("Photo 2x2", "Display an image on your desktop", "image-x-generic-symbolic"), + "photo2x4": ("Photo 2x4", "Display a wide image on your desktop", "image-x-generic-symbolic"), + "photo4x2": ("Photo 4x2", "Display a portrait image on your desktop", "image-x-generic-symbolic"), +} + +# Fixed default size (px) for each widget type, used when none is stored. +DESKTOP_WIDGET_SIZES: dict = { + "calendar": (320, 320), + "calendar2x4": (320, 160), + "weather": (320, 240), + "weather2x4": (320, 160), + "weather2x2": (160, 160), + "music2x2": (160, 160), + "music2x4": (320, 160), + "clockdigital": (160, 160), + "clockanalog": (160, 160), + "worldclock": (320, 160), + "storage": (160, 160), + "storage2x4": (320, 160), + "network": (160, 160), + "network2x4": (320, 160), + "network1x4": (320, 80), + "network1x3": (240, 80), + "power": (160, 160), + "power1x3": (240, 80), + "bluetooth": (160, 160), + "note": (160, 160), + "batteryring": (160, 160), + "batteryring2x4": (320, 160), + "feed": (320, 160), + "sysmonitor": (160, 160), + "sysmonitor2x4": (320, 160), + "battery": (160, 160), + "photo": (160, 160), + "photo2x4": (320, 160), + "photo4x2": (160, 320), +} + +DESKTOP_MIN_WIDGETS = 0 + def desktop_path() -> Path: return shell_config_dir() / "desktop.json" @@ -521,6 +599,45 @@ def save_desktop(data: dict) -> None: save_shell_json("desktop", data) +# ── desktop_widgets.json ──────────────────────────────────────────────── +# Dedicated file for desktop widget instances. Kept separate from +# desktop.json because Quickshell's JsonAdapter does not load top-level +# array properties; the shell's DesktopWidgets reads/writes this file with +# manual JSON so the widget list/positions round-trip reliably. + +def desktop_widgets_path() -> Path: + return shell_config_dir() / "desktop_widgets.json" + + +def load_desktop_widgets() -> list[dict]: + """Return the list of desktop widget instances ``[{id,type,x,y,width,height}]``.""" + try: + data = json.loads(desktop_widgets_path().read_text()) + except (json.JSONDecodeError, OSError): + return [] + if isinstance(data, dict) and isinstance(data.get("widgets"), list): + return data["widgets"] + return [] + + +def save_desktop_widgets(widgets: list[dict]) -> None: + """Atomically write the desktop widget instances to desktop_widgets.json.""" + path = desktop_widgets_path() + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".desktop_widgets.", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump({"widgets": widgets}, fh, indent=1) + fh.write("\n") + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + # ── system.json (Misc/OCR) ────────────────────────────────────────────── # OCR defaults from ``modules/retroshell/files/config/defaults/system.js``. @@ -864,7 +981,7 @@ def save_tools(data: dict) -> None: # hot-action buttons. Groups are all reorderable but never removable; the # QuickControls buttons can be added/removed down to DASHBOARD_MIN_CONTROLS. DASHBOARD_WIDGET_IDS = ("player", "quickactions", "notifications", "controls") -DASHBOARD_CONTROL_IDS = ("wifi", "bluetooth", "quickshare", "caffeine", "darkmode", "nightlight") +DASHBOARD_CONTROL_IDS = ("wifi", "bluetooth", "quickshare", "caffeine", "typingSounds", "darkmode", "nightlight", "docker") DASHBOARD_MIN_CONTROLS = 5 DASHBOARD_DEFAULTS: dict = { @@ -904,3 +1021,74 @@ def load_notifications() -> dict: def save_notifications(data: dict) -> None: save_shell_json("notifications", data) + + +# ── typing_sounds.json ──────────────────────────────────────────────── + +# Bundled sound pack directory inside the retroshell module. +TYPING_SOUNDS_PACKS_DIR = Path("/opt/retrolinux/modules/retroshell/files/assets/typing-sounds-soundpacks") + +# Catalog of bundled sound packs: id -> display name. +# Scanned dynamically at runtime; this dict provides fallback names. +TYPING_SOUNDS_PACK_NAMES: dict = { + "nk-cream": "NK Cream", + "cherrymx-black-abs": "Cherry MX Black (ABS)", + "cherrymx-black-pbt": "Cherry MX Black (PBT)", + "cherrymx-blue-abs": "Cherry MX Blue (ABS)", + "cherrymx-blue-pbt": "Cherry MX Blue (PBT)", + "cherrymx-brown-abs": "Cherry MX Brown (ABS)", + "cherrymx-brown-pbt": "Cherry MX Brown (PBT)", + "cherrymx-red-abs": "Cherry MX Red (ABS)", + "cherrymx-red-pbt": "Cherry MX Red (PBT)", + "cream-travel": "Cream Travel", + "eg-crystal-purple": "EG Crystal Purple", + "eg-oreo": "EG Oreo", + "holy-pandas": "Holy Pandas", + "mxblack-travel": "MX Black Travel", + "mxblue-travel": "MX Blue Travel", + "mxbrown-travel": "MX Brown Travel", + "topre-purple-hybrid-pbt": "Topre Purple Hybrid (PBT)", + "turquoise": "Turquoise", +} + +TYPING_SOUNDS_DEFAULTS: dict = { + "enabled": False, + "volume": 100, + "mouseEnabled": False, + "selectedPackId": "nk-cream", + "selectedDevicePath": "all", +} + + +def typing_sounds_path() -> Path: + return shell_config_dir() / "typing_sounds.json" + + +def load_typing_sounds() -> dict: + return load_shell_json("typing_sounds", TYPING_SOUNDS_DEFAULTS) + + +def save_typing_sounds(data: dict) -> None: + save_shell_json("typing_sounds", data) + + +def scan_typing_sound_packs() -> list[dict]: + """Return list of {id, name} for every bundled sound pack.""" + packs: list[dict] = [] + if not TYPING_SOUNDS_PACKS_DIR.is_dir(): + return packs + for d in sorted(TYPING_SOUNDS_PACKS_DIR.iterdir()): + if not d.is_dir(): + continue + config_file = d / "config.json" + if config_file.exists(): + try: + import json + cfg = json.loads(config_file.read_text()) + name = cfg.get("name", d.name) + except (json.JSONDecodeError, OSError): + name = TYPING_SOUNDS_PACK_NAMES.get(d.name, d.name) + else: + name = TYPING_SOUNDS_PACK_NAMES.get(d.name, d.name) + packs.append({"id": d.name, "name": name}) + return packs diff --git a/cmds/tools/settings/pages/bluetooth.py b/cmds/tools/settings/pages/bluetooth.py index 1f6f830a..a7f60f4a 100644 --- a/cmds/tools/settings/pages/bluetooth.py +++ b/cmds/tools/settings/pages/bluetooth.py @@ -58,7 +58,10 @@ def __init__(self, mac: str, name: str, cat: str, connected: bool, can_send: bool = False, on_connect=None, on_disconnect=None, on_forget=None, on_send=None, on_trust=None, on_pair=None, - radio_on: bool = True, battery: int | None = None): + radio_on: bool = True, battery: int | None = None, + profile_options: list[str] | None = None, + current_profile_idx: int = 0, + on_profile_changed=None): super().__init__() self.mac = mac self.name = name @@ -66,6 +69,9 @@ def __init__(self, mac: str, name: str, cat: str, connected: bool, self.connected = connected self.audio_capable = audio_capable self._on_trust = on_trust + self._on_profile_changed = on_profile_changed + self._profile_dropdown: Gtk.DropDown | None = None + self._profile_handler_id: int = 0 box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) box.set_margin_top(6) @@ -120,6 +126,17 @@ def __init__(self, mac: str, name: str, cat: str, connected: bool, self._progress_bar.set_visible(False) btn_box.append(self._progress_bar) + if profile_options and connected and not is_nearby: + profile_model = Gtk.StringList.new(profile_options) + self._profile_dropdown = Gtk.DropDown(model=profile_model) + self._profile_dropdown.set_size_request(120, -1) + self._profile_dropdown.set_tooltip_text("Audio profile") + self._profile_dropdown.set_valign(Gtk.Align.CENTER) + self._profile_handler_id = self._profile_dropdown.connect( + "notify::selected", self._on_profile_selected) + self._profile_dropdown.set_selected(min(current_profile_idx, len(profile_options) - 1)) + btn_box.append(self._profile_dropdown) + if is_nearby: if on_pair: pair_btn = Gtk.Button(label="Pair") @@ -213,6 +230,18 @@ def hide_send_progress(self) -> None: self._progress_bar.set_fraction(0.0) self._progress_bar.set_text("") + def _on_profile_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._on_profile_changed: + self._on_profile_changed(self.mac, dropdown.get_selected()) + + def update_profile_options(self, options: list[str], current_idx: int = 0) -> None: + if self._profile_dropdown is None: + return + self._profile_dropdown.handler_block(self._profile_handler_id) + self._profile_dropdown.set_model(Gtk.StringList.new(options)) + self._profile_dropdown.set_selected(min(current_idx, len(options) - 1)) + self._profile_dropdown.handler_unblock(self._profile_handler_id) + class BluetoothPage: def __init__(self, window: "RetroSettingsWindow"): @@ -231,6 +260,8 @@ def __init__(self, window: "RetroSettingsWindow"): self._paired_listbox: Gtk.ListBox | None = None self._device_rows: dict[str, _DeviceRow] = {} self._send_progress: dict[str, float] = {} + self._audio_profile_info: dict[str, dict] = {} + self._audio_profile_map: dict[str, list[tuple]] = {} def build(self, header: Adw.HeaderBar) -> Adw.ToolbarView: toolbar_view, _, self._content_box, _ = make_page_layout(header=header) @@ -557,6 +588,8 @@ def _rebuild_device_list(self) -> None: self._paired_listbox.remove(child) self._device_rows.clear() + self._audio_profile_info.clear() + self._audio_profile_map.clear() for dev in self._paired_devices: mac = dev["mac"] @@ -566,13 +599,14 @@ def _rebuild_device_list(self) -> None: capture_output=True, text=True, timeout=3, stdin=subprocess.DEVNULL, ).stdout.find("Trusted: yes") != -1 can_send = cat in ("phone", "computer") + is_audio = self._is_audio(cat) row = _DeviceRow( mac=mac, name=dev["name"], cat=cat, connected=dev["connected"], trusted=is_trusted, - audio_capable=self._is_audio(cat), + audio_capable=is_audio, can_send=can_send, radio_on=self._status.get("radio") == "yes", on_connect=self._connect_device, @@ -581,6 +615,8 @@ def _rebuild_device_list(self) -> None: on_send=self._send_file, on_trust=self._toggle_trust, battery=dev.get("battery"), + profile_options=["Auto (System Default)"] if (is_audio and dev["connected"]) else None, + on_profile_changed=self._on_audio_profile_changed if is_audio else None, ) self._paired_listbox.append(row) self._device_rows[mac] = row @@ -600,10 +636,105 @@ def _rebuild_device_list(self) -> None: empty_lbl.set_halign(Gtk.Align.CENTER) self._paired_listbox.append(empty_lbl) + self._load_audio_profiles_async() + + def _update_existing_rows(self) -> None: + for dev in self._paired_devices: + mac = dev["mac"] + row = self._device_rows.get(mac) + if row is None: + continue + was_connected = row.connected + row.connected = dev["connected"] + row.set_battery(dev.get("battery")) + if was_connected != dev["connected"]: + row.set_connected(dev["connected"]) + @staticmethod def _is_audio(cat: str) -> bool: return cat in ("audio", "audio+input") + @staticmethod + def _parse_profile_info(raw: str) -> dict | None: + if not raw or raw == "NO_CARD": + return None + result: dict = {"card": "", "active_profile": "", "codec": "", "profiles": []} + for line in raw.splitlines(): + parts = line.split("|") + if parts[0] == "CARD" and len(parts) > 1: + result["card"] = parts[1] + elif parts[0] == "ACTIVE" and len(parts) > 1: + result["active_profile"] = parts[1] + elif parts[0] == "CODEC" and len(parts) > 1: + result["codec"] = parts[1] + elif parts[0] == "PROFILE" and len(parts) >= 4: + result["profiles"].append((parts[1], parts[2] == "1", parts[3])) + return result if result["card"] else None + + def _load_audio_profiles(self, mac: str) -> dict | None: + raw = _run(["--profile-info", mac], timeout=5) + return self._parse_profile_info(raw) + + def _get_forced_profile_idx(self, mac: str, profiles: list[tuple]) -> int: + from lib.python.variable import get_var + mac_key = mac.replace(":", "_") + forced = get_var(f"BT_FORCE_PROFILE_{mac_key}", "") + if not forced: + return 0 + forced_profile = forced.split("|")[0] if "|" in forced else forced + for i, (name, _, _) in enumerate(profiles): + if name == forced_profile: + return i + 1 + return 0 + + def _on_audio_profile_changed(self, mac: str, selected_idx: int) -> None: + from lib.python.variable import get_var, set_var + info = self._audio_profile_info.get(mac) + if not info: + return + mac_key = mac.replace(":", "_") + current_var = get_var(f"BT_FORCE_PROFILE_{mac_key}", "") + if selected_idx == 0: + if not current_var: + return + set_var(f"BT_FORCE_PROFILE_{mac_key}", "") + self._window.show_toast("Audio profile set to Auto") + else: + profiles = info["profiles"] + if selected_idx - 1 >= len(profiles): + return + profile_name, _, display = profiles[selected_idx - 1] + codec = profile_name.rsplit("-", 1)[-1] if profile_name.count("-") >= 2 else "" + new_val = f"{profile_name}|{codec}" + if current_var == new_val: + return + set_var(f"BT_FORCE_PROFILE_{mac_key}", new_val) + _run(["--profile-set", mac, profile_name, codec], timeout=5) + self._window.show_toast(f"Profile forced: {display}") + + def _load_audio_profiles_async(self) -> None: + audio_devices = [d for d in self._paired_devices + if d["connected"] and self._is_audio(d["cat"])] + if not audio_devices: + return + + def worker(): + for dev in audio_devices: + mac = dev["mac"] + info = self._load_audio_profiles(mac) + if info and info["profiles"]: + self._audio_profile_info[mac] = info + options = ["Auto (System Default)"] + [p[2] for p in info["profiles"]] + current_idx = self._get_forced_profile_idx(mac, info["profiles"]) + self._audio_profile_map[mac] = info["profiles"] + GLib.idle_add(self._apply_audio_profile_to_row, mac, options, current_idx) + threading.Thread(target=worker, daemon=True).start() + + def _apply_audio_profile_to_row(self, mac: str, options: list[str], current_idx: int) -> None: + row = self._device_rows.get(mac) + if row: + row.update_profile_options(options, current_idx) + def _connect_device(self, mac: str) -> None: def do_connect(): res = _run(["--connect", mac], timeout=10) @@ -754,9 +885,15 @@ def _apply_status(self, output: str, paired: list) -> None: self._sidebar_power_switch.set_active(radio_on) self._setting_value = False - # Rebuild paired device list with current connection states + battery - self._paired_devices = paired - self._rebuild_device_list() + old_macs = {d["mac"]: d for d in self._paired_devices} + new_macs = {d["mac"]: d for d in paired} + + if old_macs.keys() != new_macs.keys(): + self._paired_devices = paired + self._rebuild_device_list() + else: + self._paired_devices = paired + self._update_existing_rows() # ── Save lifecycle ── diff --git a/cmds/tools/settings/pages/disk.py b/cmds/tools/settings/pages/disk.py index e65f9b10..76b51054 100644 --- a/cmds/tools/settings/pages/disk.py +++ b/cmds/tools/settings/pages/disk.py @@ -240,9 +240,6 @@ def _draw_line(cr, data, margin, plot_w, scale_y, color): cr.stroke() -# ── Main page ── - - class DiskPage: """Disk management page with health info, mount actions, and setup toggles.""" @@ -312,7 +309,12 @@ def _load_data(self) -> None: )) except (subprocess.TimeoutExpired, FileNotFoundError, OSError): pass - self._disks.sort(key=lambda d: 1 if "USB" in d.disk_type or "Removable" in d.disk_type else 0) + self._disks.sort(key=lambda d: ( + 0 if any(m.strip() == "/" for m in d.mounts.split(",")) else + 1 if "NVMe" in d.disk_type or "SSD" in d.disk_type else + 2 if "USB" in d.disk_type or "Removable" in d.disk_type else + 3 + )) self._load_setup_config() def _load_setup_config(self) -> None: @@ -427,17 +429,48 @@ def _update_values(self) -> None: w["temp_lbl"].set_label(disk.temp) try: - uv = float(disk.used_pct.rstrip("%")) - w["used_bar"].set_value(uv) - w["used_bar"].remove_css_class("level-bar-critical") - w["used_bar"].remove_css_class("level-bar-warning") - if uv > 95: - w["used_bar"].add_css_class("level-bar-critical") - elif uv > 80: - w["used_bar"].add_css_class("level-bar-warning") - except ValueError: - w["used_bar"].set_value(0) - w["used_pct_lbl"].set_label(disk.used_pct) + pct_val = float(disk.used_pct.rstrip("%") or 0) + size_raw = disk.size.upper().strip() + + total_gb = 0.0 + if "PB" in size_raw: + total_gb = float(size_raw.replace("PB", "").replace("P", "").strip()) * 1024 * 1024 + elif "TB" in size_raw: + total_gb = float(size_raw.replace("TB", "").replace("T", "").strip()) * 1024 + elif "GB" in size_raw: + total_gb = float(size_raw.replace("GB", "").replace("G", "").strip()) + elif "MB" in size_raw: + total_gb = float(size_raw.replace("MB", "").replace("M", "").strip()) / 1024 + else: + if size_raw.endswith("G"): + total_gb = float(size_raw[:-1]) + elif size_raw.endswith("T"): + total_gb = float(size_raw[:-1]) * 1024 + elif size_raw.endswith("M"): + total_gb = float(size_raw[:-1]) / 1024 + else: + total_gb = float(size_raw) + + used_gb_total = (pct_val / 100.0) * total_gb + + if used_gb_total >= 1024 * 1024: + used_val = used_gb_total / (1024 * 1024) + used_unit = "PB" + elif used_gb_total >= 1024: + used_val = used_gb_total / 1024 + used_unit = "TB" + elif used_gb_total >= 1: + used_val = used_gb_total + used_unit = "GB" + else: + used_val = used_gb_total * 1024 + used_unit = "MB" + + used_text = f"{disk.used_pct} - {used_val:.1f}{used_unit}" + except Exception: + used_text = disk.used_pct + + w["used_pct_lbl"].set_label(used_text) def _update_sidebar(self) -> None: update_disk_sidebar(self._window, self._disks) @@ -571,25 +604,56 @@ def _populate_disks(self, group: Adw.PreferencesGroup) -> None: temp_row = self._info_row("Temperature", temp_lbl) expander.add_row(temp_row) - used_bar = Gtk.LevelBar() - used_bar.set_min_value(0) - used_bar.set_max_value(100) - used_bar.set_size_request(200, 8) + # Usage info (e.g., "24% - 120GB") try: - uv = float(disk.used_pct.rstrip("%")) - used_bar.set_value(uv) - if uv > 95: - used_bar.add_css_class("level-bar-critical") - elif uv > 80: - used_bar.add_css_class("level-bar-warning") - except ValueError: - used_bar.set_value(0) - used_pct_lbl = Gtk.Label(label=disk.used_pct) - - used_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - used_box.append(used_bar) - used_box.append(used_pct_lbl) - expander.add_row(self._info_row("Used", used_box)) + pct_val = float(disk.used_pct.rstrip("%") or 0) + size_raw = disk.size.upper().strip() + + # Convert to GB base + total_gb = 0.0 + if "PB" in size_raw: + total_gb = float(size_raw.replace("PB", "").replace("P", "").strip()) * 1024 * 1024 + elif "TB" in size_raw: + total_gb = float(size_raw.replace("TB", "").replace("T", "").strip()) * 1024 + elif "GB" in size_raw: + total_gb = float(size_raw.replace("GB", "").replace("G", "").strip()) + elif "MB" in size_raw: + total_gb = float(size_raw.replace("MB", "").replace("M", "").strip()) / 1024 + else: + # Handle cases like '500G' + if size_raw.endswith("G"): + total_gb = float(size_raw[:-1]) + elif size_raw.endswith("T"): + total_gb = float(size_raw[:-1]) * 1024 + elif size_raw.endswith("M"): + total_gb = float(size_raw[:-1]) / 1024 + else: + total_gb = float(size_raw) + + used_gb_total = (pct_val / 100.0) * total_gb + + if used_gb_total >= 1024 * 1024: + used_val = used_gb_total / (1024 * 1024) + used_unit = "PB" + elif used_gb_total >= 1024: + used_val = used_gb_total / 1024 + used_unit = "TB" + elif used_gb_total >= 1: + used_val = used_gb_total + used_unit = "GB" + else: + used_val = used_gb_total * 1024 + used_unit = "MB" + + used_text = f"{disk.used_pct} - {used_val:.1f}{used_unit}" + except Exception: + used_text = disk.used_pct + + used_row = Adw.ActionRow(title="Used") + used_pct_lbl = Gtk.Label(label=used_text) + used_pct_lbl.set_halign(Gtk.Align.END) + used_row.add_suffix(used_pct_lbl) + expander.add_row(used_row) self._populate_mounts(expander, disk) @@ -599,7 +663,6 @@ def _populate_disks(self, group: Adw.PreferencesGroup) -> None: "expander": expander, "health_lbl": health_lbl, "temp_lbl": temp_lbl, - "used_bar": used_bar, "used_pct_lbl": used_pct_lbl, "health_icon": health_icon, "wear_lbl": wear_lbl, @@ -607,9 +670,16 @@ def _populate_disks(self, group: Adw.PreferencesGroup) -> None: def _populate_mounts(self, expander: Adw.ExpanderRow, disk: DiskInfo) -> None: if not disk.mounts.strip(): - if "USB" in disk.disk_type or "Removable" in disk.disk_type: + is_removable = "USB" in disk.disk_type or "Removable" in disk.disk_type + is_ssd = "SSD" in disk.disk_type or "NVMe" in disk.disk_type + + if is_removable or is_ssd: row = Adw.ActionRow(title="Not mounted") - row.set_subtitle("Removable drive detected — no active mount points") + if is_removable: + row.set_subtitle("Removable drive detected — no active mount points") + else: + row.set_subtitle("SSD/NVMe detected — no active mount points") + mount_btn = Gtk.Button(label="Auto Mount") mount_btn.set_valign(Gtk.Align.CENTER) mount_btn.add_css_class("suggested-action") @@ -772,6 +842,20 @@ def _show_mount_dialog(self) -> None: def _mount_cb(self, dev: str, path: str | None) -> None: self._mount(dev, path) + def _persist_mount(self, dev: str, path: str | None) -> None: + cmd = ["pkexec", "bash", _DISK_CORE, "--auto-mount-persist", dev] + if path: + cmd.append(path) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, stdin=subprocess.DEVNULL) + lines = result.stdout.strip().splitlines() + if result.returncode == 0 and any(l.startswith("OK|") for l in lines): + self._window.show_toast(f"Persistent mount configured for /dev/{dev}") + else: + self._window.show_toast(f"Persistent mount failed", timeout=5) + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e: + self._window.show_toast(f"Persistent mount failed — {e}", timeout=5) + def _auto_mount(self, dev: str) -> None: try: result = subprocess.run( @@ -785,12 +869,12 @@ def _auto_mount(self, dev: str) -> None: parts = [dev] for part in parts: - self._mount(part, None) + self._persist_mount(part, None) GLib.timeout_add(1500, self._refresh) def _mount(self, dev: str, path: str | None) -> None: - cmd = ["bash", _DISK_CORE, "--mount", dev] + cmd = ["pkexec", "bash", _DISK_CORE, "--mount", dev] if path: cmd.append(path) try: @@ -820,7 +904,7 @@ def _unmount(self, mount_path: str) -> None: def do_unmount(): try: result = subprocess.run( - ["bash", _DISK_CORE, "--umount", mount_path], + ["pkexec", "bash", _DISK_CORE, "--umount", mount_path], capture_output=True, text=True, timeout=30, stdin=subprocess.DEVNULL, ) lines = result.stdout.strip().splitlines() diff --git a/cmds/tools/settings/pages/power.py b/cmds/tools/settings/pages/power.py index 34d300e6..f2da08ab 100644 --- a/cmds/tools/settings/pages/power.py +++ b/cmds/tools/settings/pages/power.py @@ -340,7 +340,7 @@ def build(self, header: Adw.HeaderBar) -> Adw.ToolbarView: ("BAT Performance", "Battery power limit for maximum performance mode", "PWR_BAT_PERFORMANCE", "bat_performance"), ]: val = int(self._orig.get(key, "15")) if self._orig.get(key, "").isdigit() else 15 - adj = Gtk.Adjustment(value=val, lower=1, upper=200, step_increment=1, page_increment=5) + adj = Gtk.Adjustment(value=val, lower=1, upper=250, step_increment=1, page_increment=5) row = Adw.ActionRow(title=label, subtitle=desc) spin = Gtk.SpinButton(adjustment=adj, digits=0) spin.set_valign(Gtk.Align.CENTER) diff --git a/cmds/tools/settings/pages/shell_dashboard.py b/cmds/tools/settings/pages/shell_dashboard.py index 4fd996ee..f93df0aa 100644 --- a/cmds/tools/settings/pages/shell_dashboard.py +++ b/cmds/tools/settings/pages/shell_dashboard.py @@ -58,8 +58,10 @@ "bluetooth": "Bluetooth", "quickshare": "Quick Share", "caffeine": "Caffeine", + "typingSounds": "Typing Sounds", "darkmode": "Dark Mode", "nightlight": "Night Light", + "docker": "Docker", } _CONTROL_DESCRIPTIONS = { @@ -67,8 +69,10 @@ "bluetooth": "Toggle Bluetooth and manage paired devices", "quickshare": "Toggle Quick Share file transfer", "caffeine": "Inhibit sleep with an optional timer", + "typingSounds": "Mechanical keyboard typing sounds", "darkmode": "Switch between dark and light theme", "nightlight": "Toggle the warm night-light filter", + "docker": "Manage Docker containers and compose projects", } _CONTROL_ICONS = { @@ -76,8 +80,10 @@ "bluetooth": "bluetooth-symbolic", "quickshare": "send-to-symbolic", "caffeine": "caffeine-symbolic", + "typingSounds": "input-keyboard-symbolic", "darkmode": "darkmode-symbolic", "nightlight": "nightlight-symbolic", + "docker": "utilities-system-monitor-symbolic", } diff --git a/cmds/tools/settings/pages/shell_desktop.py b/cmds/tools/settings/pages/shell_desktop.py index 7449163b..2bcfece8 100644 --- a/cmds/tools/settings/pages/shell_desktop.py +++ b/cmds/tools/settings/pages/shell_desktop.py @@ -1,26 +1,105 @@ -"""Shell Desktop page — configure desktop icon grid (``desktop.json``). +"""Shell Desktop page — configure the desktop icon grid and desktop widgets. Mirrors the Desktop section of the in-shell ``ShellPanel.qml``. Values are written to ``~/.config/retro/shell/desktop.json``; the shell's ``FileView`` watches that file with ``watchChanges`` and reloads on external writes, so changes apply live without a shell restart. + +In addition to the icon-grid settings this page manages the desktop widget +system: + +- **Edit Mode**: a switch that toggles ``desktop.json.editMode``. While on, + the shell shows drag handles + borders around each widget so the user can + reposition them; positions persist to ``desktop.json.widgets`` as the user + drags. +- **Desktop Widgets**: a reorderable list of the widgets placed on the + desktop. The user can add widgets from ``DESKTOP_WIDGET_CATALOG`` (each is + only addable once) or remove an existing one. """ from collections.abc import Iterable +import json +import os +import shutil +import subprocess +import sys from typing import TYPE_CHECKING +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from gi.repository import Adw, Gtk +from gi.repository import Adw, Gio, GLib, Gtk from settings.core.pending import PendingChange -from settings.core.shell_config import DESKTOP_DEFAULTS, load_desktop, save_desktop -from settings.ui import clear_children, make_page_layout +from settings.core.shell_config import ( + DESKTOP_WIDGET_CATALOG, + DESKTOP_WIDGET_SIZES, + load_desktop, + load_desktop_widgets, + save_desktop, + save_desktop_widgets, +) +from settings.ui import make_inline_hint, make_page_layout from settings.ui.color_combo import COLOR_NAMES, color_map, _draw_swatch_hex from settings.ui.icons import DESKTOP_ICON from settings.ui.managed_row import ManagedRow, make_combo_row, make_spin_int_row +from settings.ui.reorder import RowReorderController if TYPE_CHECKING: from settings.window import RetroSettingsWindow +# Available faces/styles per widget type (matched by the shell widgets). +CLOCK_FACES: dict = { + "clockanalog": ["classic", "numeric", "roman", "dots", "minimal", "sector", "skeleton", "rings", "bold", "tall", "track", "diamond", "bauhaus", "railway"], + "clockdigital": ["classic", "minimal", "compact", "large"], + "battery": ["gauge", "juice", "bars"], +} + +# Hand styles for the analog clock (matched by the shell widget). +CLOCK_HAND_STYLES: dict = { + "clockanalog": ["taper", "classic", "thin"], +} + +CLOCK_HAND_STYLE_LABELS: dict = { + "taper": "Tapered", + "classic": "Classic Lines", + "thin": "Thin", +} + +CLOCK_FACE_LABELS: dict = { + "classic": "Classic", + "numeric": "Numeric", + "roman": "Roman", + "dots": "Dots", + "minimal": "Minimal", + "sector": "Sector", + "skeleton": "Skeleton", + "rings": "Rings", + "bold": "Bold", + "tall": "Tall", + "track": "Track", + "diamond": "Diamond", + "bauhaus": "Bauhaus", + "railway": "Railway", + "compact": "Compact", + "large": "Large", + "gauge": "Gauge", + "juice": "Juice", + "bars": "Bars", +} + + +def _is_valid_timezone(name: str) -> bool: + """Return True if ``name`` is a valid IANA timezone (or UTC/GMT alias).""" + name = (name or "").strip() + if not name: + return False + if name in ("UTC", "GMT", "Etc/UTC", "Etc/GMT"): + return True + try: + ZoneInfo(name) + return True + except (ZoneInfoNotFoundError, ValueError): + return False + def _make_named_color_combo_row(title: str, current: str, *, subtitle: str = ""): ids = list(COLOR_NAMES) @@ -81,6 +160,19 @@ def __init__(self, window: "RetroSettingsWindow"): self._saved = dict(self._data) self._rows: dict[str, ManagedRow] = {} + # Widget list state (stored in desktop_widgets.json) + self._widgets: list[dict] = load_desktop_widgets() + self._saved_widgets: list[dict] = [dict(w) for w in self._widgets] + self._widget_order: list[str] = [w.get("id") for w in self._widgets] + self._saved_widget_order: list[str] = list(self._widget_order) + self._removed_ids: set[str] = set() + self._widget_rows: list[Adw.ActionRow] = [] + self._widget_group: Adw.PreferencesGroup | None = None + self._edit_row: Adw.SwitchRow | None = None + self._widget_reorder = RowReorderController( + move=self._move_widget, iter_rows=lambda: self._widget_rows + ) + def build(self, header: Adw.HeaderBar) -> Adw.ToolbarView: toolbar, _page_box, content_box, _scrolled = make_page_layout(header=header) self._content_box = content_box @@ -90,7 +182,10 @@ def build(self, header: Adw.HeaderBar) -> Adw.ToolbarView: description="Desktop icon grid appearance and behaviour.", ) self._add_switch(group, "enabled", "Enabled", - subtitle="Show desktop icons on the wallpaper") + subtitle="Show the desktop on the wallpaper") + self._add_switch(group, "showIcons", "Show Icons", + subtitle="Display icons on the desktop (off shows only widgets)", + default_value=True) self._add_spin(group, "iconSize", "Icon Size", lower=24, upper=96, suffix="px", subtitle="Size of desktop icons") self._add_spin(group, "spacingVertical", "Vertical Spacing", lower=0, upper=48, suffix="px", @@ -99,12 +194,1007 @@ def build(self, header: Adw.HeaderBar) -> Adw.ToolbarView: subtitle="Theme color for desktop icon labels") content_box.append(group) + # Desktop widget scope (global vs per monitor) + scope_group = Adw.PreferencesGroup( + title="Widget Scope", + description="Whether desktop widgets appear on all monitors or per monitor.", + ) + self._add_switch(scope_group, "perMonitor", "Widgets per monitor", + subtitle="Each monitor shows only the widgets placed on it " + "(drag a widget onto a screen to assign it)", + default_value=False) + content_box.append(scope_group) + + # Desktop widgets (edit mode + widget list in one group) + self._widget_group = Adw.PreferencesGroup( + title="Desktop Widgets", + description="Add widgets to your desktop. Toggle Edit Mode, then drag them around to position them.", + ) + add_btn = Gtk.Button(icon_name="list-add-symbolic") + add_btn.set_valign(Gtk.Align.CENTER) + add_btn.add_css_class("flat") + add_btn.set_tooltip_text("Add a desktop widget") + add_btn.connect("clicked", self._on_add_widget) + self._widget_group.set_header_suffix(add_btn) + + self._edit_row = Adw.SwitchRow( + title="Edit Mode", + subtitle="Show drag handles and reposition desktop widgets", + ) + self._edit_row.set_active(bool(self._data.get("editMode", False))) + + def _on_edit_toggled(_row, *_args): + active = self._edit_row.get_active() + self._data["editMode"] = active + self._notify_dirty() + # Tell the running shell to enter/exit edit mode immediately via + # IPC. Use the absolute shell_core.sh path resolved robustly so + # it works regardless of PATH inside the settings app. + retro_dir = os.environ.get("RETRO_DIR") or "" + if not retro_dir: + retro = shutil.which("retro") + if retro: + retro_dir = os.path.dirname(os.path.dirname(os.path.abspath(retro))) + if not retro_dir: + retro_dir = "/opt/retrolinux" + core = os.path.join(retro_dir, "scripts", "shell_core.sh") + try: + res = subprocess.run( + ["bash", core, "--run", f"desktop-edit {'on' if active else 'off'}"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + timeout=5, + ) + print(f"[ShellDesktop] editMode toggle -> {'on' if active else 'off'} " + f"core={core} rc={res.returncode} out={res.stdout.strip()} err={res.stderr.strip()}", + file=sys.stderr) + except Exception as exc: # never let IPC failure break the toggle + print(f"[ShellDesktop] editMode IPC failed: {exc}", file=sys.stderr) + + self._edit_row.connect("notify::active", _on_edit_toggled) + self._widget_group.add(self._edit_row) + content_box.append(self._widget_group) + content_box.append(make_inline_hint( + "Enable Edit Mode, then drag the widgets on your desktop to position them." + )) + self._rebuild_widgets() + return toolbar + # ── Widget list ── + + def _rebuild_widgets(self, focus_idx: int = -1) -> None: + if self._widget_group is None: + return + group = self._widget_group + for row in self._widget_rows: + group.remove(row) + self._widget_rows = [] + + for idx, wid in enumerate(self._widget_order): + label, desc, icon = DESKTOP_WIDGET_CATALOG.get( + wid, (wid, "", "view-list-symbolic") + ) + row = Adw.ActionRow(title=label, subtitle=desc) + row.set_subtitle_lines(1) + + icon_img = Gtk.Image.new_from_icon_name(icon) + icon_img.set_pixel_size(24) + icon_img.add_css_class("dim-label") + row.add_prefix(icon_img) + + handle = Gtk.Image.new_from_icon_name("drag-handle-symbolic") + handle.set_opacity(0.5) + handle.set_valign(Gtk.Align.CENTER) + row.add_suffix(handle) + + if wid in CLOCK_FACES: + edit_btn = Gtk.Button(icon_name="preferences-system-symbolic") + edit_btn.set_valign(Gtk.Align.CENTER) + edit_btn.add_css_class("flat") + edit_btn.set_tooltip_text("Change face") + edit_btn.connect("clicked", lambda _b, i=idx: self._on_edit_face(i)) + row.add_suffix(edit_btn) + elif wid == "storage": + edit_btn = Gtk.Button(icon_name="preferences-system-symbolic") + edit_btn.set_valign(Gtk.Align.CENTER) + edit_btn.add_css_class("flat") + edit_btn.set_tooltip_text("Choose storage device") + edit_btn.connect("clicked", lambda _b, i=idx: self._on_edit_storage(i)) + row.add_suffix(edit_btn) + elif wid in ("network", "network2x4", "network1x4", "network1x3"): + edit_btn = Gtk.Button(icon_name="preferences-system-symbolic") + edit_btn.set_valign(Gtk.Align.CENTER) + edit_btn.add_css_class("flat") + edit_btn.set_tooltip_text("Network widget options") + edit_btn.connect("clicked", lambda _b, i=idx: self._on_edit_network(i)) + row.add_suffix(edit_btn) + elif wid == "bluetooth": + edit_btn = Gtk.Button(icon_name="preferences-system-symbolic") + edit_btn.set_valign(Gtk.Align.CENTER) + edit_btn.add_css_class("flat") + edit_btn.set_tooltip_text("Bluetooth priority") + edit_btn.connect("clicked", lambda _b, i=idx: self._on_edit_bluetooth(i)) + row.add_suffix(edit_btn) + elif wid == "note": + edit_btn = Gtk.Button(icon_name="preferences-system-symbolic") + edit_btn.set_valign(Gtk.Align.CENTER) + edit_btn.add_css_class("flat") + edit_btn.set_tooltip_text("Choose note") + edit_btn.connect("clicked", lambda _b, i=idx: self._on_edit_note(i)) + row.add_suffix(edit_btn) + elif wid in ("batteryring", "batteryring2x4"): + edit_btn = Gtk.Button(icon_name="preferences-system-symbolic") + edit_btn.set_valign(Gtk.Align.CENTER) + edit_btn.add_css_class("flat") + edit_btn.set_tooltip_text("Battery rings options") + edit_btn.connect("clicked", lambda _b, i=idx: self._on_edit_batteryring(i)) + row.add_suffix(edit_btn) + elif wid == "feed": + edit_btn = Gtk.Button(icon_name="preferences-system-symbolic") + edit_btn.set_valign(Gtk.Align.CENTER) + edit_btn.add_css_class("flat") + edit_btn.set_tooltip_text("Feed options") + edit_btn.connect("clicked", lambda _b, i=idx: self._on_edit_feed(i)) + row.add_suffix(edit_btn) + elif wid in ("photo", "photo2x4", "photo4x2"): + edit_btn = Gtk.Button(icon_name="preferences-system-symbolic") + edit_btn.set_valign(Gtk.Align.CENTER) + edit_btn.add_css_class("flat") + edit_btn.set_tooltip_text("Choose image") + edit_btn.connect("clicked", lambda _b, i=idx: self._on_edit_photo(i)) + row.add_suffix(edit_btn) + elif wid == "worldclock": + edit_btn = Gtk.Button(icon_name="preferences-system-symbolic") + edit_btn.set_valign(Gtk.Align.CENTER) + edit_btn.add_css_class("flat") + edit_btn.set_tooltip_text("Edit timezones") + edit_btn.connect("clicked", lambda _b, i=idx: self._on_edit_timezones(i)) + row.add_suffix(edit_btn) + elif wid == "sysmonitor2x4": + edit_btn = Gtk.Button(icon_name="preferences-system-symbolic") + edit_btn.set_valign(Gtk.Align.CENTER) + edit_btn.add_css_class("flat") + edit_btn.set_tooltip_text("Graph settings") + edit_btn.connect("clicked", lambda _b, i=idx: self._on_edit_sysmonitor2x4(i)) + row.add_suffix(edit_btn) + + remove_btn = Gtk.Button(icon_name="user-trash-symbolic") + remove_btn.set_valign(Gtk.Align.CENTER) + remove_btn.add_css_class("flat") + remove_btn.set_tooltip_text("Remove from desktop") + remove_btn.connect("clicked", lambda _b, i=idx: self._on_remove_widget(i)) + row.add_suffix(remove_btn) + + self._widget_reorder.attach(row, idx) + group.add(row) + self._widget_rows.append(row) + + if focus_idx >= 0 and focus_idx < len(self._widget_rows): + self._widget_rows[focus_idx].grab_focus() + + def _move_widget(self, src: int, dst: int) -> bool: + n = len(self._widgets) + if dst == src or not (0 <= src < n and 0 <= dst < n): + return False + item = self._widgets.pop(src) + self._widgets.insert(dst, item) + self._widget_order = [w.get("id") for w in self._widgets] + self._notify_dirty() + self._rebuild_widgets(dst) + return True + + def _on_add_widget(self, _btn=None) -> None: + available = [w for w in DESKTOP_WIDGET_CATALOG if w not in self._widget_order] + if not available: + self._window.show_toast("All widgets are already added", timeout=2) + return + + group = Adw.PreferencesGroup() + combo = Adw.ComboRow(title="Widget") + combo.set_model(Gtk.StringList.new( + [DESKTOP_WIDGET_CATALOG[w][0] for w in available] + )) + combo.set_selected(0) + group.add(combo) + + dialog = Adw.AlertDialog( + heading="Add Desktop Widget", + extra_child=group, + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("add", "Add") + dialog.set_default_response("add") + dialog.set_close_response("cancel") + + def _on_response(_dialog_obj, response): + if response != "add": + return + idx = combo.get_selected() + if 0 <= idx < len(available): + wid = available[idx] + width, height = DESKTOP_WIDGET_SIZES.get(wid, (280, 300)) + # Position at center by default (0.5, 0.5 normalized). + self._widgets.append({ + "id": wid, + "type": wid, + "x": 0.5, + "y": 0.5, + "width": width, + "height": height, + }) + self._widget_order = [w.get("id") for w in self._widgets] + self._notify_dirty() + self._rebuild_widgets() + + dialog.connect("response", _on_response) + dialog.present(self._window) + + def _on_remove_widget(self, idx: int) -> None: + if idx < 0 or idx >= len(self._widgets): + return + wid = self._widgets[idx].get("id") + self._removed_ids.add(wid) + self._widgets = [w for w in self._widgets if w.get("id") != wid] + self._widget_order = [w.get("id") for w in self._widgets] + self._notify_dirty() + self._rebuild_widgets() + + # ── Clock face editor ── + + def _on_edit_face(self, idx: int) -> None: + """Let the user pick the face/style of a clock or battery widget.""" + if idx < 0 or idx >= len(self._widgets): + return + widget = self._widgets[idx] + wid = widget.get("id") + faces = CLOCK_FACES.get(wid, []) + if not faces: + return + current = widget.get("face") or "classic" + if current not in faces: + current = faces[0] + + group = Adw.PreferencesGroup() + combo = Adw.ComboRow(title="Face") + combo.set_model(Gtk.StringList.new( + [CLOCK_FACE_LABELS.get(f, f) for f in faces] + )) + combo.set_selected(faces.index(current)) + group.add(combo) + + hand_combo = None + hand_ids: list[str] = [] + hand_styles = CLOCK_HAND_STYLES.get(wid, []) + if hand_styles: + hand_ids = list(hand_styles) + cur_hand = widget.get("handStyle") or "taper" + if cur_hand not in hand_ids: + cur_hand = hand_ids[0] + hand_combo = Adw.ComboRow(title="Hand style", + subtitle="How the clock hands are drawn") + hand_combo.set_model(Gtk.StringList.new( + [CLOCK_HAND_STYLE_LABELS.get(h, h) for h in hand_styles] + )) + hand_combo.set_selected(hand_ids.index(cur_hand)) + group.add(hand_combo) + + dialog = Adw.AlertDialog( + heading="Widget Face", + body="Choose the face style for this widget.", + extra_child=group, + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("save", "Save") + dialog.set_default_response("save") + dialog.set_close_response("cancel") + + def _on_response(_dialog_obj, response): + if response != "save": + return + sel = combo.get_selected() + if 0 <= sel < len(faces): + widget["face"] = faces[sel] + if hand_combo is not None: + hs = hand_combo.get_selected() + if 0 <= hs < len(hand_ids): + widget["handStyle"] = hand_ids[hs] + self._notify_dirty() + self._rebuild_widgets() + + dialog.connect("response", _on_response) + dialog.present(self._window) + + # ── Storage device editor ── + + @staticmethod + def _list_storage_devices() -> list[str]: + """Detected block-device mount points (e.g. ['/', '/home']).""" + devices = [] + + def _collect(node): + for mp in node.get("mountpoints", []) or []: + if mp and not mp.startswith("[") and mp not in devices: + devices.append(mp) + for child in node.get("children", []) or []: + _collect(child) + + try: + out = subprocess.run( + ["lsblk", "-J", "-o", "NAME,MOUNTPOINTS"], + capture_output=True, text=True, timeout=5, + ) + if out.returncode == 0 and out.stdout.strip(): + data = json.loads(out.stdout) + for block in data.get("blockdevices", []): + _collect(block) + except (OSError, ValueError, subprocess.TimeoutExpired): + pass + if not devices: + devices = ["/"] + return devices + + def _on_edit_storage(self, idx: int) -> None: + """Let the user pick which storage device the widget displays.""" + if idx < 0 or idx >= len(self._widgets): + return + widget = self._widgets[idx] + devices = self._list_storage_devices() + current = widget.get("device") or (devices[0] if devices else "/") + if current not in devices: + current = devices[0] if devices else "/" + + group = Adw.PreferencesGroup() + combo = Adw.ComboRow(title="Storage device") + combo.set_model(Gtk.StringList.new(devices)) + combo.set_selected(devices.index(current)) + group.add(combo) + + dialog = Adw.AlertDialog( + heading="Device Storage", + body="Pick the storage device this widget should measure.", + extra_child=group, + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("save", "Save") + dialog.set_default_response("save") + dialog.set_close_response("cancel") + + def _on_response(_dialog_obj, response): + if response != "save": + return + sel = combo.get_selected() + if 0 <= sel < len(devices): + widget["device"] = devices[sel] + self._notify_dirty() + self._rebuild_widgets() + + dialog.connect("response", _on_response) + dialog.present(self._window) + + # ── Network widget editor ── + + def _on_edit_network(self, idx: int) -> None: + """Let the user choose whether the public IP is hidden by default.""" + if idx < 0 or idx >= len(self._widgets): + return + widget = self._widgets[idx] + current = widget.get("hideIp", True) + + group = Adw.PreferencesGroup() + switch = Adw.SwitchRow(title="Hide public IP by default", + subtitle="Mask the WAN address; reveal with the eye icon") + switch.set_active(bool(current)) + group.add(switch) + + dialog = Adw.AlertDialog( + heading="Network Monitor", + body="Options for the network widget.", + extra_child=group, + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("save", "Save") + dialog.set_default_response("save") + dialog.set_close_response("cancel") + + def _on_response(_dialog_obj, response): + if response != "save": + return + widget["hideIp"] = bool(switch.get_active()) + self._notify_dirty() + self._rebuild_widgets() + + dialog.connect("response", _on_response) + dialog.present(self._window) + + # ── Bluetooth widget editor ── + + def _on_edit_bluetooth(self, idx: int) -> None: + """Let the user pick which device type is shown first when connected.""" + if idx < 0 or idx >= len(self._widgets): + return + widget = self._widgets[idx] + + priority_options = [ + ("automatic", "Automatic"), + ("audio", "Headphones / Audio"), + ("gamepad", "Gamepad"), + ("phone", "Phone"), + ("mouse", "Mouse"), + ("keyboard", "Keyboard"), + ("watch", "Watch"), + ("speaker", "Speaker"), + ("other", "Other"), + ] + labels = [label for _pid, label in priority_options] + ids = [pid for pid, _label in priority_options] + current = widget.get("devicePriority") or "automatic" + if current not in ids: + current = "automatic" + + group = Adw.PreferencesGroup() + combo = Adw.ComboRow(title="Priority device type", + subtitle="Which connected device is shown first") + combo.set_model(Gtk.StringList.new(labels)) + combo.set_selected(ids.index(current)) + group.add(combo) + + dialog = Adw.AlertDialog( + heading="Bluetooth Widget", + body="Choose which type of connected device to display first.", + extra_child=group, + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("save", "Save") + dialog.set_default_response("save") + dialog.set_close_response("cancel") + + def _on_response(_dialog_obj, response): + if response != "save": + return + sel = combo.get_selected() + if 0 <= sel < len(ids): + widget["devicePriority"] = ids[sel] + self._notify_dirty() + self._rebuild_widgets() + + dialog.connect("response", _on_response) + dialog.present(self._window) + + # ── Note widget editor ── + + @staticmethod + def _list_notes() -> list[dict]: + """Notes from the retroshell notes store: [{id, title, label}].""" + base = os.path.expanduser("~/.local/share/retroshell-notes") + try: + with open(os.path.join(base, "index.json")) as f: + data = json.load(f) + except (OSError, ValueError): + return [] + notes = [] + for nid in data.get("order", []): + meta = (data.get("notes", {}) or {}).get(nid) + if not meta: + continue + title = meta.get("title", "Untitled") + ext = ".md" if meta.get("isMarkdown") else ".html" + preview = "" + try: + with open(os.path.join(base, "notes", nid + ext)) as f: + for line in f: + line = line.strip().strip("#").strip() + if line and line != title: + preview = line + break + except OSError: + pass + label = title + if preview: + label = "%s — %s" % (title, preview[:28]) + notes.append({"id": nid, "title": title, "label": label}) + return notes + + def _on_edit_note(self, idx: int) -> None: + """Let the user pick note, font size, background and fullscreen.""" + if idx < 0 or idx >= len(self._widgets): + return + widget = self._widgets[idx] + notes = self._list_notes() + current = widget.get("noteId") or "" + + group = Adw.PreferencesGroup() + + note_combo = None + ids: list[str] = [] + if notes: + labels = [n["label"] for n in notes] + ids = [n["id"] for n in notes] + sel = 0 + if current in ids: + sel = ids.index(current) + note_combo = Adw.ComboRow(title="Note to display", + subtitle="Defaults to the most recent note") + note_combo.set_model(Gtk.StringList.new(labels)) + note_combo.set_selected(sel) + group.add(note_combo) + + font_row, font_spin = make_spin_int_row( + "Font size", + value=int(widget.get("fontSize", 11)), + lower=8, + upper=24, + step=1, + page_step=2, + subtitle="Size of the note text", + ) + group.add(font_row) + suffix_lbl = Gtk.Label(label="px") + suffix_lbl.add_css_class("dim-label") + suffix_lbl.set_valign(Gtk.Align.CENTER) + font_row.add_suffix(suffix_lbl) + + fullscreen_switch = Adw.SwitchRow( + title="Open in fullscreen on click", + subtitle="Show the note in the full notes editor instead of editing inline", + ) + fullscreen_switch.set_active(bool(widget.get("openFullscreen", False))) + group.add(fullscreen_switch) + + dialog = Adw.AlertDialog( + heading="Note Widget", + body="Configure the note widget.", + extra_child=group, + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("save", "Save") + dialog.set_default_response("save") + dialog.set_close_response("cancel") + + def _on_response(_dialog_obj, response): + if response != "save": + return + if note_combo is not None: + s = note_combo.get_selected() + if 0 <= s < len(ids): + widget["noteId"] = ids[s] + widget["fontSize"] = int(font_spin.get_value()) + widget["openFullscreen"] = bool(fullscreen_switch.get_active()) + self._notify_dirty() + self._rebuild_widgets() + + dialog.connect("response", _on_response) + dialog.present(self._window) + + # ── Battery rings widget editor ── + + @staticmethod + def _list_bluetooth_devices() -> list[dict]: + """Paired bluetooth devices: [{id, name}] (address + alias).""" + try: + out = subprocess.run( + ["bluetoothctl", "devices"], + capture_output=True, text=True, timeout=5, + ) + known = [] + for line in out.stdout.splitlines(): + parts = line.split(" ") + if len(parts) >= 2 and parts[0] == "Device": + known.append((parts[1], " ".join(parts[2:]) or "Unknown device")) + + devices = [] + for addr, name in known[:15]: + try: + info = subprocess.run( + ["bluetoothctl", "info", addr], + capture_output=True, text=True, timeout=3, + ) + if "Paired: yes" in info.stdout: + devices.append({"id": addr, "name": name}) + except (OSError, subprocess.TimeoutExpired): + continue + return devices + except (OSError, subprocess.TimeoutExpired): + return [] + + def _on_edit_batteryring(self, idx: int) -> None: + """Let the user choose which devices the battery rings show.""" + if idx < 0 or idx >= len(self._widgets): + return + widget = self._widgets[idx] + hidden = widget.get("hiddenDevices") or [] + + group = Adw.PreferencesGroup() + + host_switch = Adw.SwitchRow(title="Host battery", + subtitle="Show the laptop's battery") + host_switch.set_active("host" not in hidden) + group.add(host_switch) + + device_switches = [] + for dev in self._list_bluetooth_devices(): + sw = Adw.SwitchRow(title=dev["name"], subtitle=dev["id"]) + sw.set_active(dev["id"] not in hidden) + group.add(sw) + device_switches.append((dev["id"], sw)) + + dialog = Adw.AlertDialog( + heading="Battery Rings", + body="Choose which devices appear. Only connected devices with a " + "battery reading are shown.", + extra_child=group, + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("save", "Save") + dialog.set_default_response("save") + dialog.set_close_response("cancel") + + def _on_response(_dialog_obj, response): + if response != "save": + return + new_hidden = [] + if not host_switch.get_active(): + new_hidden.append("host") + for dev_id, sw in device_switches: + if not sw.get_active(): + new_hidden.append(dev_id) + widget["hiddenDevices"] = new_hidden + self._notify_dirty() + self._rebuild_widgets() + + dialog.connect("response", _on_response) + dialog.present(self._window) + + # ── Feed editor ── + + def _on_edit_feed(self, idx: int) -> None: + """Let the user pick source, query, daily.dev key and auto-swipe.""" + if idx < 0 or idx >= len(self._widgets): + return + widget = self._widgets[idx] + + source_options = [ + ("devto", "DEV.to"), + ("hackernews", "Hacker News"), + ("dailydev", "daily.dev (needs API key)"), + ] + source_labels = [label for _v, label in source_options] + source_ids = [v for v, _l in source_options] + cur_source = widget.get("source") or "devto" + if cur_source not in source_ids: + cur_source = "devto" + + query_options = [ + ("", "All topics"), + ("linux", "linux"), + ("rust", "rust"), + ("webdev", "webdev"), + ("javascript", "javascript"), + ("typescript", "typescript"), + ("python", "python"), + ("go", "go"), + ("java", "java"), + ("devops", "devops"), + ("security", "security"), + ("cloud", "cloud"), + ("kubernetes", "kubernetes"), + ("database", "database"), + ("ai", "ai"), + ("machine-learning", "machine-learning"), + ("react", "react"), + ("opensource", "opensource"), + ("productivity", "productivity"), + ("career", "career"), + ("hardware", "hardware"), + ] + query_labels = [label for _v, label in query_options] + query_ids = [v for v, _l in query_options] + cur_query = widget.get("tag") or "" + # Custom topics not in the list → show in the entry instead. + is_custom = cur_query not in query_ids and cur_query != "" + + group = Adw.PreferencesGroup() + + source_combo = Adw.ComboRow(title="Source", subtitle="Where articles come from") + source_combo.set_model(Gtk.StringList.new(source_labels)) + source_combo.set_selected(source_ids.index(cur_source)) + group.add(source_combo) + + query_combo = Adw.ComboRow(title="Topic", subtitle="Preset tag / search query") + query_combo.set_model(Gtk.StringList.new(query_labels)) + query_combo.set_selected(query_ids.index(cur_query) if not is_custom else 0) + group.add(query_combo) + + custom_entry = Gtk.Entry(placeholder_text="Custom topic (e.g. gamedev, embedded)") + custom_entry.set_text(cur_query if is_custom else "") + custom_entry.set_hexpand(True) + custom_row = Adw.ActionRow(title="Custom topic", + subtitle="Overrides the preset topic") + custom_row.add_suffix(custom_entry) + group.add(custom_row) + + key_entry = Gtk.PasswordEntry(placeholder_text="daily.dev API key") + key_entry.set_text(widget.get("apiKey", "")) + key_entry.set_hexpand(True) + key_entry.set_sensitive(cur_source == "dailydev") + key_row = Adw.ActionRow(title="daily.dev API key", + subtitle="Required only for daily.dev") + key_row.add_suffix(key_entry) + group.add(key_row) + + def _on_source_changed(_row, *_args): + sel = source_combo.get_selected() + is_daily = 0 <= sel < len(source_ids) and source_ids[sel] == "dailydev" + key_entry.set_sensitive(is_daily) + + source_combo.connect("notify::selected", _on_source_changed) + + auto_switch = Adw.SwitchRow(title="Auto-swipe", + subtitle="Advance to the next article automatically") + auto_switch.set_active(bool(widget.get("autoSwipe", True))) + group.add(auto_switch) + + font_interval, interval_spin = make_spin_int_row( + "Auto-swipe interval", + value=int(widget.get("swipeInterval", 30)), + lower=10, upper=120, step=5, page_step=10, + subtitle="Seconds between automatic swipes", + ) + group.add(font_interval) + suffix_lbl = Gtk.Label(label="sec") + suffix_lbl.add_css_class("dim-label") + suffix_lbl.set_valign(Gtk.Align.CENTER) + font_interval.add_suffix(suffix_lbl) + + count_row, count_spin = make_spin_int_row( + "Articles to preload", + value=int(widget.get("count", 5)), + lower=3, upper=10, step=1, page_step=2, + subtitle="How many articles to load (3–10)", + ) + group.add(count_row) + count_suffix = Gtk.Label(label="articles") + count_suffix.add_css_class("dim-label") + count_suffix.set_valign(Gtk.Align.CENTER) + count_row.add_suffix(count_suffix) + + dialog = Adw.AlertDialog( + heading="Dev Feed", + body="Configure the article feed widget.", + extra_child=group, + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("save", "Save") + dialog.set_default_response("save") + dialog.set_close_response("cancel") + + def _on_response(_dialog_obj, response): + if response != "save": + return + s = source_combo.get_selected() + if 0 <= s < len(source_ids): + widget["source"] = source_ids[s] + custom = custom_entry.get_text().strip() + if custom: + widget["tag"] = custom + else: + q = query_combo.get_selected() + if 0 <= q < len(query_ids): + widget["tag"] = query_ids[q] + widget["apiKey"] = key_entry.get_text().strip() + widget["autoSwipe"] = bool(auto_switch.get_active()) + widget["swipeInterval"] = int(interval_spin.get_value()) + widget["count"] = int(count_spin.get_value()) + self._notify_dirty() + self._rebuild_widgets() + + dialog.connect("response", _on_response) + dialog.present(self._window) + + # ── Photo widget image picker ── + + def _on_edit_photo(self, idx: int) -> None: + """Let the user pick an image file for the photo widget.""" + if idx < 0 or idx >= len(self._widgets): + return + widget = self._widgets[idx] + + dialog = Gtk.FileDialog() + dialog.set_title("Choose an image") + filt = Gtk.FileFilter() + filt.set_name("Images") + for mime in ("image/png", "image/jpeg", "image/webp", "image/svg+xml", "image/gif", "image/bmp"): + filt.add_mime_type(mime) + filters = Gio.ListStore.new(Gtk.FileFilter) + filters.append(filt) + dialog.set_filters(filters) + + current_path = widget.get("imagePath") or "" + current_name = os.path.basename(current_path) if current_path else "" + + group = Adw.PreferencesGroup() + + path_row = Adw.ActionRow(title="Current image", subtitle=current_name or "(placeholder)") + group.add(path_row) + + clear_btn = Gtk.Button(label="Clear image") + clear_btn.set_valign(Gtk.Align.CENTER) + clear_btn.add_css_class("flat") + clear_btn.set_tooltip_text("Remove image and show default placeholder") + + def _on_clear(_btn): + widget["imagePath"] = "" + self._notify_dirty() + self._rebuild_widgets() + + clear_btn.connect("clicked", _on_clear) + path_row.add_suffix(clear_btn) + + border_switch = Adw.SwitchRow( + title="Show border", + subtitle="Display the themed inner border around the image", + ) + border_switch.set_active(bool(widget.get("showBorder", True))) + group.add(border_switch) + + dlg = Adw.AlertDialog( + heading="Photo Widget", + body="Set a custom image for this widget.", + extra_child=group, + ) + dlg.add_response("pick", "Choose image") + dlg.add_response("cancel", "Close") + dlg.set_default_response("pick") + dlg.set_close_response("cancel") + + def _on_response(_dlg_obj, response): + if response == "pick": + def on_chosen(_file_dlg, result): + try: + gfile = _file_dlg.open_finish(result) + except GLib.Error: + return + if not gfile: + return + path = gfile.get_path() + if not path or not os.path.isfile(path): + return + widget["imagePath"] = path + widget["showBorder"] = bool(border_switch.get_active()) + self._notify_dirty() + self._rebuild_widgets() + + dialog.open(self._window, None, on_chosen) + else: + widget["showBorder"] = bool(border_switch.get_active()) + self._notify_dirty() + self._rebuild_widgets() + + dlg.connect("response", _on_response) + dlg.present(self._window) + + # ── World clock timezone editor ── + + def _on_edit_timezones(self, idx: int) -> None: + """Let the user set up to 4 IANA timezones for the world clock widget.""" + if idx < 0 or idx >= len(self._widgets): + return + widget = self._widgets[idx] + current = widget.get("timezones") or ["UTC"] + + group = Adw.PreferencesGroup() + group.add(Adw.ActionRow( + title="Enter up to 4 IANA timezone names", + subtitle="e.g. UTC, Europe/Paris, America/New_York, Asia/Tokyo", + )) + entries = [] + for slot in range(4): + value = current[slot] if slot < len(current) else "" + entry = Gtk.Entry(placeholder_text="Timezone %d" % (slot + 1)) + entry.set_text(value) + entry.set_hexpand(True) + row = Adw.ActionRow(title="Timezone %d" % (slot + 1)) + row.add_suffix(entry) + group.add(row) + entries.append(entry) + + dialog = Adw.AlertDialog( + heading="World Clock Timezones", + body="The widget shows one column per timezone (up to 4).", + extra_child=group, + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("save", "Save") + dialog.set_default_response("save") + dialog.set_close_response("cancel") + + def _on_response(_dialog_obj, response): + if response != "save": + return + valid = [] + invalid = [] + for entry in entries: + zone = entry.get_text().strip() + if not zone: + continue + if _is_valid_timezone(zone): + if zone not in valid: + valid.append(zone) + elif zone not in invalid: + invalid.append(zone) + if len(valid) == 4: + break + widget["timezones"] = valid or ["UTC"] + self._notify_dirty() + self._rebuild_widgets() + if invalid: + warn = Adw.AlertDialog( + heading="Invalid Timezones Ignored", + body=("The following timezones were not saved because they " + "aren't valid IANA names:\n\n") + "\n".join(invalid), + ) + warn.add_response("ok", "OK") + warn.set_default_response("ok") + warn.present(self._window) + + dialog.connect("response", _on_response) + dialog.present(self._window) + + # ── System Monitor 2x4 graph settings ── + + def _on_edit_sysmonitor2x4(self, idx: int) -> None: + """Toggle which metrics the system monitor sparkline graph shows.""" + if idx < 0 or idx >= len(self._widgets): + return + widget = self._widgets[idx] + + group = Adw.PreferencesGroup() + + switches = {} + for key, label in [("showCpu", "CPU Usage"), ("showGpu", "GPU Usage"), ("showRam", "RAM Usage"), ("showDisk", "Disk Usage")]: + row = Adw.SwitchRow(title=label) + row.set_active(widget.get(key, key != "showDisk")) + group.add(row) + switches[key] = row + + temp_group = Adw.PreferencesGroup() + temp_group.add(Adw.SwitchRow(title="Show Temperatures", subtitle="Display temps next to usage values")) + for key, label in [("showCpuTemp", "CPU Temperature"), ("showGpuTemp", "GPU Temperature"), ("showDiskTemp", "Disk Temperature")]: + row = Adw.SwitchRow(title=label) + row.set_active(widget.get(key, key in ("showCpuTemp", "showGpuTemp"))) + temp_group.add(row) + switches[key] = row + + container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + container.append(group) + container.append(temp_group) + + dialog = Adw.AlertDialog( + heading="Graph Settings", + body="Toggle which metrics appear on the sparkline graph and legend.", + extra_child=container, + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("save", "Save") + dialog.set_default_response("save") + dialog.set_close_response("cancel") + + def _on_response(_dialog_obj, response): + if response != "save": + return + for key, sw in switches.items(): + widget[key] = sw.get_active() + self._notify_dirty() + self._rebuild_widgets() + + dialog.connect("response", _on_response) + dialog.present(self._window) + + # ── Simple rows (icon grid) ── + def _add_switch(self, group: Adw.PreferencesGroup, key: str, label: str, - subtitle: str = "") -> ManagedRow: + subtitle: str = "", default_value: bool = False) -> ManagedRow: row = Adw.SwitchRow(title=label, subtitle=subtitle) - row.set_active(bool(self._data.get(key, DESKTOP_DEFAULTS[key]))) + row.set_active(bool(self._data.get(key, default_value))) group.add(row) def get_value(): @@ -115,8 +1205,8 @@ def set_silent(value): mrow = ManagedRow( row, - default=DESKTOP_DEFAULTS[key], - baseline=self._saved.get(key, DESKTOP_DEFAULTS[key]), + default=default_value, + baseline=self._saved.get(key, default_value), get_value=get_value, set_value_silent=set_silent, on_value_set=lambda value, k=key: self._on_change(k, value), @@ -138,7 +1228,7 @@ def _add_spin( ) -> ManagedRow: row, spin = make_spin_int_row( label, - value=int(self._data.get(key, DESKTOP_DEFAULTS[key])), + value=int(self._data.get(key, 40)), lower=lower, upper=upper, step=1, @@ -160,8 +1250,8 @@ def set_silent(value): mrow = ManagedRow( row, - default=DESKTOP_DEFAULTS[key], - baseline=self._saved.get(key, DESKTOP_DEFAULTS[key]), + default=40, + baseline=self._saved.get(key, 40), get_value=get_value, set_value_silent=set_silent, on_value_set=lambda value, k=key: self._on_change(k, value), @@ -178,7 +1268,7 @@ def _add_color_combo( *, subtitle: str = "", ) -> ManagedRow: - current = str(self._data.get(key, DESKTOP_DEFAULTS[key])) + current = str(self._data.get(key, "overBackground")) row, ids = _make_named_color_combo_row(label, current, subtitle=subtitle) group.add(row) @@ -194,8 +1284,8 @@ def set_silent(value): mrow = ManagedRow( row, - default=DESKTOP_DEFAULTS[key], - baseline=self._saved.get(key, DESKTOP_DEFAULTS[key]), + default="overBackground", + baseline=self._saved.get(key, "overBackground"), get_value=get_value, set_value_silent=set_silent, on_value_set=lambda value, k=key: self._on_change(k, value), @@ -216,8 +1306,49 @@ def _on_change(self, key: str, value) -> None: self._data[key] = value self._notify_dirty() + def _merged_widgets(self) -> list[dict]: + """Build the list to persist, preserving the shell's live state. + + The shell updates widget positions (drag) directly in + desktop_widgets.json. The settings page only manages the widget list + (add/remove/reorder), so before writing we re-read the on-disk + widgets and: + - keep the shell's latest x/y/locked for any widget we know about, + - never drop on-disk widgets we weren't aware of (e.g. the page + loaded before they existed), so an unrelated settings change + can't wipe them, + - only drop widgets the user explicitly removed this session. + """ + disk = {w.get("id"): w for w in load_desktop_widgets() if w.get("id")} + for wid in self._removed_ids: + disk.pop(wid, None) + + merged = [] + for w in self._widgets: + wid = w.get("id") + existing = disk.pop(wid, None) + if existing: + # Preserve the shell's live state (x/y/locked) AND any extra + # fields the shell or settings page stored (face, timezones). + entry = dict(existing) + entry["id"] = wid + entry["type"] = w.get("type", existing.get("type", wid)) + for extra in ("face", "handStyle", "timezones", "device", "hideIp", "devicePriority", "noteId", "openFullscreen", "fontSize", "hiddenDevices", "tag", "monitor", "source", "apiKey", "autoSwipe", "swipeInterval", "count", "imagePath", "showBorder"): + if w.get(extra) is not None: + entry[extra] = w[extra] + merged.append(entry) + else: + merged.append(w) # newly added this session + + # Any on-disk widgets the settings page wasn't aware of (loaded empty + # or created after the page opened) are kept as-is. + for wid, existing in disk.items(): + merged.append(existing) + return merged + def _write_live(self) -> None: save_desktop(self._data) + save_desktop_widgets(self._merged_widgets()) def _notify_dirty(self) -> None: self._write_live() @@ -225,30 +1356,46 @@ def _notify_dirty(self) -> None: self._on_dirty_changed() def is_dirty(self) -> bool: - return self._data != self._saved + return (self._data != self._saved + or self._widgets != self._saved_widgets) def mark_saved(self) -> None: - if not self.is_dirty(): - return save_desktop(self._data) + save_desktop_widgets(self._merged_widgets()) self._saved = dict(self._data) + self._saved_widgets = [dict(w) for w in self._widgets] + self._saved_widget_order = [w.get("id") for w in self._widgets] for key, mrow in self._rows.items(): - mrow.set_baseline(self._data.get(key, DESKTOP_DEFAULTS[key])) + mrow.set_baseline(self._data.get(key)) def discard(self) -> None: self._data = dict(self._saved) + self._widgets = [dict(w) for w in self._saved_widgets] + self._widget_order = [w.get("id") for w in self._widgets] + self._removed_ids = set() for mrow in self._rows.values(): mrow.discard() + if self._edit_row is not None: + self._edit_row.set_active(bool(self._data.get("editMode", False))) self._write_live() + self._rebuild_widgets() def reload_from_disk(self) -> None: - """Re-read desktop.json (e.g. after applying a preset) and sync widgets.""" + """Re-read desktop.json + desktop_widgets.json and sync widgets.""" self._data = load_desktop() self._saved = dict(self._data) + self._widgets = load_desktop_widgets() + self._saved_widgets = [dict(w) for w in self._widgets] + self._widget_order = [w.get("id") for w in self._widgets] + self._saved_widget_order = list(self._widget_order) + self._removed_ids = set() for key, mrow in self._rows.items(): - value = self._data.get(key, DESKTOP_DEFAULTS[key]) + value = self._data.get(key) mrow.apply_value(value) mrow.set_baseline(value) + if self._edit_row is not None: + self._edit_row.set_active(bool(self._data.get("editMode", False))) + self._rebuild_widgets() def iter_pending_changes(self) -> Iterable[PendingChange]: if not self.is_dirty(): @@ -258,11 +1405,16 @@ def iter_pending_changes(self) -> Iterable[PendingChange]: if self._data.get(key) != self._saved.get(key): label = { "enabled": "Enabled", + "showIcons": "Show Icons", "iconSize": "Icon Size", "spacingVertical": "Spacing", "textColor": "Text Color", }.get(key, "Desktop setting") changed.append(label) + if self._widgets != self._saved_widgets: + changed.append("Widgets") + if "editMode" in self._data and self._data.get("editMode") != self._saved.get("editMode"): + changed.append("Edit Mode") if changed: yield PendingChange( category="Shell Desktop", @@ -279,6 +1431,9 @@ def get_search_entries(self) -> list[dict]: {"key": "shell_desktop:desktop", "label": "Desktop", "description": "Desktop icon grid: size, spacing and text color", "_group_id": "shell_desktop", "_group_label": "Desktop", "_section_label": "Desktop"}, + {"key": "shell_desktop:widgets", "label": "Desktop Widgets", + "description": "Add, remove and reorder desktop widgets", + "_group_id": "shell_desktop", "_group_label": "Desktop", "_section_label": "Widgets"}, ] diff --git a/cmds/tools/settings/pages/shell_theme.py b/cmds/tools/settings/pages/shell_theme.py index 7d50097b..6fd81c07 100644 --- a/cmds/tools/settings/pages/shell_theme.py +++ b/cmds/tools/settings/pages/shell_theme.py @@ -234,6 +234,9 @@ def _on_gtype_value_set(value): self._editor_rows["opacity"] = self._add_variant_float( group, vk, "opacity", "Opacity", lower=0.0, upper=1.0, step=0.01, digits=2, subtitle="Fill opacity (0–1)") + self._editor_rows["blur"] = self._add_variant_switch( + group, vk, "blur", "Blur", + subtitle="Compositor blur behind this surface") if gradient_type in ("linear", "radial"): self._editor_rows["borderColor"] = self._add_variant_border_color( @@ -729,6 +732,46 @@ def on_value_set(value, f=field): self._wire_variant_change(spin, "value-changed", vk, field, mrow) return mrow + def _add_variant_switch( + self, + group: Adw.PreferencesGroup, + vk: str, + field: str, + label: str, + subtitle: str = "", + ) -> ManagedRow: + current = bool(self._data.get(vk, {}).get(field, self._variant_default(vk, field))) + row = Adw.SwitchRow(title=label, subtitle=subtitle) + row.set_active(current) + self._editor_add(row) + + def get_value(): + return row.get_active() + + def set_silent(value): + row.set_active(bool(value)) + + def on_value_set(value, f=field): + self._on_variant_change(f, value) + + mrow = ManagedRow( + row, + default=self._variant_default(vk, field), + baseline=self._saved.get(vk, {}).get(field, self._variant_default(vk, field)), + get_value=get_value, + set_value_silent=set_silent, + on_value_set=on_value_set, + ) + + def _on_blur_toggled(*_args): + self._data[self._current_variant]["blur"] = row.get_active() + mrow.refresh() + self._push_blur_rules_live() + self._notify_dirty() + + row.connect("notify::active", _on_blur_toggled) + return mrow + def _add_variant_float( self, group: Adw.PreferencesGroup, @@ -888,9 +931,8 @@ def _border_width_changed(*_args): def _wire_change(self, widget: Gtk.Widget, signal: str, key: str, mrow: ManagedRow) -> None: def _changed(*_args): - self._data[key] = mrow.value + self._on_change(key, mrow.value) mrow.refresh() - self._notify_dirty() widget.connect(signal, _changed) @@ -918,6 +960,87 @@ def _notify_dirty(self) -> None: if self._on_dirty_changed is not None: self._on_dirty_changed() + # ── Blur layer rules (per-variant, live push + persistence) ── + + _VARIANT_NAMESPACE_MAP: dict[str, str] = { + "barbg": "retroshell", + "bg": "retroshell", + "popup": "retroshell:osd", + "internalbg": "retroshell", + "pane": "retroshell", + "common": "retroshell", + "focus": "retroshell", + "primary": "retroshell", + "primaryfocus": "retroshell", + "overprimary": "retroshell", + "secondary": "retroshell", + "secondaryfocus": "retroshell", + "oversecondary": "retroshell", + "tertiary": "retroshell", + "tertiaryfocus": "retroshell", + "overtertiary": "retroshell", + "error": "retroshell", + "errorfocus": "retroshell", + "overerror": "retroshell", + } + + def _get_blur_namespaces(self) -> list[str]: + ns_set: set[str] = set() + for vk in _VARIANT_KEYS: + if self._data.get(vk, {}).get("blur", False): + vid = _variant_id(vk) + ns = self._VARIANT_NAMESPACE_MAP.get(vid) + if ns: + ns_set.add(ns) + return sorted(ns_set) + + def _build_blur_rule_body(self) -> str | None: + ns_list = self._get_blur_namespaces() + if not ns_list: + return None + ns_regex = "^(" + "|".join(ns_list) + ")$" + return f"match:namespace {ns_regex}, blur on, ignore_alpha 0.6" + + def _push_blur_rules_live(self) -> None: + """Push blur layer rules to Hyprland via IPC for immediate effect.""" + from hyprland_config import Rule, render_rule_lua + from hyprland_socket import eval_lua + + window = self._window + if not hasattr(window, "hypr") or window.hypr is None: + return + ns_list = self._get_blur_namespaces() + try: + if ns_list: + ns_regex = "^(" + "|".join(ns_list) + ")$" + rule = Rule( + raw="", kind="layerrule", name="", enabled=True, + matchers=[("namespace", ns_regex)], + effects=[("blur", ""), ("ignore_alpha", "0.6")], + ) + lua_code = render_rule_lua(rule) + eval_lua(lua_code) + else: + # No blur — push empty rule to clear + rule = Rule( + raw="", kind="layerrule", name="", enabled=True, + matchers=[("namespace", "^()$")], + effects=[], + ) + lua_code = render_rule_lua(rule) + eval_lua(lua_code) + except Exception: + pass # Best-effort; persistence handles reboot + + def _blur_rules_to_lines(self) -> list[str]: + body = self._build_blur_rule_body() + if not body: + return [] + return [f"layerrule = {body}"] + + def get_theme_layer_rules(self) -> list[str]: + return self._blur_rules_to_lines() + # ── Lifecycle ── def is_dirty(self) -> bool: diff --git a/cmds/tools/settings/pages/wallpapers.py b/cmds/tools/settings/pages/wallpapers.py index 754a5283..4c1b2225 100644 --- a/cmds/tools/settings/pages/wallpapers.py +++ b/cmds/tools/settings/pages/wallpapers.py @@ -150,6 +150,7 @@ def __init__(self, window): self._notify_dirty = lambda: None self._pending_static: bool | None = None self._pending_static_bat: bool | None = None + self._pending_static_fullscreen: bool | None = None self._pending_collection: str | None = None self._pending_slideshow: bool | None = None self._pending_interval: int | None = None @@ -158,6 +159,7 @@ def __init__(self, window): self._needs_opt = False self._static_row: Gtk.Widget | None = None self._bat_row: Gtk.Widget | None = None + self._fullscreen_row: Gtk.Widget | None = None self._col_row: Gtk.Widget | None = None self._col_delete_btn: Gtk.Widget | None = None self._slide_row: Gtk.Widget | None = None @@ -165,6 +167,7 @@ def __init__(self, window): self._res_row: Gtk.Widget | None = None self._static_switch: Gtk.Widget | None = None self._bat_switch: Gtk.Widget | None = None + self._fullscreen_switch: Gtk.Widget | None = None self._slide_switch: Gtk.Widget | None = None self._interval_spin: Gtk.SpinButton | None = None self._spin_w: Gtk.SpinButton | None = None @@ -179,6 +182,7 @@ def __init__(self, window): self._wall_anim_preview: bool = bool(load_tools().get("wallpaperAnimatedPreview", True)) self._static_actions: RowActions | None = None self._bat_actions: RowActions | None = None + self._fullscreen_actions: RowActions | None = None self._col_actions: RowActions | None = None self._slide_actions: RowActions | None = None self._interval_actions: RowActions | None = None @@ -189,6 +193,7 @@ def _refresh_managed(self) -> None: for row, var, actions in ( (self._static_row, "WALL_STATIC_FORCED", self._static_actions), (self._bat_row, "WALL_STATIC_ON_BAT", self._bat_actions), + (self._fullscreen_row, "WALL_STATIC_ON_FULLSCREEN", self._fullscreen_actions), (self._col_row, "RETRO_WALL_COLLECTION", self._col_actions), (self._slide_row, "WALL_SLIDESHOW_ACTIVE", self._slide_actions), (self._interval_row, "WALL_SLIDESHOW_INTERVAL", self._interval_actions), @@ -220,6 +225,10 @@ def _get_pending(self, var_name: str) -> str | None: if self._pending_static_bat is None: return None return "true" if self._pending_static_bat else "false" + if var_name == "WALL_STATIC_ON_FULLSCREEN": + if self._pending_static_fullscreen is None: + return None + return "true" if self._pending_static_fullscreen else "false" if var_name == "RETRO_WALL_COLLECTION": return self._pending_collection if var_name == "WALL_SLIDESHOW_ACTIVE": @@ -239,6 +248,8 @@ def _has_pending(self, var_name: str) -> bool: return self._pending_static is not None if var_name == "WALL_STATIC_ON_BAT": return self._pending_static_bat is not None + if var_name == "WALL_STATIC_ON_FULLSCREEN": + return self._pending_static_fullscreen is not None if var_name == "RETRO_WALL_COLLECTION": return self._pending_collection is not None if var_name == "WALL_SLIDESHOW_ACTIVE": @@ -256,6 +267,8 @@ def _set_pending(self, var_name: str, value: str): self._pending_static = value == "true" elif var_name == "WALL_STATIC_ON_BAT": self._pending_static_bat = value == "true" + elif var_name == "WALL_STATIC_ON_FULLSCREEN": + self._pending_static_fullscreen = value == "true" elif var_name == "RETRO_WALL_COLLECTION": self._pending_collection = value elif var_name == "WALL_SLIDESHOW_ACTIVE": @@ -272,6 +285,8 @@ def _clear_pending(self, var_name: str): self._pending_static = None elif var_name == "WALL_STATIC_ON_BAT": self._pending_static_bat = None + elif var_name == "WALL_STATIC_ON_FULLSCREEN": + self._pending_static_fullscreen = None elif var_name == "RETRO_WALL_COLLECTION": self._pending_collection = None elif var_name == "WALL_SLIDESHOW_ACTIVE": @@ -291,6 +306,9 @@ def _set_widget_value(self, var_name: str, value: str): elif var_name == "WALL_STATIC_ON_BAT": if self._bat_switch is not None: self._bat_switch.set_active(value == "true") # type: ignore[union-attr] + elif var_name == "WALL_STATIC_ON_FULLSCREEN": + if self._fullscreen_switch is not None: + self._fullscreen_switch.set_active(value == "true") # type: ignore[union-attr] elif var_name == "RETRO_WALL_COLLECTION": def_val = value if self._col_row is not None and def_val in self._collections: @@ -331,7 +349,8 @@ def _check_dirty(self): any_dirty = any( self._has_pending(v) for v in ( - "WALL_STATIC_FORCED", "WALL_STATIC_ON_BAT", "RETRO_WALL_COLLECTION", + "WALL_STATIC_FORCED", "WALL_STATIC_ON_BAT", "WALL_STATIC_ON_FULLSCREEN", + "RETRO_WALL_COLLECTION", "WALL_SLIDESHOW_ACTIVE", "WALL_SLIDESHOW_INTERVAL", "WALL_RESOLUTION", "WALL_GPU_OFFLOAD", ) @@ -461,6 +480,21 @@ def build(self, header: Adw.HeaderBar | None = None) -> Gtk.Widget: self._bat_row = bat_row self._bat_switch = bat_row + fullscreen_row = Adw.SwitchRow(title="Static on Fullscreen") + fullscreen_row.set_subtitle("Pauses live wallpapers when a fullscreen app is running") + fullscreen_row.set_active(get_var("WALL_STATIC_ON_FULLSCREEN", "true") == "true") + fullscreen_row.connect("notify::active", self._on_static_fullscreen_toggled) + self._fullscreen_actions = RowActions( + fullscreen_row, + on_discard=lambda: self._discard_var("WALL_STATIC_ON_FULLSCREEN"), + on_reset=lambda: self._reset_var("WALL_STATIC_ON_FULLSCREEN"), + ) + fullscreen_row.add_suffix(self._fullscreen_actions.box) + self._fullscreen_actions.reorder_first() + pref_group.add(fullscreen_row) + self._fullscreen_row = fullscreen_row + self._fullscreen_switch = fullscreen_row + col_result = subprocess.run( ["bash", str(WALLPAPER_CORE), "--collection", "list"], capture_output=True, text=True, timeout=10, @@ -902,6 +936,14 @@ def get_search_entries(self) -> list[dict]: "_group_label": "Wallpapers", "_section_label": "Settings", }, + { + "key": "wallpapers:static_on_fullscreen", + "label": "Static on Fullscreen", + "description": "Pauses live wallpapers when a fullscreen app is running", + "_group_id": "wallpapers", + "_group_label": "Wallpapers", + "_section_label": "Settings", + }, { "key": "wallpapers:gpu_offload", "label": "GPU Offload", @@ -960,6 +1002,14 @@ def _on_static_bat_toggled(self, switch, _pspec): self._notify_dirty() self._refresh_managed() + def _on_static_fullscreen_toggled(self, switch, _pspec): + if self._setting_value: + return + self._pending_static_fullscreen = switch.get_active() + self._dirty = True + self._notify_dirty() + self._refresh_managed() + def _on_slideshow_toggled(self, switch, _pspec): if self._setting_value: return @@ -1084,6 +1134,7 @@ def mark_saved(self): self._dirty = False self._pending_static = None self._pending_static_bat = None + self._pending_static_fullscreen = None self._pending_collection = None self._pending_slideshow = None self._pending_interval = None @@ -1104,6 +1155,13 @@ def flush_pending(self): ["retro", "app", "all", "refresh"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) + if self._pending_static_fullscreen is not None: + from lib.python.variable import set_var + set_var("WALL_STATIC_ON_FULLSCREEN", "true" if self._pending_static_fullscreen else "false") + subprocess.Popen( + ["retro", "app", "all", "refresh"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) if self._pending_collection is not None: subprocess.Popen( ["retro", "wallpaper", "collection", self._pending_collection], diff --git a/cmds/tools/settings/ui/icons.py b/cmds/tools/settings/ui/icons.py index 093834f9..65c30727 100644 --- a/cmds/tools/settings/ui/icons.py +++ b/cmds/tools/settings/ui/icons.py @@ -57,6 +57,7 @@ SERVICE_CONTROL_ICON = "utilities-system-monitor-symbolic" DRIVER_ICON = "preferences-other-symbolic" SLEEP_ICON = "weather-clear-night-symbolic" +TYPING_SOUNDS_ICON = "input-keyboard-symbolic" KEYRING_ICON = "dialog-password-symbolic" LOGS_ICON = "document-properties-symbolic" ABOUT_ICON = "help-about-symbolic" diff --git a/cmds/tools/settings/window.py b/cmds/tools/settings/window.py index 72e60f47..6f0a5565 100644 --- a/cmds/tools/settings/window.py +++ b/cmds/tools/settings/window.py @@ -1734,6 +1734,13 @@ def emit_if[T: SectionPage]( if sections.layer_rules is not None and self._layer_rules_page is not None: sections.layer_rules_nodes = self._layer_rules_page.get_layer_rule_nodes() + # Theme blur layer rules + shell_theme_page = getattr(self, "_shell_theme_page", None) + if shell_theme_page is not None: + theme_rules = shell_theme_page.get_theme_layer_rules() + if theme_rules: + sections.theme_layer_rules = theme_rules + return sections def _perform_save(self, *, update_active_profile: bool = True): diff --git a/cmds/tools/shell.sh b/cmds/tools/shell.sh index 8214ea46..ca73319b 100755 --- a/cmds/tools/shell.sh +++ b/cmds/tools/shell.sh @@ -55,13 +55,14 @@ cmd_shell() { ;; run) local cmd="${@:2}" - local valid_cmds="launcher clipboard emoji tmux notes wallpapers screenshot screenrecord lockscreen overview powermenu tools assistant config dashboard media-play-pause media-next media-prev" + local base_cmd="${cmd%% *}" + local valid_cmds="launcher clipboard emoji tmux notes wallpapers screenshot screenrecord lockscreen overview powermenu tools assistant config dashboard media-play-pause media-next media-prev desktop-edit theme-mode" if [[ -z $cmd ]]; then - rx_log "info" "Commands: ${PINK}launcher${RESET}, ${PINK}clipboard${RESET}, ${PINK}emoji${RESET}, ${PINK}tmux${RESET}, ${PINK}notes${RESET}, ${PINK}wallpapers${RESET}, ${PINK}screenshot${RESET}, ${PINK}screenrecord${RESET}, ${PINK}lockscreen${RESET}, ${PINK}overview${RESET}, ${PINK}powermenu${RESET}, ${PINK}tools${RESET}, ${PINK}assistant${RESET}, ${PINK}config${RESET}, ${PINK}dashboard${RESET}, ${PINK}media-play-pause${RESET}, ${PINK}media-next${RESET}, ${PINK}media-prev${RESET}" + rx_log "info" "Commands: ${PINK}launcher${RESET}, ${PINK}clipboard${RESET}, ${PINK}emoji${RESET}, ${PINK}tmux${RESET}, ${PINK}notes${RESET}, ${PINK}wallpapers${RESET}, ${PINK}screenshot${RESET}, ${PINK}screenrecord${RESET}, ${PINK}lockscreen${RESET}, ${PINK}overview${RESET}, ${PINK}powermenu${RESET}, ${PINK}tools${RESET}, ${PINK}assistant${RESET}, ${PINK}config${RESET}, ${PINK}dashboard${RESET}, ${PINK}media-play-pause${RESET}, ${PINK}media-next${RESET}, ${PINK}media-prev${RESET}, ${PINK}desktop-edit ${RESET}, ${PINK}theme-mode ${RESET}" return 0 fi - if ! echo "$valid_cmds" | grep -qw "$cmd"; then - rx_log "error" "Unknown command: ${PINK}$cmd${RESET}" + if ! echo "$valid_cmds" | grep -qw "$base_cmd"; then + rx_log "error" "Unknown command: ${PINK}$base_cmd${RESET}" return 1 fi local result=$(bash "$core" --run "$cmd") diff --git a/daemon/watchers/audio.lua b/daemon/watchers/audio.lua index 3f157d46..ebb48380 100644 --- a/daemon/watchers/audio.lua +++ b/daemon/watchers/audio.lua @@ -49,36 +49,38 @@ return { elseif cur_sink ~= "" and last_sink_id == "" then Watcher.log("audio", "Default sink appeared: " .. cur_sink, "info") end + end - if primary_sink ~= "" then - local primary_id = Audio.get_sink_id_by_persistent(primary_sink) - if primary_id ~= "" then - if cur_sink ~= primary_id then - Watcher.log( - "audio", - "Enforcing primary sink: " - .. primary_sink - .. " (ID " - .. primary_id - .. "), current was " - .. cur_sink, - "info" - ) - Audio.set_default("sink", primary_id) - cur_sink = primary_id - end - else - Watcher.log("audio", "Primary sink not available: " .. primary_sink, "warn") + if primary_sink ~= "" then + local primary_id = Audio.get_sink_id_by_persistent(primary_sink) + local is_bt = primary_sink:find("bluez_output") ~= nil + if primary_id ~= "" then + if cur_sink ~= primary_id then + Watcher.log( + "audio", + "Enforcing primary sink: " + .. primary_sink + .. " (ID " + .. primary_id + .. "), current was " + .. cur_sink, + "info" + ) + Audio.set_default("sink", primary_id) + cur_sink = primary_id end + elseif not is_bt then + Watcher.log("audio", "Primary sink not available: " .. primary_sink, "warn") end - - last_sink_id = cur_sink end + last_sink_id = cur_sink + if primary_sink ~= "" then local primary_id = Audio.get_sink_id_by_persistent(primary_sink) + local is_bt = primary_sink:find("bluez_output") ~= nil local available = primary_id ~= "" - if not available and not primary_sink_was_unavail then + if not available and not primary_sink_was_unavail and not is_bt then primary_sink_was_unavail = true Watcher.log("audio", "Primary sink unavailable: " .. primary_sink, "info") if fallback_sink ~= "" then @@ -125,36 +127,39 @@ return { elseif cur_source ~= "" and last_source_id == "" then Watcher.log("audio", "Default source appeared: " .. cur_source, "info") end + end - if primary_source ~= "" then - local primary_id = Audio.get_source_id_by_persistent(primary_source) - if primary_id ~= "" then - if cur_source ~= primary_id then - Watcher.log( - "audio", - "Enforcing primary source: " - .. primary_source - .. " (ID " - .. primary_id - .. "), current was " - .. cur_source, - "info" - ) - Audio.set_default("source", primary_id) - cur_source = primary_id - end - else - Watcher.log("audio", "Primary source not available: " .. primary_source, "warn") + if primary_source ~= "" then + local primary_id = Audio.get_source_id_by_persistent(primary_source) + local is_bt = primary_source:find("bluez_input") ~= nil + if primary_id ~= "" then + if cur_source ~= primary_id then + Watcher.log( + "audio", + "Enforcing primary source: " + .. primary_source + .. " (ID " + .. primary_id + .. "), current was " + .. cur_source, + "info" + ) + Audio.set_default("source", primary_id) + Audio.clear_bt_filter_defaults() + cur_source = primary_id end + elseif not is_bt then + Watcher.log("audio", "Primary source not available: " .. primary_source, "warn") end - - last_source_id = cur_source end + last_source_id = cur_source + if primary_source ~= "" then local primary_id = Audio.get_source_id_by_persistent(primary_source) + local is_bt = primary_source:find("bluez_input") ~= nil local available = primary_id ~= "" - if not available and not primary_source_was_unavail then + if not available and not primary_source_was_unavail and not is_bt then primary_source_was_unavail = true Watcher.log("audio", "Primary source unavailable: " .. primary_source, "info") if fallback_source ~= "" then diff --git a/daemon/watchers/bluetooth.lua b/daemon/watchers/bluetooth.lua index 8a9c9cf7..9028a714 100644 --- a/daemon/watchers/bluetooth.lua +++ b/daemon/watchers/bluetooth.lua @@ -133,20 +133,10 @@ return { if card and card ~= "" then local current_profile = Bluetooth.get_audio_profile(card) local mac_key = mac:gsub(":", "_") - local forced = Watcher.get_var("BT_FORCE_PROFILE_" .. mac_key, "") - if forced ~= "" then - local forced_profile = forced:match("([^|]+)") - if current_profile ~= forced_profile then - Watcher.log("bluetooth", "Profile drift on " .. info.name .. ": " .. current_profile .. " -> " .. forced_profile, "info") - apply_forced_profile(mac) - last_profile_state[mac] = forced_profile - end - else - local mic_disabled = Watcher.get_var("BT_MIC_DISABLED_" .. mac_key, "") - if mic_disabled == "true" and not current_profile:find("a2dp%-sink") then - Watcher.log("bluetooth", "Mic disabled, switching " .. info.name .. " to a2dp-sink", "info") - Bluetooth.set_audio_profile(card, "a2dp-sink") - end + local mic_disabled = Watcher.get_var("BT_MIC_DISABLED_" .. mac_key, "") + if mic_disabled == "true" and not current_profile:find("a2dp%-sink") then + Watcher.log("bluetooth", "Mic disabled, switching " .. info.name .. " to a2dp-sink", "info") + Bluetooth.set_audio_profile(card, "a2dp-sink") end end end diff --git a/iso/profile/packages.x86_64 b/iso/profile/packages.x86_64 index 19510cc5..7d854b1d 100644 --- a/iso/profile/packages.x86_64 +++ b/iso/profile/packages.x86_64 @@ -8,7 +8,6 @@ bcachefs-tools bind bolt brltty -broadcom-wl btrfs-progs clonezilla cloud-init diff --git a/lib/lua/audio.lua b/lib/lua/audio.lua index 2c2f5537..85968b1f 100644 --- a/lib/lua/audio.lua +++ b/lib/lua/audio.lua @@ -45,6 +45,9 @@ local function _parse_wpctl_status() end section = in_audio and "sources" or "" sub_section = in_audio and "devices" or "" + elseif line:match("^%s*├─ Filters:") or line:match("^%s*└─ Filters:") then + section = "" + sub_section = "" elseif line:match("^%s*├─ Streams:") or line:match("^%s*└─ Streams:") then if seen_streams then in_audio = false @@ -180,6 +183,26 @@ function Audio.set_default(type, id) return result == "OK" end +function Audio.clear_bt_filter_defaults() + local status = run_cmd("wpctl status") + local in_filters = false + for line in status:gmatch("[^\n]+") do + if line:match("^%s*├─ Filters:") or line:match("^%s*└─ Filters:") then + in_filters = true + elseif line:match("^%s*└─") or line:match("^%s*├─%s+Streams:") or line:match("^%s*└─%s+Streams:") then + in_filters = false + elseif in_filters and line:match("%*") then + local id = line:match("(%d+)%.%s") + if id then + local pw_name = run_cmd("wpctl inspect " .. id .. " 2>/dev/null | grep 'node.name' | head -1 | awk '{print $NF}' | tr -d '\"'") + if pw_name and pw_name:match("^bluez_input%.") then + run_cmd("wpctl clear-default '" .. id .. "'") + end + end + end + end +end + function Audio.apply_fallback(type) local fallback_var if type == "sink" then diff --git a/modules/docker/packages.sh b/modules/docker/packages.sh new file mode 100644 index 00000000..f6a9a089 --- /dev/null +++ b/modules/docker/packages.sh @@ -0,0 +1,8 @@ +dockerfile-language-server +podman-docker +ducker +lazydocker +docker-compose +docker-machine +docker-buildx +docker diff --git a/modules/docker/properties.json b/modules/docker/properties.json new file mode 100644 index 00000000..8e4a6258 --- /dev/null +++ b/modules/docker/properties.json @@ -0,0 +1,10 @@ +{ + "title": "Docker", + "description": "Containerization tools and utilities for managing Docker and Podman containers.", + "type": "extra", + "access": "user", + "defaults": false, + "mode": "mirror", + "gui": false, + "overwrite": false +} diff --git a/modules/hyprland/files/lib/retro.lua b/modules/hyprland/files/lib/retro.lua index 5e634df0..a17415a5 100644 --- a/modules/hyprland/files/lib/retro.lua +++ b/modules/hyprland/files/lib/retro.lua @@ -97,7 +97,7 @@ function Retro.open_editor() end function Retro.shell_run(name) - os.execute("setsid retro shell run " .. name .. " >/dev/null 2>&1 &") + hl.exec_cmd("retro shell run " .. name) end function Retro.open_launcher() diff --git a/modules/retro/packages.sh b/modules/retro/packages.sh index 6d43093f..86040578 100755 --- a/modules/retro/packages.sh +++ b/modules/retro/packages.sh @@ -4,19 +4,20 @@ jq git lua sed +grim grep rsync unzip zenity expect notify -grim udisks2 usbutils dmidecode gstreamer net-tools inetutils +shellcheck base-devel lm_sensors calfrnnoise diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cache_format.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/cache_format.json new file mode 100644 index 00000000..8d136157 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/cache_format.json @@ -0,0 +1,3 @@ +{ + "version": "3" +} diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-black-abs/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-black-abs/config.json new file mode 100644 index 00000000..fdf0e342 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-black-abs/config.json @@ -0,0 +1,657 @@ +{ + "id": "custom-sound-pack-1582654366773", + "name": "CherryMX Black - ABS keycaps", + "key_define_type": "single", + "includes_numpad": false, + "sound": "sound.ogg", + "defines": { + "1": [ + 2926, + 125 + ], + "1-up": [ + 3051, + 77 + ], + "2": [ + 12965, + 112 + ], + "2-up": [ + 13078, + 91 + ], + "3": [ + 13470, + 128 + ], + "3-up": [ + 13598, + 77 + ], + "4": [ + 13993, + 101 + ], + "4-up": [ + 14094, + 89 + ], + "5": [ + 14481, + 105 + ], + "5-up": [ + 14618, + 122 + ], + "6": [ + 14994, + 137 + ], + "6-up": [ + 15131, + 77 + ], + "7": [ + 15505, + 128 + ], + "7-up": [ + 15633, + 90 + ], + "8": [ + 15990, + 132 + ], + "8-up": [ + 16122, + 77 + ], + "9": [ + 16529, + 115 + ], + "9-up": [ + 16644, + 77 + ], + "10": [ + 17012, + 138 + ], + "10-up": [ + 17150, + 77 + ], + "11": [ + 17550, + 131 + ], + "11-up": [ + 17681, + 77 + ], + "12": [ + 18059, + 120 + ], + "12-up": [ + 18179, + 77 + ], + "13": [ + 18584, + 92 + ], + "13-up": [ + 18675, + 77 + ], + "14": [ + 19065, + 123 + ], + "14-up": [ + 19188, + 108 + ], + "15": [ + 21734, + 137 + ], + "15-up": [ + 21871, + 87 + ], + "16": [ + 22245, + 136 + ], + "16-up": [ + 22380, + 77 + ], + "17": [ + 22790, + 123 + ], + "17-up": [ + 22913, + 77 + ], + "18": [ + 23317, + 112 + ], + "18-up": [ + 23429, + 65 + ], + "19": [ + 23817, + 124 + ], + "19-up": [ + 23941, + 77 + ], + "20": [ + 24297, + 131 + ], + "20-up": [ + 24428, + 77 + ], + "21": [ + 24816, + 109 + ], + "21-up": [ + 24925, + 77 + ], + "22": [ + 25313, + 119 + ], + "22-up": [ + 25431, + 77 + ], + "23": [ + 25795, + 118 + ], + "23-up": [ + 25913, + 77 + ], + "24": [ + 26309, + 112 + ], + "24-up": [ + 26421, + 64 + ], + "25": [ + 26804, + 117 + ], + "25-up": [ + 26921, + 77 + ], + "26": [ + 27330, + 98 + ], + "26-up": [ + 27428, + 77 + ], + "27": [ + 27901, + 95 + ], + "27-up": [ + 28017, + 77 + ], + "28": [ + 36902, + 101 + ], + "28-up": [ + 37003, + 135 + ], + "29": [ + 45327, + 83 + ], + "29-up": [ + 45410, + 77 + ], + "30": [ + 31542, + 99 + ], + "30-up": [ + 31657, + 77 + ], + "31": [ + 32031, + 115 + ], + "31-up": [ + 32146, + 77 + ], + "32": [ + 32492, + 100 + ], + "32-up": [ + 32592, + 78 + ], + "33": [ + 32973, + 112 + ], + "33-up": [ + 33108, + 77 + ], + "34": [ + 33453, + 103 + ], + "34-up": [ + 33568, + 77 + ], + "35": [ + 33982, + 122 + ], + "35-up": [ + 34114, + 77 + ], + "36": [ + 34425, + 86 + ], + "36-up": [ + 34541, + 77 + ], + "37": [ + 34932, + 111 + ], + "37-up": [ + 35043, + 77 + ], + "38": [ + 35410, + 135 + ], + "38-up": [ + 35545, + 77 + ], + "39": [ + 35914, + 132 + ], + "39-up": [ + 36046, + 77 + ], + "40": [ + 36428, + 129 + ], + "40-up": [ + 36557, + 77 + ], + "41": [ + 12476, + 120 + ], + "41-up": [ + 12596, + 92 + ], + "42": [ + 38136, + 142 + ], + "42-up": [ + 38278, + 116 + ], + "43": [ + 28429, + 108 + ], + "43-up": [ + 28537, + 95 + ], + "44": [ + 38694, + 102 + ], + "44-up": [ + 38796, + 80 + ], + "45": [ + 39148, + 121 + ], + "45-up": [ + 39269, + 91 + ], + "46": [ + 39632, + 120 + ], + "46-up": [ + 39752, + 82 + ], + "47": [ + 40136, + 134 + ], + "47-up": [ + 40270, + 83 + ], + "48": [ + 40621, + 122 + ], + "48-up": [ + 40743, + 105 + ], + "49": [ + 41103, + 131 + ], + "50": [ + 41610, + 186 + ], + "51": [ + 42110, + 183 + ], + "52": [ + 42594, + 180 + ], + "53": [ + 43105, + 190 + ], + "54": [ + 43565, + 273 + ], + "55": [ + 11270, + 200 + ], + "56": [ + 45750, + 164 + ], + "57": [ + 51541, + 287 + ], + "58": [ + 31011, + 251 + ], + "59": [ + 3610, + 195 + ], + "60": [ + 4210, + 180 + ], + "61": [ + 4758, + 180 + ], + "62": [ + 5250, + 199 + ], + "63": [ + 5831, + 209 + ], + "64": [ + 6396, + 210 + ], + "65": [ + 6900, + 210 + ], + "66": [ + 7443, + 221 + ], + "67": [ + 7955, + 181 + ], + "68": [ + 8504, + 209 + ], + "69": [ + 10132, + 208 + ], + "70": [ + 10763, + 160 + ], + "71": [ + 19634, + 171 + ], + "72": [ + 20143, + 202 + ], + "73": [ + 20647, + 193 + ], + "74": [ + 18052, + 186 + ], + "75": [ + 28914, + 212 + ], + "76": [ + 29427, + 177 + ], + "77": [ + 29928, + 190 + ], + "78": [ + 19065, + 220 + ], + "79": [ + 49837, + 176 + ], + "80": [ + 50333, + 179 + ], + "81": [ + 50783, + 221 + ], + "82": [ + 43565, + 273 + ], + "83": [ + 44251, + 220 + ], + "87": [ + 9046, + 187 + ], + "88": [ + 9582, + 191 + ], + "3612": [ + 36902, + 234 + ], + "3613": [ + 49329, + 164 + ], + "3637": [ + 10763, + 160 + ], + "3639": [ + 10132, + 208 + ], + "3640": [ + 48381, + 168 + ], + "3653": [ + 11270, + 200 + ], + "3655": [ + 20143, + 202 + ], + "3657": [ + 20647, + 193 + ], + "3663": [ + 29427, + 177 + ], + "3665": [ + 29928, + 190 + ], + "3666": [ + 19634, + 171 + ], + "3667": [ + 28914, + 212 + ], + "3675": [ + 46199, + 199 + ], + "3676": [ + 47929, + 149 + ], + "3677": [ + 49329, + 164 + ], + "57416": [ + 44251, + 220 + ], + "57419": [ + 49837, + 176 + ], + "57421": [ + 50783, + 221 + ], + "57424": [ + 50333, + 179 + ], + "60999": [ + 20143, + 202 + ], + "61000": [ + 44251, + 220 + ], + "61001": [ + 20647, + 193 + ], + "61003": [ + 49837, + 176 + ], + "61005": [ + 50783, + 221 + ], + "61007": [ + 29427, + 177 + ], + "61008": [ + 50333, + 179 + ], + "61009": [ + 29928, + 190 + ], + "61010": [ + 19634, + 171 + ], + "61011": [ + 28914, + 212 + ] + } +} diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-black-abs/sound.ogg b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-black-abs/sound.ogg new file mode 100644 index 00000000..2318e11d Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-black-abs/sound.ogg differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-black-pbt/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-black-pbt/config.json new file mode 100644 index 00000000..85a847ba --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-black-pbt/config.json @@ -0,0 +1,465 @@ +{ + "id": "custom-sound-pack-1582654366773", + "name": "CherryMX Black - PBT keycaps", + "key_define_type": "single", + "includes_numpad": false, + "sound": "sound.ogg", + "defines": { + "1": [ + 2078, + 176 + ], + "2": [ + 9291, + 164 + ], + "3": [ + 9702, + 153 + ], + "4": [ + 10097, + 140 + ], + "5": [ + 10459, + 165 + ], + "6": [ + 10859, + 165 + ], + "7": [ + 11250, + 165 + ], + "8": [ + 11648, + 159 + ], + "9": [ + 12014, + 150 + ], + "10": [ + 12414, + 136 + ], + "11": [ + 12826, + 137 + ], + "12": [ + 13227, + 138 + ], + "13": [ + 13625, + 134 + ], + "14": [ + 14035, + 137 + ], + "15": [ + 15707, + 163 + ], + "16": [ + 16114, + 157 + ], + "17": [ + 16515, + 150 + ], + "18": [ + 16889, + 147 + ], + "19": [ + 17253, + 161 + ], + "20": [ + 17630, + 156 + ], + "21": [ + 18024, + 140 + ], + "22": [ + 18416, + 134 + ], + "23": [ + 18815, + 140 + ], + "24": [ + 19229, + 128 + ], + "25": [ + 19632, + 127 + ], + "26": [ + 20024, + 131 + ], + "27": [ + 20438, + 131 + ], + "28": [ + 27154, + 171 + ], + "29": [ + 33678, + 163 + ], + "30": [ + 22850, + 160 + ], + "31": [ + 23243, + 156 + ], + "32": [ + 23645, + 155 + ], + "33": [ + 24034, + 152 + ], + "34": [ + 24445, + 146 + ], + "35": [ + 24824, + 148 + ], + "36": [ + 25181, + 164 + ], + "37": [ + 25593, + 145 + ], + "38": [ + 25993, + 139 + ], + "39": [ + 26393, + 136 + ], + "40": [ + 26801, + 128 + ], + "41": [ + 8869, + 181 + ], + "42": [ + 28061, + 186 + ], + "43": [ + 20858, + 129 + ], + "44": [ + 28466, + 161 + ], + "45": [ + 28845, + 151 + ], + "46": [ + 29208, + 156 + ], + "47": [ + 29571, + 144 + ], + "48": [ + 29926, + 159 + ], + "49": [ + 30248, + 139 + ], + "50": [ + 30598, + 140 + ], + "51": [ + 30981, + 130 + ], + "52": [ + 31353, + 128 + ], + "53": [ + 31740, + 126 + ], + "54": [ + 32135, + 169 + ], + "55": [ + 8372, + 140 + ], + "56": [ + 34451, + 164 + ], + "57": [ + 34906, + 168 + ], + "58": [ + 22462, + 166 + ], + "59": [ + 2511, + 186 + ], + "60": [ + 2919, + 177 + ], + "61": [ + 3307, + 185 + ], + "62": [ + 3700, + 176 + ], + "63": [ + 4164, + 148 + ], + "64": [ + 4613, + 155 + ], + "65": [ + 5024, + 150 + ], + "66": [ + 5438, + 147 + ], + "67": [ + 5862, + 149 + ], + "68": [ + 6270, + 138 + ], + "69": [ + 7547, + 134 + ], + "70": [ + 7957, + 141 + ], + "71": [ + 14524, + 141 + ], + "72": [ + 14949, + 119 + ], + "73": [ + 15311, + 143 + ], + "74": [ + 13227, + 138 + ], + "75": [ + 21286, + 113 + ], + "76": [ + 21671, + 146 + ], + "77": [ + 22078, + 134 + ], + "78": [ + 14035, + 137 + ], + "79": [ + 36831, + 144 + ], + "80": [ + 37213, + 144 + ], + "81": [ + 37569, + 155 + ], + "82": [ + 32135, + 169 + ], + "83": [ + 32948, + 138 + ], + "87": [ + 6697, + 137 + ], + "88": [ + 7122, + 136 + ], + "3612": [ + 27154, + 171 + ], + "3613": [ + 36449, + 137 + ], + "3637": [ + 7957, + 141 + ], + "3639": [ + 7547, + 134 + ], + "3640": [ + 35316, + 145 + ], + "3653": [ + 8372, + 140 + ], + "3655": [ + 14949, + 119 + ], + "3657": [ + 15311, + 143 + ], + "3663": [ + 21671, + 146 + ], + "3665": [ + 22078, + 134 + ], + "3666": [ + 14524, + 141 + ], + "3667": [ + 21286, + 113 + ], + "3675": [ + 34093, + 157 + ], + "3676": [ + 35716, + 136 + ], + "3677": [ + 36449, + 137 + ], + "57416": [ + 32948, + 138 + ], + "57419": [ + 36831, + 144 + ], + "57421": [ + 37569, + 155 + ], + "57424": [ + 37213, + 144 + ], + "60999": [ + 14949, + 119 + ], + "61000": [ + 32948, + 138 + ], + "61001": [ + 15311, + 143 + ], + "61003": [ + 36831, + 144 + ], + "61005": [ + 37569, + 155 + ], + "61007": [ + 21671, + 146 + ], + "61008": [ + 37213, + 144 + ], + "61009": [ + 22078, + 134 + ], + "61010": [ + 14524, + 141 + ], + "61011": [ + 21286, + 113 + ] + } +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-black-pbt/sound.ogg b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-black-pbt/sound.ogg new file mode 100644 index 00000000..d1ca8014 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-black-pbt/sound.ogg differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-blue-abs/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-blue-abs/config.json new file mode 100644 index 00000000..94319ac3 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-blue-abs/config.json @@ -0,0 +1,465 @@ +{ + "id": "custom-sound-pack-1582707445334", + "name": "CherryMX Blue - ABS keycaps", + "key_define_type": "single", + "includes_numpad": false, + "sound": "sound.ogg", + "defines": { + "1": [ + 1754, + 184 + ], + "2": [ + 10135, + 199 + ], + "3": [ + 10562, + 185 + ], + "4": [ + 10966, + 189 + ], + "5": [ + 11329, + 199 + ], + "6": [ + 11706, + 196 + ], + "7": [ + 12094, + 180 + ], + "8": [ + 12467, + 184 + ], + "9": [ + 12863, + 190 + ], + "10": [ + 13248, + 195 + ], + "11": [ + 13633, + 170 + ], + "12": [ + 13988, + 186 + ], + "13": [ + 14372, + 180 + ], + "14": [ + 14748, + 212 + ], + "15": [ + 16940, + 179 + ], + "16": [ + 17316, + 199 + ], + "17": [ + 17700, + 172 + ], + "18": [ + 18054, + 187 + ], + "19": [ + 18400, + 184 + ], + "20": [ + 18761, + 176 + ], + "21": [ + 19116, + 188 + ], + "22": [ + 19495, + 186 + ], + "23": [ + 19876, + 174 + ], + "24": [ + 20238, + 170 + ], + "25": [ + 20605, + 158 + ], + "26": [ + 20976, + 164 + ], + "27": [ + 21348, + 158 + ], + "28": [ + 28558, + 161 + ], + "29": [ + 35733, + 190 + ], + "30": [ + 24330, + 196 + ], + "31": [ + 24700, + 202 + ], + "32": [ + 25071, + 194 + ], + "33": [ + 25444, + 206 + ], + "34": [ + 25803, + 188 + ], + "35": [ + 26159, + 185 + ], + "36": [ + 26534, + 168 + ], + "37": [ + 26928, + 190 + ], + "38": [ + 27347, + 180 + ], + "39": [ + 27733, + 183 + ], + "40": [ + 28157, + 176 + ], + "41": [ + 9749, + 195 + ], + "42": [ + 29603, + 226 + ], + "43": [ + 21707, + 182 + ], + "44": [ + 30046, + 175 + ], + "45": [ + 30385, + 177 + ], + "46": [ + 30761, + 189 + ], + "47": [ + 31123, + 191 + ], + "48": [ + 31475, + 196 + ], + "49": [ + 31891, + 169 + ], + "50": [ + 32333, + 175 + ], + "51": [ + 33011, + 186 + ], + "52": [ + 33438, + 172 + ], + "53": [ + 33828, + 178 + ], + "54": [ + 34215, + 180 + ], + "55": [ + 7583, + 193 + ], + "56": [ + 36465, + 214 + ], + "57": [ + 36804, + 240 + ], + "58": [ + 23925, + 207 + ], + "59": [ + 2222, + 186 + ], + "60": [ + 2617, + 180 + ], + "61": [ + 3028, + 189 + ], + "62": [ + 3385, + 223 + ], + "63": [ + 3792, + 193 + ], + "64": [ + 4136, + 212 + ], + "65": [ + 4540, + 188 + ], + "66": [ + 4903, + 193 + ], + "67": [ + 5296, + 193 + ], + "68": [ + 5666, + 183 + ], + "69": [ + 6818, + 167 + ], + "70": [ + 7187, + 183 + ], + "71": [ + 15156, + 180 + ], + "72": [ + 15526, + 204 + ], + "73": [ + 15893, + 157 + ], + "74": [ + 13988, + 186 + ], + "75": [ + 22116, + 179 + ], + "76": [ + 22513, + 173 + ], + "77": [ + 22862, + 158 + ], + "78": [ + 14748, + 212 + ], + "79": [ + 39220, + 169 + ], + "80": [ + 39589, + 179 + ], + "81": [ + 39954, + 183 + ], + "82": [ + 34215, + 180 + ], + "83": [ + 34704, + 159 + ], + "87": [ + 6054, + 180 + ], + "88": [ + 6425, + 182 + ], + "3612": [ + 28558, + 161 + ], + "3613": [ + 38821, + 188 + ], + "3637": [ + 7187, + 183 + ], + "3639": [ + 6818, + 167 + ], + "3640": [ + 37730, + 184 + ], + "3653": [ + 7583, + 193 + ], + "3655": [ + 15526, + 204 + ], + "3657": [ + 15893, + 157 + ], + "3663": [ + 22513, + 173 + ], + "3665": [ + 22862, + 158 + ], + "3666": [ + 15156, + 180 + ], + "3667": [ + 22116, + 179 + ], + "3675": [ + 36115, + 205 + ], + "3676": [ + 38116, + 184 + ], + "3677": [ + 38821, + 188 + ], + "57416": [ + 34704, + 159 + ], + "57419": [ + 39220, + 169 + ], + "57421": [ + 39954, + 183 + ], + "57424": [ + 39589, + 179 + ], + "60999": [ + 15526, + 204 + ], + "61000": [ + 34704, + 159 + ], + "61001": [ + 15893, + 157 + ], + "61003": [ + 39220, + 169 + ], + "61005": [ + 39954, + 183 + ], + "61007": [ + 22513, + 173 + ], + "61008": [ + 39589, + 179 + ], + "61009": [ + 22862, + 158 + ], + "61010": [ + 15156, + 180 + ], + "61011": [ + 22116, + 179 + ] + } +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-blue-abs/sound.ogg b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-blue-abs/sound.ogg new file mode 100644 index 00000000..112c361f Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-blue-abs/sound.ogg differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-blue-pbt/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-blue-pbt/config.json new file mode 100644 index 00000000..30acc7e2 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-blue-pbt/config.json @@ -0,0 +1,465 @@ +{ + "id": "custom-sound-pack-1582707445334", + "name": "CherryMX Blue - PBT keycaps", + "key_define_type": "single", + "includes_numpad": false, + "sound": "sound.ogg", + "defines": { + "1": [ + 1203, + 192 + ], + "2": [ + 10794, + 197 + ], + "3": [ + 11315, + 196 + ], + "4": [ + 11814, + 198 + ], + "5": [ + 12334, + 185 + ], + "6": [ + 12845, + 196 + ], + "7": [ + 13305, + 188 + ], + "8": [ + 13846, + 187 + ], + "9": [ + 14362, + 197 + ], + "10": [ + 14881, + 188 + ], + "11": [ + 15408, + 180 + ], + "12": [ + 15901, + 200 + ], + "13": [ + 16410, + 182 + ], + "14": [ + 16906, + 230 + ], + "15": [ + 19473, + 191 + ], + "16": [ + 19944, + 207 + ], + "17": [ + 20490, + 183 + ], + "18": [ + 20995, + 192 + ], + "19": [ + 21500, + 186 + ], + "20": [ + 21993, + 198 + ], + "21": [ + 22495, + 185 + ], + "22": [ + 22970, + 194 + ], + "23": [ + 23492, + 179 + ], + "24": [ + 23972, + 212 + ], + "25": [ + 24468, + 181 + ], + "26": [ + 24955, + 187 + ], + "27": [ + 25463, + 172 + ], + "28": [ + 34274, + 204 + ], + "29": [ + 42603, + 172 + ], + "30": [ + 28961, + 164 + ], + "31": [ + 29448, + 171 + ], + "32": [ + 29968, + 163 + ], + "33": [ + 30454, + 208 + ], + "34": [ + 30944, + 172 + ], + "35": [ + 31395, + 186 + ], + "36": [ + 31884, + 174 + ], + "37": [ + 32358, + 172 + ], + "38": [ + 32358, + 172 + ], + "39": [ + 33331, + 191 + ], + "40": [ + 33824, + 166 + ], + "41": [ + 10318, + 183 + ], + "42": [ + 35598, + 197 + ], + "43": [ + 25952, + 184 + ], + "44": [ + 36143, + 204 + ], + "45": [ + 36645, + 165 + ], + "46": [ + 37120, + 164 + ], + "47": [ + 37606, + 181 + ], + "48": [ + 38062, + 179 + ], + "49": [ + 38563, + 163 + ], + "50": [ + 39025, + 171 + ], + "51": [ + 39505, + 172 + ], + "52": [ + 39982, + 152 + ], + "53": [ + 40437, + 170 + ], + "54": [ + 40939, + 176 + ], + "55": [ + 9180, + 193 + ], + "56": [ + 43991, + 192 + ], + "57": [ + 49628, + 230 + ], + "58": [ + 28470, + 191 + ], + "59": [ + 1783, + 185 + ], + "60": [ + 2336, + 204 + ], + "61": [ + 2884, + 182 + ], + "62": [ + 3404, + 188 + ], + "63": [ + 3940, + 168 + ], + "64": [ + 4487, + 165 + ], + "65": [ + 5013, + 178 + ], + "66": [ + 5510, + 187 + ], + "67": [ + 6048, + 207 + ], + "68": [ + 6609, + 174 + ], + "69": [ + 8159, + 221 + ], + "70": [ + 8668, + 222 + ], + "71": [ + 17483, + 175 + ], + "72": [ + 17955, + 189 + ], + "73": [ + 18419, + 199 + ], + "74": [ + 15901, + 200 + ], + "75": [ + 26567, + 177 + ], + "76": [ + 27069, + 190 + ], + "77": [ + 27541, + 169 + ], + "78": [ + 16906, + 230 + ], + "79": [ + 47492, + 178 + ], + "80": [ + 47981, + 201 + ], + "81": [ + 48412, + 201 + ], + "82": [ + 40939, + 176 + ], + "83": [ + 41501, + 174 + ], + "87": [ + 7119, + 175 + ], + "88": [ + 7650, + 174 + ], + "3612": [ + 34274, + 204 + ], + "3613": [ + 47002, + 188 + ], + "3637": [ + 8668, + 222 + ], + "3639": [ + 8159, + 221 + ], + "3640": [ + 46069, + 202 + ], + "3653": [ + 9180, + 193 + ], + "3655": [ + 17955, + 189 + ], + "3657": [ + 18419, + 199 + ], + "3663": [ + 27069, + 190 + ], + "3665": [ + 27541, + 169 + ], + "3666": [ + 17483, + 175 + ], + "3667": [ + 26567, + 177 + ], + "3675": [ + 43435, + 155 + ], + "3676": [ + 46544, + 174 + ], + "3677": [ + 47002, + 188 + ], + "57416": [ + 41501, + 174 + ], + "57419": [ + 47492, + 178 + ], + "57421": [ + 48412, + 201 + ], + "57424": [ + 47981, + 201 + ], + "60999": [ + 17955, + 189 + ], + "61000": [ + 41501, + 174 + ], + "61001": [ + 18419, + 199 + ], + "61003": [ + 47492, + 178 + ], + "61005": [ + 48412, + 201 + ], + "61007": [ + 27069, + 190 + ], + "61008": [ + 47981, + 201 + ], + "61009": [ + 27541, + 169 + ], + "61010": [ + 17483, + 175 + ], + "61011": [ + 26567, + 177 + ] + } +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-blue-pbt/sound.ogg b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-blue-pbt/sound.ogg new file mode 100644 index 00000000..440f1428 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-blue-pbt/sound.ogg differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-brown-abs/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-brown-abs/config.json new file mode 100644 index 00000000..085fa0ce --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-brown-abs/config.json @@ -0,0 +1,465 @@ +{ + "id": "custom-sound-pack-1582656695035", + "name": "CherryMX Brown - ABS keycaps", + "key_define_type": "single", + "includes_numpad": false, + "sound": "sound.ogg", + "defines": { + "1": [ + 1402, + 170 + ], + "2": [ + 8760, + 173 + ], + "3": [ + 9149, + 167 + ], + "4": [ + 9550, + 165 + ], + "5": [ + 9942, + 150 + ], + "6": [ + 10370, + 145 + ], + "7": [ + 10791, + 140 + ], + "8": [ + 11205, + 150 + ], + "9": [ + 11603, + 136 + ], + "10": [ + 12012, + 148 + ], + "11": [ + 12440, + 140 + ], + "12": [ + 12844, + 131 + ], + "13": [ + 13231, + 132 + ], + "14": [ + 13667, + 134 + ], + "15": [ + 15286, + 161 + ], + "16": [ + 15665, + 180 + ], + "17": [ + 16052, + 171 + ], + "18": [ + 16411, + 174 + ], + "19": [ + 16772, + 158 + ], + "20": [ + 17124, + 178 + ], + "21": [ + 17478, + 142 + ], + "22": [ + 17843, + 146 + ], + "23": [ + 18212, + 139 + ], + "24": [ + 18611, + 148 + ], + "25": [ + 19036, + 134 + ], + "26": [ + 19440, + 127 + ], + "27": [ + 19854, + 192 + ], + "28": [ + 26548, + 119 + ], + "29": [ + 32761, + 168 + ], + "30": [ + 22324, + 167 + ], + "31": [ + 22698, + 167 + ], + "32": [ + 23072, + 163 + ], + "33": [ + 23408, + 178 + ], + "34": [ + 23770, + 183 + ], + "35": [ + 24140, + 145 + ], + "36": [ + 24525, + 154 + ], + "37": [ + 24919, + 138 + ], + "38": [ + 25314, + 142 + ], + "39": [ + 25711, + 129 + ], + "40": [ + 26121, + 123 + ], + "41": [ + 8365, + 166 + ], + "42": [ + 27208, + 158 + ], + "43": [ + 20262, + 143 + ], + "44": [ + 27589, + 164 + ], + "45": [ + 27978, + 160 + ], + "46": [ + 28321, + 160 + ], + "47": [ + 28694, + 162 + ], + "48": [ + 29071, + 152 + ], + "49": [ + 29485, + 159 + ], + "50": [ + 29873, + 137 + ], + "51": [ + 30269, + 139 + ], + "52": [ + 30664, + 132 + ], + "53": [ + 31026, + 138 + ], + "54": [ + 31455, + 139 + ], + "55": [ + 7874, + 148 + ], + "56": [ + 33530, + 170 + ], + "57": [ + 37338, + 200 + ], + "58": [ + 21920, + 176 + ], + "59": [ + 1852, + 162 + ], + "60": [ + 2275, + 155 + ], + "61": [ + 2668, + 167 + ], + "62": [ + 3054, + 162 + ], + "63": [ + 3467, + 161 + ], + "64": [ + 3877, + 151 + ], + "65": [ + 4501, + 120 + ], + "66": [ + 4951, + 141 + ], + "67": [ + 5381, + 133 + ], + "68": [ + 5779, + 145 + ], + "69": [ + 7023, + 161 + ], + "70": [ + 7448, + 142 + ], + "71": [ + 14084, + 125 + ], + "72": [ + 14500, + 142 + ], + "73": [ + 14877, + 146 + ], + "74": [ + 12844, + 131 + ], + "75": [ + 20713, + 139 + ], + "76": [ + 21121, + 151 + ], + "77": [ + 21527, + 142 + ], + "78": [ + 13667, + 134 + ], + "79": [ + 35966, + 143 + ], + "80": [ + 36356, + 147 + ], + "81": [ + 36787, + 170 + ], + "82": [ + 31455, + 139 + ], + "83": [ + 32054, + 167 + ], + "87": [ + 6207, + 139 + ], + "88": [ + 6637, + 127 + ], + "3612": [ + 26548, + 119 + ], + "3613": [ + 35572, + 139 + ], + "3637": [ + 7448, + 142 + ], + "3639": [ + 7023, + 161 + ], + "3640": [ + 34354, + 157 + ], + "3653": [ + 7874, + 148 + ], + "3655": [ + 14500, + 142 + ], + "3657": [ + 14877, + 146 + ], + "3663": [ + 21121, + 151 + ], + "3665": [ + 21527, + 142 + ], + "3666": [ + 14084, + 125 + ], + "3667": [ + 20713, + 139 + ], + "3675": [ + 33157, + 170 + ], + "3676": [ + 34762, + 147 + ], + "3677": [ + 35572, + 139 + ], + "57416": [ + 32054, + 167 + ], + "57419": [ + 35966, + 143 + ], + "57421": [ + 36787, + 170 + ], + "57424": [ + 36356, + 147 + ], + "60999": [ + 14500, + 142 + ], + "61000": [ + 32054, + 167 + ], + "61001": [ + 14877, + 146 + ], + "61003": [ + 35966, + 143 + ], + "61005": [ + 36787, + 170 + ], + "61007": [ + 21121, + 151 + ], + "61008": [ + 36356, + 147 + ], + "61009": [ + 21527, + 142 + ], + "61010": [ + 14084, + 125 + ], + "61011": [ + 20713, + 139 + ] + } +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-brown-abs/sound.ogg b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-brown-abs/sound.ogg new file mode 100644 index 00000000..8caf39c9 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-brown-abs/sound.ogg differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-brown-pbt/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-brown-pbt/config.json new file mode 100644 index 00000000..55ab971b --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-brown-pbt/config.json @@ -0,0 +1,465 @@ +{ + "id": "custom-sound-pack-1582656695035", + "name": "CherryMX Brown - PBT keycaps", + "key_define_type": "single", + "includes_numpad": false, + "sound": "sound.ogg", + "defines": { + "1": [ + 2280, + 199 + ], + "2": [ + 9444, + 185 + ], + "3": [ + 9833, + 187 + ], + "4": [ + 10185, + 194 + ], + "5": [ + 10551, + 196 + ], + "6": [ + 10899, + 194 + ], + "7": [ + 11282, + 180 + ], + "8": [ + 11623, + 188 + ], + "9": [ + 11976, + 200 + ], + "10": [ + 12337, + 197 + ], + "11": [ + 12667, + 194 + ], + "12": [ + 13058, + 191 + ], + "13": [ + 13408, + 187 + ], + "14": [ + 13765, + 184 + ], + "15": [ + 15916, + 176 + ], + "16": [ + 16284, + 150 + ], + "17": [ + 16637, + 176 + ], + "18": [ + 16964, + 190 + ], + "19": [ + 17275, + 185 + ], + "20": [ + 17613, + 196 + ], + "21": [ + 17957, + 173 + ], + "22": [ + 18301, + 190 + ], + "23": [ + 18643, + 200 + ], + "24": [ + 18994, + 178 + ], + "25": [ + 19331, + 197 + ], + "26": [ + 19671, + 171 + ], + "27": [ + 20020, + 175 + ], + "28": [ + 26703, + 181 + ], + "29": [ + 33857, + 182 + ], + "30": [ + 22869, + 198 + ], + "31": [ + 23237, + 178 + ], + "32": [ + 23586, + 187 + ], + "33": [ + 23898, + 179 + ], + "34": [ + 24237, + 185 + ], + "35": [ + 24550, + 192 + ], + "36": [ + 24917, + 188 + ], + "37": [ + 25274, + 185 + ], + "38": [ + 25625, + 183 + ], + "39": [ + 25989, + 182 + ], + "40": [ + 26335, + 180 + ], + "41": [ + 9069, + 209 + ], + "42": [ + 28109, + 180 + ], + "43": [ + 20387, + 176 + ], + "44": [ + 28550, + 167 + ], + "45": [ + 28855, + 184 + ], + "46": [ + 29557, + 204 + ], + "47": [ + 29557, + 204 + ], + "48": [ + 29909, + 179 + ], + "49": [ + 30252, + 203 + ], + "50": [ + 30605, + 184 + ], + "51": [ + 30965, + 212 + ], + "52": [ + 31315, + 176 + ], + "53": [ + 31659, + 175 + ], + "54": [ + 32015, + 184 + ], + "55": [ + 8036, + 168 + ], + "56": [ + 34551, + 175 + ], + "57": [ + 34910, + 223 + ], + "58": [ + 22560, + 181 + ], + "59": [ + 2754, + 189 + ], + "60": [ + 3155, + 180 + ], + "61": [ + 3545, + 187 + ], + "62": [ + 3913, + 183 + ], + "63": [ + 4305, + 174 + ], + "64": [ + 4666, + 187 + ], + "65": [ + 5034, + 200 + ], + "66": [ + 5433, + 187 + ], + "67": [ + 7795, + 198 + ], + "68": [ + 6146, + 191 + ], + "69": [ + 7322, + 177 + ], + "70": [ + 7699, + 178 + ], + "71": [ + 14199, + 181 + ], + "72": [ + 14522, + 196 + ], + "73": [ + 14852, + 169 + ], + "74": [ + 13058, + 191 + ], + "75": [ + 20766, + 185 + ], + "76": [ + 21102, + 187 + ], + "77": [ + 21409, + 151 + ], + "78": [ + 13765, + 184 + ], + "79": [ + 36907, + 163 + ], + "80": [ + 37267, + 170 + ], + "81": [ + 37586, + 160 + ], + "82": [ + 32015, + 184 + ], + "83": [ + 32429, + 174 + ], + "87": [ + 6560, + 196 + ], + "88": [ + 6932, + 193 + ], + "3612": [ + 26703, + 181 + ], + "3613": [ + 36571, + 182 + ], + "3637": [ + 7699, + 178 + ], + "3639": [ + 7322, + 177 + ], + "3640": [ + 35492, + 157 + ], + "3653": [ + 8036, + 168 + ], + "3655": [ + 14522, + 196 + ], + "3657": [ + 14852, + 169 + ], + "3663": [ + 21102, + 187 + ], + "3665": [ + 21409, + 151 + ], + "3666": [ + 14199, + 181 + ], + "3667": [ + 20766, + 185 + ], + "3675": [ + 34181, + 177 + ], + "3676": [ + 35878, + 164 + ], + "3677": [ + 36571, + 182 + ], + "57416": [ + 32429, + 174 + ], + "57419": [ + 36907, + 163 + ], + "57421": [ + 37586, + 160 + ], + "57424": [ + 37267, + 170 + ], + "60999": [ + 14522, + 196 + ], + "61000": [ + 32429, + 174 + ], + "61001": [ + 14852, + 169 + ], + "61003": [ + 36907, + 163 + ], + "61005": [ + 37586, + 160 + ], + "61007": [ + 21102, + 187 + ], + "61008": [ + 37267, + 170 + ], + "61009": [ + 21409, + 151 + ], + "61010": [ + 14199, + 181 + ], + "61011": [ + 20766, + 185 + ] + } +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-brown-pbt/sound.ogg b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-brown-pbt/sound.ogg new file mode 100644 index 00000000..bba9c3b0 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-brown-pbt/sound.ogg differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-red-abs/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-red-abs/config.json new file mode 100644 index 00000000..ea638b57 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-red-abs/config.json @@ -0,0 +1,465 @@ +{ + "id": "custom-sound-pack-1582707445334", + "name": "CherryMX Red - ABS keycaps", + "key_define_type": "single", + "includes_numpad": false, + "sound": "sound.ogg", + "defines": { + "1": [ + 2464, + 170 + ], + "2": [ + 11371, + 178 + ], + "3": [ + 11846, + 169 + ], + "4": [ + 12302, + 194 + ], + "5": [ + 12746, + 214 + ], + "6": [ + 13183, + 174 + ], + "7": [ + 13619, + 183 + ], + "8": [ + 14064, + 177 + ], + "9": [ + 14504, + 188 + ], + "10": [ + 14956, + 166 + ], + "11": [ + 15408, + 211 + ], + "12": [ + 15823, + 192 + ], + "13": [ + 16291, + 181 + ], + "14": [ + 16727, + 227 + ], + "15": [ + 19161, + 185 + ], + "16": [ + 19606, + 168 + ], + "17": [ + 20024, + 194 + ], + "18": [ + 20478, + 175 + ], + "19": [ + 20892, + 171 + ], + "20": [ + 21250, + 177 + ], + "21": [ + 21670, + 186 + ], + "22": [ + 22098, + 197 + ], + "23": [ + 22601, + 174 + ], + "24": [ + 23102, + 181 + ], + "25": [ + 23594, + 186 + ], + "26": [ + 24062, + 150 + ], + "27": [ + 24507, + 192 + ], + "28": [ + 32833, + 190 + ], + "29": [ + 40730, + 163 + ], + "30": [ + 27942, + 151 + ], + "31": [ + 28366, + 177 + ], + "32": [ + 28771, + 168 + ], + "33": [ + 29171, + 201 + ], + "34": [ + 29577, + 175 + ], + "35": [ + 29986, + 189 + ], + "36": [ + 30413, + 183 + ], + "37": [ + 30821, + 196 + ], + "38": [ + 31527, + 243 + ], + "39": [ + 32024, + 192 + ], + "40": [ + 32459, + 193 + ], + "41": [ + 10927, + 181 + ], + "42": [ + 34277, + 212 + ], + "43": [ + 24956, + 155 + ], + "44": [ + 34773, + 192 + ], + "45": [ + 35220, + 199 + ], + "46": [ + 35671, + 155 + ], + "47": [ + 36089, + 158 + ], + "48": [ + 36453, + 180 + ], + "49": [ + 36825, + 190 + ], + "50": [ + 37239, + 189 + ], + "51": [ + 37706, + 168 + ], + "52": [ + 38150, + 177 + ], + "53": [ + 38597, + 191 + ], + "54": [ + 39015, + 200 + ], + "55": [ + 9720, + 182 + ], + "56": [ + 41606, + 182 + ], + "57": [ + 42071, + 230 + ], + "58": [ + 27462, + 179 + ], + "59": [ + 3016, + 182 + ], + "60": [ + 3534, + 177 + ], + "61": [ + 4003, + 180 + ], + "62": [ + 3924, + 200 + ], + "63": [ + 4472, + 188 + ], + "64": [ + 4988, + 179 + ], + "65": [ + 5476, + 168 + ], + "66": [ + 5956, + 183 + ], + "67": [ + 6419, + 208 + ], + "68": [ + 6927, + 202 + ], + "69": [ + 8367, + 180 + ], + "70": [ + 9329, + 184 + ], + "71": [ + 17343, + 177 + ], + "72": [ + 17757, + 181 + ], + "73": [ + 18135, + 163 + ], + "74": [ + 15823, + 192 + ], + "75": [ + 25449, + 166 + ], + "76": [ + 25889, + 175 + ], + "77": [ + 26298, + 141 + ], + "78": [ + 16727, + 227 + ], + "79": [ + 44811, + 202 + ], + "80": [ + 45226, + 203 + ], + "81": [ + 45618, + 197 + ], + "82": [ + 39015, + 200 + ], + "83": [ + 39528, + 180 + ], + "87": [ + 7434, + 188 + ], + "88": [ + 7908, + 182 + ], + "3612": [ + 32833, + 190 + ], + "3613": [ + 44374, + 197 + ], + "3637": [ + 9329, + 184 + ], + "3639": [ + 8367, + 180 + ], + "3640": [ + 43033, + 175 + ], + "3653": [ + 9720, + 182 + ], + "3655": [ + 17757, + 181 + ], + "3657": [ + 18135, + 163 + ], + "3663": [ + 25889, + 175 + ], + "3665": [ + 26298, + 141 + ], + "3666": [ + 17343, + 177 + ], + "3667": [ + 25449, + 166 + ], + "3675": [ + 41166, + 216 + ], + "3676": [ + 43497, + 203 + ], + "3677": [ + 44374, + 197 + ], + "57416": [ + 39528, + 180 + ], + "57419": [ + 44811, + 202 + ], + "57421": [ + 45618, + 197 + ], + "57424": [ + 45226, + 203 + ], + "60999": [ + 17757, + 181 + ], + "61000": [ + 39528, + 180 + ], + "61001": [ + 18135, + 163 + ], + "61003": [ + 44811, + 202 + ], + "61005": [ + 45618, + 197 + ], + "61007": [ + 25889, + 175 + ], + "61008": [ + 45226, + 203 + ], + "61009": [ + 26298, + 141 + ], + "61010": [ + 17343, + 177 + ], + "61011": [ + 25449, + 166 + ] + } +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-red-abs/sound.ogg b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-red-abs/sound.ogg new file mode 100644 index 00000000..23f528e9 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-red-abs/sound.ogg differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-red-pbt/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-red-pbt/config.json new file mode 100644 index 00000000..b387cde6 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-red-pbt/config.json @@ -0,0 +1,465 @@ +{ + "id": "custom-sound-pack-1582707445334", + "name": "CherryMX Red - PBT keycaps", + "key_define_type": "single", + "includes_numpad": false, + "sound": "sound.ogg", + "defines": { + "1": [ + 3339, + 187 + ], + "2": [ + 11673, + 137 + ], + "3": [ + 12175, + 143 + ], + "4": [ + 12601, + 185 + ], + "5": [ + 13056, + 152 + ], + "6": [ + 13512, + 150 + ], + "7": [ + 13919, + 160 + ], + "8": [ + 14411, + 147 + ], + "9": [ + 14853, + 182 + ], + "10": [ + 15330, + 162 + ], + "11": [ + 15771, + 182 + ], + "12": [ + 16215, + 158 + ], + "13": [ + 16662, + 170 + ], + "14": [ + 17067, + 207 + ], + "15": [ + 19327, + 196 + ], + "16": [ + 19717, + 153 + ], + "17": [ + 20144, + 159 + ], + "18": [ + 20556, + 150 + ], + "19": [ + 20966, + 158 + ], + "20": [ + 21371, + 193 + ], + "21": [ + 21836, + 186 + ], + "22": [ + 22303, + 163 + ], + "23": [ + 22724, + 198 + ], + "24": [ + 23198, + 170 + ], + "25": [ + 23643, + 168 + ], + "26": [ + 24072, + 179 + ], + "27": [ + 24527, + 175 + ], + "28": [ + 32734, + 230 + ], + "29": [ + 40191, + 178 + ], + "30": [ + 27803, + 158 + ], + "31": [ + 28294, + 151 + ], + "32": [ + 28716, + 178 + ], + "33": [ + 29158, + 182 + ], + "34": [ + 29563, + 156 + ], + "35": [ + 30053, + 155 + ], + "36": [ + 30477, + 148 + ], + "37": [ + 30945, + 155 + ], + "38": [ + 31372, + 172 + ], + "39": [ + 31812, + 188 + ], + "40": [ + 32290, + 200 + ], + "41": [ + 11265, + 179 + ], + "42": [ + 33773, + 225 + ], + "43": [ + 24965, + 196 + ], + "44": [ + 34211, + 139 + ], + "45": [ + 34623, + 154 + ], + "46": [ + 34981, + 174 + ], + "47": [ + 35448, + 156 + ], + "48": [ + 35870, + 188 + ], + "49": [ + 36310, + 170 + ], + "50": [ + 36715, + 188 + ], + "51": [ + 37150, + 165 + ], + "52": [ + 37577, + 177 + ], + "53": [ + 38025, + 173 + ], + "54": [ + 38477, + 180 + ], + "55": [ + 10319, + 214 + ], + "56": [ + 40985, + 175 + ], + "57": [ + 47621, + 300 + ], + "58": [ + 27372, + 190 + ], + "59": [ + 3878, + 151 + ], + "60": [ + 4347, + 169 + ], + "61": [ + 4825, + 166 + ], + "62": [ + 5272, + 151 + ], + "63": [ + 5737, + 157 + ], + "64": [ + 6174, + 1194 + ], + "65": [ + 6635, + 198 + ], + "66": [ + 7088, + 186 + ], + "67": [ + 7578, + 195 + ], + "68": [ + 7989, + 222 + ], + "69": [ + 9455, + 168 + ], + "70": [ + 9935, + 145 + ], + "71": [ + 17568, + 158 + ], + "72": [ + 18000, + 195 + ], + "73": [ + 18413, + 166 + ], + "74": [ + 16215, + 158 + ], + "75": [ + 25461, + 177 + ], + "76": [ + 25888, + 193 + ], + "77": [ + 26353, + 210 + ], + "78": [ + 17067, + 207 + ], + "79": [ + 44074, + 175 + ], + "80": [ + 44511, + 178 + ], + "81": [ + 44989, + 179 + ], + "82": [ + 38477, + 180 + ], + "83": [ + 39044, + 186 + ], + "87": [ + 8467, + 205 + ], + "88": [ + 8958, + 190 + ], + "3612": [ + 32734, + 230 + ], + "3613": [ + 43623, + 175 + ], + "3637": [ + 9935, + 145 + ], + "3639": [ + 9455, + 168 + ], + "3640": [ + 42367, + 194 + ], + "3653": [ + 10319, + 214 + ], + "3655": [ + 18000, + 195 + ], + "3657": [ + 18413, + 166 + ], + "3663": [ + 25888, + 193 + ], + "3665": [ + 26353, + 210 + ], + "3666": [ + 17568, + 158 + ], + "3667": [ + 25461, + 177 + ], + "3675": [ + 40588, + 184 + ], + "3676": [ + 43196, + 203 + ], + "3677": [ + 43623, + 175 + ], + "57416": [ + 39044, + 186 + ], + "57419": [ + 44074, + 175 + ], + "57421": [ + 44989, + 179 + ], + "57424": [ + 44511, + 178 + ], + "60999": [ + 18000, + 195 + ], + "61000": [ + 39044, + 186 + ], + "61001": [ + 18413, + 166 + ], + "61003": [ + 44074, + 175 + ], + "61005": [ + 44989, + 179 + ], + "61007": [ + 25888, + 193 + ], + "61008": [ + 44511, + 178 + ], + "61009": [ + 26353, + 210 + ], + "61010": [ + 17568, + 158 + ], + "61011": [ + 25461, + 177 + ] + } +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-red-pbt/sound.ogg b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-red-pbt/sound.ogg new file mode 100644 index 00000000..7c5e8ee4 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cherrymx-red-pbt/sound.ogg differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/config.json new file mode 100644 index 00000000..96288884 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/config.json @@ -0,0 +1,16 @@ +{ + "id": "custom-sound-pack-1720300926821", + "name": "Full Creamy Goodness", + "key_define_type": "multi", + "sound": "press/GENERIC_R{0-4}.mp3", + "soundup": "release/GENERIC.mp3", + "defines": { + "14": "press/BACKSPACE.mp3", + "14-up": "release/BACKSPACE.mp3", + "28": "press/ENTER.mp3", + "28-up": "release/ENTER.mp3", + "57": "press/SPACE.mp3", + "57-up": "release/SPACE.mp3" + }, + "version": 2 +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/BACKSPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/BACKSPACE.mp3 new file mode 100644 index 00000000..1a78cabe Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/BACKSPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/ENTER.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/ENTER.mp3 new file mode 100644 index 00000000..e49e835f Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/ENTER.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R0.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R0.mp3 new file mode 100644 index 00000000..62c7d847 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R0.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R1.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R1.mp3 new file mode 100644 index 00000000..2ccc7f84 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R1.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R2.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R2.mp3 new file mode 100644 index 00000000..f24ba508 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R2.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R3.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R3.mp3 new file mode 100644 index 00000000..6e647c52 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R3.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R4.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R4.mp3 new file mode 100644 index 00000000..b3c9544e Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/GENERIC_R4.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/SPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/SPACE.mp3 new file mode 100644 index 00000000..fb7fb8ea Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/press/SPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/release/BACKSPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/release/BACKSPACE.mp3 new file mode 100644 index 00000000..3ad32b9a Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/release/BACKSPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/release/ENTER.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/release/ENTER.mp3 new file mode 100644 index 00000000..defbcbb0 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/release/ENTER.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/release/GENERIC.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/release/GENERIC.mp3 new file mode 100644 index 00000000..4da558aa Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/release/GENERIC.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/release/SPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/release/SPACE.mp3 new file mode 100644 index 00000000..777c5ac3 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/cream-travel/release/SPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/eg-crystal-purple/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/eg-crystal-purple/config.json new file mode 100644 index 00000000..1961af56 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/eg-crystal-purple/config.json @@ -0,0 +1,469 @@ +{ + "id": "custom-sound-pack-1605530100183", + "name": "EG Crystal Purple", + "key_define_type": "single", + "includes_numpad": false, + "sound": "purple.ogg", + "defines": { + "1": [ + 1088, + 118 + ], + "2": [ + 1839, + 140 + ], + "3": [ + 2207, + 143 + ], + "4": [ + 2507, + 143 + ], + "5": [ + 2809, + 148 + ], + "6": [ + 3099, + 141 + ], + "7": [ + 3408, + 134 + ], + "8": [ + 3716, + 147 + ], + "9": [ + 4017, + 139 + ], + "10": [ + 4352, + 144 + ], + "11": [ + 4657, + 126 + ], + "12": [ + 4946, + 134 + ], + "13": [ + 5250, + 133 + ], + "14": [ + 5566, + 143 + ], + "15": [ + 6792, + 157 + ], + "16": [ + 7080, + 142 + ], + "17": [ + 7367, + 134 + ], + "18": [ + 7680, + 130 + ], + "19": [ + 7973, + 133 + ], + "20": [ + 8259, + 134 + ], + "21": [ + 8549, + 135 + ], + "22": [ + 8838, + 135 + ], + "23": [ + 9103, + 150 + ], + "24": [ + 9405, + 132 + ], + "25": [ + 9703, + 131 + ], + "26": [ + 10015, + 144 + ], + "27": [ + 10397, + 112 + ], + "28": [ + 15361, + 130 + ], + "29": [ + 21408, + 128 + ], + "30": [ + 12061, + 129 + ], + "31": [ + 12339, + 121 + ], + "32": [ + 12632, + 127 + ], + "33": [ + 12916, + 119 + ], + "34": [ + 13227, + 121 + ], + "35": [ + 13526, + 124 + ], + "36": [ + 13819, + 131 + ], + "37": [ + 14115, + 113 + ], + "38": [ + 14406, + 123 + ], + "39": [ + 14711, + 128 + ], + "40": [ + 15022, + 133 + ], + "41": [ + 1088, + 118 + ], + "42": [ + 16600, + 136 + ], + "43": [ + 10718, + 127 + ], + "44": [ + 16879, + 128 + ], + "45": [ + 17196, + 132 + ], + "46": [ + 17503, + 113 + ], + "47": [ + 17788, + 115 + ], + "48": [ + 18069, + 117 + ], + "49": [ + 18349, + 130 + ], + "50": [ + 18653, + 123 + ], + "51": [ + 18964, + 119 + ], + "52": [ + 19282, + 124 + ], + "53": [ + 19594, + 120 + ], + "54": [ + 19966, + 130 + ], + "55": [ + 10398, + 106 + ], + "56": [ + 21956, + 134 + ], + "57": [ + 22501, + 164 + ], + "58": [ + 11797, + 140 + ], + "59": [ + 1837, + 150 + ], + "60": [ + 2210, + 145 + ], + "61": [ + 2503, + 154 + ], + "62": [ + 2806, + 149 + ], + "63": [ + 3099, + 151 + ], + "64": [ + 3405, + 151 + ], + "65": [ + 3712, + 150 + ], + "66": [ + 4015, + 146 + ], + "67": [ + 4354, + 140 + ], + "68": [ + 4650, + 143 + ], + "69": [ + 9700, + 127 + ], + "70": [ + 2205, + 151 + ], + "71": [ + 11070, + 112 + ], + "72": [ + 12338, + 115 + ], + "73": [ + 12634, + 120 + ], + "74": [ + 10718, + 123 + ], + "75": [ + 12912, + 115 + ], + "76": [ + 13226, + 119 + ], + "77": [ + 13529, + 117 + ], + "78": [ + 5567, + 141 + ], + "79": [ + 13814, + 141 + ], + "80": [ + 14114, + 117 + ], + "81": [ + 14401, + 127 + ], + "82": [ + 16592, + 164 + ], + "83": [ + 14715, + 123 + ], + "87": [ + 4941, + 142 + ], + "88": [ + 5249, + 149 + ], + "91": null, + "92": null, + "93": null, + "3597": null, + "3612": [ + 11798, + 129 + ], + "3613": [ + 23720, + 119 + ], + "3637": [ + 10015, + 139 + ], + "3639": [ + 1837, + 151 + ], + "3640": [ + 23148, + 122 + ], + "3653": [ + 2499, + 153 + ], + "3655": [ + 7679, + 127 + ], + "3657": [ + 7975, + 123 + ], + "3663": [ + 9110, + 133 + ], + "3665": [ + 9405, + 125 + ], + "3666": [ + 7369, + 119 + ], + "3667": [ + 8839, + 134 + ], + "3675": [ + 21677, + 127 + ], + "3676": [ + 23425, + 120 + ], + "3677": [ + 23425, + 120 + ], + "57416": [ + 20381, + 137 + ], + "57419": [ + 23982, + 128 + ], + "57421": [ + 24215, + 143 + ], + "57424": [ + 24223, + 134 + ], + "60999": [ + 7679, + 127 + ], + "61000": [ + 20381, + 137 + ], + "61001": [ + 7975, + 123 + ], + "61003": [ + 23982, + 128 + ], + "61005": [ + 24215, + 143 + ], + "61007": [ + 9110, + 133 + ], + "61008": [ + 24223, + 134 + ], + "61009": [ + 9405, + 125 + ], + "61010": [ + 7369, + 119 + ], + "61011": [ + 8839, + 134 + ] + } +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/eg-crystal-purple/purple.ogg b/modules/retroshell/files/assets/typing-sounds-soundpacks/eg-crystal-purple/purple.ogg new file mode 100644 index 00000000..c5313579 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/eg-crystal-purple/purple.ogg differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/eg-oreo/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/eg-oreo/config.json new file mode 100644 index 00000000..af55e078 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/eg-oreo/config.json @@ -0,0 +1,469 @@ +{ + "id": "custom-sound-pack-1605532336205", + "name": "EG Oreo", + "key_define_type": "single", + "includes_numpad": false, + "sound": "oreo.ogg", + "defines": { + "1": [ + 971, + 142 + ], + "2": [ + 1276, + 115 + ], + "3": [ + 1604, + 122 + ], + "4": [ + 1929, + 122 + ], + "5": [ + 2237, + 134 + ], + "6": [ + 2571, + 120 + ], + "7": [ + 2869, + 122 + ], + "8": [ + 3177, + 130 + ], + "9": [ + 3482, + 110 + ], + "10": [ + 3782, + 121 + ], + "11": [ + 4077, + 128 + ], + "12": [ + 4376, + 120 + ], + "13": [ + 4673, + 123 + ], + "14": [ + 4991, + 131 + ], + "15": [ + 5968, + 123 + ], + "16": [ + 6221, + 114 + ], + "17": [ + 6515, + 120 + ], + "18": [ + 6823, + 113 + ], + "19": [ + 7112, + 110 + ], + "20": [ + 7412, + 107 + ], + "21": [ + 7716, + 112 + ], + "22": [ + 8012, + 118 + ], + "23": [ + 8326, + 112 + ], + "24": [ + 8623, + 111 + ], + "25": [ + 8932, + 112 + ], + "26": [ + 9246, + 117 + ], + "27": [ + 9586, + 118 + ], + "28": [ + 14603, + 116 + ], + "29": [ + 20804, + 118 + ], + "30": [ + 11171, + 109 + ], + "31": [ + 11462, + 120 + ], + "32": [ + 11784, + 106 + ], + "33": [ + 12080, + 104 + ], + "34": [ + 12400, + 117 + ], + "35": [ + 12715, + 110 + ], + "36": [ + 13014, + 109 + ], + "37": [ + 13333, + 116 + ], + "38": [ + 13638, + 113 + ], + "39": [ + 13967, + 106 + ], + "40": [ + 14280, + 101 + ], + "41": [ + 972, + 131 + ], + "42": [ + 15759, + 123 + ], + "43": [ + 9915, + 121 + ], + "44": [ + 16203, + 127 + ], + "45": [ + 16533, + 117 + ], + "46": [ + 16843, + 111 + ], + "47": [ + 17166, + 103 + ], + "48": [ + 17455, + 109 + ], + "49": [ + 17767, + 108 + ], + "50": [ + 18088, + 104 + ], + "51": [ + 18402, + 104 + ], + "52": [ + 18720, + 110 + ], + "53": [ + 19044, + 108 + ], + "54": [ + 19375, + 124 + ], + "55": [ + 6822, + 116 + ], + "56": [ + 21399, + 119 + ], + "57": [ + 24626, + 143 + ], + "58": [ + 10885, + 126 + ], + "59": [ + 1272, + 126 + ], + "60": [ + 1603, + 125 + ], + "61": [ + 1924, + 128 + ], + "62": [ + 2240, + 126 + ], + "63": [ + 2566, + 126 + ], + "64": [ + 2869, + 122 + ], + "65": [ + 3179, + 131 + ], + "66": [ + 3480, + 115 + ], + "67": [ + 3782, + 124 + ], + "68": [ + 4076, + 126 + ], + "69": [ + 6216, + 122 + ], + "70": [ + 12080, + 103 + ], + "71": [ + 8322, + 117 + ], + "72": [ + 8620, + 113 + ], + "73": [ + 8931, + 114 + ], + "74": [ + 7114, + 109 + ], + "75": [ + 9242, + 122 + ], + "76": [ + 9580, + 131 + ], + "77": [ + 9913, + 118 + ], + "78": [ + 7409, + 117 + ], + "79": [ + 10228, + 118 + ], + "80": [ + 11173, + 110 + ], + "81": [ + 11463, + 118 + ], + "82": [ + 8012, + 115 + ], + "83": [ + 12079, + 112 + ], + "87": [ + 4377, + 120 + ], + "88": [ + 4988, + 135 + ], + "91": null, + "92": null, + "93": null, + "3597": null, + "3612": [ + 7715, + 112 + ], + "3613": [ + 23519, + 127 + ], + "3637": [ + 6511, + 126 + ], + "3639": [ + 11782, + 110 + ], + "3640": [ + 22658, + 126 + ], + "3653": [ + 12715, + 106 + ], + "3655": [ + 13328, + 126 + ], + "3657": [ + 13641, + 107 + ], + "3663": [ + 14278, + 104 + ], + "3665": [ + 14602, + 120 + ], + "3666": [ + 13016, + 108 + ], + "3667": [ + 13964, + 113 + ], + "3675": [ + 21097, + 129 + ], + "3676": [ + 22917, + 129 + ], + "3677": [ + 23232, + 126 + ], + "57416": [ + 19720, + 122 + ], + "57419": [ + 23797, + 119 + ], + "57421": [ + 22657, + 129 + ], + "57424": [ + 24087, + 119 + ], + "60999": [ + 13328, + 126 + ], + "61000": [ + 19720, + 122 + ], + "61001": [ + 13641, + 107 + ], + "61003": [ + 23797, + 119 + ], + "61005": [ + 22657, + 129 + ], + "61007": [ + 14278, + 104 + ], + "61008": [ + 24087, + 119 + ], + "61009": [ + 14602, + 120 + ], + "61010": [ + 13016, + 108 + ], + "61011": [ + 13964, + 113 + ] + } +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/eg-oreo/oreo.ogg b/modules/retroshell/files/assets/typing-sounds-soundpacks/eg-oreo/oreo.ogg new file mode 100644 index 00000000..b9903ddb Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/eg-oreo/oreo.ogg differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/BACKSPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/BACKSPACE.mp3 new file mode 100644 index 00000000..4bfa1c7f Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/BACKSPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/ENTER.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/ENTER.mp3 new file mode 100644 index 00000000..ee1d7816 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/ENTER.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R0.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R0.mp3 new file mode 100644 index 00000000..031b89b8 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R0.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R1.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R1.mp3 new file mode 100644 index 00000000..2a23c63a Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R1.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R2.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R2.mp3 new file mode 100644 index 00000000..b31d2fb8 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R2.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R3.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R3.mp3 new file mode 100644 index 00000000..a8367913 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R3.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R4.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R4.mp3 new file mode 100644 index 00000000..96297b3e Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/GENERIC_R4.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/LICENSE b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/LICENSE new file mode 100644 index 00000000..69da5f11 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) Thomas Lai + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/README.md b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/README.md new file mode 100644 index 00000000..a88fd1cb --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/README.md @@ -0,0 +1 @@ +This content pack comes from https://github.com/tplai/kbsim diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/SPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/SPACE.mp3 new file mode 100644 index 00000000..326dcca5 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/SPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/config.json new file mode 100644 index 00000000..b26f60c4 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/config.json @@ -0,0 +1,16 @@ +{ + "id": "custom-sound-pack-1720300926819", + "name": "pandas", + "key_define_type": "multi", + "sound": "GENERIC_R{0-4}.mp3", + "soundup": "release/GENERIC.mp3", + "defines": { + "14": "BACKSPACE.mp3", + "14-up": "release/BACKSPACE.mp3", + "28": "ENTER.mp3", + "28-up": "release/ENTER.mp3", + "57": "SPACE.mp3", + "57-up": "release/SPACE.mp3" + }, + "version": 2 +} diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/release/BACKSPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/release/BACKSPACE.mp3 new file mode 100644 index 00000000..f4df8a0a Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/release/BACKSPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/release/ENTER.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/release/ENTER.mp3 new file mode 100644 index 00000000..ca6618a0 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/release/ENTER.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/release/GENERIC.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/release/GENERIC.mp3 new file mode 100644 index 00000000..3897e0da Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/release/GENERIC.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/release/SPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/release/SPACE.mp3 new file mode 100644 index 00000000..556c2a0a Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/holy-pandas/release/SPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/config.json new file mode 100644 index 00000000..58ef8792 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/config.json @@ -0,0 +1,16 @@ +{ + "id": "custom-sound-pack-1720300926821", + "name": "MX Black - Full Travel", + "key_define_type": "multi", + "sound": "press/GENERIC_R{0-4}.mp3", + "soundup": "release/GENERIC.mp3", + "defines": { + "14": "press/BACKSPACE.mp3", + "14-up": "release/BACKSPACE.mp3", + "28": "press/ENTER.mp3", + "28-up": "release/ENTER.mp3", + "57": "press/SPACE.mp3", + "57-up": "release/SPACE.mp3" + }, + "version": 2 +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/BACKSPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/BACKSPACE.mp3 new file mode 100644 index 00000000..23258bb4 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/BACKSPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/ENTER.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/ENTER.mp3 new file mode 100644 index 00000000..99560741 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/ENTER.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R0.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R0.mp3 new file mode 100644 index 00000000..8a47132f Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R0.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R1.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R1.mp3 new file mode 100644 index 00000000..5d8a7ced Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R1.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R2.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R2.mp3 new file mode 100644 index 00000000..7e4e373f Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R2.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R3.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R3.mp3 new file mode 100644 index 00000000..1d2de163 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R3.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R4.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R4.mp3 new file mode 100644 index 00000000..f8851a83 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/GENERIC_R4.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/SPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/SPACE.mp3 new file mode 100644 index 00000000..32c67511 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/press/SPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/release/BACKSPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/release/BACKSPACE.mp3 new file mode 100644 index 00000000..02e0d0e4 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/release/BACKSPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/release/ENTER.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/release/ENTER.mp3 new file mode 100644 index 00000000..576e0d98 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/release/ENTER.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/release/GENERIC.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/release/GENERIC.mp3 new file mode 100644 index 00000000..a3c64046 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/release/GENERIC.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/release/SPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/release/SPACE.mp3 new file mode 100644 index 00000000..1ecc1393 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblack-travel/release/SPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/config.json new file mode 100644 index 00000000..b93c5ec3 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/config.json @@ -0,0 +1,16 @@ +{ + "id": "custom-sound-pack-1720300926821", + "name": "MX Blue - Full Travel", + "key_define_type": "multi", + "sound": "press/GENERIC_R{0-4}.mp3", + "soundup": "release/GENERIC.mp3", + "defines": { + "14": "press/BACKSPACE.mp3", + "14-up": "release/BACKSPACE.mp3", + "28": "press/ENTER.mp3", + "28-up": "release/ENTER.mp3", + "57": "press/SPACE.mp3", + "57-up": "release/SPACE.mp3" + }, + "version": 2 +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R0.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R0.mp3 new file mode 100644 index 00000000..c9bc9a0f Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R0.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R1.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R1.mp3 new file mode 100644 index 00000000..9de47445 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R1.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R2.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R2.mp3 new file mode 100644 index 00000000..81b1b62a Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R2.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R3.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R3.mp3 new file mode 100644 index 00000000..c47362b7 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R3.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R4.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R4.mp3 new file mode 100644 index 00000000..2d41a4dc Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/press/GENERIC_R4.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/release/GENERIC.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/release/GENERIC.mp3 new file mode 100644 index 00000000..edd0abbe Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxblue-travel/release/GENERIC.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/config.json new file mode 100644 index 00000000..155bcde4 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/config.json @@ -0,0 +1,16 @@ +{ + "id": "custom-sound-pack-1720300926821", + "name": "MX Brown - Full Travel", + "key_define_type": "multi", + "sound": "press/GENERIC_R{0-4}.mp3", + "soundup": "release/GENERIC.mp3", + "defines": { + "14": "press/BACKSPACE.mp3", + "14-up": "release/BACKSPACE.mp3", + "28": "press/ENTER.mp3", + "28-up": "release/ENTER.mp3", + "57": "press/SPACE.mp3", + "57-up": "release/SPACE.mp3" + }, + "version": 2 +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/BACKSPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/BACKSPACE.mp3 new file mode 100644 index 00000000..0bae8fa2 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/BACKSPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/ENTER.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/ENTER.mp3 new file mode 100644 index 00000000..6e4f7586 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/ENTER.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R0.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R0.mp3 new file mode 100644 index 00000000..d525d109 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R0.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R1.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R1.mp3 new file mode 100644 index 00000000..46348436 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R1.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R2.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R2.mp3 new file mode 100644 index 00000000..62052668 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R2.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R3.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R3.mp3 new file mode 100644 index 00000000..3519815a Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R3.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R4.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R4.mp3 new file mode 100644 index 00000000..94ffaea9 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/GENERIC_R4.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/SPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/SPACE.mp3 new file mode 100644 index 00000000..0dabcb06 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/press/SPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/release/BACKSPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/release/BACKSPACE.mp3 new file mode 100644 index 00000000..0bc7d05f Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/release/BACKSPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/release/ENTER.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/release/ENTER.mp3 new file mode 100644 index 00000000..9d249286 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/release/ENTER.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/release/GENERIC.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/release/GENERIC.mp3 new file mode 100644 index 00000000..4f8114fd Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/release/GENERIC.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/release/SPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/release/SPACE.mp3 new file mode 100644 index 00000000..d1a8a8e4 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/mxbrown-travel/release/SPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/[.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/[.wav new file mode 100644 index 00000000..30474bb8 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/[.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/].wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/].wav new file mode 100644 index 00000000..8cee5643 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/].wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/a.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/a.wav new file mode 100644 index 00000000..23e25b55 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/a.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/b.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/b.wav new file mode 100644 index 00000000..04de3fc4 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/b.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/backspace.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/backspace.wav new file mode 100644 index 00000000..1dc56b97 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/backspace.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/c.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/c.wav new file mode 100644 index 00000000..5f0bb3d5 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/c.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/caps lock.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/caps lock.wav new file mode 100644 index 00000000..9f46f1ec Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/caps lock.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/config.json new file mode 100644 index 00000000..a8eef3b5 --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/config.json @@ -0,0 +1,127 @@ +{ + "id": "custom-sound-pack-1599402588730", + "name": "NK Cream (original by Ryan)", + "key_define_type": "multi", + "includes_numpad": false, + "sound": "sound.ogg", + "defines": { + "1": "q.wav", + "2": "w.wav", + "3": "e.wav", + "4": "e.wav", + "5": "r.wav", + "6": "t.wav", + "7": "y.wav", + "8": "u.wav", + "9": "i.wav", + "10": "o.wav", + "11": "p.wav", + "12": "[.wav", + "13": "].wav", + "14": "backspace.wav", + "15": "tab.wav", + "16": "q.wav", + "17": "w.wav", + "18": "e.wav", + "19": "r.wav", + "20": "t.wav", + "21": "y.wav", + "22": "u.wav", + "23": "i.wav", + "24": "o.wav", + "25": "p.wav", + "26": "[.wav", + "27": "].wav", + "28": "enter.wav", + "29": "tab.wav", + "30": "a.wav", + "31": "s.wav", + "32": "d.wav", + "33": "f.wav", + "34": "g.wav", + "35": "h.wav", + "36": "j.wav", + "37": "k.wav", + "38": "l.wav", + "39": "[.wav", + "40": "].wav", + "41": "q.wav", + "42": "shift.wav", + "43": "backspace.wav", + "44": "z.wav", + "45": "x.wav", + "46": "c.wav", + "47": "v.wav", + "48": "b.wav", + "49": "n.wav", + "50": "m.wav", + "51": "l.wav", + "52": "[.wav", + "53": "].wav", + "54": "shift.wav", + "55": "o.wav", + "56": "e.wav", + "57": "space.wav", + "58": "caps lock.wav", + "59": "w.wav", + "60": "e.wav", + "61": "e.wav", + "62": "r.wav", + "63": "t.wav", + "64": "y.wav", + "65": "u.wav", + "66": "i.wav", + "67": "o.wav", + "68": "p.wav", + "69": "u.wav", + "70": "i.wav", + "71": "y.wav", + "72": "u.wav", + "73": "i.wav", + "74": "p.wav", + "75": "h.wav", + "76": "j.wav", + "77": "k.wav", + "78": "enter.wav", + "79": "b.wav", + "80": "n.wav", + "81": "m.wav", + "82": "shift.wav", + "83": "[.wav", + "87": "].wav", + "88": "].wav", + "91": null, + "92": null, + "93": null, + "3597": null, + "3612": "shift.wav", + "3613": "backspace.wav", + "3637": "i.wav", + "3639": "u.wav", + "3640": "p.wav", + "3653": "o.wav", + "3655": "h.wav", + "3657": "k.wav", + "3663": "n.wav", + "3665": "m.wav", + "3666": "h.wav", + "3667": "b.wav", + "3675": "q.wav", + "3676": "[.wav", + "3677": "].wav", + "57416": "g.wav", + "57419": "c.wav", + "57421": "b.wav", + "57424": "v.wav", + "60999": "h.wav", + "61000": "g.wav", + "61001": "k.wav", + "61003": "c.wav", + "61005": "b.wav", + "61007": "n.wav", + "61008": "v.wav", + "61009": "m.wav", + "61010": "h.wav", + "61011": "b.wav" + } +} diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/d.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/d.wav new file mode 100644 index 00000000..372e2c48 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/d.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/e.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/e.wav new file mode 100644 index 00000000..0ad8fb82 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/e.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/enter.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/enter.wav new file mode 100644 index 00000000..717e2cb8 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/enter.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/f.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/f.wav new file mode 100644 index 00000000..7ccf61e3 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/f.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/g.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/g.wav new file mode 100644 index 00000000..4e2a78f5 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/g.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/h.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/h.wav new file mode 100644 index 00000000..b9fdc301 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/h.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/i.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/i.wav new file mode 100644 index 00000000..be7f3929 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/i.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/j.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/j.wav new file mode 100644 index 00000000..50cf399a Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/j.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/k.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/k.wav new file mode 100644 index 00000000..04f2229f Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/k.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/l.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/l.wav new file mode 100644 index 00000000..6d4b8763 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/l.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/m.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/m.wav new file mode 100644 index 00000000..1624865d Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/m.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/n.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/n.wav new file mode 100644 index 00000000..a02f0667 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/n.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/o.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/o.wav new file mode 100644 index 00000000..30474bb8 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/o.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/p.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/p.wav new file mode 100644 index 00000000..8cee5643 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/p.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/q.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/q.wav new file mode 100644 index 00000000..7032a469 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/q.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/r.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/r.wav new file mode 100644 index 00000000..e172224f Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/r.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/s.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/s.wav new file mode 100644 index 00000000..badb009f Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/s.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/shift.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/shift.wav new file mode 100644 index 00000000..717e2cb8 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/shift.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/space.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/space.wav new file mode 100644 index 00000000..b1b671ff Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/space.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/t.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/t.wav new file mode 100644 index 00000000..018b90ca Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/t.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/tab.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/tab.wav new file mode 100644 index 00000000..9f46f1ec Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/tab.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/u.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/u.wav new file mode 100644 index 00000000..563754aa Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/u.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/v.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/v.wav new file mode 100644 index 00000000..f24e97b3 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/v.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/w.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/w.wav new file mode 100644 index 00000000..61980f48 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/w.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/x.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/x.wav new file mode 100644 index 00000000..f15c6f67 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/x.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/y.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/y.wav new file mode 100644 index 00000000..4a249770 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/y.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/z.wav b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/z.wav new file mode 100644 index 00000000..400a5342 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/nk-cream/z.wav differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/topre-purple-hybrid-pbt/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/topre-purple-hybrid-pbt/config.json new file mode 100644 index 00000000..a5a517fa --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/topre-purple-hybrid-pbt/config.json @@ -0,0 +1,465 @@ +{ + "id": "custom-sound-pack-1582707445334", + "name": "Topre Purple Hybrid - PBT keycaps", + "key_define_type": "single", + "includes_numpad": false, + "sound": "sound.ogg", + "defines": { + "1": [ + 1813, + 130 + ], + "2": [ + 20987, + 180 + ], + "3": [ + 22041, + 183 + ], + "4": [ + 23126, + 194 + ], + "5": [ + 24260, + 191 + ], + "6": [ + 25371, + 185 + ], + "7": [ + 26392, + 189 + ], + "8": [ + 27457, + 180 + ], + "9": [ + 28502, + 175 + ], + "10": [ + 29566, + 192 + ], + "11": [ + 30619, + 200 + ], + "12": [ + 31656, + 191 + ], + "13": [ + 31712, + 191 + ], + "14": [ + 33746, + 190 + ], + "15": [ + 37933, + 184 + ], + "16": [ + 39000, + 180 + ], + "17": [ + 39991, + 188 + ], + "18": [ + 40955, + 200 + ], + "19": [ + 41924, + 190 + ], + "20": [ + 42918, + 192 + ], + "21": [ + 43909, + 180 + ], + "22": [ + 44822, + 185 + ], + "23": [ + 45779, + 194 + ], + "24": [ + 46763, + 175 + ], + "25": [ + 47787, + 200 + ], + "26": [ + 48805, + 208 + ], + "27": [ + 49764, + 171 + ], + "28": [ + 68501, + 174 + ], + "29": [ + 84014, + 174 + ], + "30": [ + 56440, + 177 + ], + "31": [ + 57571, + 198 + ], + "32": [ + 58641, + 182 + ], + "33": [ + 59729, + 182 + ], + "34": [ + 60765, + 183 + ], + "35": [ + 61898, + 171 + ], + "36": [ + 63008, + 180 + ], + "37": [ + 64156, + 168 + ], + "38": [ + 65199, + 171 + ], + "39": [ + 66281, + 167 + ], + "40": [ + 67418, + 175 + ], + "41": [ + 19950, + 190 + ], + "42": [ + 69592, + 186 + ], + "43": [ + 50707, + 183 + ], + "44": [ + 70895, + 174 + ], + "45": [ + 72019, + 184 + ], + "46": [ + 73119, + 192 + ], + "47": [ + 74276, + 198 + ], + "48": [ + 75328, + 193 + ], + "49": [ + 76458, + 170 + ], + "50": [ + 77514, + 190 + ], + "51": [ + 78570, + 180 + ], + "52": [ + 79601, + 175 + ], + "53": [ + 80675, + 160 + ], + "54": [ + 81734, + 176 + ], + "55": [ + 18833, + 200 + ], + "56": [ + 86211, + 187 + ], + "57": [ + 87213, + 181 + ], + "58": [ + 55235, + 205 + ], + "59": [ + 3076, + 149 + ], + "60": [ + 4300, + 166 + ], + "61": [ + 5415, + 168 + ], + "62": [ + 6581, + 162 + ], + "63": [ + 7724, + 184 + ], + "64": [ + 8860, + 197 + ], + "65": [ + 9972, + 195 + ], + "66": [ + 1119, + 182 + ], + "67": [ + 12242, + 178 + ], + "68": [ + 13401, + 194 + ], + "69": [ + 16619, + 166 + ], + "70": [ + 17743, + 200 + ], + "71": [ + 34738, + 166 + ], + "72": [ + 35728, + 186 + ], + "73": [ + 36825, + 175 + ], + "74": [ + 31656, + 191 + ], + "75": [ + 51695, + 200 + ], + "76": [ + 52666, + 163 + ], + "77": [ + 53744, + 192 + ], + "78": [ + 33746, + 190 + ], + "79": [ + 92565, + 173 + ], + "80": [ + 93711, + 174 + ], + "81": [ + 93739, + 195 + ], + "82": [ + 81734, + 176 + ], + "83": [ + 82838, + 211 + ], + "87": [ + 14479, + 189 + ], + "88": [ + 15559, + 187 + ], + "3612": [ + 68501, + 174 + ], + "3613": [ + 91445, + 198 + ], + "3637": [ + 17743, + 200 + ], + "3639": [ + 16619, + 166 + ], + "3640": [ + 88293, + 169 + ], + "3653": [ + 18833, + 200 + ], + "3655": [ + 35728, + 186 + ], + "3657": [ + 36825, + 175 + ], + "3663": [ + 52666, + 163 + ], + "3665": [ + 53744, + 192 + ], + "3666": [ + 34738, + 166 + ], + "3667": [ + 51695, + 200 + ], + "3675": [ + 85179, + 173 + ], + "3676": [ + 89346, + 164 + ], + "3677": [ + 91445, + 198 + ], + "57416": [ + 82838, + 211 + ], + "57419": [ + 92565, + 173 + ], + "57421": [ + 93739, + 195 + ], + "57424": [ + 93711, + 174 + ], + "60999": [ + 35728, + 186 + ], + "61000": [ + 82838, + 211 + ], + "61001": [ + 36825, + 175 + ], + "61003": [ + 92565, + 173 + ], + "61005": [ + 93739, + 195 + ], + "61007": [ + 52666, + 163 + ], + "61008": [ + 93711, + 174 + ], + "61009": [ + 53744, + 192 + ], + "61010": [ + 34738, + 166 + ], + "61011": [ + 51695, + 200 + ] + } +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/topre-purple-hybrid-pbt/sound.ogg b/modules/retroshell/files/assets/typing-sounds-soundpacks/topre-purple-hybrid-pbt/sound.ogg new file mode 100644 index 00000000..543a292a Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/topre-purple-hybrid-pbt/sound.ogg differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/config.json b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/config.json new file mode 100644 index 00000000..8d6d8b0a --- /dev/null +++ b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/config.json @@ -0,0 +1,16 @@ +{ + "id": "custom-sound-pack-1720300926821", + "name": "Turquoise (Full Travel)", + "key_define_type": "multi", + "sound": "press/GENERIC_R{0-4}.mp3", + "soundup": "release/GENERIC.mp3", + "defines": { + "14": "press/BACKSPACE.mp3", + "14-up": "release/BACKSPACE.mp3", + "28": "press/ENTER.mp3", + "28-up": "release/ENTER.mp3", + "57": "press/SPACE.mp3", + "57-up": "release/SPACE.mp3" + }, + "version": 2 +} \ No newline at end of file diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/BACKSPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/BACKSPACE.mp3 new file mode 100644 index 00000000..69c6029e Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/BACKSPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/ENTER.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/ENTER.mp3 new file mode 100644 index 00000000..62994353 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/ENTER.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R0.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R0.mp3 new file mode 100644 index 00000000..9c4d8912 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R0.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R1.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R1.mp3 new file mode 100644 index 00000000..2ce90c35 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R1.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R2.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R2.mp3 new file mode 100644 index 00000000..37ccdb4c Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R2.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R3.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R3.mp3 new file mode 100644 index 00000000..a41d3a1a Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R3.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R4.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R4.mp3 new file mode 100644 index 00000000..af772ff0 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/GENERIC_R4.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/SPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/SPACE.mp3 new file mode 100644 index 00000000..4cd97b5f Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/press/SPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/release/BACKSPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/release/BACKSPACE.mp3 new file mode 100644 index 00000000..e05b35f8 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/release/BACKSPACE.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/release/ENTER.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/release/ENTER.mp3 new file mode 100644 index 00000000..105035e7 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/release/ENTER.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/release/GENERIC.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/release/GENERIC.mp3 new file mode 100644 index 00000000..7df554bf Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/release/GENERIC.mp3 differ diff --git a/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/release/SPACE.mp3 b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/release/SPACE.mp3 new file mode 100644 index 00000000..10c5b6c4 Binary files /dev/null and b/modules/retroshell/files/assets/typing-sounds-soundpacks/turquoise/release/SPACE.mp3 differ diff --git a/modules/retroshell/files/config/Config.qml b/modules/retroshell/files/config/Config.qml index 061e5f0c..53555409 100644 --- a/modules/retroshell/files/config/Config.qml +++ b/modules/retroshell/files/config/Config.qml @@ -24,6 +24,7 @@ import "defaults/dock.js" as DockDefaults import "defaults/ai.js" as AiDefaults import "defaults/dashboard.js" as DashboardDefaults import "defaults/notifications.js" as NotificationsDefaults +import "defaults/typing_sounds.js" as TypingSoundsDefaults import "ConfigValidator.js" as ConfigValidator Singleton { @@ -59,11 +60,12 @@ Singleton { property bool dockReady: false property bool dashboardReady: false property bool notificationsReady: false + property bool typingSoundsReady: false property bool aiReady: false property bool toolsReady: false property bool keybindsInitialLoadComplete: false - property bool initialLoadComplete: themeReady && barReady && workspacesReady && overviewReady && notchReady && compositorReady && performanceReady && weatherReady && desktopReady && lockscreenReady && prefixReady && systemReady && dockReady && dashboardReady && notificationsReady && aiReady && toolsReady + property bool initialLoadComplete: themeReady && barReady && workspacesReady && overviewReady && notchReady && compositorReady && performanceReady && weatherReady && desktopReady && lockscreenReady && prefixReady && systemReady && dockReady && dashboardReady && notificationsReady && aiReady && toolsReady && typingSoundsReady // Compatibility aliases property alias loader: themeLoader @@ -104,6 +106,30 @@ Singleton { command: ["bash", "-c", `test -f '${root.configDir}/hyprland.json' && ! test -f '${root.configDir}/compositor.json' && mv '${root.configDir}/hyprland.json' '${root.configDir}/compositor.json' && echo 'Migrated hyprland.json to compositor.json' || true`] } + // Read RETRO_FONT_EMOJI from variables.sh so the shell respects the retro + // emoji font, and apply it to the theme's emojiFont (persisted on save). + Process { + id: emojiFontProcess + running: false + command: [] + + stdout: StdioCollector { + onStreamFinished: { + var val = text.trim(); + if (val && root.themeReady) { + root.theme.emojiFont = val; + console.log("[Config] emojiFont from RETRO_FONT_EMOJI:", val); + } + } + } + } + + function loadEmojiFont() { + var cfg = Quickshell.env("RETRO_CONFIG") || Quickshell.env("HOME") + "/.config/retro"; + emojiFontProcess.command = ["bash", "-c", "source '" + cfg + "/variables.sh' 2>/dev/null; echo $RETRO_FONT_EMOJI"]; + emojiFontProcess.running = true; + } + // ============================================ // THEME MODULE // ============================================ @@ -116,10 +142,11 @@ Singleton { if (!root.themeReady) { validateModule("theme", themeLoader, ThemeDefaults.data, () => { root.themeReady = true; + root.loadEmojiFont(); }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.themeReady) { handleMissingConfig("theme", themeLoader, ThemeDefaults.data, () => { root.themeReady = true; @@ -146,6 +173,7 @@ Singleton { property int fontSize: 14 property string monoFont: "Iosevka Nerd Font Mono" property int monoFontSize: 14 + property string emojiFont: "Noto Color Emoji" property bool tintIcons: false property bool enableCorners: true property int animDuration: 300 @@ -514,7 +542,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.barReady) { handleMissingConfig("bar", barLoader, BarDefaults.data, () => { root.barReady = true; @@ -563,7 +591,7 @@ Singleton { property bool showWeatherTemp: false property bool showDayOfWeek: false property string batteryStyle: "arch" - property var toolboxOrder: ["screenshot", "screenshots", "separator", "recorder", "recordings", "separator", "colorpicker", "ocr", "qr", "lens", "shazam", "webcam"] + property var toolboxOrder: ["screenshot", "screenshots", "separator", "recorder", "recordings", "separator", "colorpicker", "ocr", "qr", "lens", "shazam", "webcam", "docker"] property var clockOrder: ["clock", "weather", "pomodoro"] property var barLeftOrder: ["launcher", "workspaces", "pin"] property var barRightOrder: ["tools", "tray", "wifi", "bluetooth", "controls", "battery", "clock", "power"] @@ -585,7 +613,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.workspacesReady) { handleMissingConfig("workspaces", workspacesLoader, WorkspacesDefaults.data, () => { root.workspacesReady = true; @@ -629,7 +657,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.overviewReady) { handleMissingConfig("overview", overviewLoader, OverviewDefaults.data, () => { root.overviewReady = true; @@ -671,7 +699,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.notchReady) { handleMissingConfig("notch", notchLoader, NotchDefaults.data, () => { root.notchReady = true; @@ -717,7 +745,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.compositorReady) { handleMissingConfig("compositor", compositorLoader, CompositorDefaults.data, () => { root.compositorReady = true; @@ -797,7 +825,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.performanceReady) { handleMissingConfig("performance", performanceLoader, PerformanceDefaults.data, () => { root.performanceReady = true; @@ -841,7 +869,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.toolsReady) { handleMissingConfig("tools", toolsLoader, ToolsDefaults.data, () => { root.toolsReady = true; @@ -889,7 +917,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.weatherReady) { handleMissingConfig("weather", weatherLoader, WeatherDefaults.data, () => { root.weatherReady = true; @@ -929,7 +957,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.desktopReady) { handleMissingConfig("desktop", desktopLoader, DesktopDefaults.data, () => { root.desktopReady = true; @@ -950,9 +978,14 @@ Singleton { adapter: JsonAdapter { property bool enabled: false + property bool showIcons: true property int iconSize: 40 property int spacingVertical: 16 property string textColor: "overBackground" + property bool editMode: false + property bool perMonitor: false + property list widgetOrder: [] + property list widgets: [] } } @@ -971,7 +1004,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.lockscreenReady) { handleMissingConfig("lockscreen", lockscreenLoader, LockscreenDefaults.data, () => { root.lockscreenReady = true; @@ -1021,7 +1054,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.prefixReady) { handleMissingConfig("prefix", prefixLoader, PrefixDefaults.data, () => { root.prefixReady = true; @@ -1064,7 +1097,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.systemReady) { handleMissingConfig("system", systemLoader, SystemDefaults.data, () => { root.systemReady = true; @@ -1147,7 +1180,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.dockReady) { handleMissingConfig("dock", dockLoader, DockDefaults.data, () => { root.dockReady = true; @@ -1203,7 +1236,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.dashboardReady) { handleMissingConfig("dashboard", dashboardLoader, DashboardDefaults.data, () => { root.dashboardReady = true; @@ -1278,7 +1311,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.notificationsReady) { handleMissingConfig("notifications", notificationsLoader, NotificationsDefaults.data, () => { root.notificationsReady = true; @@ -1319,7 +1352,7 @@ Singleton { }); } } - onLoadFailed: { + onLoadFailed: (error) => { if (error.toString().includes("FileNotFound") && !root.aiReady) { handleMissingConfig("ai", aiLoader, AiDefaults.data, () => { root.aiReady = true; @@ -1349,6 +1382,49 @@ Singleton { } } + // ============================================ + // TYPING SOUNDS MODULE + // ============================================ + FileView { + id: typingSoundsLoader + path: root.configDir + "/typing_sounds.json" + atomicWrites: true + watchChanges: true + onLoaded: { + if (!root.typingSoundsReady) { + validateModule("typingSounds", typingSoundsLoader, TypingSoundsDefaults.data, () => { + root.typingSoundsReady = true; + }); + } + } + onLoadFailed: (error) => { + if (error.toString().includes("FileNotFound") && !root.typingSoundsReady) { + handleMissingConfig("typingSounds", typingSoundsLoader, TypingSoundsDefaults.data, () => { + root.typingSoundsReady = true; + }); + } + } + onFileChanged: { + root.pauseAutoSave = true; + reload(); + root.pauseAutoSave = false; + } + onPathChanged: reload() + onAdapterUpdated: { + if (root.typingSoundsReady && !root.pauseAutoSave) { + typingSoundsLoader.writeAdapter(); + } + } + + adapter: JsonAdapter { + property bool enabled: false + property int volume: 100 + property bool mouseEnabled: false + property string selectedPackId: "nk-cream" + property string selectedDevicePath: "all" + } + } + // Keybinds (binds.json) // Timer to repair keybinds after initial load Timer { @@ -3533,6 +3609,7 @@ Singleton { property int roundness: theme.roundness property string defaultFont: theme.font + property string emojiFont: theme.emojiFont property int animDuration: theme.animDuration property bool tintIcons: theme.tintIcons @@ -3611,6 +3688,10 @@ Singleton { // Desktop configuration property QtObject desktop: desktopLoader.adapter + // Whether the desktop layer (wallpaper + widgets) should be present. + // Active when the desktop is enabled OR any widgets are placed. + readonly property bool desktopLayerActive: desktop.enabled || (desktop.widgetOrder && desktop.widgetOrder.length > 0) + // Lockscreen configuration property QtObject lockscreen: lockscreenLoader.adapter @@ -3635,6 +3716,9 @@ Singleton { // AI configuration property QtObject ai: aiLoader.adapter + // Typing Sounds configuration + property QtObject typingSounds: typingSoundsLoader.adapter + // Module save functions function saveBar() { barLoader.writeAdapter(); @@ -3681,6 +3765,9 @@ Singleton { function saveAi() { aiLoader.writeAdapter(); } + function saveTypingSounds() { + typingSoundsLoader.writeAdapter(); + } // Color helpers function isHexColor(colorValue) { diff --git a/modules/retroshell/files/config/defaults/bar.js b/modules/retroshell/files/config/defaults/bar.js index bc8f577f..3b6add86 100644 --- a/modules/retroshell/files/config/defaults/bar.js +++ b/modules/retroshell/files/config/defaults/bar.js @@ -29,7 +29,7 @@ var data = { "showWeatherTemp": false, "showDayOfWeek": false, "batteryStyle": "arch", - "toolboxOrder": ["screenshot", "screenshots", "separator", "recorder", "recordings", "separator", "colorpicker", "ocr", "qr", "lens", "shazam", "webcam"], + "toolboxOrder": ["screenshot", "screenshots", "separator", "recorder", "recordings", "separator", "colorpicker", "ocr", "qr", "lens", "shazam", "webcam", "docker"], "clockOrder": ["clock", "weather", "pomodoro"], "barLeftOrder": ["launcher", "workspaces", "pin"], "barRightOrder": ["tools", "tray", "wifi", "bluetooth", "controls", "battery", "clock", "power"] diff --git a/modules/retroshell/files/config/defaults/desktop.js b/modules/retroshell/files/config/defaults/desktop.js index 64cb27e9..0ce3258d 100644 --- a/modules/retroshell/files/config/defaults/desktop.js +++ b/modules/retroshell/files/config/defaults/desktop.js @@ -2,7 +2,11 @@ var data = { "enabled": false, + "showIcons": true, "iconSize": 40, "spacingVertical": 16, - "textColor": "overBackground" + "textColor": "overBackground", + "editMode": false, + "widgetOrder": [], + "widgets": [] } diff --git a/modules/retroshell/files/config/defaults/theme.js b/modules/retroshell/files/config/defaults/theme.js index ec7003db..fe890354 100644 --- a/modules/retroshell/files/config/defaults/theme.js +++ b/modules/retroshell/files/config/defaults/theme.js @@ -8,6 +8,7 @@ var data = { "fontSize": 14, "monoFont": "Iosevka Nerd Font Mono", "monoFontSize": 14, + "emojiFont": "Noto Color Emoji", "tintIcons": false, "enableCorners": true, "animDuration": 300, diff --git a/modules/retroshell/files/config/defaults/typing_sounds.js b/modules/retroshell/files/config/defaults/typing_sounds.js new file mode 100644 index 00000000..f22088ba --- /dev/null +++ b/modules/retroshell/files/config/defaults/typing_sounds.js @@ -0,0 +1,9 @@ +.pragma library + +var data = { + "enabled": false, + "volume": 100, + "mouseEnabled": false, + "selectedPackId": "nk-cream", + "selectedDevicePath": "all" +} diff --git a/modules/retroshell/files/modules/bar/BarContent.qml b/modules/retroshell/files/modules/bar/BarContent.qml index b72a178b..0c731e06 100644 --- a/modules/retroshell/files/modules/bar/BarContent.qml +++ b/modules/retroshell/files/modules/bar/BarContent.qml @@ -576,6 +576,8 @@ Item { case "battery": return batteryComponent; case "clock": return clockComponent; case "power": return powerComponent; + case "typingSounds": return typingSoundsComponent; + case "docker": return dockerComponent; } return undefined; } @@ -806,4 +808,32 @@ Item { enableShadow: root.shadowsEnabled } } + + Component { + id: typingSoundsComponent + Bar.QuickPopupButton { + iconName: TypingSoundsService.enabled ? Icons.keyboard : Icons.keyboard + tooltipText: "Typing Sounds" + panelSource: "../widgets/dashboard/controls/TypingSoundsPanel.qml" + isActive: TypingSoundsService.enabled + bar: root + vertical: root.orientation === "vertical" + layerEnabled: root.shadowsEnabled + } + } + + Component { + id: dockerComponent + Bar.QuickPopupButton { + iconName: Icons.docker + tooltipText: "Docker" + panelSource: "../widgets/dashboard/controls/DockerPanel.qml" + isActive: DockerService.runningCount > 0 + popupWidth: 350 + popupHeight: 450 + bar: root + vertical: root.orientation === "vertical" + layerEnabled: root.shadowsEnabled + } + } } diff --git a/modules/retroshell/files/modules/bar/BatteryIndicator.qml b/modules/retroshell/files/modules/bar/BatteryIndicator.qml index f0d0e2c8..2aefb606 100644 --- a/modules/retroshell/files/modules/bar/BatteryIndicator.qml +++ b/modules/retroshell/files/modules/bar/BatteryIndicator.qml @@ -41,6 +41,15 @@ Item { usageProc.command = ["bash", rd + "/scripts/battery_core.sh", "--usage", "10"]; usageProc.running = true; } + if (popupOpen && Battery.available) { + batteryInfoProc.command = ["bash", "-c", + "BAT=$(find /sys/class/power_supply/BAT* -maxdepth 0 2>/dev/null | head -1);" + + "cat \"$BAT/cycle_count\" 2>/dev/null || echo 0;" + + "cat \"$BAT/energy_full_design\" 2>/dev/null || echo 0;" + + "cat \"$BAT/energy_full\" 2>/dev/null || echo 0;" + + "cat \"$BAT/power_now\" 2>/dev/null || cat \"$BAT/current_now\" 2>/dev/null || echo 0"]; + batteryInfoProc.running = true; + } if (popupOpen && !Battery.available) { var rd = Quickshell.env("RETRO_DIR"); sysInfoProc.command = ["python3", rd + "/modules/retroshell/files/scripts/system_monitor.py", "3000", "/"]; @@ -97,6 +106,11 @@ Item { property bool saverCardHovered: false property bool dimCardHovered: false + property string batteryHealth: "N/A" + property real batteryHealthPct: 0 + property int batteryCycles: 0 + property real batteryWatts: 0 + function getWattageText(profileName) { var retroProfile = profileName.replace("power-", ""); var key = powerSource + "_" + retroProfile; @@ -189,6 +203,26 @@ Item { Process { id: dimBrightnessSetProc; running: false; stdout: SplitParser {} } + Process { + id: batteryInfoProc + running: false + stdout: StdioCollector { + onStreamFinished: { + var lines = text.trim().split("\n"); + if (lines.length >= 4) { + var cycles = parseInt(lines[0]) || 0; + batteryCycles = cycles; + var eDesign = parseFloat(lines[1]) || 0; + var eFull = parseFloat(lines[2]) || 0; + batteryHealthPct = eDesign > 0 ? Math.round(eFull / eDesign * 100) : 0; + batteryHealth = batteryHealthPct + "%"; + var pNow = parseFloat(lines[3]) || 0; + batteryWatts = parseFloat((pNow / 1000000).toFixed(1)); + } + } + } + } + Component.onCompleted: { var rd = Quickshell.env("RETRO_DIR"); var cfg = Quickshell.env("RETRO_CONFIG") || Quickshell.env("HOME") + "/.config/retro"; @@ -449,7 +483,7 @@ Item { bar: root.bar contentWidth: Math.max(300, mainColumn.implicitWidth + batteryPopup.popupPadding * 2) - contentHeight: (Battery.available ? (64 + 4 + 64) : 64) + 36 + batteryPopup.popupPadding * 2 + contentHeight: (Battery.available ? (64 + 4 + 50 + 4 + 64) : 64) + 36 + batteryPopup.popupPadding * 2 ColumnLayout { id: mainColumn @@ -586,6 +620,52 @@ Item { } } + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 50 + spacing: 4 + visible: Battery.available + + Repeater { + model: [ + { title: "Health", value: batteryHealth }, + { title: "Capacity", value: Math.round(Battery.percentage) + "%" }, + { title: "Watts", value: batteryWatts + "W" } + ] + + delegate: StyledRect { + required property var modelData + Layout.fillWidth: true + Layout.fillHeight: true + variant: "common" + enableShadow: false + radius: Styling.radius(0) + + ColumnLayout { + anchors.centerIn: parent + spacing: 2 + + Text { + Layout.alignment: Qt.AlignHCenter + text: modelData.title + font.family: Styling.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.primary + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: modelData.value + font.family: Styling.defaultFont + font.pixelSize: Styling.fontSize(2) + font.bold: true + color: Colors.overBackground + } + } + } + } + } + StyledRect { Layout.fillWidth: true Layout.preferredHeight: 64 diff --git a/modules/retroshell/files/modules/bar/ControlsButton.qml b/modules/retroshell/files/modules/bar/ControlsButton.qml index 47a02d1c..0fd3f4ff 100644 --- a/modules/retroshell/files/modules/bar/ControlsButton.qml +++ b/modules/retroshell/files/modules/bar/ControlsButton.qml @@ -498,7 +498,7 @@ Item { } Connections { - target: parent.parent.node?.audio + target: parent.parent.node?.audio ?? null ignoreUnknownSignals: true function onVolumeChanged() { if (!appSlider.isDragging) diff --git a/modules/retroshell/files/modules/bar/QuickPopupButton.qml b/modules/retroshell/files/modules/bar/QuickPopupButton.qml index 5fcac3e4..5b5cd2dc 100644 --- a/modules/retroshell/files/modules/bar/QuickPopupButton.qml +++ b/modules/retroshell/files/modules/bar/QuickPopupButton.qml @@ -17,12 +17,14 @@ Item { property bool isActive: true property int popupWidth: 300 property int popupHeight: 360 + property int iconPixelSize: 18 property bool vertical: false property real radius: 0 property real startRadius: radius property real endRadius: radius property bool layerEnabled: true + property bool hasOpened: false property bool isHovered: false readonly property bool popupOpen: popup.isOpen @@ -71,15 +73,18 @@ Item { Text { anchors.centerIn: parent text: root.iconName - font.family: Icons.font - font.pixelSize: 18 + font.family: root.iconName.includes(" CompositorData.workspaceOccupationMap[ws.id]).map(ws => ws.id).sort((a, b) => a - b).slice(0, Config.workspaces.shown); // Always include active workspace, even if empty - const activeId = (monitor && monitor.activeWorkspace ? monitor.activeWorkspace.id : undefined) || 1; + const activeId = activeWorkspaceId; if (!occupiedIds.includes(activeId)) { occupiedIds.push(activeId); occupiedIds.sort((a, b) => a - b); @@ -301,7 +302,6 @@ Item { implicitHeight: workspaceButtonWidth - activeWorkspaceMargin * 2 radius: { - const activeWorkspaceId = (monitor && monitor.activeWorkspace ? monitor.activeWorkspace.id : undefined) || 1; const currentWorkspaceHasWindows = CompositorData.workspaceOccupationMap[activeWorkspaceId]; if (workspacesWidget.radius === 0) return 0; @@ -357,7 +357,6 @@ Item { implicitHeight: Math.abs(idx1 - idx2) * workspaceButtonWidth + workspaceButtonWidth - activeWorkspaceMargin * 2 radius: { - const activeWorkspaceId = (monitor && monitor.activeWorkspace ? monitor.activeWorkspace.id : undefined) || 1; const currentWorkspaceHasWindows = CompositorData.workspaceOccupationMap[activeWorkspaceId]; if (workspacesWidget.radius === 0) return 0; @@ -452,7 +451,7 @@ Item { font.pixelSize: workspaceLabelFontSize(text) text: `${button.workspaceValue}` elide: Text.ElideRight - color: ((monitor && monitor.activeWorkspace ? monitor.activeWorkspace.id : undefined) == button.workspaceValue) ? Styling.srItem("primary") : (workspaceOccupied[index] ? Colors.overBackground : Colors.overSecondaryFixedVariant) + color: (activeWorkspaceId == button.workspaceValue) ? Styling.srItem("primary") : (workspaceOccupied[index] ? Colors.overBackground : Colors.overSecondaryFixedVariant) Behavior on opacity { enabled: Config.animDuration > 0 @@ -463,13 +462,13 @@ Item { } } Rectangle { - opacity: (Config.workspaces.showNumbers || Config.workspaces.alwaysShowNumbers || (Config.workspaces.showAppIcons && workspaceButtonBackground.focusedWindow)) ? 0 : (((monitor && monitor.activeWorkspace ? monitor.activeWorkspace.id : undefined) == button.workspaceValue) || workspaceOccupied[index] ? 1 : 0.5) + opacity: (Config.workspaces.showNumbers || Config.workspaces.alwaysShowNumbers || (Config.workspaces.showAppIcons && workspaceButtonBackground.focusedWindow)) ? 0 : ((activeWorkspaceId == button.workspaceValue) || workspaceOccupied[index] ? 1 : 0.5) visible: opacity > 0 anchors.centerIn: parent width: workspaceButtonWidth * 0.2 height: width radius: width / 2 - color: ((monitor && monitor.activeWorkspace ? monitor.activeWorkspace.id : undefined) == button.workspaceValue) ? Styling.srItem("primary") : Colors.overBackground + color: (activeWorkspaceId == button.workspaceValue) ? Styling.srItem("primary") : Colors.overBackground Behavior on opacity { enabled: Config.animDuration > 0 @@ -589,7 +588,7 @@ Item { font.pixelSize: workspaceLabelFontSize(text) text: `${buttonVert.workspaceValue}` elide: Text.ElideRight - color: ((monitor && monitor.activeWorkspace ? monitor.activeWorkspace.id : undefined) == buttonVert.workspaceValue) ? Styling.srItem("primary") : (workspaceOccupied[index] ? Colors.overBackground : Colors.overSecondaryFixedVariant) + color: (activeWorkspaceId == buttonVert.workspaceValue) ? Styling.srItem("primary") : (workspaceOccupied[index] ? Colors.overBackground : Colors.overSecondaryFixedVariant) Behavior on opacity { enabled: Config.animDuration > 0 @@ -600,13 +599,13 @@ Item { } } Rectangle { - opacity: (Config.workspaces.showNumbers || Config.workspaces.alwaysShowNumbers || (Config.workspaces.showAppIcons && workspaceButtonBackgroundVert.focusedWindow)) ? 0 : (((monitor && monitor.activeWorkspace ? monitor.activeWorkspace.id : undefined) == buttonVert.workspaceValue) || workspaceOccupied[index] ? 1 : 0.5) + opacity: (Config.workspaces.showNumbers || Config.workspaces.alwaysShowNumbers || (Config.workspaces.showAppIcons && workspaceButtonBackgroundVert.focusedWindow)) ? 0 : ((activeWorkspaceId == buttonVert.workspaceValue) || workspaceOccupied[index] ? 1 : 0.5) visible: opacity > 0 anchors.centerIn: parent width: workspaceButtonWidth * 0.2 height: width radius: width / 2 - color: ((monitor && monitor.activeWorkspace ? monitor.activeWorkspace.id : undefined) == buttonVert.workspaceValue) ? Styling.srItem("primary") : Colors.overBackground + color: (activeWorkspaceId == buttonVert.workspaceValue) ? Styling.srItem("primary") : Colors.overBackground Behavior on opacity { enabled: Config.animDuration > 0 diff --git a/modules/retroshell/files/modules/components/BarPopup.qml b/modules/retroshell/files/modules/components/BarPopup.qml index dbcecd39..96069f2d 100644 --- a/modules/retroshell/files/modules/components/BarPopup.qml +++ b/modules/retroshell/files/modules/components/BarPopup.qml @@ -161,7 +161,7 @@ PopupWindow { id: background anchors.fill: parent variant: root.variant - enableShadow: true + enableShadow: !Config.compositor.blurEnabled radius: Styling.radius(8) Item { diff --git a/modules/retroshell/files/modules/components/BatteryRingCard.qml b/modules/retroshell/files/modules/components/BatteryRingCard.qml new file mode 100644 index 00000000..4c3ff7f0 --- /dev/null +++ b/modules/retroshell/files/modules/components/BatteryRingCard.qml @@ -0,0 +1,166 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.config + +// Apple-style battery rings card. Theme-aware surface. Shows one hero device +// (square 2x2) or up to 4 devices centered in a row (wide 2x4). No placeholders. +Rectangle { + id: root + + property bool showBackground: true + property bool wide: false + property var devices: [] // [{id,name,type,battery,isCharging,isConnected}] + + readonly property real ringD: root.wide ? 64 : 98 + readonly property real ringStroke: root.wide ? 6 : 8 + + function ringColor(b, charging) { + if (charging) return "#30D158"; + if (b < 10) return "#FF453A"; + if (b <= 20) return "#FFD60A"; + return "#30D158"; + } + + function deviceIcon(type) { + switch (type) { + case "laptop": return Icons.computer; + case "audio": + case "headphones": return Icons.headphones; + case "keyboard": return Icons.keyboard; + case "mouse": + case "trackpad": return Icons.mouse; + case "gamepad": return Icons.gamepad; + case "phone": return Icons.phone; + case "watch": return Icons.watch; + case "speaker": return Icons.speaker; + } + return Icons.bluetooth; + } + + readonly property color trackColor: Qt.rgba( + Colors.overBackground.r, Colors.overBackground.g, Colors.overBackground.b, 0.06) + + radius: 24 + clip: true + + // Matte surface + Rectangle { + anchors.fill: parent + visible: root.showBackground + radius: 24 + color: Colors.surfaceContainer + } + + // Centered device row + Row { + anchors.centerIn: parent + spacing: root.wide ? 16 : 0 + + Repeater { + model: root.devices + + delegate: Item { + id: slotItem + required property var modelData + + width: root.ringD + 8 + height: root.ringD + 34 + + readonly property real pct: Number(modelData.battery || 0) + readonly property bool charging: modelData.isCharging === true + readonly property string type: String(modelData.type || "other") + + ColumnLayout { + anchors.centerIn: parent + spacing: 4 + + // Ring + center icon + Item { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: root.ringD + Layout.preferredHeight: root.ringD + + Canvas { + id: slotRing + width: root.ringD + height: root.ringD + anchors.centerIn: parent + + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + Component.onCompleted: requestPaint() + + onPaint: { + var ctx = getContext("2d"); + ctx.reset(); + var cx = width / 2, cy = height / 2; + var r = width / 2 - root.ringStroke / 2; + + // Track + ctx.lineWidth = root.ringStroke; + ctx.strokeStyle = root.trackColor; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); + + // Progress arc (top, clockwise, round caps) + var start = -Math.PI / 2; + var end = start + (slotItem.pct / 100) * Math.PI * 2; + ctx.lineCap = "round"; + ctx.strokeStyle = root.ringColor(slotItem.pct, slotItem.charging); + + ctx.beginPath(); + ctx.arc(cx, cy, r, start, end, false); + ctx.stroke(); + } + } + + Text { + anchors.centerIn: parent + text: root.deviceIcon(slotItem.type) + font.family: Icons.font + font.pixelSize: root.wide ? 20 : 34 + color: Colors.overBackground + } + } + + // Percentage label (with charging bolt) + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 3 + + Text { + visible: slotItem.charging + text: Icons.lightning + font.family: Icons.font + font.pixelSize: root.wide ? 11 : 14 + color: "#30D158" + } + + Text { + text: Math.round(slotItem.pct) + "%" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: root.wide ? 14 : 20 + font.weight: Font.Bold + } + } + + // Device name + Text { + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + text: slotItem.modelData.name || "" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: root.wide ? 9 : 11 + opacity: 0.7 + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/components/BluetoothCard.qml b/modules/retroshell/files/modules/components/BluetoothCard.qml new file mode 100644 index 00000000..3ccc80d8 --- /dev/null +++ b/modules/retroshell/files/modules/components/BluetoothCard.qml @@ -0,0 +1,388 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import qs.modules.theme +import qs.config + +// Bluetooth & Quick Pair card. Theme-aware. Accepts live BT telemetry via its +// properties; the widget wires it to BluetoothService. +Rectangle { + id: root + + property bool showBackground: true + property bool isEnabled: false + property bool isScanning: false + property var connectedDevices: [] // [{mac,name,type,battery}] priority-sorted + property var pairedDevices: [] // [{mac,name,type}] paired, not connected + property var discoveredDevices: [] // [{mac,name,type}] found while scanning + property string priority: "automatic" + + signal toggleRequested() + signal scanRequested() + signal disconnectRequested(string mac) + signal pairRequested(string mac) + + function deviceIcon(type) { + switch (type) { + case "audio": return Icons.headphones; + case "keyboard": return Icons.keyboard; + case "mouse": return Icons.mouse; + case "gamepad": return Icons.gamepad; + case "phone": return Icons.phone; + case "watch": return Icons.watch; + case "speaker": return Icons.speaker; + case "printer": return Icons.printer; + case "camera": return Icons.camera; + } + return Icons.bluetooth; + } + + function batteryColor(b) { + if (b < 0) return Colors.outline; + if (b < 20) return Colors.red; + if (b <= 50) return Colors.yellow; + return Colors.green; + } + + radius: 20 + clip: true + + // Theme-aware card surface + Rectangle { + anchors.fill: parent + visible: root.showBackground + radius: 20 + gradient: Gradient { + GradientStop { position: 0.0; color: Colors.surfaceContainer } + GradientStop { position: 1.0; color: Colors.surfaceContainerLow } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 4 + + // ── Header: status + scan + toggle ── + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Text { + text: root.isEnabled + ? (root.connectedDevices.length > 0 ? Icons.bluetoothConnected : Icons.bluetooth) + : Icons.bluetoothOff + font.family: Icons.font + font.pixelSize: 15 + color: root.isEnabled ? Colors.primary : Colors.outline + Layout.alignment: Qt.AlignVCenter + } + + Text { + Layout.fillWidth: true + text: "Bluetooth" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 12 + font.weight: Font.Bold + elide: Text.ElideRight + Layout.alignment: Qt.AlignVCenter + } + + Text { + id: scanIcon + Layout.alignment: Qt.AlignVCenter + text: Icons.sync + font.family: Icons.font + font.pixelSize: 13 + color: root.isScanning ? Colors.primary : Colors.outline + rotation: 0 + + MouseArea { + anchors.fill: parent + anchors.margins: -4 + cursorShape: Qt.PointingHandCursor + onClicked: root.scanRequested() + } + + RotationAnimation on rotation { + from: 0 + to: 360 + duration: 900 + loops: Animation.Infinite + running: root.isScanning + } + } + + Item { + id: toggle + Layout.alignment: Qt.AlignVCenter + width: 34 + height: 20 + + Rectangle { + anchors.fill: parent + radius: height / 2 + color: root.isEnabled ? Colors.primary : Colors.outlineVariant + Behavior on color { ColorAnimation { duration: 150 } } + } + + Rectangle { + id: knob + width: 16 + height: 16 + radius: width / 2 + color: Colors.surfaceContainerLowest + x: root.isEnabled ? parent.width - knob.width - 2 : 2 + anchors.verticalCenter: parent.verticalCenter + Behavior on x { + NumberAnimation { duration: 150; easing.type: Easing.OutCubic } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleRequested() + } + } + } + + // ── Scrollable device list ── + Flickable { + Layout.fillWidth: true + Layout.fillHeight: true + contentHeight: deviceColumn.implicitHeight + clip: true + flickableDirection: Flickable.VerticalFlick + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: ScrollBar { + policy: parent.contentHeight > parent.height ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff + } + + ColumnLayout { + id: deviceColumn + width: parent.width + spacing: 4 + + // ── Connected devices ── + Repeater { + model: root.connectedDevices + delegate: Rectangle { + required property var modelData + required property int index + Layout.fillWidth: true + Layout.preferredHeight: 34 + radius: 10 + color: Colors.surfaceContainerHigh + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 8 + spacing: 6 + + Text { + text: root.deviceIcon(modelData.type) + font.family: Icons.font + font.pixelSize: 15 + color: Colors.primary + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { + Layout.fillWidth: true + text: modelData.name + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 11 + font.weight: Font.Bold + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: modelData.type + color: Colors.outline + font.family: Config.theme.font + font.pixelSize: 8 + } + } + + Text { + visible: modelData.battery >= 0 + text: modelData.battery + "%" + color: root.batteryColor(modelData.battery) + font.family: Config.theme.font + font.pixelSize: 11 + font.weight: Font.Bold + Layout.alignment: Qt.AlignVCenter + } + + Text { + text: Icons.cancel + font.family: Icons.font + font.pixelSize: 12 + color: Colors.outline + Layout.alignment: Qt.AlignVCenter + + MouseArea { + anchors.fill: parent + anchors.margins: -4 + cursorShape: Qt.PointingHandCursor + onClicked: root.disconnectRequested(modelData.mac) + } + } + } + } + } + + // ── Paired devices (not connected) ── + Repeater { + model: root.pairedDevices + delegate: Rectangle { + required property var modelData + required property int index + Layout.fillWidth: true + Layout.preferredHeight: 32 + radius: 10 + color: Colors.surfaceContainerHigh + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 8 + spacing: 6 + + Text { + text: root.deviceIcon(modelData.type) + font.family: Icons.font + font.pixelSize: 14 + color: Colors.outline + Layout.alignment: Qt.AlignVCenter + } + + Text { + Layout.fillWidth: true + text: modelData.name + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 11 + font.weight: Font.Bold + elide: Text.ElideRight + } + + Text { + text: "Connect" + color: Colors.primary + font.family: Config.theme.font + font.pixelSize: 10 + font.weight: Font.Bold + Layout.alignment: Qt.AlignVCenter + + MouseArea { + anchors.fill: parent + anchors.margins: -6 + cursorShape: Qt.PointingHandCursor + onClicked: root.pairRequested(modelData.mac) + } + } + } + } + } + + // ── Discovered devices (quick pair while scanning) ── + Repeater { + model: root.isScanning ? root.discoveredDevices : [] + delegate: Rectangle { + required property var modelData + required property int index + Layout.fillWidth: true + Layout.preferredHeight: 30 + visible: index < 3 + radius: 10 + color: Colors.surfaceContainerHigh + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 8 + spacing: 6 + + Text { + text: root.deviceIcon(modelData.type) + font.family: Icons.font + font.pixelSize: 13 + color: Colors.outline + Layout.alignment: Qt.AlignVCenter + } + + Text { + Layout.fillWidth: true + text: modelData.name + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 11 + font.weight: Font.Bold + elide: Text.ElideRight + } + + Text { + text: "Pair" + color: Colors.primary + font.family: Config.theme.font + font.pixelSize: 10 + font.weight: Font.Bold + Layout.alignment: Qt.AlignVCenter + + MouseArea { + anchors.fill: parent + anchors.margins: -6 + cursorShape: Qt.PointingHandCursor + onClicked: root.pairRequested(modelData.mac) + } + } + } + } + } + + // ── Empty state / searching ── + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 24 + visible: root.connectedDevices.length === 0 && root.pairedDevices.length === 0 + + Text { + Layout.fillWidth: true + text: !root.isEnabled ? "Bluetooth is off" + : root.isScanning ? "Searching…" + : "No devices" + color: Colors.outline + font.family: Config.theme.font + font.pixelSize: 10 + elide: Text.ElideRight + } + + Text { + visible: root.isEnabled && !root.isScanning + text: "Scan" + color: Colors.primary + font.family: Config.theme.font + font.pixelSize: 10 + font.weight: Font.Bold + + MouseArea { + anchors.fill: parent + anchors.margins: -6 + cursorShape: Qt.PointingHandCursor + onClicked: root.scanRequested() + } + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/components/ContextMenu.qml b/modules/retroshell/files/modules/components/ContextMenu.qml index f61bb0cc..a9242a49 100644 --- a/modules/retroshell/files/modules/components/ContextMenu.qml +++ b/modules/retroshell/files/modules/components/ContextMenu.qml @@ -56,13 +56,14 @@ PanelWindow { if (coords.length === 2) { let x = parseInt(coords[0].trim()); let y = parseInt(coords[1].trim()); - menu.x = x; - menu.y = y; - menu.visible = false; - Qt.callLater(() => { - menu.popup(); - }); + if (isFinite(x) && isFinite(y)) { + menu.x = x; + menu.y = y; + } } + // Always open the menu even if the cursor query was empty, so a + // transient axctl hiccup never swallows a right-click. + menu.popup(); } } } @@ -280,6 +281,20 @@ PanelWindow { } } + Component.onCompleted: { + // Warm up the menu popup so the very first right-click opens + // immediately (the first popup in a layer surface is otherwise + // not ready and gets swallowed). + Qt.callLater(() => { + if (menu) { + menu.x = 0; + menu.y = 0; + menu.popup(); + menu.close(); + } + }); + } + Connections { target: menu function onClosed() { diff --git a/modules/retroshell/files/modules/components/DeviceStorageCard.qml b/modules/retroshell/files/modules/components/DeviceStorageCard.qml new file mode 100644 index 00000000..2f6efdc2 --- /dev/null +++ b/modules/retroshell/files/modules/components/DeviceStorageCard.qml @@ -0,0 +1,218 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.config + +// Apple-style "Device Storage" card. Theme-aware (uses Colors variants). +// segments: array of {label, sizeGB, color, striped|hasStripes}. +Rectangle { + id: root + + property bool showBackground: true + property bool wide: false + property bool showBorder: true + property string title: "Device Storage" + property var segments: [] + property real totalGB: 512 + // Header used value; -1 = compute from segment sizes. + property real usedGB: -1 + property real gapSize: 3 + property int padding: 16 + + readonly property real computedUsedGB: { + var s = 0; + for (var i = 0; i < root.segments.length; i++) + s += Number(root.segments[i].sizeGB || 0); + return s; + } + + radius: 20 + clip: true + + // Theme-aware card surface (hidden when hosted inside WidgetHost) + Rectangle { + anchors.fill: parent + visible: root.showBackground + radius: 20 + border.color: Colors.outlineVariant + border.width: root.showBorder ? 1 : 0 + gradient: Gradient { + GradientStop { position: 0.0; color: Colors.surfaceContainer } + GradientStop { position: 1.0; color: Colors.surfaceContainerLow } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: root.padding + spacing: 6 + + // Header: title left, stat right (wide) / title alone (compact) + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Text { + text: root.title + color: Colors.outline + font.family: Config.theme.font + font.pixelSize: root.wide ? 12 : 10 + font.weight: Font.Medium + Layout.fillWidth: root.wide + elide: Text.ElideRight + } + + // Stat line (wide, right-aligned) + RowLayout { + visible: root.wide + Layout.alignment: Qt.AlignRight + spacing: 4 + + Text { + text: { + var gb = root.usedGB >= 0 ? root.usedGB : root.computedUsedGB; + return (gb < 10 ? gb.toFixed(1) : Math.round(gb)) + " GB"; + } + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 22 + font.weight: Font.Bold + } + + Text { + text: "/ " + (root.totalGB < 10 ? root.totalGB.toFixed(1) : Math.round(root.totalGB)) + " GB" + color: Colors.outline + font.family: Config.theme.font + font.pixelSize: 13 + Layout.alignment: Qt.AlignBaseline + } + } + } + + // Stat line (compact): used (bold) + total (muted, baseline-aligned) + RowLayout { + visible: !root.wide + spacing: 4 + + Text { + text: { + var gb = root.usedGB >= 0 ? root.usedGB : root.computedUsedGB; + return (gb < 10 ? gb.toFixed(1) : Math.round(gb)) + " GB"; + } + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 20 + font.weight: Font.Bold + } + + Text { + text: "/ " + Math.round(root.totalGB) + " GB" + color: Colors.outline + font.family: Config.theme.font + font.pixelSize: 12 + Layout.alignment: Qt.AlignBaseline + } + } + + // Multi-segment progress bar + Rectangle { + id: track + Layout.fillWidth: true + height: 30 + radius: height / 2 + color: Colors.surfaceContainerHigh + border.color: Colors.outlineVariant + border.width: 1 + clip: true + + Row { + id: segRow + anchors.fill: parent + anchors.leftMargin: 1 + anchors.rightMargin: 1 + spacing: root.gapSize + + Repeater { + model: root.segments + delegate: Item { + required property var modelData + required property int index + width: { + var total = 0; + for (var i = 0; i < root.segments.length; i++) + total += Number(root.segments[i].sizeGB || 0); + if (total <= 0) return 0; + var usable = segRow.width - (root.segments.length - 1) * segRow.spacing; + return Math.max(0, usable * (Number(root.segments[index].sizeGB || 0) / total)); + } + height: segRow.height + + Rectangle { + anchors.fill: parent + radius: 6 + color: Qt.color(modelData.color || Colors.primary) + } + } + } + } + } + + // Category legend (2x2 grid compact / single row wide) + GridLayout { + Layout.fillWidth: true + Layout.topMargin: 6 + columns: root.wide ? root.segments.length : 2 + columnSpacing: root.wide ? 4 : 10 + rowSpacing: 3 + + Repeater { + model: root.segments + delegate: RowLayout { + Layout.fillWidth: true + Layout.preferredWidth: 40 + spacing: 5 + + Rectangle { + width: 6 + height: 6 + radius: 3 + color: Qt.color(modelData.color || Colors.primary) + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Text { + text: { + var gb = Number(modelData.sizeGB || 0); + return gb > 0 ? modelData.label : "Unknown"; + } + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: root.wide ? 10 : 10 + elide: Text.ElideRight + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + } + + Text { + visible: root.wide + text: { + var gb = Number(modelData.sizeGB || 0); + return gb > 0 ? (gb < 10 ? gb.toFixed(1) : Math.round(gb)) + " GB" : "—"; + } + color: Qt.color(modelData.color || Colors.primary) + font.family: Config.theme.font + font.pixelSize: 10 + font.weight: Font.Bold + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + } + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/components/FeedCard.qml b/modules/retroshell/files/modules/components/FeedCard.qml new file mode 100644 index 00000000..70d50875 --- /dev/null +++ b/modules/retroshell/files/modules/components/FeedCard.qml @@ -0,0 +1,302 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.config + +// Feed card — single article with full-cover background, swipeable through the +// loaded articles (2x4, 320x160). Source-aware branding; click opens the URL. +Rectangle { + id: root + + property bool showBackground: true + property var articles: [] // [{id,title,url,readTime,upvotes,source,author,image}] + property int currentIndex: 0 + property string source: "devto" // devto | hackernews | dailydev + property string tag: "linux" + + signal previousRequested() + signal nextRequested() + signal openRequested(string url) + + readonly property var currentArticle: (root.articles.length > 0 && root.currentIndex >= 0 && root.currentIndex < root.articles.length) + ? root.articles[root.currentIndex] : null + + function accentColor() { + if (root.source === "hackernews") return "#FF6600"; + if (root.source === "dailydev") return "#A259FF"; + return Colors.magenta; + } + + function brandLabel() { + if (root.source === "hackernews") return "HackerNews"; + if (root.source === "dailydev") return "daily.dev"; + return "DEV"; + } + + readonly property real brandMaxWidth: 92 + + // Auto-scale the badge font so the brand always fits the badge. + function badgeFontSize() { + var label = root.brandLabel(); + var base = 9; + var est = label.length * base * 0.62 + 14; + if (est > root.brandMaxWidth) + return Math.max(7, Math.floor(base * root.brandMaxWidth / est)); + return base; + } + + function badgeBg() { + if (root.source === "hackernews") return "#FF6600"; + if (root.source === "dailydev") return "#A259FF"; + return "#FFFFFF"; + } + + function badgeFg() { + if (root.source === "hackernews") return "#FFFFFF"; + if (root.source === "dailydev") return "#FFFFFF"; + return "#000000"; + } + + radius: 20 + clip: true + + // Surface fallback (behind the cover) + Rectangle { + anchors.fill: parent + visible: root.showBackground + radius: 20 + color: Colors.surfaceContainer + } + + Item { + id: articleView + anchors.fill: parent + opacity: 1 + + // Article cover background + Image { + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + source: root.currentArticle && root.currentArticle.image ? root.currentArticle.image : "" + visible: root.currentArticle && root.currentArticle.image + cache: false + } + + // Scrim for readability + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0.35) } + GradientStop { position: 0.55; color: Qt.rgba(0, 0, 0, 0.15) } + GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.72) } + } + } + + // ── Header overlay ── + RowLayout { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 10 + spacing: 6 + + Rectangle { + Layout.alignment: Qt.AlignVCenter + width: Math.min(root.brandMaxWidth, brandText.implicitWidth + 14) + height: 18 + radius: 9 + color: root.badgeBg() + + Text { + id: brandText + anchors.centerIn: parent + text: root.brandLabel() + color: root.badgeFg() + font.family: Config.theme.font + font.pixelSize: root.badgeFontSize() + font.weight: Font.Black + elide: Text.ElideRight + width: parent.width - 10 + horizontalAlignment: Text.AlignHCenter + } + } + } + + // Tag pill (top-right) + Rectangle { + anchors.top: parent.top + anchors.topMargin: 10 + anchors.right: parent.right + anchors.rightMargin: 10 + visible: root.tag.length > 0 + height: 16 + width: tagPillText.implicitWidth + 10 + radius: 8 + color: Qt.rgba(0, 0, 0, 0.45) + z: 5 + + Text { + id: tagPillText + anchors.centerIn: parent + text: "#" + root.tag + color: root.accentColor() + font.family: Config.theme.font + font.pixelSize: 9 + font.weight: Font.Bold + } + } + + // ── Bottom article overlay ── + ColumnLayout { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 10 + spacing: 3 + + Text { + Layout.fillWidth: true + text: root.currentArticle ? root.currentArticle.title : "" + color: "#FFFFFF" + font.family: Config.theme.font + font.pixelSize: 14 + font.weight: Font.Bold + maximumLineCount: 2 + elide: Text.ElideRight + wrapMode: Text.Wrap + } + + RowLayout { + Layout.fillWidth: true + spacing: 5 + + Text { + text: Icons.caretUp + font.family: Icons.font + font.pixelSize: 10 + color: root.accentColor() + } + + Text { + text: root.currentArticle ? (root.currentArticle.upvotes || 0) : 0 + color: root.accentColor() + font.family: Config.theme.font + font.pixelSize: 11 + font.weight: Font.Bold + } + + Text { + visible: root.currentArticle && root.currentArticle.readTime > 0 + text: root.currentArticle ? ("· " + root.currentArticle.readTime + " min read") : "" + color: Qt.rgba(1, 1, 1, 0.7) + font.family: Config.theme.font + font.pixelSize: 11 + } + + Text { + visible: root.currentArticle && root.currentArticle.source + text: root.currentArticle ? ("· " + root.currentArticle.source) : "" + color: Qt.rgba(1, 1, 1, 0.7) + font.family: Config.theme.font + font.pixelSize: 11 + elide: Text.ElideRight + } + + Item { Layout.fillWidth: true } + } + } + + // Tap vs drag: tap opens, horizontal drag swipes. + MouseArea { + anchors.fill: parent + property real pressX: 0 + property real pressY: 0 + cursorShape: Qt.PointingHandCursor + + onPressed: (m) => { pressX = m.x; pressY = m.y; } + onReleased: (m) => { + var dx = m.x - pressX; + var dy = m.y - pressY; + if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) { + if (dx < 0) root.nextRequested(); + else root.previousRequested(); + } else if (Math.abs(dx) < 12 && Math.abs(dy) < 12) { + if (root.currentArticle) + root.openRequested(root.currentArticle.url); + } + } + } + } + + // Fade content in on index change + onCurrentIndexChanged: { + articleView.opacity = 0; + slideIn.start(); + } + + NumberAnimation { + id: slideIn + target: articleView + property: "opacity" + to: 1 + duration: 260 + easing.type: Easing.OutQuad + } + + // ── Swipe arrows ── + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 6 + text: Icons.caretLeft + font.family: Icons.font + font.pixelSize: 16 + color: Qt.rgba(1, 1, 1, 0.75) + z: 5 + + MouseArea { + anchors.fill: parent + anchors.margins: -6 + cursorShape: Qt.PointingHandCursor + onClicked: root.previousRequested() + } + } + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: 6 + text: Icons.caretRight + font.family: Icons.font + font.pixelSize: 16 + color: Qt.rgba(1, 1, 1, 0.75) + z: 5 + + MouseArea { + anchors.fill: parent + anchors.margins: -6 + cursorShape: Qt.PointingHandCursor + onClicked: root.nextRequested() + } + } + + // ── Dots (bottom-right) ── + Row { + anchors.bottom: parent.bottom + anchors.bottomMargin: 14 + anchors.right: parent.right + anchors.rightMargin: 12 + spacing: 3 + z: 5 + + Repeater { + model: root.articles.length + delegate: Rectangle { + width: 5 + height: 5 + radius: 2.5 + color: index === root.currentIndex ? "#FFFFFF" : Qt.rgba(1, 1, 1, 0.35) + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/components/NetworkCard.qml b/modules/retroshell/files/modules/components/NetworkCard.qml new file mode 100644 index 00000000..8fed0bb8 --- /dev/null +++ b/modules/retroshell/files/modules/components/NetworkCard.qml @@ -0,0 +1,503 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Io +import qs.modules.theme +import qs.config + +// Network & Bandwidth Monitor card. Theme-aware. Accepts real-time streams via +// its properties; the widget wires it to NetworkMetrics/NetworkService. +// variant: "compact" (2x2 160x160), "wide" (2x4 320x160), "slim" (1x4 320x80). +Rectangle { + id: root + + property string variant: "compact" + property bool showBackground: true + property string interfaceName: "" + property string ssid: "" + property bool isConnected: false + property int wifiStrength: 0 + property bool wifiEnabled: false + property real downloadMbps: 0 + property real uploadMbps: 0 + property string localIp: "" + property string publicIp: "" + property bool publicIpValid: false + property bool hideIp: true + property var downloadHistory: [] + property var uploadHistory: [] + + signal toggleWifiRequested() + + // Session-only reveal (eye) toggling the mask regardless of the saved state. + property bool revealed: false + + function maskIp(ip) { + var parts = String(ip).split("."); + if (parts.length !== 4) return ip; + return parts[0] + "." + parts[1] + ".***.***"; + } + + readonly property string displayedPublicIp: { + if (!root.publicIpValid || !root.publicIp) return "—"; + return (root.hideIp && !root.revealed) ? root.maskIp(root.publicIp) : root.publicIp; + } + + function wifiIcon() { + if (!root.isConnected) return Icons.wifiX; + if (root.wifiStrength > 80) return Icons.wifiHigh; + if (root.wifiStrength > 55) return Icons.wifiMedium; + if (root.wifiStrength > 30) return Icons.wifiLow; + return Icons.wifiNone; + } + + function speedText(v) { + return v.toFixed(1); + } + + function copyIp(ip) { + if (!ip) return; + copyProc.command = ["sh", "-c", "printf '%s' '" + String(ip).replace(/'/g, "") + "' | wl-copy"]; + copyProc.running = true; + } + + Process { + id: copyProc + running: false + } + + // Draw a dual-line sparkline (magenta upload behind, cyan download on top). + function paintSpark(canvas, color, arr, fillAlpha) { + var ctx = canvas.getContext("2d"); + var w = canvas.width, h = canvas.height; + var dh = root.downloadHistory, uh = root.uploadHistory; + var maxN = Math.max(dh.length, uh.length, 2); + var maxV = 1; + for (var i = 0; i < dh.length; i++) maxV = Math.max(maxV, dh[i]); + for (var j = 0; j < uh.length; j++) maxV = Math.max(maxV, uh[j]); + var pad = 2; + var cw = w - pad * 2, ch = h - pad * 2; + + function pts(arr2) { + var p = []; + for (var k = 0; k < arr2.length; k++) + p.push({ x: pad + (k / (maxN - 1)) * cw, y: pad + ch - (arr2[k] / maxV) * ch }); + return p; + } + + function stroke(p2) { + if (p2.length < 2) return; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.lineJoin = "round"; + ctx.beginPath(); + ctx.moveTo(p2[0].x, p2[0].y); + for (var m = 1; m < p2.length; m++) ctx.lineTo(p2[m].x, p2[m].y); + ctx.stroke(); + } + + function fillUnder(p2) { + if (p2.length < 2) return; + var grad = ctx.createLinearGradient(0, 0, 0, h); + grad.addColorStop(0, Qt.rgba(color.r, color.g, color.b, fillAlpha)); + grad.addColorStop(1, Qt.rgba(color.r, color.g, color.b, 0)); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.moveTo(p2[0].x, h); + for (var n = 0; n < p2.length; n++) ctx.lineTo(p2[n].x, p2[n].y); + ctx.lineTo(p2[p2.length - 1].x, h); + ctx.closePath(); + ctx.fill(); + } + + var upload = pts(uh), download = pts(dh); + stroke(upload); fillUnder(upload); + stroke(download); fillUnder(download); + } + + radius: 20 + clip: true + + // Theme-aware card surface (hidden when hosted inside WidgetHost) + Rectangle { + anchors.fill: parent + visible: root.showBackground + radius: 20 + gradient: Gradient { + GradientStop { position: 0.0; color: Colors.surfaceContainer } + GradientStop { position: 1.0; color: Colors.surfaceContainerLow } + } + } + + Loader { + anchors.fill: parent + sourceComponent: root.variant === "mini" ? miniLayout + : root.variant === "slim" ? slimLayout + : root.variant === "wide" ? wideLayout + : compactLayout + } + + // ── iOS-style toggle (shared) ── + Component { + id: toggleComp + Item { + id: toggle + width: 34 + height: 20 + Layout.alignment: Qt.AlignVCenter + + Rectangle { + anchors.fill: parent + radius: height / 2 + color: root.wifiEnabled ? Colors.primary : Colors.outlineVariant + Behavior on color { ColorAnimation { duration: 150 } } + } + + Rectangle { + id: knob + width: 16 + height: 16 + radius: width / 2 + color: Colors.surfaceContainerLowest + x: root.wifiEnabled ? parent.width - knob.width - 2 : 2 + anchors.verticalCenter: parent.verticalCenter + Behavior on x { + NumberAnimation { duration: 150; easing.type: Easing.OutCubic } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleWifiRequested() + } + } + } + + // ── 2x2 compact ── + Component { + id: compactLayout + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: root.wifiIcon() + font.family: Icons.font + font.pixelSize: 16 + color: root.isConnected ? Colors.overBackground : Colors.outline + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { + Layout.fillWidth: true + text: root.ssid.length > 0 ? root.ssid : (root.isConnected ? "Connected" : "Disconnected") + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 12 + font.weight: Font.Bold + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: root.interfaceName + color: Colors.outline + font.family: Config.theme.font + font.pixelSize: 9 + elide: Text.ElideRight + } + } + + Loader { sourceComponent: toggleComp } + } + + RowLayout { + Layout.fillWidth: true + spacing: 4 + + Text { text: Icons.arrowDown; font.family: Icons.font; font.pixelSize: 11; color: Colors.primary } + Text { text: root.speedText(root.downloadMbps); color: Colors.overBackground; font.family: Config.theme.font; font.pixelSize: 16; font.weight: Font.Bold } + Text { text: "Mbps"; color: Colors.outline; font.family: Config.theme.font; font.pixelSize: 9; Layout.alignment: Qt.AlignBaseline } + Item { Layout.fillWidth: true } + Text { text: Icons.arrowUp; font.family: Icons.font; font.pixelSize: 11; color: Colors.magenta } + Text { text: root.speedText(root.uploadMbps); color: Colors.overBackground; font.family: Config.theme.font; font.pixelSize: 16; font.weight: Font.Bold } + Text { text: "Mbps"; color: Colors.outline; font.family: Config.theme.font; font.pixelSize: 9; Layout.alignment: Qt.AlignBaseline } + } + + Canvas { + id: compactSpark + Layout.fillWidth: true + Layout.preferredHeight: 30 + antialiasing: true + Connections { + target: root + function onDownloadHistoryChanged() { compactSpark.requestPaint(); } + function onUploadHistoryChanged() { compactSpark.requestPaint(); } + } + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + root.paintSpark(compactSpark, Colors.magenta, root.uploadHistory, 0.10); + root.paintSpark(compactSpark, Colors.primary, root.downloadHistory, 0.16); + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 24 + radius: 10 + color: Colors.surfaceContainerHigh + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 5 + + Text { text: "LAN"; color: Colors.outline; font.family: Config.theme.font; font.pixelSize: 9 } + Text { text: root.localIp.length > 0 ? root.localIp : "—"; color: Colors.overBackground; font.family: Config.theme.monoFont; font.pixelSize: 10; elide: Text.ElideRight } + Text { + Layout.fillWidth: true + text: "" + } + Text { + text: Icons.copy; font.family: Icons.font; font.pixelSize: 12; color: Colors.outline + Layout.alignment: Qt.AlignVCenter + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: root.copyIp(root.localIp) } + } + } + } + } + } + + // ── 2x4 wide ── + Component { + id: wideLayout + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Text { + text: root.wifiIcon() + font.family: Icons.font + font.pixelSize: 18 + color: root.isConnected ? Colors.overBackground : Colors.outline + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { + Layout.fillWidth: true + text: root.ssid.length > 0 ? root.ssid : (root.isConnected ? "Connected" : "Disconnected") + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 14 + font.weight: Font.Bold + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: root.interfaceName + color: Colors.outline + font.family: Config.theme.font + font.pixelSize: 10 + elide: Text.ElideRight + } + } + + Item { Layout.fillWidth: true } + + Loader { sourceComponent: toggleComp } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { text: Icons.arrowDown; font.family: Icons.font; font.pixelSize: 12; color: Colors.primary } + Text { text: root.speedText(root.downloadMbps); color: Colors.overBackground; font.family: Config.theme.font; font.pixelSize: 21; font.weight: Font.Bold } + Text { text: "Mbps"; color: Colors.outline; font.family: Config.theme.font; font.pixelSize: 10; Layout.alignment: Qt.AlignBaseline } + Item { Layout.fillWidth: true } + Text { text: Icons.arrowUp; font.family: Icons.font; font.pixelSize: 12; color: Colors.magenta } + Text { text: root.speedText(root.uploadMbps); color: Colors.overBackground; font.family: Config.theme.font; font.pixelSize: 21; font.weight: Font.Bold } + Text { text: "Mbps"; color: Colors.outline; font.family: Config.theme.font; font.pixelSize: 10; Layout.alignment: Qt.AlignBaseline } + } + + Canvas { + id: wideSpark + Layout.fillWidth: true + Layout.preferredHeight: 34 + antialiasing: true + Connections { + target: root + function onDownloadHistoryChanged() { wideSpark.requestPaint(); } + function onUploadHistoryChanged() { wideSpark.requestPaint(); } + } + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + root.paintSpark(wideSpark, Colors.magenta, root.uploadHistory, 0.10); + root.paintSpark(wideSpark, Colors.primary, root.downloadHistory, 0.16); + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 24 + radius: 10 + color: Colors.surfaceContainerHigh + + RowLayout { + anchors.fill: parent + anchors.margins: 6 + spacing: 6 + + Text { text: "LAN"; color: Colors.outline; font.family: Config.theme.font; font.pixelSize: 9 } + Text { text: root.localIp.length > 0 ? root.localIp : "—"; color: Colors.overBackground; font.family: Config.theme.monoFont; font.pixelSize: 10; elide: Text.ElideRight } + Text { text: "·"; color: Colors.outline; font.family: Config.theme.font; font.pixelSize: 9 } + Text { text: "WAN"; color: Colors.outline; font.family: Config.theme.font; font.pixelSize: 9 } + Text { Layout.fillWidth: true; text: root.displayedPublicIp; color: Colors.overBackground; font.family: Config.theme.monoFont; font.pixelSize: 10; elide: Text.ElideRight } + Text { + text: Icons.xeyes; font.family: Icons.font; font.pixelSize: 12 + color: (root.hideIp && !root.revealed) ? Colors.primary : Colors.outline + Layout.alignment: Qt.AlignVCenter + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: root.revealed = !root.revealed } + } + Text { + text: Icons.copy; font.family: Icons.font; font.pixelSize: 12; color: Colors.outline + Layout.alignment: Qt.AlignVCenter + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: root.copyIp(root.publicIpValid ? root.publicIp : root.localIp) } + } + } + } + } + } + + // ── 1x3 mini (240x80) ── + Component { + id: miniLayout + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 4 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: root.wifiIcon() + font.family: Icons.font + font.pixelSize: 14 + color: root.isConnected ? Colors.overBackground : Colors.outline + Layout.alignment: Qt.AlignVCenter + } + + Text { + Layout.fillWidth: true + text: root.ssid.length > 0 ? root.ssid : (root.isConnected ? "Connected" : "Disconnected") + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 12 + font.weight: Font.Bold + elide: Text.ElideRight + } + + Loader { sourceComponent: toggleComp } + } + + RowLayout { + Layout.fillWidth: true + spacing: 4 + + Text { text: Icons.arrowDown; font.family: Icons.font; font.pixelSize: 10; color: Colors.primary } + Text { text: root.speedText(root.downloadMbps); color: Colors.overBackground; font.family: Config.theme.font; font.pixelSize: 13; font.weight: Font.Bold } + Text { text: "Mbps"; color: Colors.outline; font.family: Config.theme.font; font.pixelSize: 8; Layout.alignment: Qt.AlignBaseline } + Text { text: "·"; color: Colors.outline; font.family: Config.theme.font; font.pixelSize: 9 } + Text { text: Icons.arrowUp; font.family: Icons.font; font.pixelSize: 10; color: Colors.magenta } + Text { text: root.speedText(root.uploadMbps); color: Colors.overBackground; font.family: Config.theme.font; font.pixelSize: 13; font.weight: Font.Bold } + Text { text: "Mbps"; color: Colors.outline; font.family: Config.theme.font; font.pixelSize: 8; Layout.alignment: Qt.AlignBaseline } + } + } + } + + // ── 1x4 slim ── + Component { + id: slimLayout + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 4 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: root.wifiIcon() + font.family: Icons.font + font.pixelSize: 16 + color: root.isConnected ? Colors.overBackground : Colors.outline + Layout.alignment: Qt.AlignVCenter + } + + Text { + Layout.fillWidth: true + text: root.ssid.length > 0 ? root.ssid : (root.isConnected ? "Connected" : "Disconnected") + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 13 + font.weight: Font.Bold + elide: Text.ElideRight + } + + Loader { sourceComponent: toggleComp } + } + + RowLayout { + Layout.fillWidth: true + spacing: 4 + + Text { text: Icons.arrowDown; font.family: Icons.font; font.pixelSize: 11; color: Colors.primary } + Text { text: root.speedText(root.downloadMbps); color: Colors.overBackground; font.family: Config.theme.font; font.pixelSize: 15; font.weight: Font.Bold } + Text { text: "Mbps"; color: Colors.outline; font.family: Config.theme.font; font.pixelSize: 9; Layout.alignment: Qt.AlignBaseline } + Text { text: "·"; color: Colors.outline; font.family: Config.theme.font; font.pixelSize: 10 } + Text { text: Icons.arrowUp; font.family: Icons.font; font.pixelSize: 11; color: Colors.magenta } + Text { text: root.speedText(root.uploadMbps); color: Colors.overBackground; font.family: Config.theme.font; font.pixelSize: 15; font.weight: Font.Bold } + Text { text: "Mbps"; color: Colors.outline; font.family: Config.theme.font; font.pixelSize: 9; Layout.alignment: Qt.AlignBaseline } + + Canvas { + id: slimSpark + Layout.fillWidth: true + Layout.minimumWidth: 60 + Layout.preferredHeight: 28 + antialiasing: true + Connections { + target: root + function onDownloadHistoryChanged() { slimSpark.requestPaint(); } + function onUploadHistoryChanged() { slimSpark.requestPaint(); } + } + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + root.paintSpark(slimSpark, Colors.magenta, root.uploadHistory, 0.10); + root.paintSpark(slimSpark, Colors.primary, root.downloadHistory, 0.16); + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/components/NoteCard.qml b/modules/retroshell/files/modules/components/NoteCard.qml new file mode 100644 index 00000000..0ba4f44f --- /dev/null +++ b/modules/retroshell/files/modules/components/NoteCard.qml @@ -0,0 +1,231 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.modules.theme +import qs.config + +// Pinned note card — Apple Notes style: amber folder header with the note +// date, dotted perforation divider, off-white body with editable note content. +Rectangle { + id: root + + property bool showBackground: true + property string noteTitle: "" + property string noteBody: "" + property string richBody: "" + property bool hasNotes: false + property string dateText: "" + property bool openFullscreen: false + property real fontSize: 13 + property bool editing: false + + signal saveRequested(string body) + signal openFullscreenRequested() + + onEditingChanged: { + if (root.editing) + Qt.callLater(function () { editor.forceActiveFocus(); }); + } + + readonly property color bodyBg: Colors.surfaceContainer + readonly property color headerBg: "#FFC107" + readonly property color charcoal: Colors.overBackground + readonly property color dividerColor: Colors.outlineVariant + readonly property int headerHeight: 30 + + radius: 24 + clip: true + + // Body surface + Rectangle { + anchors.fill: parent + visible: root.showBackground + radius: 24 + color: root.bodyBg + } + + // ── Amber header (title + date) ── + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: root.headerHeight + color: root.headerBg + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 12 + spacing: 8 + + Text { + text: Icons.folder + font.family: Icons.font + font.pixelSize: 14 + color: "#FFFFFF" + Layout.alignment: Qt.AlignVCenter + } + + Text { + Layout.fillWidth: true + text: root.hasNotes && root.noteTitle.length > 0 ? root.noteTitle : "Notes" + color: "#FFFFFF" + font.family: Config.theme.font + font.pixelSize: 13 + font.weight: Font.Bold + elide: Text.ElideRight + Layout.alignment: Qt.AlignVCenter + } + + Text { + text: root.dateText + color: Qt.rgba(1, 1, 1, 0.9) + font.family: Config.theme.font + font.pixelSize: 10 + Layout.alignment: Qt.AlignVCenter + } + } + } + + // ── Perforation divider (dotted) ── + Repeater { + model: Math.max(0, Math.ceil(root.width / 6)) + delegate: Rectangle { + width: 2 + height: 1 + color: root.dividerColor + x: index * 6 + y: root.headerHeight + 1 + } + } + + // ── Body: formatted review (click to edit) / plain editor ── + Rectangle { + anchors.top: parent.top + anchors.topMargin: root.headerHeight + 10 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 14 + anchors.rightMargin: 14 + anchors.bottomMargin: 10 + radius: 6 + color: "transparent" + + // Formatted markdown/html review (read-only, scrollable) + Flickable { + id: viewFlick + anchors.fill: parent + anchors.margins: 2 + visible: root.hasNotes && !root.editing + contentHeight: view.contentHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + TextEdit { + id: view + width: viewFlick.width + height: view.contentHeight + readOnly: true + color: root.charcoal + font.family: Config.theme.font + font.pixelSize: root.fontSize + wrapMode: TextEdit.Wrap + selectByMouse: true + textFormat: TextEdit.RichText + text: root.richBody + + TapHandler { + onTapped: { + if (root.openFullscreen) + root.openFullscreenRequested(); + else + root.editing = true; + } + } + } + } + + // Plain-text editor (edit mode, scrollable) + Flickable { + id: editFlick + anchors.fill: parent + anchors.margins: 2 + visible: root.hasNotes && root.editing && !root.openFullscreen + contentHeight: editor.contentHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + TextEdit { + id: editor + width: editFlick.width + height: editor.contentHeight + readOnly: false + color: root.charcoal + font.family: Config.theme.font + font.pixelSize: root.fontSize + wrapMode: TextEdit.Wrap + selectByMouse: true + textFormat: TextEdit.PlainText + + property bool _syncing: false + + Connections { + target: root + function onNoteBodyChanged() { + if (!editor._syncing && editor.text !== root.noteBody) { + editor._syncing = true; + editor.text = root.noteBody; + editor._syncing = false; + } + } + } + + onTextChanged: { + if (!editor._syncing && editor.text !== root.noteBody) + saveTimer.restart(); + } + + TapHandler { + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: editor.forceActiveFocus() + } + + onActiveFocusChanged: { + if (!editor.activeFocus && root.editing) + root.editing = false; + } + + // Keep the caret visible while typing. + onCursorRectangleChanged: { + var f = editFlick; + var y = editor.cursorRectangle.y + editor.cursorRectangle.height; + if (y > f.contentY + f.height - 4) + f.contentY = y - f.height + 4; + else if (editor.cursorRectangle.y < f.contentY + 4) + f.contentY = editor.cursorRectangle.y - 4; + } + + Timer { + id: saveTimer + interval: 600 + repeat: false + onTriggered: { + if (editor.text !== root.noteBody) + root.saveRequested(editor.text); + } + } + } + } + } + + // Fullscreen preview click (covers the whole card) + MouseArea { + anchors.fill: parent + visible: root.openFullscreen && root.hasNotes + cursorShape: Qt.PointingHandCursor + onClicked: root.openFullscreenRequested() + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/components/PowerCard.qml b/modules/retroshell/files/modules/components/PowerCard.qml new file mode 100644 index 00000000..5f5fa12b --- /dev/null +++ b/modules/retroshell/files/modules/components/PowerCard.qml @@ -0,0 +1,359 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.config + +// Power Profiles & Thermals card. Theme-aware. Accepts live telemetry + a +// profile list; emits profileSelected(id) when the user picks a profile. +Rectangle { + id: root + + property bool showBackground: true + property string variant: "compact" + property string activeProfile: "" + property real powerDrawWatts: 0 + property int cpuTempC: -1 + property int gpuTempC: -1 + property int diskTempC: -1 + property int fanSpeedRpm: -1 + property var profiles: [] + + signal profileSelected(string id) + + function tempColor(t) { + if (t < 0) return Colors.overBackground; + if (t < 60) return Colors.green; + if (t < 80) return Colors.orange; + return Colors.red; + } + + function fanColor(rpm) { + if (rpm < 0) return Colors.overBackground; + if (rpm < 2000) return Colors.green; + if (rpm <= 3200) return Colors.orange; + return Colors.red; + } + + // Telemetry cells; the GPU cell is omitted when there's no reading (the + // SSD temp is shown in its place). + function telemetryModel() { + var items = [ + { icon: Icons.thermometer, label: "CPU", value: root.cpuTempC >= 0 ? root.cpuTempC + "°C" : "—°C", color: root.tempColor(root.cpuTempC) } + ]; + if (root.gpuTempC >= 0) { + items.push({ icon: Icons.gpu, label: "GPU", value: root.gpuTempC + "°C", color: root.tempColor(root.gpuTempC) }); + } else if (root.diskTempC >= 0) { + items.push({ icon: Icons.disk, label: "SSD", value: root.diskTempC + "°C", color: root.tempColor(root.diskTempC) }); + } + items.push({ + icon: Icons.sync, + label: root.fanSpeedRpm > 0 ? "RPM" : "Fan", + value: root.fanSpeedRpm > 0 ? root.formatRpm(root.fanSpeedRpm) : "Quiet", + color: root.fanColor(root.fanSpeedRpm) + }); + return items; + } + + function formatRpm(v) { + var s = String(v); + var out = ""; + while (s.length > 3) { + out = "," + s.slice(-3) + out; + s = s.slice(0, -3); + } + return s + out; + } + + radius: 20 + clip: true + + // Theme-aware card surface + Rectangle { + anchors.fill: parent + visible: root.showBackground + radius: 20 + gradient: Gradient { + GradientStop { position: 0.0; color: Colors.surfaceContainer } + GradientStop { position: 1.0; color: Colors.surfaceContainerLow } + } + } + + Loader { + anchors.fill: parent + sourceComponent: root.variant === "mini" ? miniLayout : compactLayout + } + + Component { + id: compactLayout + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 6 + + // ── Header: title + live power draw ── + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Text { + text: Icons.lightning + font.family: Icons.font + font.pixelSize: 14 + color: Colors.primary + Layout.alignment: Qt.AlignVCenter + } + + Text { + Layout.fillWidth: true + text: "Power" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 12 + font.weight: Font.Bold + elide: Text.ElideRight + Layout.alignment: Qt.AlignVCenter + } + + Rectangle { + Layout.alignment: Qt.AlignVCenter + Layout.preferredHeight: 18 + Layout.preferredWidth: wattText.implicitWidth + 12 + radius: 9 + color: Colors.surfaceContainerHigh + + Text { + id: wattText + anchors.centerIn: parent + text: root.powerDrawWatts > 0 ? Math.round(root.powerDrawWatts) + " W" : "—" + color: Colors.primary + font.family: Config.theme.font + font.pixelSize: 11 + font.weight: Font.Bold + } + } + } + + // ── Telemetry grid (CPU / GPU / Fan) ── + RowLayout { + Layout.fillWidth: true + spacing: 4 + + Repeater { + model: [ + { icon: Icons.thermometer, label: "CPU", value: root.cpuTempC >= 0 ? root.cpuTempC + "°C" : "—°C", color: root.tempColor(root.cpuTempC) }, + { icon: Icons.gpu, label: "GPU", value: root.gpuTempC >= 0 ? root.gpuTempC + "°C" : "—°C", color: root.tempColor(root.gpuTempC) }, + { icon: Icons.sync, label: root.fanSpeedRpm > 0 ? "RPM" : "Fan", value: root.fanSpeedRpm > 0 ? root.formatRpm(root.fanSpeedRpm) : "Quiet", color: Colors.overBackground } + ] + + delegate: ColumnLayout { + Layout.fillWidth: true + spacing: 1 + + Text { + Layout.alignment: Qt.AlignHCenter + text: modelData.icon + font.family: Icons.font + font.pixelSize: 13 + color: modelData.color + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: modelData.value + color: modelData.color + font.family: Config.theme.font + font.pixelSize: 13 + font.weight: Font.Bold + elide: Text.ElideRight + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: modelData.label + color: Colors.outline + font.family: Config.theme.font + font.pixelSize: 8 + } + } + } + } + + // ── Profile switcher ── + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 2 + spacing: 4 + + Repeater { + model: root.profiles + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + Layout.preferredHeight: 34 + + readonly property bool isActive: root.activeProfile === modelData.id + + radius: 10 + color: isActive ? Colors.primary : Colors.surfaceContainerHigh + + Behavior on color { + ColorAnimation { duration: 200 } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 1 + + Text { + Layout.alignment: Qt.AlignHCenter + text: modelData.icon + font.family: Icons.font + font.pixelSize: 14 + color: isActive ? Colors.surfaceContainerLowest : Colors.outline + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: modelData.label + color: isActive ? Colors.surfaceContainerLowest : Colors.outline + font.family: Config.theme.font + font.pixelSize: 8 + font.weight: Font.Medium + elide: Text.ElideRight + Layout.maximumWidth: 60 + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (!isActive) + root.profileSelected(modelData.id) + } + } + } + } + } + } + } + + // ── 1x3 mini (240x80): header + profile pills ── + Component { + id: miniLayout + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 4 + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Text { + text: Icons.lightning + font.family: Icons.font + font.pixelSize: 12 + color: Colors.primary + Layout.alignment: Qt.AlignVCenter + } + + Text { + Layout.fillWidth: true + text: "Power" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 12 + font.weight: Font.Bold + elide: Text.ElideRight + Layout.alignment: Qt.AlignVCenter + } + + Text { + text: root.cpuTempC >= 0 ? root.cpuTempC + "°C" : "" + color: root.tempColor(root.cpuTempC) + font.family: Config.theme.font + font.pixelSize: 12 + font.weight: Font.Bold + Layout.alignment: Qt.AlignVCenter + } + + Rectangle { + Layout.alignment: Qt.AlignVCenter + Layout.preferredHeight: 17 + Layout.preferredWidth: miniWattText.implicitWidth + 10 + radius: 9 + color: Colors.surfaceContainerHigh + + Text { + id: miniWattText + anchors.centerIn: parent + text: root.powerDrawWatts > 0 ? Math.round(root.powerDrawWatts) + " W" : "—" + color: Colors.primary + font.family: Config.theme.font + font.pixelSize: 10 + font.weight: Font.Bold + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 4 + + Repeater { + model: root.profiles + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + Layout.preferredHeight: 28 + + readonly property bool isActive: root.activeProfile === modelData.id + + radius: 9 + color: isActive ? Colors.primary : Colors.surfaceContainerHigh + + Behavior on color { + ColorAnimation { duration: 200 } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 0 + + Text { + Layout.alignment: Qt.AlignHCenter + text: modelData.icon + font.family: Icons.font + font.pixelSize: 13 + color: isActive ? Colors.surfaceContainerLowest : Colors.outline + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: modelData.label + color: isActive ? Colors.surfaceContainerLowest : Colors.outline + font.family: Config.theme.font + font.pixelSize: 8 + font.weight: Font.Medium + elide: Text.ElideRight + Layout.maximumWidth: 64 + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (!isActive) + root.profileSelected(modelData.id) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/components/SoundEffectWrapper.qml b/modules/retroshell/files/modules/components/SoundEffectWrapper.qml new file mode 100644 index 00000000..002821f5 --- /dev/null +++ b/modules/retroshell/files/modules/components/SoundEffectWrapper.qml @@ -0,0 +1,25 @@ +import QtQuick +import QtMultimedia + +Item { + id: root + property string keycode: "" + property alias source: player.source + property alias volume: player.volume + + // Define sourcePath and volumeValue aliases for clean dynamic loading + property alias sourcePath: player.source + property alias volumeValue: player.volume + + SoundEffect { + id: player + } + + Component.onDestruction: { + player.stop(); + } + + function play() { + player.play(); + } +} diff --git a/modules/retroshell/files/modules/components/SystemPulseCard.qml b/modules/retroshell/files/modules/components/SystemPulseCard.qml new file mode 100644 index 00000000..5e88fd56 --- /dev/null +++ b/modules/retroshell/files/modules/components/SystemPulseCard.qml @@ -0,0 +1,299 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.config + +// System Monitor card — multi-series sparkline graph for CPU/GPU/RAM/Disk + temps. +Rectangle { + id: root + + property bool showBackground: true + + // Live metrics + property real cpuUsage: 0 + property int cpuTemp: -1 + property real gpuUsage: 0 + property int gpuTemp: -1 + property real ramUsage: 0 + property real diskUsage: 0 + property int diskTemp: -1 + + // Usage history (0..1) + property var cpuHistory: [] + property var gpuHistory: [] + property var ramHistory: [] + + // Temp history (raw degrees) + property var cpuTempHistory: [] + property var gpuTempHistory: [] + property var diskTempHistory: [] + + // Visibility toggles + property bool showCpu: true + property bool showGpu: true + property bool showRam: true + property bool showDisk: false + property bool showCpuTemp: true + property bool showGpuTemp: true + property bool showDiskTemp: false + + // Distinct graph colors per metric + readonly property color cpuColor: "#FF6B6B" + readonly property color gpuColor: "#C084FC" + readonly property color ramColor: "#34D399" + readonly property color diskColor: "#FBBF24" + readonly property color cpuTempColor: "#FB923C" + readonly property color gpuTempColor: "#F472B6" + readonly property color diskTempColor: "#A78BFA" + + radius: 20 + clip: true + + Rectangle { + anchors.fill: parent + visible: root.showBackground + radius: 20 + gradient: Gradient { + GradientStop { position: 0.0; color: Colors.surfaceContainer } + GradientStop { position: 1.0; color: Colors.surfaceContainerLow } + } + } + + function paintSeries(ctx, canvas, arr, color, fillAlpha, maxN, maxV) { + if (!arr || arr.length < 2) return; + var w = canvas.width, h = canvas.height; + var pad = 2; + var cw = w - pad * 2, ch = h - pad * 2; + + var pts = []; + for (var k = 0; k < arr.length; k++) { + pts.push({ + x: pad + (k / (maxN - 1)) * cw, + y: pad + ch - (Math.min(arr[k], maxV) / maxV) * ch + }); + } + + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.lineJoin = "round"; + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + for (var m = 1; m < pts.length; m++) ctx.lineTo(pts[m].x, pts[m].y); + ctx.stroke(); + + var grad = ctx.createLinearGradient(0, 0, 0, h); + grad.addColorStop(0, Qt.rgba(color.r, color.g, color.b, fillAlpha)); + grad.addColorStop(1, Qt.rgba(color.r, color.g, color.b, 0)); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.moveTo(pts[0].x, h); + for (var n = 0; n < pts.length; n++) ctx.lineTo(pts[n].x, pts[n].y); + ctx.lineTo(pts[pts.length - 1].x, h); + ctx.closePath(); + ctx.fill(); + } + + function paintTempSeries(ctx, canvas, arr, color, maxN, tempMax) { + if (!arr || arr.length < 2) return; + var w = canvas.width, h = canvas.height; + var pad = 2; + var cw = w - pad * 2, ch = h - pad * 2; + + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.setLineDash([4, 3]); + ctx.lineJoin = "round"; + ctx.beginPath(); + var started = false; + for (var k = 0; k < arr.length; k++) { + if (arr[k] < 0) continue; + var px = pad + (k / (maxN - 1)) * cw; + var py = pad + ch - (Math.min(arr[k], tempMax) / tempMax) * ch; + if (!started) { ctx.moveTo(px, py); started = true; } + else ctx.lineTo(px, py); + } + ctx.stroke(); + ctx.setLineDash([]); + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Text { + text: Icons.circuitry + font.family: Icons.font + font.pixelSize: 14 + color: Colors.primary + Layout.alignment: Qt.AlignVCenter + } + + Text { + Layout.fillWidth: true + text: "System Monitor" + color: Colors.primary + font.family: Config.theme.font + font.pixelSize: 12 + font.weight: Font.Bold + elide: Text.ElideRight + } + } + + Canvas { + id: sparkCanvas + Layout.fillWidth: true + Layout.fillHeight: true + antialiasing: true + + Connections { + target: root + function onCpuHistoryChanged() { sparkCanvas.requestPaint(); } + function onGpuHistoryChanged() { sparkCanvas.requestPaint(); } + function onRamHistoryChanged() { sparkCanvas.requestPaint(); } + function onCpuTempHistoryChanged() { sparkCanvas.requestPaint(); } + function onGpuTempHistoryChanged() { sparkCanvas.requestPaint(); } + function onDiskTempHistoryChanged() { sparkCanvas.requestPaint(); } + function onShowCpuChanged() { sparkCanvas.requestPaint(); } + function onShowGpuChanged() { sparkCanvas.requestPaint(); } + function onShowRamChanged() { sparkCanvas.requestPaint(); } + function onShowDiskChanged() { sparkCanvas.requestPaint(); } + function onShowCpuTempChanged() { sparkCanvas.requestPaint(); } + function onShowGpuTempChanged() { sparkCanvas.requestPaint(); } + function onShowDiskTempChanged() { sparkCanvas.requestPaint(); } + } + + onPaint: { + var ctx = getContext("2d"); + var w = width, h = height; + ctx.clearRect(0, 0, w, h); + + ctx.strokeStyle = Qt.rgba(Colors.outline.r, Colors.outline.g, Colors.outline.b, 0.15); + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + var pad = 2, cw = w - pad * 2, ch = h - pad * 2; + for (var g = 1; g <= 3; g++) { + var gy = pad + ch * (1 - g * 0.25); + ctx.beginPath(); + ctx.moveTo(pad, gy); + ctx.lineTo(pad + cw, gy); + ctx.stroke(); + } + ctx.setLineDash([]); + + var maxN = 2; + var maxV = 1; + if (root.showCpu) { + for (var i = 0; i < root.cpuHistory.length; i++) maxV = Math.max(maxV, root.cpuHistory[i]); + maxN = Math.max(maxN, root.cpuHistory.length); + } + if (root.showGpu) { + for (var j = 0; j < root.gpuHistory.length; j++) maxV = Math.max(maxV, root.gpuHistory[j]); + maxN = Math.max(maxN, root.gpuHistory.length); + } + if (root.showRam) { + for (var k = 0; k < root.ramHistory.length; k++) maxV = Math.max(maxV, root.ramHistory[k]); + maxN = Math.max(maxN, root.ramHistory.length); + } + + var tempMax = 100; + if (root.showCpuTemp) maxN = Math.max(maxN, root.cpuTempHistory.length); + if (root.showGpuTemp) maxN = Math.max(maxN, root.gpuTempHistory.length); + if (root.showDiskTemp) maxN = Math.max(maxN, root.diskTempHistory.length); + + if (root.showRam) root.paintSeries(ctx, sparkCanvas, root.ramHistory, root.ramColor, 0.10, maxN, maxV); + if (root.showGpu) root.paintSeries(ctx, sparkCanvas, root.gpuHistory, root.gpuColor, 0.12, maxN, maxV); + if (root.showCpu) root.paintSeries(ctx, sparkCanvas, root.cpuHistory, root.cpuColor, 0.16, maxN, maxV); + + if (root.showCpuTemp) root.paintTempSeries(ctx, sparkCanvas, root.cpuTempHistory, root.cpuTempColor, maxN, tempMax); + if (root.showGpuTemp) root.paintTempSeries(ctx, sparkCanvas, root.gpuTempHistory, root.gpuTempColor, maxN, tempMax); + if (root.showDiskTemp) root.paintTempSeries(ctx, sparkCanvas, root.diskTempHistory, root.diskTempColor, maxN, tempMax); + } + } + + // Bottom legend + RowLayout { + Layout.fillWidth: true + spacing: 8 + + RowLayout { + visible: root.showCpu + spacing: 3 + Text { text: Icons.cpu; font.family: Icons.font; font.pixelSize: 10; color: root.cpuColor } + Text { + text: Math.round(root.cpuUsage) + "%" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 9 + font.weight: Font.Bold + } + Text { + visible: root.showCpuTemp && root.cpuTemp >= 0 + text: root.cpuTemp + "°" + color: root.cpuTempColor + font.family: Config.theme.font + font.pixelSize: 9 + } + } + + RowLayout { + visible: root.showGpu + spacing: 3 + Text { text: Icons.gpu; font.family: Icons.font; font.pixelSize: 10; color: root.gpuColor } + Text { + text: Math.round(root.gpuUsage) + "%" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 9 + font.weight: Font.Bold + } + Text { + visible: root.showGpuTemp && root.gpuTemp >= 0 + text: root.gpuTemp + "°" + color: root.gpuTempColor + font.family: Config.theme.font + font.pixelSize: 9 + } + } + + RowLayout { + visible: root.showRam + spacing: 3 + Text { text: Icons.ram; font.family: Icons.font; font.pixelSize: 10; color: root.ramColor } + Text { + text: Math.round(root.ramUsage) + "%" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 9 + font.weight: Font.Bold + } + } + + RowLayout { + visible: root.showDisk + spacing: 3 + Text { text: Icons.disk; font.family: Icons.font; font.pixelSize: 10; color: root.diskColor } + Text { + text: Math.round(root.diskUsage) + "%" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 9 + font.weight: Font.Bold + } + Text { + visible: root.showDiskTemp && root.diskTemp >= 0 + text: root.diskTemp + "°" + color: root.diskTempColor + font.family: Config.theme.font + font.pixelSize: 9 + } + } + + Item { Layout.fillWidth: true } + } + } +} diff --git a/modules/retroshell/files/modules/desktop/Desktop.qml b/modules/retroshell/files/modules/desktop/Desktop.qml index daf50702..b03c8f4e 100644 --- a/modules/retroshell/files/modules/desktop/Desktop.qml +++ b/modules/retroshell/files/modules/desktop/Desktop.qml @@ -3,6 +3,7 @@ import QtQuick.Layouts import Quickshell import Quickshell.Wayland import qs.modules.desktop +import qs.modules.widgets.desktop import qs.modules.services import qs.modules.theme import qs.config @@ -25,10 +26,10 @@ PanelWindow { WlrLayershell.layer: WlrLayer.Bottom WlrLayershell.namespace: "retroshell:desktop" - WlrLayershell.keyboardFocus: iconContainer._editActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: iconContainer._editActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.OnDemand exclusionMode: ExclusionMode.Ignore - visible: Config.desktop.enabled + visible: Config.desktopLayerActive Component.onCompleted: { DesktopService.initialize(); @@ -39,6 +40,7 @@ PanelWindow { Item { id: iconContainer anchors.fill: parent + visible: Config.desktop.showIcons anchors.margins: 16 anchors.bottomMargin: desktop.barPosition === "bottom" ? desktop.barSize + 16 : 16 anchors.topMargin: desktop.barPosition === "top" ? desktop.barSize + 16 : 16 @@ -308,7 +310,26 @@ PanelWindow { } }); - Visibilities.contextMenu.openCustomMenu(menuItems, 200, 32, "desktop"); + // ── Widgets section ── + menuItems.push({ isSeparator: true, text: "" }); + menuItems.push({ + text: (Config.desktop.editMode ? "Disable" : "Enable") + " Edit Mode", + icon: Icons.edit, + isSeparator: false, + onTriggered: function () { + Config.desktop.editMode = !Config.desktop.editMode; + } + }); + menuItems.push({ + text: (Config.desktop.showIcons ? "Hide" : "Show") + " Desktop Icons", + icon: Icons.squaresFour, + isSeparator: false, + onTriggered: function () { + Config.desktop.showIcons = !Config.desktop.showIcons; + } + }); + + Visibilities.contextMenu.openCustomMenu(menuItems, 260, 32, "desktop"); } } @@ -477,7 +498,7 @@ PanelWindow { onTriggered: function () { DesktopService.trashFile(delegateRoot.path); DesktopService.refreshTrash(); } } ]; } - Visibilities.contextMenu.openCustomMenu(items, 200, 32, "desktop"); + Visibilities.contextMenu.openCustomMenu(items, 260, 32, "desktop"); } opacity: dragHandler.active ? 0.3 : 1.0 @@ -649,13 +670,21 @@ PanelWindow { } } + // Floating desktop widget layer (above icons, below bar) + DesktopWidgets { + id: desktopWidgets + anchors.fill: parent + z: 50 + screenName: screen ? screen.name : "" + } + Rectangle { anchors.centerIn: parent width: 200 height: 60 color: Qt.rgba(0, 0, 0, 0.7) radius: Styling.radius(0) - visible: !DesktopService.initialLoadComplete + visible: Config.desktop.showIcons && !DesktopService.initialLoadComplete Text { anchors.centerIn: parent diff --git a/modules/retroshell/files/modules/dock/DockContent.qml b/modules/retroshell/files/modules/dock/DockContent.qml index 13da8d7b..7ee01f8e 100644 --- a/modules/retroshell/files/modules/dock/DockContent.qml +++ b/modules/retroshell/files/modules/dock/DockContent.qml @@ -174,9 +174,11 @@ Item { p = layout.mapToItem(dockContainer, 0, 0); } else if (clamped >= pinnedCount) { var last = rep.itemAt(pinnedCount - 1); + if (!last) return root.isVertical ? layout.height : layout.width; p = last.mapToItem(dockContainer, root.isVertical ? last.width / 2 : last.width, root.isVertical ? last.height : last.height / 2); } else { var item = rep.itemAt(clamped); + if (!item) return 0; p = item.mapToItem(dockContainer, 0, root.isVertical ? 0 : item.height / 2); } return root.isVertical ? p.y : p.x; diff --git a/modules/retroshell/files/modules/services/AxctlService.qml b/modules/retroshell/files/modules/services/AxctlService.qml index 8099fd6d..47cce200 100644 --- a/modules/retroshell/files/modules/services/AxctlService.qml +++ b/modules/retroshell/files/modules/services/AxctlService.qml @@ -77,9 +77,23 @@ Singleton { if (!screen) return null; let screenName = screen.name || screen; let values = root.monitors.values || []; + // Exact match for (let i = 0; i < values.length; i++) { if (values[i].name === screenName) return values[i]; } + // Case-insensitive match + let lower = screenName.toLowerCase(); + for (let i = 0; i < values.length; i++) { + if (values[i].name && values[i].name.toLowerCase() === lower) return values[i]; + } + // Fallback: match by screen index position + let screens = Quickshell.screens || []; + for (let i = 0; i < screens.length; i++) { + if (screens[i] === screen || (screens[i] && screens[i].name === screenName)) { + if (i < values.length) return values[i]; + break; + } + } return null; } @@ -133,21 +147,32 @@ Singleton { } } + // Build name→id lookup for workspace names (axctl may report active_workspace as name, not ID) + let wsNameToId = {}; + let wsList = root.workspaces.values || []; + for (let i = 0; i < wsList.length; i++) { + wsNameToId[wsList[i].name] = wsList[i].id; + } + // --- Monitors --- if (state.monitors) { - let mappedMonitors = state.monitors.map(mon => ({ - id: parseInt(mon.id) || 0, - name: mon.name, - focused: mon.is_focused, - width: mon.width, - height: mon.height, - refreshRate: mon.refresh_rate, - scale: mon.scale, - x: parseInt(mon.metadata ? mon.metadata.x : 0) || 0, - y: parseInt(mon.metadata ? mon.metadata.y : 0) || 0, - transform: parseInt(mon.metadata ? mon.metadata.transform : 0) || 0, - activeWorkspace: { id: parseInt(mon.metadata ? mon.metadata.active_workspace : 0) || 0, name: mon.metadata ? mon.metadata.active_workspace : "" } - })); + let mappedMonitors = state.monitors.map(mon => { + let rawWs = mon.metadata ? mon.metadata.active_workspace : ""; + let wsId = parseInt(rawWs) || wsNameToId[rawWs] || 0; + return { + id: parseInt(mon.id) || 0, + name: mon.name, + focused: mon.is_focused, + width: mon.width, + height: mon.height, + refreshRate: mon.refresh_rate, + scale: mon.scale, + x: parseInt(mon.metadata ? mon.metadata.x : 0) || 0, + y: parseInt(mon.metadata ? mon.metadata.y : 0) || 0, + transform: parseInt(mon.metadata ? mon.metadata.transform : 0) || 0, + activeWorkspace: { id: wsId, name: rawWs } + }; + }); root.monitors.values = mappedMonitors; let focused = mappedMonitors.find(m => m.focused) || null; if (focused !== root.focusedMonitor) { diff --git a/modules/retroshell/files/modules/services/DockerService.qml b/modules/retroshell/files/modules/services/DockerService.qml new file mode 100644 index 00000000..c61cdbb3 --- /dev/null +++ b/modules/retroshell/files/modules/services/DockerService.qml @@ -0,0 +1,366 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.globals + +Singleton { + id: root + + // ── Settings ── + property bool enabled: false + property bool systemdRunAvailable: false + property bool dockerAvailable: false + property int debounceDelay: 300 + property string dockerBinary: "docker" + property string terminalApp: "kitty" + property string shellPath: "/bin/sh" + property int pollingInterval: 0 + property bool showPorts: true + + // ── State ── + property var containers: [] + property int runningCount: 0 + property var composeProjects: [] + property string _inspectBuffer: "" + + // ── Tool checks ── + property bool inputToolMissing: false + + Component.onCompleted: { + loadSettings(); + initialize(); + } + + function loadSettings() { + enabled = StateService.get("docker:enabled", false); + dockerBinary = StateService.get("docker:dockerBinary", "docker"); + terminalApp = StateService.get("docker:terminalApp", "kitty"); + shellPath = StateService.get("docker:shellPath", "/bin/sh"); + debounceDelay = StateService.get("docker:debounceDelay", 300); + pollingInterval = StateService.get("docker:pollingInterval", 0); + showPorts = StateService.get("docker:showPorts", true); + } + + function saveSetting(key, value) { + if (key === "enabled") enabled = value; + else if (key === "dockerBinary") dockerBinary = value; + else if (key === "terminalApp") terminalApp = value; + else if (key === "shellPath") shellPath = value; + else if (key === "debounceDelay") debounceDelay = value; + else if (key === "pollingInterval") pollingInterval = value; + else if (key === "showPorts") showPorts = value; + + StateService.set("docker:" + key, value); + } + + // ── Initialize ── + + function initialize() { + systemdRunCheckProc.running = true; + refresh(); + eventsProcess.running = true; + } + + function refresh() { + dockerInfoProc.running = true; + } + + // ── Processes ── + + Process { + id: systemdRunCheckProc + command: ["which", "systemd-run"] + running: false + onExited: (code) => { + root.systemdRunAvailable = (code === 0); + } + } + + Process { + id: dockerInfoProc + command: [root.dockerBinary, "info"] + running: false + onExited: (code) => { + root.dockerAvailable = (code === 0); + if (root.dockerAvailable) { + fetchContainers(); + } else { + updateContainers([], 0, []); + } + } + } + + function fetchContainers() { + dockerInspectProc.running = true; + } + + Process { + id: dockerInspectProc + command: ["sh", "-c", root.dockerBinary + " container inspect $(" + root.dockerBinary + " container ls -aq 2>/dev/null) 2>/dev/null"] + running: false + stdout: SplitParser { + splitMarker: "\n" + onRead: data => { + root._inspectBuffer += data; + } + } + onExited: (code) => { + if (code === 0 && root._inspectBuffer.trim()) { + try { + var parsed = JSON.parse(root._inspectBuffer.trim()); + if (Array.isArray(parsed)) { + root._processContainers(parsed); + } + } catch(e) { + console.error("[DockerService] Failed to parse inspect output:", e); + updateContainers([], 0, []); + } + } else { + updateContainers([], 0, []); + } + root._inspectBuffer = ""; + } + } + + // Re-parse: docker inspect outputs a JSON array as a single blob. + // Use a bash wrapper to get it cleanly. + function _processContainers(rawContainers) { + try { + var result = rawContainers.map(function(container) { + try { + var labels = (container.Config && container.Config.Labels) || {}; + var state = (container.State && container.State.Status) || ""; + var startedAt = new Date((container.State && container.State.StartedAt) || 0).getTime(); + var finishedAt = new Date((container.State && container.State.FinishedAt) || 0).getTime(); + var lastActivity = Math.max(startedAt, finishedAt); + + var ports = []; + var portBindings = (container.NetworkSettings && container.NetworkSettings.Ports) || {}; + for (var containerPort in portBindings) { + var hostBindings = portBindings[containerPort]; + if (hostBindings && hostBindings.length > 0) { + for (var j = 0; j < hostBindings.length; j++) { + var binding = hostBindings[j]; + var hostPort = binding.HostPort; + var hostIp = binding.HostIp || "0.0.0.0"; + if (hostPort) { + ports.push({ + containerPort: containerPort, + hostPort: hostPort, + hostIp: hostIp + }); + } + } + } + } + + return { + id: container.Id || "", + name: (container.Name || "").replace(/^\//, ""), + status: state.charAt(0).toUpperCase() + state.slice(1), + state: state, + image: (container.Config && container.Config.Image) || container.ImageName || "", + isRunning: (container.State && container.State.Running) || false, + isPaused: (container.State && container.State.Paused) || false, + created: container.Created || "", + lastActivity: lastActivity, + ports: ports, + composeProject: labels["com.docker.compose.project"] || labels["io.podman.compose.project"] || "", + composeService: labels["com.docker.compose.service"] || labels["io.podman.compose.service"] || "", + composeWorkingDir: labels["com.docker.compose.project.working_dir"] || "", + composeConfigFiles: labels["com.docker.compose.project.config_files"] || "compose.yaml" + }; + } catch (e) { + console.error("[DockerService] Failed to parse container:", e); + return null; + } + }).filter(function(c) { return c !== null; }); + + // Sort: running first, then paused, then stopped; by lastActivity desc; then by name + result.sort(function(a, b) { + var priority = { running: 1, paused: 2 }; + var aP = priority[a.state] || 3; + var bP = priority[b.state] || 3; + if (aP !== bP) return aP - bP; + if (a.lastActivity !== b.lastActivity) return b.lastActivity - a.lastActivity; + return a.name.localeCompare(b.name); + }); + + // Build compose project map + var projectMap = {}; + for (var i = 0; i < result.length; i++) { + var c = result[i]; + if (c.composeProject) { + if (!projectMap[c.composeProject]) { + projectMap[c.composeProject] = { + name: c.composeProject, + containers: [], + runningCount: 0, + totalCount: 0, + workingDir: c.composeWorkingDir, + configFile: c.composeConfigFiles + }; + } + projectMap[c.composeProject].containers.push(c); + projectMap[c.composeProject].totalCount++; + if (c.isRunning) { + projectMap[c.composeProject].runningCount++; + } + } + } + + var projects = Object.values(projectMap).sort(function(a, b) { + if (a.runningCount !== b.runningCount) return b.runningCount - a.runningCount; + return a.name.localeCompare(b.name); + }); + + var rc = 0; + for (var j = 0; j < result.length; j++) { + if (result[j].isRunning) rc++; + } + + updateContainers(result, rc, projects); + } catch (e) { + console.error("[DockerService] Failed to process containers:", e); + updateContainers([], 0, []); + } + } + + function updateContainers(containers, running, projects) { + root.containers = containers || []; + root.runningCount = running || 0; + root.composeProjects = projects || []; + } + + // ── Actions ── + + function executeAction(containerId, action) { + var commands = { + start: [dockerBinary, "start", containerId], + stop: [dockerBinary, "stop", containerId], + restart: [dockerBinary, "restart", containerId], + pause: [dockerBinary, "pause", containerId], + unpause: [dockerBinary, "unpause", containerId] + }; + + if (commands[action]) { + var cmd = commands[action]; + if (systemdRunAvailable) { + cmd = ["systemd-run", "--user", "--scope", "--"].concat(cmd); + } + Quickshell.execDetached(cmd); + Qt.callLater(refresh); + return true; + } + return false; + } + + function executeComposeAction(workingDir, configFile, action) { + if (!workingDir) { + console.error("[DockerService] No working directory for compose action"); + return false; + } + + var composeCommands = { + up: [dockerBinary, "compose", "-f", configFile, "up", "-d"], + down: [dockerBinary, "compose", "-f", configFile, "down"], + restart: [dockerBinary, "compose", "-f", configFile, "restart"], + stop: [dockerBinary, "compose", "-f", configFile, "stop"], + start: [dockerBinary, "compose", "-f", configFile, "start"], + pull: [dockerBinary, "compose", "-f", configFile, "pull"] + }; + + if (action === "logs") { + var logCmd = 'cd "' + workingDir + '" && ' + dockerBinary + ' compose -f ' + configFile + ' logs -f'; + Quickshell.execDetached(["sh", "-c", terminalApp + " -e sh -c '" + logCmd + "'"]); + return true; + } + + if (composeCommands[action]) { + var cmd = ["sh", "-c", 'cd "' + workingDir + '" && ' + composeCommands[action].join(" ")]; + if (systemdRunAvailable) { + cmd = ["systemd-run", "--user", "--scope", "--"].concat(cmd); + } + Quickshell.execDetached(cmd); + Qt.callLater(refresh); + return true; + } + return false; + } + + function openLogs(containerId) { + Quickshell.execDetached(["sh", "-c", terminalApp + " -e " + dockerBinary + " logs -f " + containerId]); + } + + function openExec(containerId) { + Quickshell.execDetached(["sh", "-c", terminalApp + " -e " + dockerBinary + " exec -it " + containerId + " " + shellPath]); + } + + // ── Events listener ── + + property var debounceTimer: Timer { + interval: root.debounceDelay + running: false + repeat: false + onTriggered: fetchContainers() + } + + Process { + id: eventsProcess + command: [root.dockerBinary, "events", "--format", "json", "--filter", "type=container"] + running: false + + stdout: SplitParser { + splitMarker: "\n" + onRead: data => { + try { + var event = JSON.parse(data); + var action = event.Status || event.status; + var known = ["start", "stop", "die", "died", "kill", "restart", + "pause", "unpause", "create", "destroy", "remove", "cleanup"]; + if (known.indexOf(action) >= 0) { + debounceTimer.restart(); + } + } catch (e) { + // ignore parse errors from partial lines + } + } + } + + onRunningChanged: { + if (!running) { + restartTimer.start(); + } + } + } + + Timer { + id: restartTimer + interval: 5000 + running: false + repeat: false + onTriggered: { + if (root.dockerAvailable) { + eventsProcess.running = true; + } + } + } + + Timer { + interval: root.pollingInterval + running: root.dockerAvailable && root.pollingInterval > 0 + repeat: true + onTriggered: fetchContainers() + } + + onEnabledChanged: { + if (enabled) { + refresh(); + eventsProcess.running = true; + } else { + eventsProcess.running = false; + } + } +} diff --git a/modules/retroshell/files/modules/services/GlobalShortcuts.qml b/modules/retroshell/files/modules/services/GlobalShortcuts.qml index d7039dd9..7603ef81 100644 --- a/modules/retroshell/files/modules/services/GlobalShortcuts.qml +++ b/modules/retroshell/files/modules/services/GlobalShortcuts.qml @@ -56,6 +56,14 @@ QtObject { return; } + if (command.indexOf("desktop-edit ") === 0) { + var edit = command.substring("desktop-edit ".length).trim(); + if (edit === "on" || edit === "off") { + Config.desktop.editMode = (edit === "on"); + } + return; + } + switch (command) { // Launcher (Standalone Notch Module) case "launcher": toggleLauncher(); break; diff --git a/modules/retroshell/files/modules/services/NetworkMetrics.qml b/modules/retroshell/files/modules/services/NetworkMetrics.qml new file mode 100644 index 00000000..b5877ed9 --- /dev/null +++ b/modules/retroshell/files/modules/services/NetworkMetrics.qml @@ -0,0 +1,59 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * Real-time network metrics for the Network widget. + * Runs a single streaming process (shared by all network widgets). + */ +Singleton { + id: root + + property string interfaceName: "" + property real downloadMbps: 0 + property real uploadMbps: 0 + property string localIp: "" + property string publicIp: "" + property bool publicIpValid: false + + property var downloadHistory: [] + property var uploadHistory: [] + property int maxHistory: 60 + + Process { + id: monitor + running: true + command: ["python3", Quickshell.shellDir + "/scripts/network_monitor.py"] + + stdout: SplitParser { + onRead: data => { + try { + const s = JSON.parse(data); + root.interfaceName = s.interface || root.interfaceName; + root.downloadMbps = s.download || 0; + root.uploadMbps = s.upload || 0; + root.localIp = s.localIp || ""; + if (s.publicIpValid) { + root.publicIp = s.publicIp; + root.publicIpValid = true; + } + + const dh = root.downloadHistory.slice(); + dh.push(s.download || 0); + if (dh.length > root.maxHistory) dh.shift(); + root.downloadHistory = dh; + + const uh = root.uploadHistory.slice(); + uh.push(s.upload || 0); + if (uh.length > root.maxHistory) uh.shift(); + root.uploadHistory = uh; + } catch (e) { + console.warn("NetworkMetrics: parse error", e); + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/services/NetworkService.qml b/modules/retroshell/files/modules/services/NetworkService.qml index 55623f53..6e137452 100644 --- a/modules/retroshell/files/modules/services/NetworkService.qml +++ b/modules/retroshell/files/modules/services/NetworkService.qml @@ -211,6 +211,7 @@ Singleton { updateConnectionType.startCheck(); wifiStatusProcess.running = true; updateNetworkName.running = true; + getDnsProvider(); if (uiOpen) { updateNetworkStrength.running = true; @@ -401,8 +402,182 @@ Singleton { WifiAccessPoint {} } + // Net stats properties + property bool netStatsActive: false + property real netPingMs: 0 + property real netPacketLoss: 0 + property string netDeviceIp: "N/A" + property string netGatewayIp: "N/A" + property real netDownloadSpeed: 0 + property real netUploadSpeed: 0 + property real netTotalDownloaded: 0 + property real netTotalUploaded: 0 + property real _netPrevRxBytes: 0 + property real _netPrevTxBytes: 0 + property real _netPrevTimestamp: 0 + + // DNS provider + property string netDnsProvider: "cloudflare" + + function setDnsProvider(provider) { + var rd = Quickshell.env("RETRO_DIR"); + dnsProviderProc.command = ["bash", rd + "/scripts/network_core.sh", "--set-dns-provider", provider]; + dnsProviderProc.running = true; + } + + function getDnsProvider() { + var rd = Quickshell.env("RETRO_DIR"); + dnsGetProc.command = ["bash", rd + "/scripts/network_core.sh", "--get-dns-provider"]; + dnsGetProc.running = true; + } + + Process { + id: dnsProviderProc + running: false + property string buffer: "" + stdout: SplitParser { + onRead: data => dnsProviderProc.buffer += data + "\n" + } + onExited: (exitCode, exitStatus) => { + var text = dnsProviderProc.buffer.trim(); + dnsProviderProc.buffer = ""; + if (text.includes("result=success")) { + var providerMatch = text.match(/provider=(\w+)/); + if (providerMatch) root.netDnsProvider = providerMatch[1]; + } + } + } + + Process { + id: dnsGetProc + running: false + property string buffer: "" + stdout: SplitParser { + onRead: data => dnsGetProc.buffer += data + "\n" + } + onExited: (exitCode, exitStatus) => { + var text = dnsGetProc.buffer.trim(); + dnsGetProc.buffer = ""; + var providerMatch = text.match(/provider=(\w+)/); + if (providerMatch) root.netDnsProvider = providerMatch[1]; + } + } + + // Speed test + property bool speedTestRunning: false + property string speedTestPhase: "" + property real speedTestPing: 0 + property real speedTestDown: 0 + property real speedTestUp: 0 + + function runSpeedTest() { + if (speedTestRunning) return; + speedTestRunning = true; + speedTestPhase = "ping"; + speedTestPing = 0; + speedTestDown = 0; + speedTestUp = 0; + var rd = Quickshell.env("RETRO_DIR"); + speedTestProc.command = ["bash", rd + "/scripts/network_core.sh", "--speed-test"]; + speedTestProc.running = true; + } + + Process { + id: speedTestProc + running: false + stdout: SplitParser { + onRead: data => { + try { + var msg = JSON.parse(data.trim()); + if (msg.type === "phase") { + root.speedTestPhase = msg.phase; + } else if (msg.type === "ping") { + root.speedTestPing = msg.value; + } else if (msg.type === "download") { + root.speedTestDown = msg.value; + } else if (msg.type === "download_done") { + root.speedTestDown = msg.value; + } else if (msg.type === "upload") { + root.speedTestUp = msg.value; + } else if (msg.type === "upload_done") { + root.speedTestUp = msg.value; + } else if (msg.type === "done") { + root.speedTestRunning = false; + root.speedTestPhase = ""; + } else if (msg.type === "error") { + root.speedTestRunning = false; + root.speedTestPhase = ""; + } + } catch (e) {} + } + } + onExited: (exitCode, exitStatus) => { + root.speedTestRunning = false; + root.speedTestPhase = ""; + } + } + + Timer { + id: fastStatsTimer + interval: 2000 + repeat: true + running: root.netStatsActive + onTriggered: root._pollNetStats() + } + + Timer { + id: slowStatsTimer + interval: 60000 + repeat: true + running: !root.netStatsActive + onTriggered: root._pollNetStats() + } + + function _pollNetStats() { + var rd = Quickshell.env("RETRO_DIR"); + netStatsProc.command = ["bash", rd + "/scripts/network_core.sh", "--net-stats"]; + netStatsProc.running = true; + } + + Process { + id: netStatsProc + running: false + property string buffer: "" + stdout: SplitParser { + onRead: data => netStatsProc.buffer += data + "\n" + } + onExited: (exitCode, exitStatus) => { + var text = netStatsProc.buffer.trim(); + netStatsProc.buffer = ""; + var parts = text.split("|"); + if (parts.length < 8) return; + + root.netPingMs = parseFloat(parts[0]) || 0; + root.netPacketLoss = parseFloat(parts[1]) || 0; + var rxBytes = parseFloat(parts[2]) || 0; + var txBytes = parseFloat(parts[3]) || 0; + root.netDeviceIp = parts[4] || "N/A"; + root.netGatewayIp = parts[5] || "N/A"; + root.netTotalDownloaded = parseFloat(parts[6]) || 0; + root.netTotalUploaded = parseFloat(parts[7]) || 0; + + var now = Date.now(); + if (root._netPrevTimestamp > 0) { + var elapsed = (now - root._netPrevTimestamp) / 1000; + if (elapsed > 0) { + root.netDownloadSpeed = Math.max(0, (rxBytes - root._netPrevRxBytes) / elapsed); + root.netUploadSpeed = Math.max(0, (txBytes - root._netPrevTxBytes) / elapsed); + } + } + root._netPrevRxBytes = rxBytes; + root._netPrevTxBytes = txBytes; + root._netPrevTimestamp = now; + } + } + Component.onCompleted: { update(); wifiStatusProcess.running = true; + _pollNetStats(); } } diff --git a/modules/retroshell/files/modules/services/SystemResources.qml b/modules/retroshell/files/modules/services/SystemResources.qml index c16ff085..90d6df2a 100644 --- a/modules/retroshell/files/modules/services/SystemResources.qml +++ b/modules/retroshell/files/modules/services/SystemResources.qml @@ -17,6 +17,8 @@ Singleton { property real cpuUsage: 0.0 property string cpuModel: "" property int cpuTemp: -1 + property int fanRpm: -1 + property int diskTemp: -1 // RAM metrics property real ramUsage: 0.0 @@ -48,6 +50,7 @@ Singleton { property var gpuHistories: [] property var cpuTempHistory: [] property var gpuTempHistories: [] + property var diskTempHistory: [] property int maxHistoryPoints: 50 property int totalDataPoints: 0 @@ -55,11 +58,11 @@ Singleton { property int updateInterval: 2000 // Unified monitor process. - // Resource-efficient: only runs when dashboard is open. - // Optimized GPU polling avoids waking dGPUs. + // Lightweight and avoids waking dGPUs; runs whenever disks are configured + // so desktop widgets and the dashboard both get live metrics. property Process monitorProcess: Process { id: monitorProcess - running: GlobalStates.dashboardOpen && GlobalStates.dashboardCurrentTab === 2 && root.validDisks.length > 0 + running: root.validDisks.length > 0 command: { let cmd = ["python3", Quickshell.shellDir + "/scripts/system_monitor.py", root.updateInterval.toString()]; @@ -87,6 +90,14 @@ Singleton { root.cpuUsage = stats.cpu.usage; root.cpuTemp = stats.cpu.temp; } + + if (stats.fan_rpm !== undefined) { + root.fanRpm = stats.fan_rpm; + } + + if (stats.disk_temp !== undefined) { + root.diskTemp = stats.disk_temp; + } if (stats.ram) { root.ramUsage = stats.ram.usage; @@ -155,6 +166,7 @@ Singleton { cpuHistory = pushHistory(cpuHistory, cpuUsage / 100); cpuTempHistory = pushHistory(cpuTempHistory, cpuTemp); ramHistory = pushHistory(ramHistory, ramUsage / 100); + diskTempHistory = pushHistory(diskTempHistory, diskTemp); if (gpuDetected && gpuCount > 0) { let newGpuHistories = gpuHistories.slice(); diff --git a/modules/retroshell/files/modules/services/TypingSoundsService.qml b/modules/retroshell/files/modules/services/TypingSoundsService.qml new file mode 100644 index 00000000..d46300a8 --- /dev/null +++ b/modules/retroshell/files/modules/services/TypingSoundsService.qml @@ -0,0 +1,621 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.components +import qs.modules.globals +import qs.config + +Singleton { + id: root + + // ── Stored settings (synced from Config, NOT bound) ── + // Using stored properties prevents flickering when Config.saveTypingSounds() + // rewrites the JSON file — the adapter briefly becomes undefined during write, + // which would flip readonly bindings and kill the input process. + property bool enabled: false + property int volume: 100 + property bool mouseEnabled: false + property string selectedPackId: "nk-cream" + property string selectedDevicePath: "all" + + // Guard: have we ever successfully synced from Config? + property bool _configSynced: false + + // Sync from Config on startup and when config changes externally. + // Guarded by Config.typingSoundsReady to avoid reading adapter defaults + // before the JSON file has been loaded — this is what caused the + // true→false→fake-true startup toggle. + function _syncFromConfig() { + if (!Config.typingSoundsReady) return; + var cfg = Config.typingSounds; + if (!cfg) return; + var newEnabled = cfg.enabled !== undefined ? cfg.enabled : false; + var newVolume = cfg.volume !== undefined ? cfg.volume : 100; + var newMouse = cfg.mouseEnabled !== undefined ? cfg.mouseEnabled : false; + var newPack = cfg.selectedPackId !== undefined ? cfg.selectedPackId : "nk-cream"; + var newDevice = cfg.selectedDevicePath !== undefined ? cfg.selectedDevicePath : "all"; + + // Adapter flicker guard: during a Config FileView reload cycle the + // adapter briefly resets to its QML defaults (enabled: false, etc.). + // If we've already synced once with real values, refuse to downgrade + // enabled from true → false in a single tick — that's a reload artifact. + if (_configSynced && newEnabled !== root.enabled && newEnabled === false + && root.enabled === true) { + console.warn("[TypingSounds] Skipping enabled=false during Config adapter flicker"); + return; + } + + _configSynced = true; + + // Only trigger change handlers for values that actually changed + if (newEnabled !== root.enabled) root.enabled = newEnabled; + if (newVolume !== root.volume) root.volume = newVolume; + if (newMouse !== root.mouseEnabled) root.mouseEnabled = newMouse; + if (newPack !== root.selectedPackId) root.selectedPackId = newPack; + if (newDevice !== root.selectedDevicePath) root.selectedDevicePath = newDevice; + } + + // ── Derived paths ── + readonly property string soundPacksDir: { + var rd = Quickshell.env("RETRO_DIR") || "/opt/retrolinux"; + return rd + "/modules/retroshell/files/assets/typing-sounds-soundpacks"; + } + readonly property string selectedPackPath: soundPacksDir + "/" + selectedPackId + readonly property string sliceScript: { + var rd = Quickshell.env("RETRO_DIR") || "/opt/retrolinux"; + return rd + "/modules/retroshell/files/scripts/slice_audio.py"; + } + readonly property string cacheBaseDir: { + var home = Quickshell.env("HOME") || "/home/" + Quickshell.env("USER") || "/tmp"; + return home + "/.cache/retroshell/typing-sounds"; + } + readonly property string cachePath: cacheBaseDir + "/" + selectedPackId + + // ── Runtime state ── + property var currentDefines: ({}) + property var _pendingDefines: ({}) + property var soundMap: ({}) + property var soundKeys: [] + property bool isPreparing: false + property bool inputToolMissing: false + property bool notInInputGroup: false + property string cacheFormatVersion: "" + property bool cacheFormatReady: false + + // ── Available packs (scanned at startup) ── + property var availablePacks: [] // [{id, name, path}] + + // ── Keyboard devices ── + property var availableDevices: [{label: "All Keyboards (Auto)", value: "all"}] + + readonly property string requiredTool: selectedDevicePath === "all" ? "libinput" : "evtest" + + // ── Helper functions ── + + function usableDefines(defines) { + const result = {}; + for (const keycode of Object.keys(defines || {})) { + const define = defines[keycode]; + if (define !== null && define !== undefined && define !== "") { + result[keycode] = define; + } + } + return result; + } + + function triggerKeySound(keycode) { + if (!root.enabled || root.isPreparing) return; + const effect = root.soundMap[keycode.toString()]; + if (effect) { + effect.play(); + } else { + const fallback = root.soundMap["57"]; + if (fallback) fallback.play(); + } + } + + function triggerMouseSound() { + root.triggerKeySound("30"); + } + + function saveSetting(key, value) { + // Update local stored property directly — this does NOT trigger + // the Config FileView write, so no adapter flickering + if (key === "enabled") root.enabled = value; + else if (key === "volume") root.volume = value; + else if (key === "mouseEnabled") root.mouseEnabled = value; + else if (key === "selectedPackId") root.selectedPackId = value; + else if (key === "selectedDevicePath") root.selectedDevicePath = value; + + // Write to Config for persistence + if (Config.typingSounds) { + Config.typingSounds[key] = value; + Config.saveTypingSounds(); + } + } + + // ── Tool availability checks ── + + function checkTools() { + toolCheck.running = true; + groupCheck.running = true; + } + + Process { + id: toolCheck + command: ["sh", "-c", "command -v " + root.requiredTool + " >/dev/null 2>&1"] + running: false + onExited: (exitCode) => { + root.inputToolMissing = (exitCode !== 0); + } + } + + Process { + id: groupCheck + command: ["sh", "-c", "id -nG | tr ' ' '\\n' | grep -qx input"] + running: false + onExited: (exitCode) => { + root.notInInputGroup = (exitCode !== 0); + } + } + + // ── Sound pack scanning ── + + function scanSoundPacks() { + var script = ` +import os, json, sys +res = [] +pack_dir = sys.argv[1] +if os.path.exists(pack_dir): + for d in sorted(os.listdir(pack_dir)): + dp = os.path.join(pack_dir, d) + if not os.path.isdir(dp): + continue + cfg = os.path.join(dp, 'config.json') + if os.path.exists(cfg): + try: + with open(cfg) as f: + name = json.load(f).get('name', d) + res.append({"id": d, "name": name, "path": dp}) + except: + res.append({"id": d, "name": d, "path": dp}) +print(json.dumps(res)) +`; + scanPacksProc.command = ["python3", "-c", script, root.soundPacksDir]; + scanPacksProc.running = true; + } + + Process { + id: scanPacksProc + running: false + stdout: SplitParser { + splitMarker: "\n" + onRead: data => { + try { + root.availablePacks = JSON.parse(data.trim()); + } catch(e) { + console.warn("[TypingSounds] Failed to parse pack list:", e); + } + } + } + } + + // ── Device scanning ── + + function scanDevices() { + var script = ` +import os, json, re +include_pattern = "kanata" +exclude_pattern = ["power button", "video bus", "speaker", "headphone", + "lid switch", "touchpad", "extra buttons", "uinput", "server", + "hitune", "inphic", "instant", "webcam", "video"] +devs = [] +if os.path.exists('/proc/bus/input/devices'): + with open('/proc/bus/input/devices', encoding='utf-8', errors='replace') as f: + content = f.read() + sections = content.strip().split('\\n\\n') + for section in sections: + name = "" + handlers = "" + for line in section.split('\\n'): + if line.startswith('N: Name='): + m = re.search(r'Name="([^"]+)"', line) + if m: name = m.group(1) + elif line.startswith('H: Handlers='): + handlers = line.split('=')[1] + if name and handlers: + lower_name = name.lower() + is_included = include_pattern in lower_name + is_excluded = any(x in lower_name for x in exclude_pattern) + if 'kbd' in handlers and (is_included or ('mouse' not in handlers and not is_excluded)): + event_match = re.search(r'event(\\d+)', handlers) + if event_match: + event_path = "/dev/input/event" + event_match.group(1) + devs.append({"label": name + " (" + event_path.split('/')[-1] + ")", "value": event_path}) +print(json.dumps(devs)) +`; + scanDevicesProc.command = ["python3", "-c", script]; + scanDevicesProc.running = true; + } + + Process { + id: scanDevicesProc + running: false + stdout: SplitParser { + splitMarker: "\n" + onRead: data => { + try { + var devs = JSON.parse(data.trim()); + root.availableDevices = [{label: "All Keyboards (Auto)", value: "all"}].concat(devs); + } catch(e) { + console.warn("[TypingSounds] Failed to parse device list:", e); + } + } + } + } + + // ── Pack loading (single chain: config → cache marker → done) ── + + property bool _chainActive: false + + function loadPack() { + if (!cacheFormatReady || !selectedPackId) { + isPreparing = false; + return; + } + + // Stop any in-flight processes from a previous loadPack call + if (cacheCheckProc.running) cacheCheckProc.running = false; + if (sliceProc.running) sliceProc.running = false; + + console.log("[TypingSounds] Loading pack:", selectedPackId); + + // Fast path: check if cache marker exists via bash (avoids FileView chain delays) + cacheCheckProc.command = ["sh", "-c", + "cat '" + cachePath + "/.complete' 2>/dev/null"]; + cacheCheckProc.running = true; + } + + Process { + id: cacheCheckProc + running: false + property string buffer: "" + property bool cacheValid: false + stdout: SplitParser { + onRead: data => cacheCheckProc.buffer += data + } + onExited: (exitCode) => { + var marker = cacheCheckProc.buffer.trim(); + cacheCheckProc.buffer = ""; + cacheValid = (exitCode === 0 && marker === root.cacheFormatVersion); + if (cacheValid) { + console.log("[TypingSounds] Cache valid for:", root.selectedPackId); + } else { + console.log("[TypingSounds] Cache missing/outdated for:", root.selectedPackId); + root.isPreparing = true; + } + // Now read the pack config to get defines + root.currentDefines = {}; + root.soundMap = {}; + root._chainActive = true; + configFileReader.path = root.selectedPackPath + "/config.json"; + } + } + + FileView { + id: configFileReader + printErrors: false + onLoaded: { + try { + const raw = text(); + if (!raw || raw.trim().length === 0) { + console.warn("[TypingSounds] Empty config.json"); + root._finishPreparing(false); + return; + } + const config = JSON.parse(raw); + if (!config || !config.defines) { + console.warn("[TypingSounds] Invalid config.json"); + root._finishPreparing(false); + return; + } + root._pendingDefines = config.defines; + + if (cacheCheckProc.cacheValid) { + // Fast path: cache already verified, load defines directly + console.log("[TypingSounds] Loading defines from cache:", root.cachePath); + root.currentDefines = root.usableDefines(root._pendingDefines); + root.soundKeys = Object.keys(root.currentDefines); + root._finishPreparing(true); + } else { + // Slow path: need to check/slice cache + console.log("[TypingSounds] Config loaded, checking cache at:", root.cachePath); + cacheMarkerReader.path = root.cachePath + "/.complete"; + } + } catch(e) { + console.warn("[TypingSounds] Failed to parse config.json:", e); + root._finishPreparing(false); + } + } + onLoadFailed: { + console.warn("[TypingSounds] Config file not found:", path); + root._finishPreparing(false); + } + } + + FileView { + id: cacheMarkerReader + printErrors: false + onLoaded: { + const markerVersion = text().trim(); + if (markerVersion === root.cacheFormatVersion) { + console.log("[TypingSounds] Cache complete for:", root.selectedPackId); + root.currentDefines = root.usableDefines(root._pendingDefines); + root.soundKeys = Object.keys(root.currentDefines); + root._finishPreparing(true); + } else { + console.log("[TypingSounds] Cache outdated (", markerVersion, "vs", root.cacheFormatVersion, "), rebuilding"); + sliceProc.command = [ + "python3", root.sliceScript, + "--pack-dir", root.selectedPackPath, + "--cache-dir", root.cachePath + ]; + sliceProc.running = true; + } + } + onLoadFailed: { + console.log("[TypingSounds] No cache found, slicing:", root.selectedPackId); + sliceProc.command = [ + "python3", root.sliceScript, + "--pack-dir", root.selectedPackPath, + "--cache-dir", root.cachePath + ]; + sliceProc.running = true; + } + } + + function _finishPreparing(success) { + _chainActive = false; + isPreparing = false; + if (success) { + console.log("[TypingSounds] Pack loaded, defines:", Object.keys(currentDefines).length, "soundMap:", Object.keys(soundMap).length); + _startInputMonitor(); + } else { + console.log("[TypingSounds] Pack load failed"); + } + } + + function _startInputMonitor() { + if (root.enabled && !root.inputToolMissing && !root.notInInputGroup && !inputProc.running) { + console.log("[TypingSounds] Starting input monitor"); + inputProc.running = true; + } + } + + // ── Slice process ── + + Process { + id: sliceProc + running: false + stderr: StdioCollector { + onStreamFinished: { + if (text.trim()) { + console.error("[TypingSounds] Slicing details:", text.trim()); + } + } + } + onExited: (exitCode) => { + if (exitCode === 0) { + console.log("[TypingSounds] Slicing succeeded"); + root.currentDefines = root.usableDefines(root._pendingDefines); + root.soundKeys = Object.keys(root.currentDefines); + root._finishPreparing(true); + } else { + console.error("[TypingSounds] Slicing failed:", exitCode); + root._finishPreparing(false); + } + } + } + + // ── Dynamic sound effect loading ── + + Item { + id: soundLoaderContainer + visible: false + Repeater { + id: soundRepeater + model: root.soundKeys + delegate: SoundEffectWrapper { + keycode: modelData + sourcePath: "file://" + root.cachePath + "/" + modelData + ".wav" + volumeValue: Math.min(root.volume / 200.0, 1.0) + + Component.onCompleted: { + root.soundMap[keycode] = this; + } + Component.onDestruction: { + delete root.soundMap[keycode]; + } + } + } + } + + // ── Input monitoring ── + + Process { + id: inputProc + command: { + const cmd = root.selectedDevicePath === "all" + ? ["libinput", "debug-events", "--show-keycodes"] + : ["evtest", root.selectedDevicePath]; + return cmd; + } + running: false + + onRunningChanged: { + console.log("[TypingSounds] inputProc running:", running); + } + onExited: (exitCode) => { + console.log("[TypingSounds] inputProc exited with code:", exitCode); + // Auto-restart if it was killed externally (not by us disabling) + if (root.enabled && !root.isPreparing && exitCode !== 0) { + console.log("[TypingSounds] inputProc crashed, restarting in 1s"); + restartTimer.start(); + } + } + + stdout: SplitParser { + splitMarker: "\n" + onRead: data => { + if (data.includes("EV_KEY")) { + const keyMatch = data.match(/code\s+(\d+)/); + if (keyMatch && data.includes("value 1")) { + const code = parseInt(keyMatch[1]); + if (code >= 272 && code <= 287) { + if (root.mouseEnabled) root.triggerMouseSound(); + } else { + root.triggerKeySound(code); + } + } + } else if (data.includes("KEYBOARD_KEY")) { + const keyMatch = data.match(/\((\d+)\)/); + if (keyMatch && data.includes("pressed")) { + root.triggerKeySound(keyMatch[1]); + } + } else if (root.mouseEnabled && data.includes("POINTER_BUTTON")) { + if (data.includes("pressed")) root.triggerMouseSound(); + } + } + } + stderr: StdioCollector { + onStreamFinished: { + if (text.trim()) + console.warn("[TypingSounds] inputProc stderr:", text.trim()); + } + } + } + + Timer { + id: restartTimer + interval: 1000 + repeat: false + onTriggered: { + if (root.enabled && !inputProc.running) { + console.log("[TypingSounds] Auto-restarting input monitor"); + inputProc.running = true; + } + } + } + + // ── Cache format version (read once at startup) ── + + FileView { + id: cacheVersionReader + printErrors: false + path: root.sliceScript.replace("slice_audio.py", "cache_format.json") + onLoaded: { + try { + root.cacheFormatVersion = JSON.parse(text()).version.toString(); + root.cacheFormatReady = true; + console.log("[TypingSounds] Cache format version:", root.cacheFormatVersion); + root._syncFromConfig(); + root.loadPack(); + } catch(e) { + console.error("[TypingSounds] Failed to read cache format:", e); + root.cacheFormatVersion = "4"; + root.cacheFormatReady = true; + root._syncFromConfig(); + root.loadPack(); + } + } + onLoadFailed: { + console.warn("[TypingSounds] Missing cache_format.json, defaulting to v4"); + root.cacheFormatVersion = "4"; + root.cacheFormatReady = true; + root._syncFromConfig(); + root.loadPack(); + } + } + + // ── React to config changes ── + + // React to Config becoming ready — this is the primary sync trigger + // on startup, replacing the unreliable timer-based approach. + Connections { + target: Config + function onTypingSoundsReadyChanged() { + if (Config.typingSoundsReady) { + console.log("[TypingSounds] Config ready, syncing"); + root._syncFromConfig(); + root.checkTools(); + if (root.enabled) { + if (root.cacheFormatReady && Object.keys(root.currentDefines).length === 0) { + root.loadPack(); + } else if (Object.keys(root.currentDefines).length > 0) { + root._startInputMonitor(); + } + } + } + } + } + + onSelectedPackIdChanged: { + console.log("[TypingSounds] Pack changed to:", selectedPackId); + loadPack(); + } + + onEnabledChanged: { + console.log("[TypingSounds] enabled changed to:", enabled); + if (enabled) { + checkTools(); + if (cacheFormatReady && Object.keys(currentDefines).length === 0) { + loadPack(); + } else { + _startInputMonitor(); + } + } else { + if (inputProc.running) inputProc.running = false; + } + } + + // Periodically re-sync from config to catch external edits (e.g. from GTK settings app) + Timer { + id: configSyncTimer + interval: 5000 + repeat: true + running: true + onTriggered: root._syncFromConfig() + } + + // Re-check config after a short delay in case the adapter loads late. + Timer { + id: lateInitTimer + interval: 3000 + repeat: false + running: true + onTriggered: { + root._syncFromConfig(); + if (root.enabled) { + root.checkTools(); + if (root.cacheFormatReady && Object.keys(root.currentDefines).length === 0) { + root.loadPack(); + } + root._startInputMonitor(); + } + } + } + + // ── Init ── + + Component.onCompleted: { + // Sync initial values from Config + _syncFromConfig(); + console.log("[TypingSounds] Service init, pack:", selectedPackId, "enabled:", enabled); + checkTools(); + scanSoundPacks(); + scanDevices(); + // cacheVersionReader auto-loads via its path binding → triggers loadPack() + } +} diff --git a/modules/retroshell/files/modules/shell/UnifiedShellPanel.qml b/modules/retroshell/files/modules/shell/UnifiedShellPanel.qml index effb1901..ec283b9a 100644 --- a/modules/retroshell/files/modules/shell/UnifiedShellPanel.qml +++ b/modules/retroshell/files/modules/shell/UnifiedShellPanel.qml @@ -98,7 +98,7 @@ PanelWindow { // Check active toplevel first (fast path) const toplevel = ToplevelManager.activeToplevel; - if (toplevel && toplevel.fullscreen && AxctlService.focusedMonitor.id === monId) { + if (toplevel && toplevel.fullscreen && AxctlService.focusedMonitor && AxctlService.focusedMonitor.id === monId) { return true; } diff --git a/modules/retroshell/files/modules/theme/Colors.qml b/modules/retroshell/files/modules/theme/Colors.qml index 46e39666..c43c30f0 100644 --- a/modules/retroshell/files/modules/theme/Colors.qml +++ b/modules/retroshell/files/modules/theme/Colors.qml @@ -61,6 +61,7 @@ FileView { property color lightCyan: "#84d5c4" property color lightGreen: "#b7d085" property color lightMagenta: "#fcb0d5" + property color lightOrange: "#ffb88a" property color lightRed: "#ffb4ab" property color lightYellow: "#dec56e" property color magenta: "#fcb0d5" @@ -98,6 +99,12 @@ FileView { property color overWhiteContainer: "#9eeffd" property color overYellow: "#3b2f00" property color overYellowContainer: "#fce186" + property color orange: "#ffb88a" + property color orangeContainer: "#5a2e00" + property color orangeSource: "#ff9500" + property color orangeValue: "#ff9500" + property color overOrange: "#3c1a00" + property color overOrangeContainer: "#ffdcc0" property color outline: "#a08c8b" property color outlineVariant: "#534342" property color primary: "#ffb3ae" @@ -174,12 +181,17 @@ FileView { property color lightCyan: adapter.lightCyan property color lightGreen: adapter.lightGreen property color lightMagenta: adapter.lightMagenta + property color lightOrange: adapter.lightOrange property color lightRed: adapter.lightRed property color lightYellow: adapter.lightYellow property color magenta: adapter.magenta property color magentaContainer: adapter.magentaContainer property color magentaSource: adapter.magentaSource property color magentaValue: adapter.magentaValue + property color orange: adapter.orange + property color orangeContainer: adapter.orangeContainer + property color orangeSource: adapter.orangeSource + property color orangeValue: adapter.orangeValue property color overBackground: adapter.overBackground property color overBlue: adapter.overBlue property color overBlueContainer: adapter.overBlueContainer @@ -191,6 +203,8 @@ FileView { property color overGreenContainer: adapter.overGreenContainer property color overMagenta: adapter.overMagenta property color overMagentaContainer: adapter.overMagentaContainer + property color overOrange: adapter.overOrange + property color overOrangeContainer: adapter.overOrangeContainer property color overPrimary: adapter.overPrimary property color overPrimaryContainer: adapter.overPrimaryContainer property color overPrimaryFixed: adapter.overPrimaryFixed @@ -249,5 +263,5 @@ FileView { property color success: adapter.green // List of available color names for color pickers (excludes internal/source colors) - readonly property var availableColorNames: ["background", "surface", "surfaceBright", "surfaceContainer", "surfaceContainerHigh", "surfaceContainerHighest", "surfaceContainerLow", "surfaceContainerLowest", "surfaceDim", "surfaceTint", "surfaceVariant", "primary", "primaryContainer", "primaryFixed", "primaryFixedDim", "secondary", "secondaryContainer", "secondaryFixed", "secondaryFixedDim", "tertiary", "tertiaryContainer", "tertiaryFixed", "tertiaryFixedDim", "error", "errorContainer", "overBackground", "overSurface", "overSurfaceVariant", "overPrimary", "overPrimaryContainer", "overPrimaryFixed", "overPrimaryFixedVariant", "overSecondary", "overSecondaryContainer", "overSecondaryFixed", "overSecondaryFixedVariant", "overTertiary", "overTertiaryContainer", "overTertiaryFixed", "overTertiaryFixedVariant", "overError", "overErrorContainer", "outline", "outlineVariant", "inversePrimary", "inverseSurface", "inverseOnSurface", "shadow", "scrim", "blue", "blueContainer", "overBlue", "overBlueContainer", "lightBlue", "cyan", "cyanContainer", "overCyan", "overCyanContainer", "lightCyan", "green", "greenContainer", "overGreen", "overGreenContainer", "lightGreen", "magenta", "magentaContainer", "overMagenta", "overMagentaContainer", "lightMagenta", "red", "redContainer", "overRed", "overRedContainer", "lightRed", "yellow", "yellowContainer", "overYellow", "overYellowContainer", "lightYellow", "white", "whiteContainer", "overWhite", "overWhiteContainer"] + readonly property var availableColorNames: ["background", "surface", "surfaceBright", "surfaceContainer", "surfaceContainerHigh", "surfaceContainerHighest", "surfaceContainerLow", "surfaceContainerLowest", "surfaceDim", "surfaceTint", "surfaceVariant", "primary", "primaryContainer", "primaryFixed", "primaryFixedDim", "secondary", "secondaryContainer", "secondaryFixed", "secondaryFixedDim", "tertiary", "tertiaryContainer", "tertiaryFixed", "tertiaryFixedDim", "error", "errorContainer", "overBackground", "overSurface", "overSurfaceVariant", "overPrimary", "overPrimaryContainer", "overPrimaryFixed", "overPrimaryFixedVariant", "overSecondary", "overSecondaryContainer", "overSecondaryFixed", "overSecondaryFixedVariant", "overTertiary", "overTertiaryContainer", "overTertiaryFixed", "overTertiaryFixedVariant", "overError", "overErrorContainer", "outline", "outlineVariant", "inversePrimary", "inverseSurface", "inverseOnSurface", "shadow", "scrim", "blue", "blueContainer", "overBlue", "overBlueContainer", "lightBlue", "cyan", "cyanContainer", "overCyan", "overCyanContainer", "lightCyan", "green", "greenContainer", "overGreen", "overGreenContainer", "lightGreen", "magenta", "magentaContainer", "overMagenta", "overMagentaContainer", "lightMagenta", "lightOrange", "red", "redContainer", "overRed", "overRedContainer", "lightRed", "yellow", "yellowContainer", "overYellow", "overYellowContainer", "lightYellow", "orange", "orangeContainer", "overOrange", "overOrangeContainer", "white", "whiteContainer", "overWhite", "overWhiteContainer"] } diff --git a/modules/retroshell/files/modules/theme/Icons.qml b/modules/retroshell/files/modules/theme/Icons.qml index d92b2926..54345217 100644 --- a/modules/retroshell/files/modules/theme/Icons.qml +++ b/modules/retroshell/files/modules/theme/Icons.qml @@ -122,11 +122,11 @@ QtObject { readonly property string repeat: "" readonly property string repeatOnce: "" readonly property string player: "" - readonly property string spotify: "󰓇" - readonly property string firefox: "󰈹" - readonly property string chromium: "" - readonly property string telegram: "" - readonly property string discord: "" + readonly property string spotify: "󰓇" + readonly property string firefox: "󰈹" + readonly property string chromium: "" + readonly property string telegram: "" + readonly property string discord: "" // Clock readonly property string clock: "" @@ -198,6 +198,7 @@ QtObject { readonly property string sync: "" readonly property string cube: "" readonly property string picker: "" + readonly property string docker: "\ue78c" readonly property string textT: "" readonly property string qrCode: "" readonly property string webcam: "" diff --git a/modules/retroshell/files/modules/widgets/dashboard/controls/DockerPanel.qml b/modules/retroshell/files/modules/widgets/dashboard/controls/DockerPanel.qml new file mode 100644 index 00000000..fec5129a --- /dev/null +++ b/modules/retroshell/files/modules/widgets/dashboard/controls/DockerPanel.qml @@ -0,0 +1,903 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config + +Item { + id: root + + property int maxContentWidth: 480 + readonly property int contentWidth: Math.min(width, maxContentWidth) + + property bool groupByCompose: false + property var expandedContainers: ({}) + property var expandedProjects: ({}) + + function toggleContainer(cid) { + var e = root.expandedContainers; + e[cid] = !e[cid]; + root.expandedContainers = e; + root.expandedContainersChanged(); + Qt.callLater(scrollTimer.restart); + } + + function toggleProject(pname) { + var e = root.expandedProjects; + e[pname] = !e[pname]; + root.expandedProjects = e; + root.expandedProjectsChanged(); + Qt.callLater(scrollTimer.restart); + } + + function ensureVisible(lv, idx) { + if (idx >= 0) lv.positionViewAtIndex(idx, ListView.Contain); + } + + function getContainerIndex(cid) { + var conts = DockerService.containers; + for (var i = 0; i < conts.length; i++) { + if (conts[i].id === cid) return i; + } + return -1; + } + + function getProjectIndex(pname) { + var projs = DockerService.composeProjects; + for (var i = 0; i < projs.length; i++) { + if (projs[i].name === pname) return i; + } + return -1; + } + + Timer { + id: scrollTimer + interval: 50 + repeat: false + onTriggered: { + var e = root.expandedContainers; + for (var cid in e) { + if (e[cid]) { + var idx = root.getContainerIndex(cid); + if (idx >= 0) root.ensureVisible(containerList, idx); + return; + } + } + var ep = root.expandedProjects; + for (var pname in ep) { + if (ep[pname]) { + var idx2 = root.getProjectIndex(pname); + if (idx2 >= 0) root.ensureVisible(projectList, idx2); + return; + } + } + } + } + + implicitWidth: Math.min(parent ? parent.width : 0, maxContentWidth) + + Component.onCompleted: DockerService.refresh() + + // ── Shared header content ── + ColumnLayout { + id: headerColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: 6 + + PanelTitlebar { + Layout.fillWidth: true + Layout.preferredHeight: 38 + title: "Docker" + statusText: DockerService.dockerAvailable + ? DockerService.runningCount + " running" + : "Not available" + statusColor: DockerService.dockerAvailable + ? Styling.srItem("overprimary") + : Colors.error + actions: [ + { + icon: root.groupByCompose ? Icons.list : Icons.folder, + tooltip: root.groupByCompose ? "Flat view" : "Compose view", + onClicked: function() { root.groupByCompose = !root.groupByCompose; } + }, + { + icon: Icons.sync, + tooltip: "Refresh", + onClicked: function() { DockerService.refresh(); } + } + ] + } + + StyledRect { + Layout.fillWidth: true + visible: !DockerService.dockerAvailable + variant: "popup" + enableShadow: false + radius: Styling.radius(0) + + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + anchors.rightMargin: 10 + anchors.topMargin: 6 + anchors.bottomMargin: 6 + text: "Docker is not running or not installed" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.warning + wrapMode: Text.WordWrap + } + } + } + + // ── Flat container list ── + ListView { + id: containerList + anchors.top: headerColumn.bottom + anchors.topMargin: 6 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + visible: !root.groupByCompose && DockerService.dockerAvailable + clip: true + spacing: 4 + model: DockerService.containers + + delegate: Item { + id: cDel + width: containerList.width + height: cCard.height + + property var cData: modelData + property bool isExpanded: root.expandedContainers[modelData.id] || false + + StyledRect { + id: cCard + width: parent.width + height: headerRow.height + (cDel.isExpanded ? expandedCol.height : 0) + variant: "popup" + radius: Styling.radius(0) + border.color: cDel.cData.isRunning + ? Qt.rgba(Styling.srItem("overprimary").r, Styling.srItem("overprimary").g, Styling.srItem("overprimary").b, 0.5) + : cDel.cData.isPaused + ? Qt.rgba(Colors.warning.r, Colors.warning.g, Colors.warning.b, 0.5) + : Colors.outline + border.width: 1 + clip: true + + Behavior on height { + enabled: Config.animDuration > 0 + NumberAnimation { duration: Config.animDuration; easing.type: Easing.OutQuart } + } + + // ── Header row ── + Item { + id: headerRow + width: parent.width + height: 44 + + Rectangle { + width: 6 + height: 6 + radius: 3 + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + color: cDel.cData.isRunning + ? Styling.srItem("overprimary") + : cDel.cData.isPaused ? Colors.warning : Colors.outline + } + + Column { + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.right: cArrow.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Text { + text: cDel.cData.name + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.Medium + color: Colors.overBackground + elide: Text.ElideRight + width: parent.width + } + + Text { + text: cDel.cData.image + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + color: Colors.overSurfaceVariant + elide: Text.ElideRight + width: parent.width + } + } + + Text { + id: cArrow + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: cDel.isExpanded ? Icons.caretDown : Icons.caretRight + font.family: Icons.font + font.pixelSize: 12 + color: Colors.overSurfaceVariant + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleContainer(cDel.cData.id) + } + } + + // ── Expanded content (inside the card) ── + Column { + id: expandedCol + anchors.top: headerRow.bottom + width: parent.width + visible: cDel.isExpanded + spacing: 0 + + // Divider line + Rectangle { + width: parent.width + height: 1 + color: Colors.outline + opacity: 0.3 + } + + // Ports + Flow { + width: parent.width + visible: DockerService.showPorts && cDel.cData.ports && cDel.cData.ports.length > 0 + spacing: 4 + leftPadding: 10 + rightPadding: 10 + topPadding: 6 + bottomPadding: 6 + + Repeater { + model: cDel.cData.ports || [] + delegate: Item { + height: 20 + width: cPortRow.width + 12 + + StyledRect { + anchors.fill: parent + radius: 10 + color: Qt.rgba(Styling.srItem("overprimary").r, Styling.srItem("overprimary").g, Styling.srItem("overprimary").b, 0.15) + border.color: Qt.rgba(Styling.srItem("overprimary").r, Styling.srItem("overprimary").g, Styling.srItem("overprimary").b, 0.3) + border.width: 1 + + Row { + id: cPortRow + anchors.centerIn: parent + spacing: 3 + Text { + text: modelData.hostPort + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + font.weight: Font.Medium + color: Styling.srItem("overprimary") + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: "\u2192" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + color: Qt.rgba(Styling.srItem("overprimary").r, Styling.srItem("overprimary").g, Styling.srItem("overprimary").b, 0.6) + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: modelData.containerPort.replace("/tcp", "").replace("/udp", "") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + color: Qt.rgba(Styling.srItem("overprimary").r, Styling.srItem("overprimary").g, Styling.srItem("overprimary").b, 0.8) + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + } + } + + // Action buttons + StyledRect { + width: parent.width + height: actionRow.height + 12 + variant: "focus" + radius: Styling.radius(0) + + Row { + id: actionRow + anchors.centerIn: parent + spacing: 4 + + Repeater { + model: ListModel { + ListElement { iconCode: "play"; act: "start" } + ListElement { iconCode: "sync"; act: "restart" } + ListElement { iconCode: "pause"; act: "pause" } + ListElement { iconCode: "stop"; act: "stop" } + ListElement { iconCode: "terminal"; act: "shell" } + ListElement { iconCode: "file"; act: "logs" } + } + + delegate: Item { + width: 36 + height: 28 + + property bool isActionEnabled: { + var c = cDel.cData; + if (!c) return false; + switch (act) { + case "start": return !c.isRunning; + case "restart": return c.isRunning && !c.isPaused; + case "pause": return c.isRunning && !c.isPaused; + case "stop": return c.isRunning || c.isPaused; + case "shell": return c.isRunning; + case "logs": return true; + } + return false; + } + + property string iconText: { + switch (iconCode) { + case "play": return Icons.play; + case "sync": return Icons.sync; + case "pause": return Icons.pause; + case "stop": return Icons.stop; + case "terminal": return Icons.terminal; + case "file": return Icons.file; + } + return ""; + } + + StyledRect { + anchors.fill: parent + radius: Styling.radius(0) + variant: "primary" + opacity: isActionEnabled ? 1.0 : 0.4 + + Text { + anchors.centerIn: parent + text: iconText + font.family: Icons.font + font.pixelSize: 13 + color: Styling.srItem("primary") + } + + MouseArea { + anchors.fill: parent + cursorShape: isActionEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + enabled: isActionEnabled + onClicked: { + var c = cDel.cData; + if (!c) return; + var cid = c.id || c.name; + if (act === "shell") DockerService.openExec(cid); + else if (act === "logs") DockerService.openLogs(cid); + else DockerService.executeAction(cid, act); + } + } + } + } + } + } + } + } + } + } + + StyledRect { + anchors.centerIn: parent + width: parent.width - 20 + height: 60 + visible: DockerService.containers.length === 0 + variant: "popup" + enableShadow: false + radius: Styling.radius(0) + + Text { + anchors.centerIn: parent + text: "No containers found" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(0) + font.weight: Font.Medium + color: Colors.overSurfaceVariant + } + } + } + + // ── Compose project list ── + ListView { + id: projectList + anchors.top: headerColumn.bottom + anchors.topMargin: 6 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + visible: root.groupByCompose && DockerService.dockerAvailable + clip: true + spacing: 4 + model: DockerService.composeProjects + + delegate: Item { + id: pDel + width: projectList.width + height: pCard.height + + property var pData: modelData + property bool isExpanded: root.expandedProjects[modelData.name] || false + + StyledRect { + id: pCard + width: parent.width + height: pHeaderRow.height + (pDel.isExpanded ? pExpandedCol.height : 0) + variant: "popup" + radius: Styling.radius(0) + border.color: pDel.pData.runningCount === pDel.pData.totalCount && pDel.pData.totalCount > 0 + ? Qt.rgba(Styling.srItem("overprimary").r, Styling.srItem("overprimary").g, Styling.srItem("overprimary").b, 0.5) + : pDel.pData.runningCount > 0 + ? Qt.rgba(Colors.warning.r, Colors.warning.g, Colors.warning.b, 0.5) + : Colors.outline + border.width: 1 + clip: true + + Behavior on height { + enabled: Config.animDuration > 0 + NumberAnimation { duration: Config.animDuration; easing.type: Easing.OutQuart } + } + + // ── Project header ── + Item { + id: pHeaderRow + width: parent.width + height: 44 + + Text { + text: Icons.folder + font.family: Icons.font + font.pixelSize: 14 + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + color: pDel.pData.runningCount === pDel.pData.totalCount && pDel.pData.totalCount > 0 + ? Styling.srItem("overprimary") + : pDel.pData.runningCount > 0 + ? Colors.warning + : Colors.overSurfaceVariant + } + + Column { + anchors.left: parent.left + anchors.leftMargin: 32 + anchors.right: pCountText.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Text { + text: pDel.pData.name + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.Bold + color: Colors.overBackground + elide: Text.ElideRight + width: parent.width + } + + Text { + text: pDel.pData.containers.length + " service" + (pDel.pData.containers.length !== 1 ? "s" : "") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + color: Colors.overSurfaceVariant + } + } + + Text { + id: pCountText + anchors.right: pArrow.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: pDel.pData.runningCount + "/" + pDel.pData.totalCount + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.Medium + color: pDel.pData.runningCount === pDel.pData.totalCount && pDel.pData.totalCount > 0 + ? Styling.srItem("overprimary") + : Colors.overSurfaceVariant + } + + Text { + id: pArrow + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: pDel.isExpanded ? Icons.caretDown : Icons.caretRight + font.family: Icons.font + font.pixelSize: 12 + color: Colors.overSurfaceVariant + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleProject(pDel.pData.name) + } + } + + // ── Expanded content (inside the card) ── + Column { + id: pExpandedCol + anchors.top: pHeaderRow.bottom + width: parent.width + visible: pDel.isExpanded + spacing: 0 + + // Divider + Rectangle { + width: parent.width + height: 1 + color: Colors.outline + opacity: 0.3 + } + + // Project actions + StyledRect { + width: parent.width + height: pActionRow.height + 12 + variant: "focus" + radius: Styling.radius(0) + + Row { + id: pActionRow + anchors.centerIn: parent + spacing: 4 + + Repeater { + model: ListModel { + ListElement { iconCode: "play"; act: "start" } + ListElement { iconCode: "sync"; act: "restart" } + ListElement { iconCode: "stop"; act: "stop" } + ListElement { iconCode: "file"; act: "logs" } + } + + delegate: Item { + width: 36 + height: 28 + + property bool isActionEnabled: { + var p = pDel.pData; + if (!p) return false; + switch (act) { + case "start": return p.runningCount < p.totalCount; + case "restart": return p.runningCount > 0; + case "stop": return p.runningCount > 0; + case "logs": return true; + } + return false; + } + + property string iconText: { + switch (iconCode) { + case "play": return Icons.play; + case "sync": return Icons.sync; + case "stop": return Icons.stop; + case "file": return Icons.file; + } + return ""; + } + + StyledRect { + anchors.fill: parent + radius: Styling.radius(0) + variant: "primary" + opacity: isActionEnabled ? 1.0 : 0.4 + + Text { + anchors.centerIn: parent + text: iconText + font.family: Icons.font + font.pixelSize: 13 + color: Styling.srItem("primary") + } + + MouseArea { + anchors.fill: parent + cursorShape: isActionEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + enabled: isActionEnabled + onClicked: { + var p = pDel.pData; + if (!p) return; + if (act === "logs") DockerService.executeComposeAction(p.workingDir, p.configFile, "logs"); + else DockerService.executeComposeAction(p.workingDir, p.configFile, act); + } + } + } + } + } + } + } + + // Services container + StyledRect { + width: parent.width + height: servicesCol.implicitHeight + 16 + variant: "internalbg" + radius: Styling.radius(0) + + ColumnLayout { + id: servicesCol + anchors.fill: parent + anchors.margins: 8 + spacing: 4 + + Text { + text: "Services" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.bold: true + color: Colors.overSurfaceVariant + Layout.leftMargin: 2 + } + + Repeater { + model: pDel.pData ? pDel.pData.containers : [] + + delegate: Item { + id: sDel + Layout.fillWidth: true + Layout.preferredHeight: sCard.height + + property var sData: modelData + property bool isExpanded: root.expandedContainers[modelData.name] || false + + StyledRect { + id: sCard + width: parent.width + height: sHeaderRow.height + (sDel.isExpanded ? sExpandedContent.height : 0) + variant: "popup" + radius: Styling.radius(0) + border.color: sDel.sData.isRunning + ? Qt.rgba(Styling.srItem("overprimary").r, Styling.srItem("overprimary").g, Styling.srItem("overprimary").b, 0.4) + : sDel.sData.isPaused + ? Qt.rgba(Colors.warning.r, Colors.warning.g, Colors.warning.b, 0.4) + : Colors.outline + border.width: 1 + clip: true + + Behavior on height { + enabled: Config.animDuration > 0 + NumberAnimation { duration: Config.animDuration; easing.type: Easing.OutQuart } + } + + Item { + id: sHeaderRow + width: parent.width + height: 40 + + Rectangle { + width: 6 + height: 6 + radius: 3 + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + color: sDel.sData.isRunning + ? Styling.srItem("overprimary") + : sDel.sData.isPaused ? Colors.warning : Colors.outline + } + + Column { + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.right: sStatusCol.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Text { + text: sDel.sData.composeService || sDel.sData.name + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.Medium + color: Colors.overBackground + elide: Text.ElideRight + width: parent.width + } + + Text { + text: sDel.sData.image + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + color: Colors.overSurfaceVariant + elide: Text.ElideRight + width: parent.width + visible: text.length > 0 + } + } + + Column { + id: sStatusCol + anchors.right: sArrow.left + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + Text { + text: sDel.sData.status + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + font.weight: Font.Medium + color: sDel.sData.isRunning + ? Styling.srItem("overprimary") + : sDel.sData.isPaused + ? Colors.warning + : Colors.overSurfaceVariant + horizontalAlignment: Text.AlignRight + } + } + + Text { + id: sArrow + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: sDel.isExpanded ? Icons.caretDown : Icons.caretRight + font.family: Icons.font + font.pixelSize: 10 + color: Colors.overSurfaceVariant + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleContainer(sDel.sData.name) + } + } + + Column { + id: sExpandedContent + anchors.top: sHeaderRow.bottom + width: parent.width + visible: sDel.isExpanded + spacing: 0 + + Rectangle { + width: parent.width + height: 1 + color: Colors.outline + opacity: 0.2 + } + + StyledRect { + width: parent.width + height: sActionRow.height + 10 + variant: "focus" + radius: Styling.radius(0) + + Row { + id: sActionRow + anchors.centerIn: parent + spacing: 4 + + Repeater { + model: ListModel { + ListElement { iconCode: "play"; act: "start" } + ListElement { iconCode: "sync"; act: "restart" } + ListElement { iconCode: "pause"; act: "pause" } + ListElement { iconCode: "stop"; act: "stop" } + ListElement { iconCode: "terminal"; act: "shell" } + ListElement { iconCode: "file"; act: "logs" } + } + + delegate: Item { + width: 32 + height: 26 + + property bool isActionEnabled: { + var c = sDel.sData; + if (!c) return false; + switch (act) { + case "start": return !c.isRunning; + case "restart": return c.isRunning && !c.isPaused; + case "pause": return c.isRunning && !c.isPaused; + case "stop": return c.isRunning || c.isPaused; + case "shell": return c.isRunning; + case "logs": return true; + } + return false; + } + + property string iconText: { + switch (iconCode) { + case "play": return Icons.play; + case "sync": return Icons.sync; + case "pause": return Icons.pause; + case "stop": return Icons.stop; + case "terminal": return Icons.terminal; + case "file": return Icons.file; + } + return ""; + } + + StyledRect { + anchors.fill: parent + radius: Styling.radius(0) + variant: "primary" + opacity: isActionEnabled ? 1.0 : 0.4 + + Text { + anchors.centerIn: parent + text: iconText + font.family: Icons.font + font.pixelSize: 12 + color: Styling.srItem("primary") + } + + MouseArea { + anchors.fill: parent + cursorShape: isActionEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + enabled: isActionEnabled + onClicked: { + var c = sDel.sData; + if (!c) return; + var cid = c.id || c.name; + if (act === "shell") DockerService.openExec(cid); + else if (act === "logs") DockerService.openLogs(cid); + else DockerService.executeAction(cid, act); + } + } + } + } + } + } + } + } + } + } + } + } + } + + Item { + width: 1 + height: 4 + } + } + } + } + + StyledRect { + anchors.centerIn: parent + width: parent.width - 20 + height: 60 + visible: DockerService.composeProjects.length === 0 + variant: "popup" + enableShadow: false + radius: Styling.radius(0) + + Text { + anchors.centerIn: parent + text: "No compose projects found" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(0) + font.weight: Font.Medium + color: Colors.overSurfaceVariant + } + } + } +} diff --git a/modules/retroshell/files/modules/widgets/dashboard/controls/TypingSoundsPanel.qml b/modules/retroshell/files/modules/widgets/dashboard/controls/TypingSoundsPanel.qml new file mode 100644 index 00000000..d4f6aba7 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/dashboard/controls/TypingSoundsPanel.qml @@ -0,0 +1,323 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config + +Item { + id: root + + property int maxContentWidth: 480 + readonly property int contentWidth: Math.min(width, maxContentWidth) + + signal requestClose() + + implicitWidth: Math.min(parent ? parent.width : 0, maxContentWidth) + + ListView { + id: listView + anchors.fill: parent + clip: true + spacing: 6 + model: 1 + + header: Item { + width: listView.width + height: contentColumn.implicitHeight + 6 + + ColumnLayout { + id: contentColumn + width: parent.width + spacing: 6 + + PanelTitlebar { + Layout.fillWidth: true + Layout.preferredHeight: 38 + title: "Typing Sounds" + statusText: { + if (TypingSoundsService.isPreparing) return "Preparing..."; + if (TypingSoundsService.inputToolMissing) return "Missing " + TypingSoundsService.requiredTool; + if (TypingSoundsService.notInInputGroup) return "Not in input group"; + return TypingSoundsService.enabled ? "Enabled" : "Disabled"; + } + statusColor: { + if (TypingSoundsService.inputToolMissing || TypingSoundsService.notInInputGroup) + return Colors.warning; + return TypingSoundsService.enabled ? Styling.srItem("overprimary") : Colors.outline; + } + showToggle: true + toggleChecked: TypingSoundsService.enabled + + onToggleChanged: checked => { + TypingSoundsService.saveSetting("enabled", checked); + } + } + + StyledRect { + Layout.fillWidth: true + visible: TypingSoundsService.inputToolMissing || TypingSoundsService.notInInputGroup + variant: "common" + enableShadow: false + radius: Styling.radius(0) + + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + anchors.rightMargin: 10 + anchors.topMargin: 6 + anchors.bottomMargin: 6 + text: { + if (TypingSoundsService.notInInputGroup) + return "Add user to input group: sudo usermod -aG input $USER"; + if (TypingSoundsService.inputToolMissing) + return "Install " + TypingSoundsService.requiredTool + " to use typing sounds"; + return ""; + } + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.warning + wrapMode: Text.WordWrap + } + } + + StyledRect { + Layout.fillWidth: true + Layout.preferredHeight: 38 + variant: "common" + enableShadow: false + radius: Styling.radius(0) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + + Text { + text: Icons.speakerHigh + font.family: Icons.font + font.pixelSize: 14 + color: Colors.primary + } + + Slider { + id: volumeSlider + Layout.fillWidth: true + from: 0 + to: 200 + stepSize: 1 + value: TypingSoundsService.volume + onMoved: TypingSoundsService.saveSetting("volume", Math.round(value)) + + background: Rectangle { + x: volumeSlider.leftPadding + y: volumeSlider.topPadding + volumeSlider.availableHeight / 2 - height / 2 + implicitWidth: 200 + implicitHeight: 4 + width: volumeSlider.availableWidth + height: implicitHeight + radius: 2 + color: Colors.outlineVariant + + Rectangle { + width: volumeSlider.visualPosition * parent.width + height: parent.height + radius: 2 + color: Colors.primary + } + } + + handle: Rectangle { + x: volumeSlider.leftPadding + volumeSlider.visualPosition * (volumeSlider.availableWidth - width) + y: volumeSlider.topPadding + volumeSlider.availableHeight / 2 - height / 2 + implicitWidth: 14 + implicitHeight: 14 + radius: width / 2 + color: Colors.primary + } + } + + Text { + text: Math.round(volumeSlider.value) + "%" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.bold: true + color: Colors.overBackground + Layout.preferredWidth: 32 + horizontalAlignment: Text.AlignRight + } + } + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: packAndDeviceColumn.implicitHeight + 16 + variant: "common" + enableShadow: false + radius: Styling.radius(0) + + ColumnLayout { + id: packAndDeviceColumn + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: "Sound Pack" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.bold: true + color: Colors.overBackground + horizontalAlignment: Text.AlignLeft + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: Icons.note + font.family: Icons.font + font.pixelSize: 14 + color: Colors.primary + } + + StyledCombo { + id: packCombo + Layout.fillWidth: true + model: TypingSoundsService.availablePacks.map(p => p.name) + currentIndex: { + var packs = TypingSoundsService.availablePacks; + for (var i = 0; i < packs.length; i++) { + if (packs[i].id === TypingSoundsService.selectedPackId) + return i; + } + return 0; + } + onActivated: index => { + var packs = TypingSoundsService.availablePacks; + if (index >= 0 && index < packs.length) + TypingSoundsService.saveSetting("selectedPackId", packs[index].id); + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: "Keyboard Device" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.bold: true + color: Colors.overBackground + horizontalAlignment: Text.AlignLeft + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: Icons.keyboard + font.family: Icons.font + font.pixelSize: 14 + color: Colors.primary + } + + StyledCombo { + id: deviceCombo + Layout.fillWidth: true + model: TypingSoundsService.availableDevices.map(d => d.label) + currentIndex: { + var devs = TypingSoundsService.availableDevices; + for (var i = 0; i < devs.length; i++) { + if (devs[i].value === TypingSoundsService.selectedDevicePath) + return i; + } + return 0; + } + onActivated: index => { + var devs = TypingSoundsService.availableDevices; + if (index >= 0 && index < devs.length) + TypingSoundsService.saveSetting("selectedDevicePath", devs[index].value); + } + } + } + } + } + } + + StyledRect { + Layout.fillWidth: true + Layout.preferredHeight: 38 + variant: "common" + enableShadow: false + radius: Styling.radius(0) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + + Text { + text: Icons.mouse + font.family: Icons.font + font.pixelSize: 14 + color: Colors.primary + } + + Text { + text: "Mouse Clicks" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + Layout.fillWidth: true + } + + Switch { + id: mouseSwitch + checked: TypingSoundsService.mouseEnabled + onCheckedChanged: TypingSoundsService.saveSetting("mouseEnabled", checked) + + indicator: Rectangle { + implicitWidth: 36 + implicitHeight: 18 + x: mouseSwitch.leftPadding + y: parent.height / 2 - height / 2 + radius: height / 2 + color: mouseSwitch.checked ? Styling.srItem("overprimary") : Colors.surfaceBright + border.color: mouseSwitch.checked ? Styling.srItem("overprimary") : Colors.outline + + Rectangle { + x: mouseSwitch.checked ? parent.width - width - 2 : 2 + y: 2 + width: parent.height - 4 + height: width + radius: width / 2 + color: mouseSwitch.checked ? Colors.background : Colors.overSurfaceVariant + } + } + background: null + } + } + } + } + } + } +} diff --git a/modules/retroshell/files/modules/widgets/dashboard/controls/WifiPanel.qml b/modules/retroshell/files/modules/widgets/dashboard/controls/WifiPanel.qml index 373088db..d73dc75d 100644 --- a/modules/retroshell/files/modules/widgets/dashboard/controls/WifiPanel.qml +++ b/modules/retroshell/files/modules/widgets/dashboard/controls/WifiPanel.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Quickshell +import Quickshell.Io import qs.modules.theme import qs.modules.components import qs.modules.services @@ -16,19 +17,53 @@ Item { readonly property int contentWidth: Math.min(width, maxContentWidth) readonly property real sideMargin: (width - contentWidth) / 2 + property bool showQr: false + property string qrImagePath: "" + property bool showSpeedTest: false + + function formatBytes(bytes) { + if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + " GB"; + if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + " MB"; + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB"; + return bytes + " B"; + } + Component.onCompleted: { - // Defer scan to avoid blocking UI initialization initialScanTimer.start(); } + onVisibleChanged: { + if (visible) { + NetworkService.netStatsActive = true; + } else { + NetworkService.netStatsActive = false; + } + } + Timer { id: initialScanTimer interval: 300 repeat: false - onTriggered: NetworkService.rescanWifi() + onTriggered: { + NetworkService.rescanWifi(); + } + } + + Process { + id: qrGenProc + running: false + stdout: StdioCollector { + onStreamFinished: { + var path = text.trim(); + if (path !== "") { + qrImagePath = path; + showQr = true; + networkList.positionViewAtBeginning(); + } + } + } } - // Network list - fills entire width for scroll/drag ListView { id: networkList anchors.fill: parent @@ -41,7 +76,11 @@ Item { header: Item { width: networkList.width - height: titlebar.height + 8 + height: titlebar.height + + (showQr && qrImagePath !== "" ? 196 : 0) + + (showSpeedTest ? 144 : 0) + + (NetworkService.wifi ? 148 : 0) + + 2 PanelTitlebar { id: titlebar @@ -55,18 +94,25 @@ Item { actions: [ { - icon: Icons.globe, - tooltip: "Open captive portal", - enabled: NetworkService.wifiStatus === "limited", + icon: Icons.timer, + tooltip: "Run speed test", + enabled: NetworkService.wifi && !NetworkService.speedTestRunning, onClicked: function () { - NetworkService.openPublicWifiPortal(); + if (showSpeedTest) { + showSpeedTest = false; + } else { + NetworkService.runSpeedTest(); + showSpeedTest = true; + } + networkList.positionViewAtBeginning(); } }, { - icon: Icons.gear, - tooltip: "Open Network Settings", + icon: Icons.globe, + tooltip: "Open captive portal", + enabled: NetworkService.wifiStatus === "limited", onClicked: function () { - Quickshell.execDetached(["retro", "settings", "network"]); + NetworkService.openPublicWifiPortal(); } }, { @@ -77,6 +123,21 @@ Item { onClicked: function () { NetworkService.rescanWifi(); } + }, + { + icon: Icons.qrCode, + tooltip: "Share WiFi via QR code", + enabled: NetworkService.wifi && NetworkService.active !== null, + onClicked: function () { + if (showQr) { + showQr = false; + } else { + var rd = Quickshell.env("RETRO_DIR"); + qrGenProc.command = ["bash", rd + "/scripts/network_core.sh", "--wifi-qr"]; + qrGenProc.running = true; + } + networkList.positionViewAtBeginning(); + } } ] @@ -87,6 +148,496 @@ Item { } } } + + // Network stats card + Item { + id: statsCard + anchors.top: titlebar.bottom + anchors.topMargin: 4 + anchors.horizontalCenter: parent.horizontalCenter + width: root.contentWidth + height: NetworkService.wifi ? 88 : 0 + visible: NetworkService.wifi + + StyledRect { + anchors.fill: parent + variant: "common" + enableShadow: false + radius: Styling.radius(0) + + Grid { + anchors.left: parent.left + anchors.leftMargin: 20 + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + columns: 2 + columnSpacing: 16 + rowSpacing: 2 + + // Ping + Item { + width: (statsCard.width - 16) / 2 + height: 14 + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Text { + text: Icons.clock + font.family: Icons.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.primary + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: NetworkService.netPingMs.toFixed(0) + " ms" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.bold: true + color: Colors.overBackground + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // Packet Loss + Item { + width: (statsCard.width - 16) / 2 + height: 14 + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Text { + text: Icons.wifiX + font.family: Icons.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.primary + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: NetworkService.netPacketLoss.toFixed(1) + " %" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.bold: true + color: Colors.overBackground + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // Downloading + Item { + width: (statsCard.width - 16) / 2 + height: 14 + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Text { + text: Icons.arrowDown + font.family: Icons.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.primary + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: NetworkService.netDownloadSpeed >= 1048576 + ? (NetworkService.netDownloadSpeed / 1048576).toFixed(1) + " MB/s" + : (NetworkService.netDownloadSpeed / 1024).toFixed(1) + " KB/s" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.bold: true + color: Colors.overBackground + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // Uploading + Item { + width: (statsCard.width - 16) / 2 + height: 14 + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Text { + text: Icons.arrowUp + font.family: Icons.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.primary + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: NetworkService.netUploadSpeed >= 1048576 + ? (NetworkService.netUploadSpeed / 1048576).toFixed(1) + " MB/s" + : (NetworkService.netUploadSpeed / 1024).toFixed(1) + " KB/s" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.bold: true + color: Colors.overBackground + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // Downloaded + Item { + width: (statsCard.width - 16) / 2 + height: 14 + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Text { + text: Icons.arrowFatLinesDown + font.family: Icons.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.primary + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: formatBytes(NetworkService.netTotalDownloaded) + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.bold: true + color: Colors.overBackground + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // Uploaded + Item { + width: (statsCard.width - 16) / 2 + height: 14 + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Text { + text: Icons.arrowUp + font.family: Icons.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.primary + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: formatBytes(NetworkService.netTotalUploaded) + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.bold: true + color: Colors.overBackground + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // IP Address + Item { + width: (statsCard.width - 16) / 2 + height: 14 + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Text { + text: Icons.globe + font.family: Icons.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.primary + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: NetworkService.netDeviceIp + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.bold: true + color: Colors.overBackground + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // Gateway + Item { + width: (statsCard.width - 16) / 2 + height: 14 + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Text { + text: Icons.globe + font.family: Icons.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.primary + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: NetworkService.netGatewayIp + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.bold: true + color: Colors.overBackground + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + } + } + + // DNS provider card + Item { + id: dnsCard + anchors.top: statsCard.bottom + anchors.topMargin: 4 + anchors.horizontalCenter: parent.horizontalCenter + width: root.contentWidth + height: NetworkService.wifi ? 48 : 0 + visible: NetworkService.wifi + + StyledRect { + anchors.fill: parent + variant: "common" + enableShadow: false + radius: Styling.radius(0) + + Grid { + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + columns: 4 + columnSpacing: 6 + + Repeater { + model: [ + { key: "cloudflare", label: "Cloudflare" }, + { key: "google", label: "Google" }, + { key: "cloud9", label: "Cloud9" }, + { key: "dhcp", label: "DHCP" } + ] + + delegate: Item { + required property var modelData + width: (dnsCard.width - 28) / 4 + height: 32 + + property bool isActive: NetworkService.netDnsProvider === modelData.key + + StyledRect { + anchors.fill: parent + variant: parent.isActive ? "focus" : "common" + radius: Styling.radius(-4) + + Text { + anchors.centerIn: parent + text: modelData.label + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + font.bold: parent.parent.isActive + color: parent.parent.isActive ? Colors.overPrimary : Colors.overBackground + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + NetworkService.setDnsProvider(modelData.key); + } + } + } + } + } + } + } + } + + // Speed test card + Item { + id: speedTestCard + anchors.top: dnsCard.bottom + anchors.topMargin: 4 + anchors.horizontalCenter: parent.horizontalCenter + width: root.contentWidth + height: showSpeedTest ? 140 : 0 + visible: showSpeedTest + clip: true + + StyledRect { + anchors.fill: parent + variant: "common" + enableShadow: false + radius: Styling.radius(0) + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 6 + + // Title + Text { + Layout.alignment: Qt.AlignHCenter + text: NetworkService.speedTestRunning + ? (NetworkService.speedTestPhase === "ping" ? "Testing ping..." + : NetworkService.speedTestPhase === "download" ? "Testing download..." + : "Testing upload...") + : (NetworkService.speedTestDown > 0 ? "Speed Test Results" : "Speed Test") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.bold: true + color: Colors.overBackground + } + + // 3-column results: Download, Upload, Ping + Row { + Layout.alignment: Qt.AlignHCenter + spacing: 28 + + // Download + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: Icons.arrowDown + font.family: Icons.font + font.pixelSize: 16 + color: NetworkService.speedTestPhase === "download" ? Colors.primary : Colors.overSurfaceVariant + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: NetworkService.speedTestDown > 0 ? NetworkService.speedTestDown.toFixed(1) : "---" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(3) + font.bold: true + color: Colors.overBackground + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Mbit/s" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-4) + color: Colors.primary + } + } + + // Upload + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: Icons.arrowUp + font.family: Icons.font + font.pixelSize: 16 + color: NetworkService.speedTestPhase === "upload" ? Colors.primary : Colors.overSurfaceVariant + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: NetworkService.speedTestUp > 0 ? NetworkService.speedTestUp.toFixed(1) : "---" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(3) + font.bold: true + color: Colors.overBackground + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Mbit/s" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-4) + color: Colors.primary + } + } + + // Ping + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: Icons.clock + font.family: Icons.font + font.pixelSize: 16 + color: NetworkService.speedTestPhase === "ping" ? Colors.primary : Colors.overSurfaceVariant + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: NetworkService.speedTestPing > 0 ? NetworkService.speedTestPing.toFixed(1) : "---" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(3) + font.bold: true + color: Colors.overBackground + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "ms" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-4) + color: Colors.primary + } + } + } + + // Idle prompt + Text { + Layout.alignment: Qt.AlignHCenter + visible: !NetworkService.speedTestRunning && NetworkService.speedTestDown === 0 + text: "Tap the timer button to start" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + color: Colors.overSurfaceVariant + } + } + } + } + + // QR Code display + Item { + id: qrDisplay + anchors.top: speedTestCard.visible ? speedTestCard.bottom : dnsCard.bottom + anchors.topMargin: 4 + anchors.horizontalCenter: parent.horizontalCenter + width: root.contentWidth + height: showQr && qrImagePath !== "" ? 180 : 0 + visible: showQr && qrImagePath !== "" + clip: true + + StyledRect { + anchors.fill: parent + variant: "common" + enableShadow: false + radius: Styling.radius(0) + + ColumnLayout { + anchors.centerIn: parent + spacing: 4 + + Image { + Layout.alignment: Qt.AlignHCenter + source: qrImagePath !== "" ? "file://" + qrImagePath : "" + Layout.preferredWidth: 130 + Layout.preferredHeight: 130 + fillMode: Image.PreserveAspectFit + cache: false + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: "Scan to connect" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.overBackground + opacity: 0.7 + } + } + } + } } delegate: Item { @@ -102,7 +653,6 @@ Item { } } - // Empty state Text { anchors.centerIn: parent visible: networkList.count === 0 && !NetworkService.wifiScanning diff --git a/modules/retroshell/files/modules/widgets/dashboard/widgets/LockWeather.qml b/modules/retroshell/files/modules/widgets/dashboard/widgets/LockWeather.qml index 88ae9207..a81a8f5d 100644 --- a/modules/retroshell/files/modules/widgets/dashboard/widgets/LockWeather.qml +++ b/modules/retroshell/files/modules/widgets/dashboard/widgets/LockWeather.qml @@ -100,7 +100,7 @@ StyledRect { Text { text: WeatherService.weatherSymbol - font.pixelSize: 24; font.family: Config.theme.font + font.pixelSize: 24; font.family: Config.emojiFont color: Colors.overBackground Layout.alignment: Qt.AlignVCenter } @@ -160,6 +160,7 @@ StyledRect { Text { anchors.horizontalCenter: parent.horizontalCenter text: fItem.modelData.emoji || "" + font.family: Config.emojiFont font.pixelSize: Styling.fontSize(2) } Text { diff --git a/modules/retroshell/files/modules/widgets/dashboard/widgets/QuickControls.qml b/modules/retroshell/files/modules/widgets/dashboard/widgets/QuickControls.qml index fcf38ffb..fd633940 100644 --- a/modules/retroshell/files/modules/widgets/dashboard/widgets/QuickControls.qml +++ b/modules/retroshell/files/modules/widgets/dashboard/widgets/QuickControls.qml @@ -18,12 +18,12 @@ StyledRect { implicitHeight: columnLayout.implicitHeight + 8 radius: Styling.radius(4) - property int expandedPanel: -1 // -1: none, 0: wifi, 1: bluetooth, 2: quickshare, 3: caffeine + property int expandedPanel: -1 // -1: none, 0: wifi, 1: bluetooth, 2: quickshare, 3: caffeine, 4: typingSounds, 5: docker property bool darkMode: GlobalStates.themeMode === "dark" property var controlOrder: (Config.dashboard && Config.dashboard.controlOrder) ? Config.dashboard.controlOrder - : ["wifi", "bluetooth", "quickshare", "caffeine", "darkmode", "nightlight"] + : ["wifi", "bluetooth", "quickshare", "caffeine", "typingSounds", "darkmode", "nightlight", "docker"] function controlComponentFor(id) { switch (id) { @@ -32,7 +32,9 @@ StyledRect { case "quickshare": return quickshareComponent; case "caffeine": return caffeineComponent; case "darkmode": return darkModeComponent; + case "typingSounds": return typingSoundsComponent; case "nightlight": return nightLightComponent; + case "docker": return dockerComponent; } return undefined; } @@ -109,6 +111,8 @@ StyledRect { Layout.preferredHeight: { if (root.expandedPanel === -1) return 0; if (root.expandedPanel === 3) return 120; + if (root.expandedPanel === 4) return 240; + if (root.expandedPanel === 5) return 450; return 280; } clip: true @@ -217,11 +221,52 @@ StyledRect { Behavior on opacity { enabled: Config.animDuration > 0; NumberAnimation { duration: Config.animDuration; easing.type: Easing.OutQuart } } Behavior on x { enabled: Config.animDuration > 0; NumberAnimation { duration: Config.animDuration; easing.type: Easing.OutQuart } } } + + Loader { + id: typingSoundsLoader + anchors.fill: parent + active: root.expandedPanel === 4 + source: "../controls/TypingSoundsPanel.qml" + asynchronous: true + + opacity: root.expandedPanel === 4 ? 1 : 0 + x: root.expandedPanel === 4 ? 0 : (root.expandedPanel < 4 ? width : -width) + + onLoaded: { + if (item) { + item.maxContentWidth = width; + } + } + + Behavior on opacity { enabled: Config.animDuration > 0; NumberAnimation { duration: Config.animDuration; easing.type: Easing.OutQuart } } + Behavior on x { enabled: Config.animDuration > 0; NumberAnimation { duration: Config.animDuration; easing.type: Easing.OutQuart } } + } + + Loader { + id: dockerLoader + anchors.fill: parent + active: root.expandedPanel === 5 + source: "../controls/DockerPanel.qml" + asynchronous: true + + opacity: root.expandedPanel === 5 ? 1 : 0 + x: root.expandedPanel === 5 ? 0 : (root.expandedPanel < 5 ? width : -width) + + onLoaded: { + if (item) { + item.maxContentWidth = width; + } + } + + Behavior on opacity { enabled: Config.animDuration > 0; NumberAnimation { duration: Config.animDuration; easing.type: Easing.OutQuart } } + Behavior on x { enabled: Config.animDuration > 0; NumberAnimation { duration: Config.animDuration; easing.type: Easing.OutQuart } } + } + } } } } - + function togglePanel(index) { if (root.expandedPanel === index) { root.expandedPanel = -1; @@ -353,6 +398,20 @@ StyledRect { } } + Component { + id: typingSoundsComponent + ControlButton { + Layout.preferredWidth: 48 + Layout.preferredHeight: 48 + iconName: Icons.keyboard + isActive: TypingSoundsService.enabled || root.expandedPanel === 4 + tooltipText: TypingSoundsService.enabled ? "Typing Sounds: On" : "Typing Sounds: Off" + onClicked: TypingSoundsService.saveSetting("enabled", !TypingSoundsService.enabled) + onRightClicked: root.togglePanel(4) + onLongPressed: root.togglePanel(4) + } + } + Component { id: darkModeComponent ControlButton { @@ -379,4 +438,18 @@ StyledRect { } } + Component { + id: dockerComponent + ControlButton { + Layout.preferredWidth: 48 + Layout.preferredHeight: 48 + iconName: Icons.docker + isActive: root.expandedPanel === 5 + tooltipText: "Docker" + onClicked: root.togglePanel(5) + onRightClicked: root.togglePanel(5) + onLongPressed: root.togglePanel(5) + } + } + } diff --git a/modules/retroshell/files/modules/widgets/desktop/BatteryRingWidget.qml b/modules/retroshell/files/modules/widgets/desktop/BatteryRingWidget.qml new file mode 100644 index 00000000..c73e5e50 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/BatteryRingWidget.qml @@ -0,0 +1,80 @@ +import QtQuick +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config +import qs.modules.widgets.desktop + +// Desktop battery rings widget — shows the host battery + connected Bluetooth +// devices as Apple-style rings. 2x2 (single) or 2x4 (up to 4 devices). +WidgetHost { + id: root + + implicitWidth: 160 + implicitHeight: 160 + contentMargins: 0 + cardBorder: false + + property bool wide: false + + readonly property var hiddenDevices: (root.widgetData && root.widgetData.hiddenDevices) + ? root.widgetData.hiddenDevices : [] + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + function typeOf(icon) { + var ic = String(icon || ""); + if (ic.includes("audio-headset") || ic.includes("headphone") || ic.includes("headset")) return "audio"; + if (ic.includes("input-keyboard")) return "keyboard"; + if (ic.includes("input-mouse")) return "mouse"; + if (ic.includes("input-gaming") || ic.includes("gamepad")) return "gamepad"; + if (ic.includes("phone")) return "phone"; + if (ic.includes("watch")) return "watch"; + if (ic.includes("audio-speakers") || ic.includes("speaker")) return "speaker"; + if (ic.includes("printer")) return "printer"; + if (ic.includes("camera")) return "camera"; + return "other"; + } + + function buildDevices() { + var out = []; + if (Battery.available && root.hiddenDevices.indexOf("host") === -1) { + out.push({ + id: "host", + name: "Laptop", + type: "laptop", + battery: Battery.percentage, + isCharging: Battery.isCharging, + isConnected: true + }); + } + var list = BluetoothService.friendlyDeviceList; + var max = root.wide ? 4 : 1; + for (var i = 0; i < list.length && out.length < max; i++) { + var d = list[i]; + if (d && d.connected && d.battery >= 0 + && root.hiddenDevices.indexOf(d.address) === -1) { + out.push({ + id: d.address, + name: d.name, + type: content.typeOf(d.icon), + battery: d.battery, + isCharging: false, + isConnected: true + }); + } + } + return out; + } + + BatteryRingCard { + anchors.fill: parent + wide: root.wide + devices: content.buildDevices() + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/BatteryWidget.qml b/modules/retroshell/files/modules/widgets/desktop/BatteryWidget.qml new file mode 100644 index 00000000..1b86317f --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/BatteryWidget.qml @@ -0,0 +1,257 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config +import qs.modules.widgets.desktop + +// Desktop battery widget — 2x2 (160x160) card. +// Multiple faces switchable via a gear button: gauge, juice, bars. +WidgetHost { + id: root + + implicitWidth: 160 + implicitHeight: 160 + contentMargins: 0 + + property string face: "gauge" + signal faceSelected(string face) + + onWidgetDataChanged: { + root.face = (root.widgetData && root.widgetData.face) ? root.widgetData.face : "gauge"; + } + + function setFace(f) { + root.face = f; + root.faceSelected(f); + } + + function openFaceMenu() { + var faces = ["gauge", "juice", "bars"]; + var labels = ["Gauge", "Juice", "Bars"]; + var items = []; + for (var i = 0; i < faces.length; i++) { + (function (f, l) { + items.push({ + text: (root.face === f ? "✓ " : "") + l, + isSeparator: false, + onTriggered: function () { root.setFace(f); } + }); + })(faces[i], labels[i]); + } + Visibilities.contextMenu.openCustomMenu(items, 160, 32, "battery"); + } + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + function batteryColor() { + if (!Battery.available) + return Colors.overBackground; + var pct = Battery.percentage; + if (pct <= 15) + return Colors.red; + if (pct >= 85) + return Colors.green; + var ratio = (pct - 15) / (85 - 15); + return Qt.rgba( + Colors.red.r + (Colors.green.r - Colors.red.r) * ratio, + Colors.red.g + (Colors.green.g - Colors.red.g) * ratio, + Colors.red.b + (Colors.green.b - Colors.red.b) * ratio, + 1 + ); + } + + // Black/white foreground that contrasts with the liquid color. + function contrastColor() { + var c = content.batteryColor(); + var lum = 0.299 * c.r + 0.587 * c.g + 0.114 * c.b; + return lum > 0.5 ? Qt.rgba(0, 0, 0, 0.75) : Qt.rgba(1, 1, 1, 0.92); + } + + // Whether the liquid has risen past the centered text block. + property bool textOnLiquid: root.face === "juice" && content.height > 0 && + (content.height * (100 - Battery.percentage) / 100) <= content.height / 2 + + // Base card background (clipped to the card's rounded corners) + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: Colors.surfaceContainer } + GradientStop { position: 1.0; color: Colors.surfaceContainerLow } + } + } + + // ── Juice face: liquid fills the whole widget background ── + Rectangle { + visible: root.face === "juice" + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: parent.height * (Battery.percentage / 100) + color: content.batteryColor() + opacity: 0.7 + + Behavior on height { + enabled: Config.animDuration > 0 + NumberAnimation { + duration: 400 + easing.type: Easing.OutCubic + } + } + } + + WavyLine { + visible: root.face === "juice" + anchors.left: parent.left + anchors.right: parent.right + y: parent.height * (100 - Battery.percentage) / 100 - height / 2 + height: 16 + color: content.batteryColor() + lineWidth: 3 + amplitudeMultiplier: 0.6 + frequency: 3 + running: true + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 6 + + // ── Face: gauge (battery body with fill) ── + Item { + visible: root.face === "gauge" + Layout.alignment: Qt.AlignHCenter + width: 84 + height: 44 + + Rectangle { + anchors.fill: parent + radius: 6 + color: Colors.surfaceContainerHighest + border.color: Colors.outlineVariant + border.width: 2 + } + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: 3 + width: (parent.width - 6) * (Battery.percentage / 100) + radius: 3 + color: Battery.percentage > 20 ? Colors.primary : Colors.error + } + + Rectangle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: -5 + width: 5 + height: 16 + radius: 2 + color: Colors.outlineVariant + } + + Text { + anchors.centerIn: parent + text: Battery.isCharging ? Icons.lightning : "" + font.family: Icons.font + font.pixelSize: 24 + color: Battery.isCharging ? Colors.overSurface : "transparent" + visible: Battery.isCharging + } + } + + // ── Face: bars (segmented line bar across the width) ── + Item { + id: barsFace + visible: root.face === "bars" + Layout.alignment: Qt.AlignHCenter + width: 128 + height: 18 + + readonly property int barSegments: 26 + readonly property int filledSegments: Math.round(Battery.percentage / 100 * barSegments) + + Rectangle { + anchors.fill: parent + radius: 9 + color: Colors.surfaceContainerHighest + } + + Row { + anchors.centerIn: parent + spacing: 2 + + Repeater { + model: barsFace.barSegments + delegate: Rectangle { + width: 3 + height: 18 + radius: 1.5 + color: index < barsFace.filledSegments + ? content.batteryColor() + : Colors.surfaceContainerLow + } + } + } + } + + // Percentage + Text { + Layout.alignment: Qt.AlignHCenter + text: Battery.available ? Math.round(Battery.percentage) + "%" : "--%" + color: content.textOnLiquid ? content.contrastColor() : Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 28 + font.weight: Font.Bold + } + + // State + Text { + Layout.alignment: Qt.AlignHCenter + text: Battery.isCharging ? "Charging" + : Battery.isPluggedIn ? "Plugged in" + : Battery.available ? "On battery" + : "No battery" + color: content.textOnLiquid ? content.contrastColor() : Colors.outline + font.family: Config.theme.font + font.pixelSize: 12 + } + } + + // Settings gear (edit mode only) + StyledRect { + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: 6 + width: 22 + height: 22 + radius: 11 + variant: gearHover.hovered ? "focus" : "common" + z: 20 + visible: Config.desktop.editMode + + Text { + anchors.centerIn: parent + text: Icons.gear + font.family: Icons.font + font.pixelSize: 12 + color: Styling.srItem("common") + } + + MouseArea { + id: gearHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.openFaceMenu() + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/BluetoothWidget.qml b/modules/retroshell/files/modules/widgets/desktop/BluetoothWidget.qml new file mode 100644 index 00000000..0666450d --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/BluetoothWidget.qml @@ -0,0 +1,108 @@ +import QtQuick +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config +import qs.modules.widgets.desktop + +// Desktop bluetooth widget — 2x2 (160x160) bluetooth & quick pair. +WidgetHost { + id: root + + implicitWidth: 160 + implicitHeight: 160 + contentMargins: 0 + + readonly property string devicePriority: (root.widgetData && root.widgetData.devicePriority) + ? String(root.widgetData.devicePriority) : "automatic" + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + function typeOf(icon) { + var ic = String(icon || ""); + if (ic.includes("audio-headset") || ic.includes("headphone") || ic.includes("headset")) return "audio"; + if (ic.includes("input-keyboard")) return "keyboard"; + if (ic.includes("input-mouse")) return "mouse"; + if (ic.includes("input-gaming") || ic.includes("gamepad")) return "gamepad"; + if (ic.includes("phone")) return "phone"; + if (ic.includes("watch")) return "watch"; + if (ic.includes("audio-speakers") || ic.includes("speaker")) return "speaker"; + if (ic.includes("printer")) return "printer"; + if (ic.includes("camera")) return "camera"; + return "other"; + } + + function rank(type) { + if (root.devicePriority === "automatic") return 0; + return type === root.devicePriority ? -1 : 1; + } + + function buildConnected() { + var out = []; + var list = BluetoothService.friendlyDeviceList; + for (var i = 0; i < list.length; i++) { + var d = list[i]; + if (d && d.connected) { + out.push({ mac: d.address, name: d.name, type: content.typeOf(d.icon), battery: d.battery }); + } + } + out.sort(function (a, b) { return content.rank(a.type) - content.rank(b.type); }); + return out; + } + + function buildPaired() { + var out = []; + var list = BluetoothService.friendlyDeviceList; + for (var i = 0; i < list.length; i++) { + var d = list[i]; + if (d && d.paired && !d.connected) { + out.push({ mac: d.address, name: d.name, type: content.typeOf(d.icon) }); + } + } + out.sort(function (a, b) { return content.rank(a.type) - content.rank(b.type); }); + return out; + } + + function buildDiscovered() { + var out = []; + var list = BluetoothService.friendlyDeviceList; + for (var i = 0; i < list.length; i++) { + var d = list[i]; + if (d && !d.paired && !d.connected && d.name && d.name !== "Unknown") { + out.push({ mac: d.address, name: d.name, type: content.typeOf(d.icon) }); + } + } + return out; + } + + // The service's own device poll only runs while the dashboard is + // open; the desktop needs its own refresh so discovered/connected + // devices stay live. + Timer { + interval: 4000 + running: BluetoothService.enabled + repeat: true + onTriggered: BluetoothService.updateDevices() + } + + Component.onCompleted: BluetoothService.updateDevices() + + BluetoothCard { + anchors.fill: parent + isEnabled: BluetoothService.enabled + isScanning: BluetoothService.discovering + connectedDevices: content.buildConnected() + pairedDevices: content.buildPaired() + discoveredDevices: content.buildDiscovered() + priority: root.devicePriority + onToggleRequested: BluetoothService.toggle() + onScanRequested: BluetoothService.discovering ? BluetoothService.stopDiscovery() : BluetoothService.startDiscovery() + onDisconnectRequested: function (mac) { BluetoothService.disconnectDevice(mac); } + onPairRequested: function (mac) { BluetoothService.pairDevice(mac); } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/Calendar.qml b/modules/retroshell/files/modules/widgets/desktop/Calendar.qml new file mode 100644 index 00000000..132f203e --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/Calendar.qml @@ -0,0 +1,214 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.modules.components +import qs.config +import "layout.js" as CalendarLayout + +Item { + id: root + + property int monthShift: 0 + property date currentDate: new Date() + property var viewingDate: CalendarLayout.getDateInXMonthsTime(monthShift, currentDate) + property var calendarLayoutData: CalendarLayout.getCalendarLayout(viewingDate, monthShift === 0) + property var calendarLayout: calendarLayoutData.calendar + property int currentWeekRow: calendarLayoutData.currentWeekRow + property int currentDayOfWeek: { + if (monthShift !== 0) + return -1; + return (currentDate.getDay() + 6) % 7; + } + + // Adaptive sizing (dashboard look at any widget size) + readonly property real headerH: Math.max(18, Math.min(24, height * 0.08)) + readonly property real cellH: Math.max(14, (height - root.headerH - 26) / 7) + readonly property real cellW: (width - 24) / 7 + readonly property real cellSize: Math.max(12, Math.min(root.cellW, root.cellH)) + readonly property real dayFont: Math.max(7, root.cellSize * 0.4) + readonly property real headerFont: Math.max(8, Math.min(width * 0.05, root.headerH * 0.6)) + + Timer { + interval: 60000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.currentDate = new Date() + } + + // Helper function to get localized day abbreviation + function getDayAbbrev(dayIndex) { + var d = new Date(2024, 0, 1 + dayIndex); + var dayName = d.toLocaleDateString(Qt.locale(), "ddd"); + return (dayName.charAt(0).toUpperCase() + dayName.slice(1, 2)).replace(".", ""); + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + StyledRect { + id: calendarPane + variant: "pane" + Layout.fillWidth: true + Layout.fillHeight: true + radius: Styling.radius(4) + clip: true + + ColumnLayout { + anchors.fill: parent + anchors.margins: 4 + spacing: 4 + + // Header: month title + prev/next buttons + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: root.headerH + Layout.maximumHeight: root.headerH + spacing: 4 + + StyledRect { + id: titleRect + variant: "internalbg" + Layout.fillWidth: true + Layout.fillHeight: true + radius: Styling.radius(0) + + Text { + anchors.centerIn: parent + text: viewingDate.toLocaleDateString(Qt.locale(), "MMMM yyyy") + font.family: Config.defaultFont + font.pixelSize: root.headerFont + font.weight: Font.Bold + color: titleRect.item + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + + StyledRect { + id: leftButton + variant: leftMouseArea.pressed ? "primary" : (leftMouseArea.containsMouse ? "focus" : "internalbg") + Layout.preferredWidth: Math.max(16, root.headerH - 6) + Layout.fillHeight: true + radius: Styling.radius(0) + + readonly property color buttonItem: leftMouseArea.pressed ? itemColor : Styling.srItem("overprimary") + + Text { + anchors.centerIn: parent + text: Icons.caretLeft + font.family: Icons.font + font.pixelSize: root.headerFont + color: leftButton.buttonItem + } + + MouseArea { + id: leftMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: monthShift-- + cursorShape: Qt.PointingHandCursor + } + } + + StyledRect { + id: rightButton + variant: rightMouseArea.pressed ? "primary" : (rightMouseArea.containsMouse ? "focus" : "internalbg") + Layout.preferredWidth: Math.max(16, root.headerH - 6) + Layout.fillHeight: true + radius: Styling.radius(0) + + readonly property color buttonItem: rightMouseArea.pressed ? itemColor : Styling.srItem("overprimary") + + Text { + anchors.centerIn: parent + text: Icons.caretRight + font.family: Icons.font + font.pixelSize: root.headerFont + color: rightButton.buttonItem + } + + MouseArea { + id: rightMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: monthShift++ + cursorShape: Qt.PointingHandCursor + } + } + } + + // Day grid + StyledRect { + variant: "internalbg" + Layout.fillWidth: true + Layout.fillHeight: true + radius: Styling.radius(0) + + ColumnLayout { + anchors.fill: parent + anchors.margins: 6 + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + + Repeater { + model: 7 + delegate: CalendarDayButton { + required property int index + day: root.getDayAbbrev(index) + isToday: 0 + bold: true + isCurrentDayOfWeek: index === root.currentDayOfWeek + cellSize: root.cellSize + fontPixel: root.dayFont + } + } + } + + Separator { + Layout.fillWidth: true + Layout.leftMargin: 8 + Layout.rightMargin: 8 + Layout.preferredHeight: 2 + vert: false + } + + Repeater { + model: 6 + delegate: StyledRect { + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + Layout.preferredHeight: root.cellH + variant: (rowIndex === root.currentWeekRow) ? "pane" : "transparent" + radius: Styling.radius(-4) + + required property int index + property int rowIndex: index + + RowLayout { + anchors.fill: parent + spacing: 0 + + Repeater { + model: 7 + delegate: CalendarDayButton { + required property int index + day: calendarLayout[rowIndex][index].day + isToday: calendarLayout[rowIndex][index].today + cellSize: root.cellSize + fontPixel: root.dayFont + } + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/CalendarDayButton.qml b/modules/retroshell/files/modules/widgets/desktop/CalendarDayButton.qml new file mode 100644 index 00000000..dff14b70 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/CalendarDayButton.qml @@ -0,0 +1,61 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.modules.components +import qs.config + +Rectangle { + id: button + + required property string day + required property int isToday + property bool bold: false + property bool isCurrentDayOfWeek: false + + // Adaptive sizing so the calendar can scale to any widget size. + property real cellSize: 28 + property real fontPixel: Styling.fontSize(-2) + + Layout.fillWidth: true + Layout.fillHeight: false + Layout.preferredWidth: root.cellSize + Layout.preferredHeight: root.cellSize + + color: "transparent" + radius: Styling.radius(-2) + + StyledRect { + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) + height: width + variant: (isToday === 1) ? "primary" : "transparent" + radius: parent.radius + + Text { + anchors.fill: parent + text: day + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.weight: Font.Bold + font.pixelSize: button.fontPixel + font.family: Config.defaultFont + color: { + if (isToday === 1) + return Styling.srItem("primary"); + if (bold) { + return isCurrentDayOfWeek ? Colors.overBackground : Colors.outline; + } + if (isToday === 0) + return Colors.overSurface; + return Colors.surfaceBright; + } + + Behavior on color { + enabled: Config.animDuration > 0 + ColorAnimation { + duration: 150 + } + } + } + } +} diff --git a/modules/retroshell/files/modules/widgets/desktop/CalendarGrid.qml b/modules/retroshell/files/modules/widgets/desktop/CalendarGrid.qml new file mode 100644 index 00000000..2d4df056 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/CalendarGrid.qml @@ -0,0 +1,183 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.modules.components +import qs.config +import "layout.js" as CalendarLayout + +// Adaptive month calendar that fills its parent and scales the day cells to +// whatever size it is given (used by the desktop calendar widgets). +Item { + id: root + + property int monthShift: 0 + property date currentDate: new Date() + property var viewingDate: CalendarLayout.getDateInXMonthsTime(monthShift, currentDate) + property var layoutData: CalendarLayout.getCalendarLayout(viewingDate, monthShift === 0) + property var calendar: layoutData.calendar + property int currentWeekRow: layoutData.currentWeekRow + property int currentDayOfWeek: (monthShift !== 0) ? -1 : (currentDate.getDay() + 6) % 7 + + readonly property real headerH: Math.max(20, Math.min(32, height * 0.14)) + readonly property real cellW: width / 7 + readonly property real cellH: Math.max(11, (height - headerH - 6) / 7) + readonly property real dayFontSize: Math.max(7, Math.min(cellW, cellH) * 0.4) + readonly property real headerFontSize: Math.max(9, Math.min(width * 0.06, headerH * 0.5)) + + Timer { + interval: 60000; running: true; repeat: true; triggeredOnStart: true + onTriggered: root.currentDate = new Date() + } + + function getDayAbbrev(dayIndex) { + var d = new Date(2024, 0, 1 + dayIndex); + var dayName = d.toLocaleDateString(Qt.locale(), "ddd"); + return (dayName.charAt(0).toUpperCase() + dayName.slice(1, 2)).replace(".", ""); + } + + ColumnLayout { + anchors.fill: parent + spacing: 3 + + // Header: prev / month title / next + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: root.headerH + spacing: 4 + + StyledRect { + Layout.preferredWidth: root.headerH + Layout.fillHeight: true + radius: Styling.radius(0) + variant: prevMouse.pressed ? "primary" : (prevMouse.containsMouse ? "focus" : "internalbg") + Text { + anchors.centerIn: parent + text: Icons.caretLeft + font.family: Icons.font + font.pixelSize: root.dayFontSize + color: Styling.srItem("overprimary") + } + MouseArea { + id: prevMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.monthShift-- + } + } + + StyledRect { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Styling.radius(0) + variant: "internalbg" + Text { + anchors.centerIn: parent + text: root.viewingDate.toLocaleDateString(Qt.locale(), "MMMM yyyy") + font.family: Config.theme.font + font.pixelSize: root.headerFontSize + font.weight: Font.Bold + color: Styling.srItem("overprimary") + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + + StyledRect { + Layout.preferredWidth: root.headerH + Layout.fillHeight: true + radius: Styling.radius(0) + variant: nextMouse.pressed ? "primary" : (nextMouse.containsMouse ? "focus" : "internalbg") + Text { + anchors.centerIn: parent + text: Icons.caretRight + font.family: Icons.font + font.pixelSize: root.dayFontSize + color: Styling.srItem("overprimary") + } + MouseArea { + id: nextMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.monthShift++ + } + } + } + + // Day-of-week header + Row { + Layout.fillWidth: true + Layout.preferredHeight: root.cellH + Repeater { + model: 7 + delegate: Item { + required property int index + width: root.cellW + height: root.cellH + Text { + anchors.centerIn: parent + text: root.getDayAbbrev(index) + font.family: Config.theme.font + font.pixelSize: root.dayFontSize + font.weight: Font.Bold + color: (index === root.currentDayOfWeek) ? Styling.srItem("overprimary") : Colors.outline + } + } + } + } + + // 6 day rows + Repeater { + model: 6 + delegate: Item { + id: rowItem + required property int index + width: root.width + height: root.cellH + + Row { + anchors.fill: parent + Repeater { + model: 7 + delegate: Item { + required property int index + readonly property var info: root.calendar[rowItem.index][index] + readonly property bool isToday: info.today === 1 + readonly property bool isDim: info.today === -1 + readonly property bool isWeekRow: rowItem.index === root.currentWeekRow + + width: root.cellW + height: root.cellH + + // current-week-row background + Rectangle { + anchors.fill: parent + anchors.margins: 1 + radius: Math.max(2, root.cellW * 0.18) + color: isWeekRow && !isDim ? Colors.surfaceContainerLow : "transparent" + } + + // today highlight circle + Rectangle { + anchors.centerIn: parent + width: Math.min(root.cellW, root.cellH) - 2 + height: width + radius: width / 2 + color: isToday ? Colors.primary : "transparent" + } + + Text { + anchors.centerIn: parent + text: info.day + font.family: Config.theme.font + font.pixelSize: root.dayFontSize + font.weight: (isToday || isWeekRow) ? Font.Bold : Font.Normal + opacity: isDim ? 0.35 : 1.0 + color: isToday ? Colors.overPrimary : Colors.overSurface + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/CalendarMini.qml b/modules/retroshell/files/modules/widgets/desktop/CalendarMini.qml new file mode 100644 index 00000000..5522d5a8 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/CalendarMini.qml @@ -0,0 +1,125 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.modules.components +import qs.config +import "layout.js" as CalendarLayout + +// Month calendar styled exactly like the Clock.qml mini calendar: a month +// header in the outline color, day-of-week abbreviations, and day numbers in +// circles (today = primary fill). Scales to fill any size it is given. +Item { + id: root + + property int monthShift: 0 + property date currentDate: new Date() + property var viewingDate: CalendarLayout.getDateInXMonthsTime(monthShift, currentDate) + property var layoutData: CalendarLayout.getCalendarLayout(viewingDate, monthShift === 0) + property var calendar: layoutData.calendar + property int currentDayOfWeek: (monthShift !== 0) ? -1 : (currentDate.getDay() + 6) % 7 + + readonly property real headerH: Math.max(16, Math.min(26, height * 0.12)) + readonly property real cellW: width / 7 + readonly property real cellH: Math.max(11, (height - headerH - 4) / 7) + readonly property real circle: Math.min(cellW, cellH) - 2 + readonly property real dayFont: Math.max(7, circle * 0.45) + readonly property real headerFont: Math.max(9, Math.min(width * 0.06, headerH * 0.6)) + + Timer { + interval: 60000; running: true; repeat: true; triggeredOnStart: true + onTriggered: root.currentDate = new Date() + } + + function getDayAbbrev(dayIndex) { + var d = new Date(2024, 0, 1 + dayIndex); + var dayName = d.toLocaleDateString(Qt.locale(), "ddd"); + return (dayName.charAt(0).toUpperCase() + dayName.slice(1, 2)).replace(".", ""); + } + + ColumnLayout { + anchors.fill: parent + spacing: 2 + + // Month header (Clock.qml style) + Text { + Layout.fillWidth: true + Layout.preferredHeight: root.headerH + text: root.viewingDate.toLocaleDateString(Qt.locale(), "MMMM yyyy") + color: Colors.outline + font.family: Config.theme.font + font.pixelSize: root.headerFont + font.weight: Font.Medium + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + + // Day-of-week abbreviations + Row { + Layout.fillWidth: true + Layout.preferredHeight: root.cellH + Repeater { + model: 7 + delegate: Item { + required property int index + width: root.cellW + height: root.cellH + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: root.getDayAbbrev(index) + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: root.dayFont + font.weight: Font.Medium + } + } + } + } + + // 6 day rows + Repeater { + model: 6 + delegate: Item { + id: rowItem + required property int index + width: root.width + height: root.cellH + + Row { + anchors.fill: parent + Repeater { + model: 7 + delegate: Item { + required property int index + readonly property var info: root.calendar[rowItem.index][index] + readonly property bool isToday: info.today === 1 + readonly property bool isDim: info.today === -1 + + width: root.cellW + height: root.cellH + + // Day circle (today = primary fill) + Rectangle { + anchors.centerIn: parent + width: root.circle + height: root.circle + radius: width / 2 + color: isToday ? Styling.srItem("overprimary") : "transparent" + } + + Text { + anchors.centerIn: parent + text: info.day + color: isToday ? Colors.background : Colors.overBackground + font.family: Config.theme.font + font.pixelSize: root.dayFont + font.weight: isToday ? Font.Bold : Font.Normal + opacity: isDim ? 0.35 : 1.0 + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/CalendarWidget.qml b/modules/retroshell/files/modules/widgets/desktop/CalendarWidget.qml new file mode 100644 index 00000000..48ad914d --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/CalendarWidget.qml @@ -0,0 +1,16 @@ +import QtQuick +import qs.modules.widgets.desktop + +// Desktop calendar widget — adaptive dashboard-style month calendar with +// prev/next buttons. Sizes to whatever the desktop layer assigns it +// (4x4 = 320x320, 2x4 = 320x160). +WidgetHost { + id: root + + implicitWidth: 320 + implicitHeight: 320 + + contentComponent: Component { + Calendar {} + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/CalendarWidget2x2.qml b/modules/retroshell/files/modules/widgets/desktop/CalendarWidget2x2.qml new file mode 100644 index 00000000..0a7f5965 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/CalendarWidget2x2.qml @@ -0,0 +1,14 @@ +import QtQuick +import qs.modules.widgets.desktop + +// Desktop calendar widget — small square (2x2) card, 160x160. +WidgetHost { + id: root + + implicitWidth: 160 + implicitHeight: 160 + + contentComponent: Component { + CalendarGrid {} + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/CalendarWidget2x4.qml b/modules/retroshell/files/modules/widgets/desktop/CalendarWidget2x4.qml new file mode 100644 index 00000000..294a7386 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/CalendarWidget2x4.qml @@ -0,0 +1,14 @@ +import QtQuick +import qs.modules.widgets.desktop + +// Desktop calendar widget — wide (2x4) card, 320x160. +WidgetHost { + id: root + + implicitWidth: 320 + implicitHeight: 160 + + contentComponent: Component { + CalendarGrid {} + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/CalendarWidget4x4.qml b/modules/retroshell/files/modules/widgets/desktop/CalendarWidget4x4.qml new file mode 100644 index 00000000..425cce40 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/CalendarWidget4x4.qml @@ -0,0 +1,14 @@ +import QtQuick +import qs.modules.widgets.desktop + +// Desktop calendar widget — large square (4x4) card, 320x320. +WidgetHost { + id: root + + implicitWidth: 320 + implicitHeight: 320 + + contentComponent: Component { + CalendarGrid {} + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/ClockAnalogWidget.qml b/modules/retroshell/files/modules/widgets/desktop/ClockAnalogWidget.qml new file mode 100644 index 00000000..055cebd4 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/ClockAnalogWidget.qml @@ -0,0 +1,456 @@ +import QtQuick +import qs.modules.theme +import qs.modules.services +import qs.modules.components +import qs.config +import qs.modules.widgets.desktop + +// Desktop analog clock — 2x2 (160x160) with multiple faces switchable via a +// gear button. Faces: classic, numeric, roman, dots, sector, minimal, skeleton, +// rings, bold, tall. Hands are drawn as tapered shapes with a counterweight. +WidgetHost { + id: root + + implicitWidth: 160 + implicitHeight: 160 + contentMargins: 0 + + property string face: "classic" + property string handStyle: "taper" + signal faceSelected(string face) + signal handStyleSelected(string handStyle) + + onWidgetDataChanged: { + root.face = (root.widgetData && root.widgetData.face) ? root.widgetData.face : "classic"; + root.handStyle = (root.widgetData && root.widgetData.handStyle) ? root.widgetData.handStyle : "taper"; + } + + function setFace(f) { + root.face = f; + root.faceSelected(f); + } + + function setHandStyle(h) { + root.handStyle = h; + root.handStyleSelected(h); + clockCanvas.requestPaint(); + } + + function openFaceMenu() { + var faces = ["classic", "numeric", "roman", "dots", "sector", "minimal", "skeleton", "rings", "bold", "tall", "track", "diamond", "bauhaus", "railway"]; + var labels = ["Classic", "Numeric", "Roman", "Dots", "Sector", "Minimal", "Skeleton", "Rings", "Bold", "Tall", "Track", "Diamond", "Bauhaus", "Railway"]; + var hands = ["taper", "classic", "thin"]; + var handLabels = ["Tapered", "Classic Lines", "Thin"]; + var items = []; + for (var i = 0; i < faces.length; i++) { + (function (f, l) { + items.push({ + text: (root.face === f ? "✓ " : "") + l, + isSeparator: false, + onTriggered: function () { root.setFace(f); } + }); + })(faces[i], labels[i]); + } + items.push({ isSeparator: true, text: "Hands" }); + for (var j = 0; j < hands.length; j++) { + (function (h, l) { + items.push({ + text: (root.handStyle === h ? "✓ " : "") + l, + isSeparator: false, + onTriggered: function () { root.setHandStyle(h); } + }); + })(hands[j], handLabels[j]); + } + Visibilities.contextMenu.openCustomMenu(items, 200, 32, "clock"); + } + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + property int h: 0 + property int m: 0 + property int s: 0 + + Timer { + interval: 1000; running: true; repeat: true; triggeredOnStart: true + onTriggered: { + var now = new Date(); + content.h = now.getHours() % 12; + content.m = now.getMinutes(); + content.s = now.getSeconds(); + clockCanvas.requestPaint(); + } + } + + Connections { + target: root + function onFaceChanged() { + clockCanvas.requestPaint(); + } + function onWidgetDataChanged() { + clockCanvas.requestPaint(); + } + } + + Canvas { + id: clockCanvas + anchors.fill: parent + antialiasing: true + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + Component.onCompleted: requestPaint() + + onPaint: { + var ctx = getContext("2d"); + ctx.reset(); + var w = width, h = height; + var cx = w / 2, cy = h / 2; + var R = Math.min(w, h) / 2 - 6; + + // Face + ctx.beginPath(); + ctx.arc(cx, cy, R, 0, Math.PI * 2); + ctx.fillStyle = Colors.surfaceContainerLow; + ctx.fill(); + ctx.lineWidth = 1; + ctx.strokeStyle = Colors.surfaceVariant; + ctx.stroke(); + + var f = root.face; + if (f === "classic") content.drawTicks(ctx, cx, cy, R); + else if (f === "numeric") content.drawTextMarkers(ctx, cx, cy, R, false); + else if (f === "roman") content.drawTextMarkers(ctx, cx, cy, R, true); + else if (f === "dots") content.drawDots(ctx, cx, cy, R); + else if (f === "sector") content.drawSector(ctx, cx, cy, R); + else if (f === "rings") content.drawRings(ctx, cx, cy, R); + else if (f === "bold") content.drawBold(ctx, cx, cy, R); + else if (f === "tall") content.drawTall(ctx, cx, cy, R); + else if (f === "track") content.drawTrack(ctx, cx, cy, R); + else if (f === "diamond") content.drawDiamond(ctx, cx, cy, R); + else if (f === "bauhaus") content.drawBauhaus(ctx, cx, cy, R); + else if (f === "railway") content.drawRailway(ctx, cx, cy, R); + // minimal / skeleton = no markers + + content.drawHands(ctx, cx, cy, R); + } + } + + // Tapered hand drawn pointing up (12); rotate by the angle to place it. + function drawTaperedHand(ctx, cx, cy, angleRad, length, baseWidth, tail, color) { + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(angleRad); + ctx.beginPath(); + ctx.moveTo(-baseWidth / 2, tail); + ctx.lineTo(-baseWidth * 0.3, 0); + ctx.lineTo(0, -length); + ctx.lineTo(baseWidth * 0.3, 0); + ctx.lineTo(baseWidth / 2, tail); + ctx.closePath(); + ctx.fillStyle = color; + ctx.fill(); + ctx.restore(); + } + + function drawHands(ctx, cx, cy, R) { + var hs = root.handStyle; + if (hs === "classic") content.drawHandsClassic(ctx, cx, cy, R); + else if (hs === "thin") content.drawHandsThin(ctx, cx, cy, R); + else content.drawHandsTaper(ctx, cx, cy, R); + } + + // Previous style: simple round line strokes from the center. + function drawHandsClassic(ctx, cx, cy, R) { + ctx.lineCap = "round"; + var ha = (content.h + content.m / 60) * 30 * Math.PI / 180; + ctx.strokeStyle = Colors.overBackground; + ctx.lineWidth = 5; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(cx + R * 0.5 * Math.sin(ha), cy - R * 0.5 * Math.cos(ha)); + ctx.stroke(); + + var ma = (content.m + content.s / 60) * 6 * Math.PI / 180; + ctx.strokeStyle = Colors.overBackground; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(cx + R * 0.72 * Math.sin(ma), cy - R * 0.72 * Math.cos(ma)); + ctx.stroke(); + + var sa = content.s * 6 * Math.PI / 180; + ctx.strokeStyle = Colors.primary; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(cx + R * 0.8 * Math.sin(sa), cy - R * 0.8 * Math.cos(sa)); + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(cx, cy, 4, 0, Math.PI * 2); + ctx.fillStyle = Colors.primary; + ctx.fill(); + } + + // Thin elegant lines. + function drawHandsThin(ctx, cx, cy, R) { + ctx.lineCap = "round"; + var ha = (content.h + content.m / 60) * 30 * Math.PI / 180; + ctx.strokeStyle = Colors.overBackground; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(cx + R * 0.52 * Math.sin(ha), cy - R * 0.52 * Math.cos(ha)); + ctx.stroke(); + + var ma = (content.m + content.s / 60) * 6 * Math.PI / 180; + ctx.strokeStyle = Colors.overBackground; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(cx + R * 0.74 * Math.sin(ma), cy - R * 0.74 * Math.cos(ma)); + ctx.stroke(); + + var sa = content.s * 6 * Math.PI / 180; + ctx.strokeStyle = Colors.primary; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(cx + R * 0.82 * Math.sin(sa), cy - R * 0.82 * Math.cos(sa)); + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(cx, cy, 3, 0, Math.PI * 2); + ctx.fillStyle = Colors.primary; + ctx.fill(); + } + + // Current style: tapered shapes with a counterweight tail. + function drawHandsTaper(ctx, cx, cy, R) { + var skeleton = root.face === "skeleton"; + var ha = (content.h + content.m / 60) * 30 * Math.PI / 180; + content.drawTaperedHand(ctx, cx, cy, ha, R * (skeleton ? 0.58 : 0.5), skeleton ? 3 : 5, R * 0.06, Colors.overBackground); + + var ma = (content.m + content.s / 60) * 6 * Math.PI / 180; + content.drawTaperedHand(ctx, cx, cy, ma, R * 0.76, skeleton ? 2 : 3.4, R * 0.11, Colors.overBackground); + + var sa = content.s * 6 * Math.PI / 180; + content.drawTaperedHand(ctx, cx, cy, sa, R * 0.84, skeleton ? 1.2 : 1.6, R * 0.17, Colors.primary); + + // Cap: primary ring + inner dot + ctx.beginPath(); + ctx.arc(cx, cy, skeleton ? 3 : 4.5, 0, Math.PI * 2); + ctx.fillStyle = Colors.primary; + ctx.fill(); + ctx.beginPath(); + ctx.arc(cx, cy, skeleton ? 1.2 : 1.8, 0, Math.PI * 2); + ctx.fillStyle = Colors.surfaceContainerLow; + ctx.fill(); + } + + function drawTicks(ctx, cx, cy, R) { + for (var i = 0; i < 12; i++) { + var a = i * 30 * Math.PI / 180; + var long = (i % 3 === 0); + var r1 = R - (long ? 4 : 2), r2 = R - (long ? 13 : 9); + ctx.beginPath(); + ctx.moveTo(cx + r1 * Math.sin(a), cy - r1 * Math.cos(a)); + ctx.lineTo(cx + r2 * Math.sin(a), cy - r2 * Math.cos(a)); + ctx.strokeStyle = Colors.overSurfaceVariant; + ctx.lineWidth = long ? 2.5 : 1.5; + ctx.stroke(); + } + } + + function drawTextMarkers(ctx, cx, cy, R, roman) { + var labels = roman + ? ["XII", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI"] + : ["12", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"]; + ctx.fillStyle = Colors.overSurfaceVariant; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = "bold " + (roman ? R * 0.11 : R * 0.17) + "px " + Config.theme.font; + for (var i = 0; i < 12; i++) { + var a = i * 30 * Math.PI / 180; + var r = R - (roman ? R * 0.2 : R * 0.19); + ctx.fillText(labels[i], cx + r * Math.sin(a), cy - r * Math.cos(a)); + } + } + + function drawDots(ctx, cx, cy, R) { + for (var i = 0; i < 12; i++) { + var a = i * 30 * Math.PI / 180; + var r = R - R * 0.12; + ctx.beginPath(); + ctx.arc(cx + r * Math.sin(a), cy - r * Math.cos(a), (i % 3 === 0) ? 3.5 : 2, 0, Math.PI * 2); + ctx.fillStyle = Colors.overSurfaceVariant; + ctx.fill(); + } + } + + function drawSector(ctx, cx, cy, R) { + for (var i = 0; i < 12; i++) { + var a = i * 30 * Math.PI / 180; + var long = (i % 3 === 0); + var r1 = R - (long ? 2 : 3), r2 = R - (long ? R * 0.22 : R * 0.12); + ctx.beginPath(); + ctx.moveTo(cx + r1 * Math.sin(a), cy - r1 * Math.cos(a)); + ctx.lineTo(cx + r2 * Math.sin(a), cy - r2 * Math.cos(a)); + ctx.strokeStyle = Colors.overSurfaceVariant; + ctx.lineWidth = long ? 7 : 2; + ctx.stroke(); + } + } + + // Rings: hollow circles at each hour position. + function drawRings(ctx, cx, cy, R) { + for (var i = 0; i < 12; i++) { + var a = i * 30 * Math.PI / 180; + var r = R - R * 0.16; + var x = cx + r * Math.sin(a), y = cy - r * Math.cos(a); + ctx.beginPath(); + ctx.arc(x, y, (i % 3 === 0) ? 3.6 : 2.4, 0, Math.PI * 2); + ctx.strokeStyle = Colors.overSurfaceVariant; + ctx.lineWidth = (i % 3 === 0) ? 2 : 1.2; + ctx.stroke(); + } + } + + // Bold: chunky rectangular markers. + function drawBold(ctx, cx, cy, R) { + for (var i = 0; i < 12; i++) { + var a = i * 30 * Math.PI / 180; + var long = (i % 3 === 0); + var r1 = R - (long ? 2 : 3), r2 = R - (long ? R * 0.2 : R * 0.1); + ctx.beginPath(); + ctx.moveTo(cx + r1 * Math.sin(a), cy - r1 * Math.cos(a)); + ctx.lineTo(cx + r2 * Math.sin(a), cy - r2 * Math.cos(a)); + ctx.strokeStyle = Colors.overSurfaceVariant; + ctx.lineWidth = long ? 8 : 2.5; + ctx.stroke(); + } + } + + // Tall: single 12 dot, otherwise clean. + function drawTall(ctx, cx, cy, R) { + ctx.beginPath(); + ctx.arc(cx, cy - (R - R * 0.07), 2.5, 0, Math.PI * 2); + ctx.fillStyle = Colors.overSurfaceVariant; + ctx.fill(); + } + + // Track: inner ring with hour ticks radiating out to the edge. + function drawTrack(ctx, cx, cy, R) { + ctx.beginPath(); + ctx.arc(cx, cy, R * 0.86, 0, Math.PI * 2); + ctx.strokeStyle = Colors.overSurfaceVariant; + ctx.lineWidth = 1.5; + ctx.stroke(); + for (var i = 0; i < 12; i++) { + var a = i * 30 * Math.PI / 180; + var long = (i % 3 === 0); + var r1 = R * 0.86, r2 = R - (long ? 2 : 5); + ctx.beginPath(); + ctx.moveTo(cx + r1 * Math.sin(a), cy - r1 * Math.cos(a)); + ctx.lineTo(cx + r2 * Math.sin(a), cy - r2 * Math.cos(a)); + ctx.strokeStyle = Colors.overSurfaceVariant; + ctx.lineWidth = long ? 2.5 : 1.5; + ctx.stroke(); + } + } + + // Diamond: rhombus markers pointing at each hour. + function drawDiamond(ctx, cx, cy, R) { + for (var i = 0; i < 12; i++) { + var a = i * 30 * Math.PI / 180; + var r = R - R * 0.14; + var x = cx + r * Math.sin(a), y = cy - r * Math.cos(a); + var s = (i % 3 === 0) ? 4.5 : 3; + ctx.save(); + ctx.translate(x, y); + ctx.rotate(a); + ctx.beginPath(); + ctx.moveTo(0, -s); + ctx.lineTo(s, 0); + ctx.lineTo(0, s); + ctx.lineTo(-s, 0); + ctx.closePath(); + ctx.fillStyle = Colors.overSurfaceVariant; + ctx.fill(); + ctx.restore(); + } + } + + // Bauhaus: slim, modern radial lines. + function drawBauhaus(ctx, cx, cy, R) { + for (var i = 0; i < 12; i++) { + var a = i * 30 * Math.PI / 180; + var long = (i % 3 === 0); + var r1 = R - (long ? 4 : 3), r2 = R - (long ? R * 0.22 : R * 0.14); + ctx.beginPath(); + ctx.moveTo(cx + r1 * Math.sin(a), cy - r1 * Math.cos(a)); + ctx.lineTo(cx + r2 * Math.sin(a), cy - r2 * Math.cos(a)); + ctx.strokeStyle = Colors.overSurfaceVariant; + ctx.lineWidth = long ? 3 : 1.5; + ctx.stroke(); + } + } + + // Railway: big block numerals at 12/3/6/9 + minute ticks. + function drawRailway(ctx, cx, cy, R) { + for (var j = 0; j < 60; j++) { + var t = j * 6 * Math.PI / 180; + var long = (j % 5 === 0); + var r1 = R - (long ? 6 : 3), r2 = R - (long ? 13 : 8); + ctx.beginPath(); + ctx.moveTo(cx + r1 * Math.sin(t), cy - r1 * Math.cos(t)); + ctx.lineTo(cx + r2 * Math.sin(t), cy - r2 * Math.cos(t)); + ctx.strokeStyle = Colors.overSurfaceVariant; + ctx.lineWidth = long ? 2 : 1; + ctx.stroke(); + } + ctx.fillStyle = Colors.overSurfaceVariant; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = "bold " + (R * 0.28) + "px " + Config.theme.font; + var labels = ["12", "3", "6", "9"]; + for (var i = 0; i < 4; i++) { + var a = i * 90 * Math.PI / 180; + var r = R * 0.68; + ctx.fillText(labels[i], cx + r * Math.sin(a), cy - r * Math.cos(a)); + } + } + + // Settings gear (edit mode only) + StyledRect { + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: 6 + width: 22 + height: 22 + radius: 11 + variant: gearHover.hovered ? "focus" : "common" + z: 20 + visible: Config.desktop.editMode + + Text { + anchors.centerIn: parent + text: Icons.gear + font.family: Icons.font + font.pixelSize: 12 + color: Styling.srItem("common") + } + + MouseArea { + id: gearHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.openFaceMenu() + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/ClockDigitalWidget.qml b/modules/retroshell/files/modules/widgets/desktop/ClockDigitalWidget.qml new file mode 100644 index 00000000..38b77760 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/ClockDigitalWidget.qml @@ -0,0 +1,144 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config +import qs.modules.widgets.desktop + +// Desktop digital clock — 2x2 (160x160) card with time + date. +// Multiple styles switchable via a gear button: classic, minimal, compact, large. +WidgetHost { + id: root + + implicitWidth: 160 + implicitHeight: 160 + contentMargins: 0 + + property string face: "classic" + signal faceSelected(string face) + + onWidgetDataChanged: { + root.face = (root.widgetData && root.widgetData.face) ? root.widgetData.face : "classic"; + } + + function setFace(f) { + root.face = f; + root.faceSelected(f); + } + + function openFaceMenu() { + var faces = ["classic", "minimal", "compact", "large"]; + var labels = ["Classic", "Minimal", "Compact", "Large"]; + var items = []; + for (var i = 0; i < faces.length; i++) { + (function (f, l) { + items.push({ + text: (root.face === f ? "✓ " : "") + l, + isSeparator: false, + onTriggered: function () { root.setFace(f); } + }); + })(faces[i], labels[i]); + } + Visibilities.contextMenu.openCustomMenu(items, 170, 32, "clock"); + } + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + property string timeText: "" + property string secText: "" + property string dateText: "" + + Timer { + interval: 1000; running: true; repeat: true; triggeredOnStart: true + onTriggered: { + var now = new Date(); + var fmt = Config.bar.use12hFormat ? "h:mm" : "HH:mm"; + content.timeText = Qt.formatDateTime(now, fmt); + content.secText = Qt.formatDateTime(now, Config.bar.use12hFormat ? "ss ap" : "ss"); + content.dateText = Qt.formatDateTime(now, "ddd, MMM d"); + } + } + + // Card background + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: Colors.surfaceContainer } + GradientStop { position: 1.0; color: Colors.surfaceContainerLow } + } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 4 + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 3 + + Text { + text: content.timeText + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: root.face === "large" ? 52 : 40 + font.weight: Font.Bold + } + + Text { + visible: root.face !== "minimal" && root.face !== "compact" + text: content.secText + color: Colors.outline + font.family: Config.theme.font + font.pixelSize: 12 + font.weight: Font.Bold + Layout.alignment: Qt.AlignBottom + Layout.bottomMargin: 8 + } + } + + Text { + visible: root.face !== "minimal" + text: content.dateText + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 13 + opacity: 0.7 + Layout.alignment: Qt.AlignHCenter + } + } + + // Settings gear (edit mode only) + StyledRect { + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: 6 + width: 22 + height: 22 + radius: 11 + variant: gearHover.hovered ? "focus" : "common" + z: 20 + visible: Config.desktop.editMode + + Text { + anchors.centerIn: parent + text: Icons.gear + font.family: Icons.font + font.pixelSize: 12 + color: Styling.srItem("common") + } + + MouseArea { + id: gearHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.openFaceMenu() + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/DesktopWidgets.qml b/modules/retroshell/files/modules/widgets/desktop/DesktopWidgets.qml new file mode 100644 index 00000000..ead0a68b --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/DesktopWidgets.qml @@ -0,0 +1,624 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.modules.theme +import qs.modules.components +import qs.modules.globals +import qs.modules.services +import qs.config +import qs.modules.widgets.desktop + +// Floating desktop widget layer. Renders each enabled widget as a draggable +// card positioned by its saved normalized coordinates. Widget data lives in +// its own config file (desktop_widgets.json) read/written with manual JSON so +// it is not subject to JsonAdapter array-loading limitations. In edit mode the +// user can drag widgets around and positions persist back to the file. +Item { + id: root + + anchors.fill: parent + + // Screen name this layer belongs to (empty for the global/first case). + property string screenName: "" + + Connections { + target: Config.desktop + function onEditModeChanged() { + console.log("[DesktopWidgets] editMode changed to", Config.desktop.editMode); + } + } + + // ── Widget config file ── + readonly property string widgetsFile: (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/retro/shell/desktop_widgets.json" + + // Loaded widgets: [{id, type, x, y, width, height}] (x/y normalized 0..1) + property var widgets: [] + // Widgets visible on THIS monitor. Global (off) shows all; per-monitor + // shows unassigned widgets plus ones matching this screen's name. + readonly property var visibleWidgets: { + if (!Config.desktop.perMonitor) return root.widgets; + return root.widgets.filter(function (w) { + var m = w.monitor; + return m === undefined || m === "" || m === root.screenName; + }); + } + readonly property var widgetOrder: root.visibleWidgets.map(function (w) { return w.id; }) + // Whether any widgets are present (used to activate the desktop layer). + readonly property bool hasWidgets: root.visibleWidgets.length > 0 + + FileView { + id: widgetsFileView + path: root.widgetsFile + watchChanges: true + atomicWrites: true + printErrors: false + + onLoaded: root.parseWidgets(text()) + onFileChanged: reload() + } + + function parseWidgets(raw) { + var list = []; + if (raw && raw.trim().length > 0) { + try { + var data = JSON.parse(raw); + if (data && Array.isArray(data.widgets)) { + list = data.widgets; + } + } catch (e) { + console.warn("[DesktopWidgets] parse error:", e); + } + } + // Only reassign when the content actually changed, so the Repeater + // does not recreate delegates on every identical file reload. + if (JSON.stringify(list) !== JSON.stringify(root.widgets)) { + root.widgets = list; + } + } + + function saveWidgets() { + widgetsFileView.setText(JSON.stringify({ "widgets": root.widgets }, null, 2)); + } + + // ── Catalog of widget types available to add ── + // type -> {label, icon}. Extend to add more widget types. + readonly property var widgetCatalog: ({ + "calendar": { label: "Calendar 4x4", icon: Icons.clock }, + "calendar2x4": { label: "Calendar 2x4", icon: Icons.clock }, + "weather": { label: "Weather Widget", icon: Icons.sun }, + "weather2x4": { label: "Weather 2x4", icon: Icons.sun }, + "weather2x2": { label: "Weather 2x2", icon: Icons.sun }, + "music2x2": { label: "Music Player 2x2", icon: Icons.player }, + "music2x4": { label: "Music Player 2x4", icon: Icons.player }, + "clockdigital": { label: "Digital Clock", icon: Icons.clock }, + "clockanalog": { label: "Analog Clock", icon: Icons.clock }, + "worldclock": { label: "World Clock", icon: Icons.clock }, + "storage": { label: "Device Storage", icon: Icons.disk }, + "storage2x4": { label: "Device Storage 2x4", icon: Icons.disk }, + "network": { label: "Network Monitor", icon: Icons.wifiHigh }, + "network2x4": { label: "Network Monitor 2x4", icon: Icons.wifiHigh }, + "network1x4": { label: "Network Monitor Slim", icon: Icons.wifiHigh }, + "network1x3": { label: "Network Monitor 1x3", icon: Icons.wifiHigh }, + "power": { label: "Power & Performance", icon: Icons.lightning }, + "power1x3": { label: "Power & Performance 1x3", icon: Icons.lightning }, + "bluetooth": { label: "Bluetooth", icon: Icons.bluetooth }, + "note": { label: "Note", icon: Icons.notepad }, + "batteryring": { label: "Battery Rings", icon: Icons.batteryFull }, + "batteryring2x4": { label: "Battery Rings 2x4", icon: Icons.batteryFull }, + "feed": { label: "Dev Feed", icon: Icons.globe }, + "sysmonitor": { label: "System Monitor", icon: Icons.cpu }, + "sysmonitor2x4": { label: "System Monitor 2x4", icon: Icons.cpu }, + "battery": { label: "Battery", icon: Icons.batteryFull }, + "photo": { label: "Photo 2x2", icon: Icons.folder }, + "photo2x4": { label: "Photo 2x4", icon: Icons.folder }, + "photo4x2": { label: "Photo 4x2", icon: Icons.folder } + }) + + // Look up a widget's data entry by id. + function widgetDataById(id) { + for (var i = 0; i < root.widgets.length; i++) { + if (root.widgets[i].id === id) + return root.widgets[i]; + } + return null; + } + + // Map a widget type to a component. Extend to add more widget types. + function componentForType(type) { + switch (type) { + case "calendar": return calendarComponent; + case "calendar2x4": return calendarComponent; + case "weather": return weatherComponent; + case "weather2x4": return weather2x4Component; + case "weather2x2": return weather2x2Component; + case "music2x2": return music2x2Component; + case "music2x4": return music2x4Component; + case "clockdigital": return clockDigitalComponent; + case "clockanalog": return clockAnalogComponent; + case "worldclock": return worldClockComponent; + case "storage": return storageComponent; + case "storage2x4": return storageWideComponent; + case "network": return networkComponent; + case "network2x4": return networkWideComponent; + case "network1x4": return networkSlimComponent; + case "network1x3": return networkMiniComponent; + case "power": return powerComponent; + case "power1x3": return powerMiniComponent; + case "bluetooth": return bluetoothComponent; + case "note": return noteComponent; + case "batteryring": return batteryRingComponent; + case "batteryring2x4": return batteryRingWideComponent; + case "feed": return feedComponent; + case "sysmonitor": return sysMonitorComponent; + case "sysmonitor2x4": return sysPulseComponent; + case "battery": return batteryComponent; + case "photo": return photoComponent; + case "photo2x4": return photoWideComponent; + case "photo4x2": return photoTallComponent; + } + return null; + } + + function widgetSizeFor(type) { + switch (type) { + case "calendar": return [320, 320]; + case "calendar2x4": return [320, 160]; + case "weather": return [320, 240]; + case "weather2x4": return [320, 160]; + case "weather2x2": return [160, 160]; + case "music2x2": return [160, 160]; + case "music2x4": return [320, 160]; + case "clockdigital": return [160, 160]; + case "clockanalog": return [160, 160]; + case "worldclock": return [320, 80]; + case "storage": return [160, 160]; + case "storage2x4": return [320, 160]; + case "network": return [160, 160]; + case "network2x4": return [320, 160]; + case "network1x4": return [320, 80]; + case "network1x3": return [240, 80]; + case "power": return [160, 160]; + case "power1x3": return [240, 80]; + case "bluetooth": return [160, 160]; + case "note": return [160, 160]; + case "batteryring": return [160, 160]; + case "batteryring2x4": return [320, 160]; + case "feed": return [320, 160]; + case "sysmonitor": return [160, 160]; + case "sysmonitor2x4": return [320, 160]; + case "battery": return [160, 160]; + case "photo": return [160, 160]; + case "photo2x4": return [320, 160]; + case "photo4x2": return [160, 320]; + } + return [280, 240]; + } + + // Copy a widget entry, preserving optional fields (face, timezones, etc.) + function cloneWidgetEntry(w) { + var e = { + id: w.id, + type: w.type, + x: w.x, + y: w.y, + width: w.width, + height: w.height, + locked: w.locked === true + }; + if (w.face !== undefined) e.face = w.face; + if (w.handStyle !== undefined) e.handStyle = w.handStyle; + if (w.timezones !== undefined) e.timezones = w.timezones; + if (w.device !== undefined) e.device = w.device; + if (w.hideIp !== undefined) e.hideIp = w.hideIp; + if (w.devicePriority !== undefined) e.devicePriority = w.devicePriority; + if (w.noteId !== undefined) e.noteId = w.noteId; + if (w.openFullscreen !== undefined) e.openFullscreen = w.openFullscreen; + if (w.fontSize !== undefined) e.fontSize = w.fontSize; + if (w.hiddenDevices !== undefined) e.hiddenDevices = w.hiddenDevices; + if (w.tag !== undefined) e.tag = w.tag; + if (w.monitor !== undefined) e.monitor = w.monitor; + if (w.source !== undefined) e.source = w.source; + if (w.apiKey !== undefined) e.apiKey = w.apiKey; + if (w.autoSwipe !== undefined) e.autoSwipe = w.autoSwipe; + if (w.swipeInterval !== undefined) e.swipeInterval = w.swipeInterval; + if (w.count !== undefined) e.count = w.count; + if (w.imagePath !== undefined) e.imagePath = w.imagePath; + if (w.showBorder !== undefined) e.showBorder = w.showBorder; + if (w.showCpu !== undefined) e.showCpu = w.showCpu; + if (w.showGpu !== undefined) e.showGpu = w.showGpu; + if (w.showRam !== undefined) e.showRam = w.showRam; + if (w.showDisk !== undefined) e.showDisk = w.showDisk; + if (w.showCpuTemp !== undefined) e.showCpuTemp = w.showCpuTemp; + if (w.showGpuTemp !== undefined) e.showGpuTemp = w.showGpuTemp; + if (w.showDiskTemp !== undefined) e.showDiskTemp = w.showDiskTemp; + return e; + } + + // Persist an updated (x,y) for a widget id. + function updateWidgetPosition(id, nx, ny) { + var list = root.widgets.slice(); + for (var i = 0; i < list.length; i++) { + if (list[i].id === id) { + var e = root.cloneWidgetEntry(list[i]); + e.x = nx; + e.y = ny; + if (Config.desktop.perMonitor) + e.monitor = root.screenName; + list[i] = e; + break; + } + } + root.widgets = list; + root.saveWidgets(); + } + + // Persist a lock state change for a widget id. + function updateWidgetLock(id, locked) { + var list = root.widgets.slice(); + for (var i = 0; i < list.length; i++) { + if (list[i].id === id) { + var e = root.cloneWidgetEntry(list[i]); + e.locked = locked === true; + list[i] = e; + break; + } + } + root.widgets = list; + root.saveWidgets(); + } + + // Persist a face/style change for a widget id (clocks). + function updateWidgetFace(id, face) { + var list = root.widgets.slice(); + for (var i = 0; i < list.length; i++) { + if (list[i].id === id) { + var e = root.cloneWidgetEntry(list[i]); + e.face = face; + list[i] = e; + break; + } + } + root.widgets = list; + root.saveWidgets(); + } + + // Persist a hand-style change for a widget id (analog clock). + function updateWidgetHandStyle(id, handStyle) { + var list = root.widgets.slice(); + for (var i = 0; i < list.length; i++) { + if (list[i].id === id) { + var e = root.cloneWidgetEntry(list[i]); + e.handStyle = handStyle; + list[i] = e; + break; + } + } + root.widgets = list; + root.saveWidgets(); + } + + function lockAll(locked) { + var list = root.widgets.slice(); + for (var i = 0; i < list.length; i++) { + var e = root.cloneWidgetEntry(list[i]); + e.locked = locked === true; + list[i] = e; + } + root.widgets = list; + root.saveWidgets(); + } + + function removeWidget(id) { + root.widgets = root.widgets.filter(function (w) { return w.id !== id; }); + root.saveWidgets(); + } + + // Add a widget of the given type at a normalized position (or center). + function addWidget(type, nx, ny) { + for (var i = 0; i < root.widgets.length; i++) { + if (root.widgets[i].id === type) + return; + } + var size = root.widgetSizeFor(type); + var entry = { + id: type, + type: type, + x: (nx !== undefined && nx !== null) ? nx : 0.5, + y: (ny !== undefined && ny !== null) ? ny : 0.5, + width: size[0], + height: size[1], + locked: false + }; + if (Config.desktop.perMonitor) + entry.monitor = root.screenName; + var list = root.widgets.slice(); + list.push(entry); + root.widgets = list; + root.saveWidgets(); + } + + // Widget management context menu (right-click on the desktop). + function openWidgetMenu() { + var items = []; + var order = root.widgetOrder; + + items.push({ + text: (Config.desktop.editMode ? "Disable" : "Enable") + " Edit Mode", + icon: Icons.edit, + isSeparator: false, + onTriggered: function () { + Config.desktop.editMode = !Config.desktop.editMode; + } + }); + items.push({ + text: (Config.desktop.showIcons ? "Hide" : "Show") + " Desktop Icons", + icon: Icons.squaresFour, + isSeparator: false, + onTriggered: function () { + Config.desktop.showIcons = !Config.desktop.showIcons; + } + }); + + if (order.length > 0) { + items.push({ isSeparator: true, text: "" }); + items.push({ + text: "Lock All Widgets", + icon: Icons.lock, + isSeparator: false, + onTriggered: function () { + root.lockAll(true); + } + }); + items.push({ + text: "Unlock All Widgets", + icon: Icons.unpin, + isSeparator: false, + onTriggered: function () { + root.lockAll(false); + } + }); + } + + Visibilities.contextMenu.openCustomMenu(items, 260, 32, "desktop"); + } + + Repeater { + model: root.widgetOrder + + delegate: Item { + id: witem + required property string modelData + + readonly property var entry: root.widgetDataById(modelData) + + // Working position, updated live while dragging and initialized + // from the saved entry when the delegate is created. + property real curX: entry && entry.x !== undefined ? entry.x : 0.5 + property real curY: entry && entry.y !== undefined ? entry.y : 0.5 + + visible: entry !== null + width: entry && entry.width ? entry.width : 280 + height: entry && entry.height ? entry.height : 240 + + x: (curX * root.width) - width / 2 + y: (curY * root.height) - height / 2 + + Loader { + anchors.fill: parent + sourceComponent: root.componentForType(entry ? entry.type : "") + + onLoaded: { + if (item) { + // Keep the widget's data in sync: the entry can change + // live (settings updates face/timezones/etc.) without + // the delegate being recreated, so bind instead of + // assigning once. + item.widgetLayer = root; + function syncWidgetData() { + item.widgetData = witem.entry; + } + syncWidgetData(); + witem.entryChanged.connect(syncWidgetData); + // Live visual move only — no file write per frame. + item.positionChanged.connect(function (nx, ny) { + witem.curX = nx; + witem.curY = ny; + }); + // Commit once on release. + item.positionCommitted.connect(function (nx, ny) { + root.updateWidgetPosition(witem.modelData, nx, ny); + }); + item.lockToggled.connect(function (newLocked) { + root.updateWidgetLock(witem.modelData, newLocked); + }); + if (item.faceSelected) { + item.faceSelected.connect(function (face) { + root.updateWidgetFace(witem.modelData, face); + }); + } + if (item.handStyleSelected) { + item.handStyleSelected.connect(function (handStyle) { + root.updateWidgetHandStyle(witem.modelData, handStyle); + }); + } + item.requestRemove.connect(function () { + root.removeWidget(witem.modelData); + }); + } + } + } + } + } + + // Right-click on the desktop opens the widget management menu. + // Only active when icons are hidden, so it doesn't override the icon + // background menu (which also contains the widget items when icons show). + TapHandler { + acceptedButtons: Qt.RightButton + enabled: !Config.desktop.showIcons + onTapped: root.openWidgetMenu() + } + + Component { + id: calendarComponent + CalendarWidget {} + } + + Component { + id: weatherComponent + WeatherWidget {} + } + + Component { + id: weather2x4Component + WeatherWidget2x4 {} + } + + Component { + id: weather2x2Component + WeatherWidget2x2 {} + } + + Component { + id: music2x2Component + MusicPlayer2x2 {} + } + + Component { + id: music2x4Component + MusicPlayer2x4 {} + } + + Component { + id: clockDigitalComponent + ClockDigitalWidget {} + } + + Component { + id: clockAnalogComponent + ClockAnalogWidget {} + } + + Component { + id: worldClockComponent + WorldClockWidget {} + } + + Component { + id: storageComponent + StorageWidget {} + } + + Component { + id: storageWideComponent + StorageWidget { wide: true; showBorder: false } + } + + Component { + id: networkComponent + NetworkWidget {} + } + + Component { + id: networkWideComponent + NetworkWidget { variant: "wide" } + } + + Component { + id: networkSlimComponent + NetworkWidget { variant: "slim" } + } + + Component { + id: networkMiniComponent + NetworkWidget { variant: "mini" } + } + + Component { + id: powerComponent + PowerWidget {} + } + + Component { + id: powerMiniComponent + PowerWidget { variant: "mini" } + } + + Component { + id: bluetoothComponent + BluetoothWidget {} + } + + Component { + id: noteComponent + NoteWidget {} + } + + Component { + id: batteryRingComponent + BatteryRingWidget {} + } + + Component { + id: batteryRingWideComponent + BatteryRingWidget { wide: true } + } + + Component { + id: feedComponent + FeedWidget {} + } + + Component { + id: sysMonitorComponent + SystemMonitorWidget {} + } + + Component { + id: sysPulseComponent + SystemPulseWidget {} + } + + Component { + id: batteryComponent + BatteryWidget {} + } + + Component { + id: photoComponent + PhotoWidget {} + } + + Component { + id: photoWideComponent + PhotoWidget { implicitWidth: 320 } + } + + Component { + id: photoTallComponent + PhotoWidget { implicitHeight: 320 } + } + + // Hint shown when in edit mode with no widgets yet. + Rectangle { + anchors.centerIn: parent + width: 360 + height: 60 + radius: 12 + color: Qt.rgba(0, 0, 0, 0.6) + visible: Config.desktop.editMode && root.widgetOrder.length === 0 + + Text { + anchors.centerIn: parent + text: "No widgets yet — right-click the desktop to add one" + color: "white" + font.family: Config.defaultFont + font.pixelSize: 14 + } + } + + Component.onCompleted: { + widgetsFileView.reload(); + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/FeedWidget.qml b/modules/retroshell/files/modules/widgets/desktop/FeedWidget.qml new file mode 100644 index 00000000..58046843 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/FeedWidget.qml @@ -0,0 +1,118 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.theme +import qs.modules.components +import qs.config +import qs.modules.widgets.desktop + +// Desktop feed widget — 2x4 (320x160) single full-cover article, swipeable +// through the top articles. Source: devto (default) | hackernews | dailydev. +WidgetHost { + id: root + + implicitWidth: 320 + implicitHeight: 160 + contentMargins: 0 + cardBorder: false + + readonly property string source: (root.widgetData && root.widgetData.source) + ? String(root.widgetData.source) : "devto" + readonly property string tag: (root.widgetData && root.widgetData.tag !== undefined) + ? String(root.widgetData.tag) : "" + readonly property string apiKey: (root.widgetData && root.widgetData.apiKey) + ? String(root.widgetData.apiKey) : "" + readonly property bool autoSwipe: (root.widgetData && root.widgetData.autoSwipe !== undefined) + ? root.widgetData.autoSwipe === true : true + readonly property int swipeInterval: (root.widgetData && root.widgetData.swipeInterval) + ? Number(root.widgetData.swipeInterval) : 30 + readonly property int count: (root.widgetData && root.widgetData.count) + ? Math.max(3, Math.min(10, Number(root.widgetData.count))) : 5 + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + property var articles: [] + property int currentIndex: 0 + + function refresh() { + var cmd = ["python3", Quickshell.shellDir + "/scripts/feed_fetch.py", + root.source, root.tag, root.apiKey, String(root.count)]; + fetchProc.command = cmd; + fetchProc.running = true; + } + + function next() { + if (content.articles.length === 0) return; + content.currentIndex = (content.currentIndex + 1) % content.articles.length; + } + + function prev() { + if (content.articles.length === 0) return; + content.currentIndex = (content.currentIndex - 1 + content.articles.length) % content.articles.length; + } + + Process { + id: fetchProc + running: false + stdout: StdioCollector { + onStreamFinished: { + var out = text.trim(); + if (!out) return; + try { + content.articles = JSON.parse(out); + if (content.currentIndex >= content.articles.length) + content.currentIndex = 0; + } catch (e) { + console.warn("FeedWidget: parse error", e); + } + } + } + } + + // Refetch every 30 minutes. + Timer { + interval: 1800000 + repeat: true + running: true + onTriggered: content.refresh() + } + + // Auto-swipe every N seconds (configurable, can be disabled). + Timer { + id: autoSwipeTimer + interval: Math.max(5000, root.swipeInterval * 1000) + repeat: true + running: root.autoSwipe && content.articles.length > 1 + onTriggered: content.next() + } + + Connections { + target: root + function onSourceChanged() { content.currentIndex = 0; content.refresh(); } + function onTagChanged() { content.currentIndex = 0; content.refresh(); } + function onApiKeyChanged() { content.currentIndex = 0; content.refresh(); } + function onAutoSwipeChanged() { + autoSwipeTimer.running = root.autoSwipe && content.articles.length > 1; + } + } + + FeedCard { + anchors.fill: parent + articles: content.articles + currentIndex: content.currentIndex + source: root.source + tag: root.tag + onPreviousRequested: content.prev() + onNextRequested: content.next() + onOpenRequested: function (url) { + if (url) Qt.openUrlExternally(url); + } + } + + Component.onCompleted: content.refresh() + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/MusicPlayer2x2.qml b/modules/retroshell/files/modules/widgets/desktop/MusicPlayer2x2.qml new file mode 100644 index 00000000..3022762b --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/MusicPlayer2x2.qml @@ -0,0 +1,246 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Mpris +import qs.modules.theme +import qs.modules.services +import qs.modules.globals +import qs.modules.components +import qs.config +import qs.modules.widgets.desktop + +// Desktop music player — square (2x2) card, 160x160 (1 grid unit = 80px). +// Album cover fills the whole card (rounded corners), info + controls overlaid. +WidgetHost { + id: root + + implicitWidth: 160 + implicitHeight: 160 + contentMargins: 0 + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + readonly property bool hasActivePlayer: MprisController.activePlayer !== null + readonly property bool isPlaying: MprisController.activePlayer?.playbackState === MprisPlaybackState.Playing + readonly property real position: MprisController.activePlayer?.position ?? 0.0 + readonly property real length: MprisController.activePlayer?.length ?? 1.0 + readonly property bool hasArtwork: (MprisController.activePlayer?.trackArtUrl ?? "") !== "" + readonly property string artwork: content.hasArtwork ? MprisController.activePlayer.trackArtUrl : content.wallpaperPath + readonly property string wallpaperPath: { + if (typeof GlobalStates === "undefined" || !GlobalStates.wallpaperManager) return ""; + let path = GlobalStates.wallpaperManager.currentWallpaper; + let frame = GlobalStates.wallpaperManager.getLockscreenFramePath(path); + return frame ? "file://" + frame : ""; + } + + function formatTime(seconds) { + const total = Math.floor(seconds); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + return m + ":" + (s < 10 ? "0" : "") + s; + } + + // Full-card album cover background + Image { + id: bgArt + anchors.fill: parent + source: content.artwork + sourceSize: Qt.size(160, 160) + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: false + } + + // Dim overlay + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0.5) } + GradientStop { position: 0.55; color: Qt.rgba(0, 0, 0, 0.2) } + GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.6) } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 3 + + // Top row: player source icon (top-right) + RowLayout { + Layout.fillWidth: true + spacing: 0 + + Item { Layout.fillWidth: true } + + Text { + text: content.hasActivePlayer ? root.getPlayerIcon(MprisController.activePlayer) : Icons.player + font.family: Icons.font + font.pixelSize: 14 + color: "#ffffff" + opacity: content.hasActivePlayer ? 0.9 : 0.4 + layer.enabled: true + layer.effect: BgShadow {} + MouseArea { + anchors.fill: parent + anchors.margins: -5 + cursorShape: Qt.PointingHandCursor + onClicked: MprisController.cyclePlayer(1) + } + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + } + + // Title + Text { + Layout.fillWidth: true + text: content.hasActivePlayer ? (MprisController.activePlayer?.trackTitle ?? "Unknown") : "Nothing Playing" + color: "#ffffff" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(1) + font.weight: Font.Bold + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + maximumLineCount: 1 + layer.enabled: true + layer.effect: BgShadow {} + } + + // Artist + Text { + Layout.fillWidth: true + text: content.hasActivePlayer ? (MprisController.activePlayer?.trackArtist ?? "") : "Enjoy the silence" + color: Qt.rgba(1, 1, 1, 0.85) + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + maximumLineCount: 1 + layer.enabled: true + layer.effect: BgShadow {} + } + + // Seek row: time — slider — time + RowLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: content.hasActivePlayer ? content.formatTime(content.position) : "--:--" + color: Qt.rgba(1, 1, 1, 0.75) + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + layer.enabled: true + layer.effect: BgShadow {} + } + + PositionSlider { + Layout.fillWidth: true + Layout.preferredHeight: 5 + player: MprisController.activePlayer + useCustomColors: true + customProgressColor: Colors.primary + } + + Text { + text: content.hasActivePlayer ? content.formatTime(content.length) : "--:--" + color: Qt.rgba(1, 1, 1, 0.75) + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + layer.enabled: true + layer.effect: BgShadow {} + } + } + + // Controls + RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + spacing: 10 + + Text { + text: Icons.previous + font.family: Icons.font + font.pixelSize: 18 + color: MprisController.canGoPrevious ? "#ffffff" : Qt.rgba(1, 1, 1, 0.4) + layer.enabled: true + layer.effect: BgShadow {} + MouseArea { + anchors.fill: parent + anchors.margins: -6 + cursorShape: Qt.PointingHandCursor + enabled: MprisController.canGoPrevious + onClicked: MprisController.previous() + } + } + + StyledRect { + id: playBtn + Layout.preferredWidth: 34 + Layout.preferredHeight: 34 + radius: 17 + variant: "primary" + opacity: content.hasActivePlayer ? 1.0 : 0.5 + + Text { + anchors.centerIn: parent + text: content.isPlaying ? Icons.pause : Icons.play + font.family: Icons.font + font.pixelSize: 18 + color: playBtn.item + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + enabled: content.hasActivePlayer + onClicked: MprisController.togglePlaying() + } + } + + Text { + text: Icons.next + font.family: Icons.font + font.pixelSize: 18 + color: MprisController.canGoNext ? "#ffffff" : Qt.rgba(1, 1, 1, 0.4) + layer.enabled: true + layer.effect: BgShadow {} + MouseArea { + anchors.fill: parent + anchors.margins: -6 + cursorShape: Qt.PointingHandCursor + enabled: MprisController.canGoNext + onClicked: MprisController.next() + } + } + } + } + } + } + + // Player icon lookup (same as CompactPlayer) + function getPlayerIcon(player) { + if (!player) + return Icons.player; + const dbusName = (player.dbusName || "").toLowerCase(); + const desktopEntry = (player.desktopEntry || "").toLowerCase(); + const identity = (player.identity || "").toLowerCase(); + if (dbusName.includes("spotify") || desktopEntry.includes("spotify") || identity.includes("spotify")) + return Icons.spotify; + if (dbusName.includes("chromium") || dbusName.includes("chrome") || desktopEntry.includes("chromium") || desktopEntry.includes("chrome")) + return Icons.chromium; + if (dbusName.includes("firefox") || desktopEntry.includes("firefox")) + return Icons.firefox; + if (dbusName.includes("telegram") || desktopEntry.includes("telegram") || identity.includes("telegram")) + return Icons.telegram; + return Icons.player; + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/MusicPlayer2x4.qml b/modules/retroshell/files/modules/widgets/desktop/MusicPlayer2x4.qml new file mode 100644 index 00000000..186597b3 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/MusicPlayer2x4.qml @@ -0,0 +1,393 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Mpris +import qs.modules.theme +import qs.modules.services +import qs.modules.globals +import qs.modules.components +import qs.config +import qs.modules.widgets.desktop + +// Desktop music player — wide (2x4) card, 320x160 (1 grid unit = 80px). +// Album cover fills the whole background; circular seek disc on the left and +// controls on the right. +WidgetHost { + id: root + + implicitWidth: 320 + implicitHeight: 160 + contentMargins: 0 + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + readonly property bool hasActivePlayer: MprisController.activePlayer !== null + readonly property bool isPlaying: MprisController.activePlayer?.playbackState === MprisPlaybackState.Playing + readonly property real position: MprisController.activePlayer?.position ?? 0.0 + readonly property real length: MprisController.activePlayer?.length ?? 1.0 + readonly property bool hasArtwork: (MprisController.activePlayer?.trackArtUrl ?? "") !== "" + readonly property string artwork: content.hasArtwork ? MprisController.activePlayer.trackArtUrl : content.wallpaperPath + readonly property string wallpaperPath: { + if (typeof GlobalStates === "undefined" || !GlobalStates.wallpaperManager) return ""; + let path = GlobalStates.wallpaperManager.currentWallpaper; + let frame = GlobalStates.wallpaperManager.getLockscreenFramePath(path); + return frame ? "file://" + frame : ""; + } + + function formatTime(seconds) { + const total = Math.floor(seconds); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + return m + ":" + (s < 10 ? "0" : "") + s; + } + + // Full-card album cover background + Image { + id: bgArt + anchors.fill: parent + source: content.artwork + sourceSize: Qt.size(160, 160) + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: false + } + + // Dim overlay + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0.55) } + GradientStop { position: 0.6; color: Qt.rgba(0, 0, 0, 0.3) } + GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.65) } + } + } + + RowLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 12 + + // ── Left: circular seek disc ── + Item { + Layout.preferredWidth: 136 + Layout.preferredHeight: 136 + Layout.alignment: Qt.AlignVCenter + + CircularSeekBar { + id: disc + anchors.fill: parent + value: content.length > 0 ? content.position / content.length : 0 + accentColor: Colors.primary + trackColor: Qt.rgba(1, 1, 1, 0.25) + lineWidth: 6 + ringPadding: 12 + handleSpacing: 20 + startAngleDeg: 180 + spanAngleDeg: 180 + wavy: Config.performance.wavyLine + waveAmplitude: content.isPlaying ? 2 : 0 + waveFrequency: 24 + enabled: content.hasActivePlayer && (MprisController.activePlayer?.canSeek ?? false) + + onValueEdited: newValue => { + if (MprisController.activePlayer && MprisController.activePlayer.canSeek) { + MprisController.activePlayer.position = newValue * content.length; + } + } + + // Spinning CD (cover art) in the middle — bounces back to rest on stop + Item { + id: cdContainer + anchors.centerIn: parent + width: parent.width - 56 + height: parent.height - 56 + + property real cdRotation: 0 + rotation: cdRotation + + // Continuous spin while playing + NumberAnimation { + id: spinAnim + target: cdContainer + property: "cdRotation" + duration: 8000 + loops: Animation.Infinite + } + + // Bounce back into position when playback stops + // (same OutBack overshoot as CompactPlayer) + NumberAnimation { + id: bounceBackAnim + target: cdContainer + property: "cdRotation" + duration: 700 + easing.type: Easing.OutBack + easing.overshoot: 1.5 + } + + function resumeSpin() { + bounceBackAnim.stop(); + let cur = cdContainer.cdRotation % 360; + if (cur < 0) cur += 360; + cdContainer.cdRotation = cur; + spinAnim.from = cur; + spinAnim.to = cur + 360; + spinAnim.restart(); + } + + function stopSpin() { + spinAnim.stop(); + let cur = cdContainer.cdRotation % 360; + if (cur < 0) cur += 360; + bounceBackAnim.from = cur; + bounceBackAnim.to = cur > 180 ? 360 : 0; + bounceBackAnim.restart(); + } + + Connections { + target: content + function onIsPlayingChanged() { + if (content.isPlaying) + cdContainer.resumeSpin(); + else + cdContainer.stopSpin(); + } + } + + Component.onCompleted: { + if (content.isPlaying) + cdContainer.resumeSpin(); + } + + ClippingRectangle { + anchors.fill: parent + radius: width / 2 + color: Colors.surface + clip: true + + Image { + id: discArt + anchors.fill: parent + source: content.artwork + sourceSize: Qt.size(128, 128) + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: false + } + + // Dim only when nothing is playing + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.35) + visible: !content.hasActivePlayer + } + } + } + } + } + + // ── Right: metadata + controls ── + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignVCenter + spacing: 3 + + Text { + Layout.alignment: Qt.AlignHCenter + text: content.hasActivePlayer ? root.getPlayerIcon(MprisController.activePlayer) : Icons.player + font.family: Icons.font + font.pixelSize: 16 + color: "#ffffff" + opacity: content.hasActivePlayer ? 1.0 : 0.5 + layer.enabled: true + layer.effect: BgShadow {} + MouseArea { + anchors.fill: parent + anchors.margins: -5 + cursorShape: Qt.PointingHandCursor + onClicked: MprisController.cyclePlayer(1) + } + } + + Text { + Layout.fillWidth: true + text: content.hasActivePlayer ? (MprisController.activePlayer?.trackTitle ?? "Unknown") : "Nothing Playing" + color: "#ffffff" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(1) + font.weight: Font.Bold + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + maximumLineCount: 1 + layer.enabled: true + layer.effect: BgShadow {} + } + + Text { + Layout.fillWidth: true + text: content.hasActivePlayer ? (MprisController.activePlayer?.trackArtist ?? "") : "Enjoy the silence" + color: Qt.rgba(1, 1, 1, 0.85) + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + maximumLineCount: 1 + layer.enabled: true + layer.effect: BgShadow {} + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: content.hasActivePlayer + ? content.formatTime(content.position) + " / " + content.formatTime(content.length) + : "--:-- / --:--" + color: Qt.rgba(1, 1, 1, 0.75) + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + layer.enabled: true + layer.effect: BgShadow {} + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 8 + + Text { + text: Icons.previous + font.family: Icons.font + font.pixelSize: 16 + color: MprisController.canGoPrevious ? "#ffffff" : Qt.rgba(1, 1, 1, 0.4) + layer.enabled: true + layer.effect: BgShadow {} + MouseArea { + anchors.fill: parent + anchors.margins: -5 + cursorShape: Qt.PointingHandCursor + enabled: MprisController.canGoPrevious + onClicked: MprisController.previous() + } + } + + StyledRect { + id: playBtn + Layout.preferredWidth: 30 + Layout.preferredHeight: 30 + radius: 15 + variant: "primary" + opacity: content.hasActivePlayer ? 1.0 : 0.5 + + Text { + anchors.centerIn: parent + text: content.isPlaying ? Icons.pause : Icons.play + font.family: Icons.font + font.pixelSize: 16 + color: playBtn.item + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + enabled: content.hasActivePlayer + onClicked: MprisController.togglePlaying() + } + } + + Text { + text: Icons.next + font.family: Icons.font + font.pixelSize: 16 + color: MprisController.canGoNext ? "#ffffff" : Qt.rgba(1, 1, 1, 0.4) + layer.enabled: true + layer.effect: BgShadow {} + MouseArea { + anchors.fill: parent + anchors.margins: -5 + cursorShape: Qt.PointingHandCursor + enabled: MprisController.canGoNext + onClicked: MprisController.next() + } + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 10 + + Text { + text: Icons.shuffle + font.family: Icons.font + font.pixelSize: 12 + color: MprisController.hasShuffle ? "#ffffff" : Qt.rgba(1, 1, 1, 0.5) + opacity: MprisController.shuffleSupported ? 1.0 : 0.35 + layer.enabled: true + layer.effect: BgShadow {} + MouseArea { + anchors.fill: parent + anchors.margins: -5 + cursorShape: Qt.PointingHandCursor + enabled: MprisController.shuffleSupported + onClicked: { + if (MprisController.hasShuffle) { + MprisController.setShuffle(false); + MprisController.setLoopState(MprisLoopState.Playlist); + } else { + MprisController.setShuffle(true); + } + } + } + } + + Text { + text: MprisController.loopState === MprisLoopState.Track ? Icons.repeatOnce + : MprisController.loopState === MprisLoopState.Playlist ? Icons.repeat + : Icons.repeat + font.family: Icons.font + font.pixelSize: 12 + color: MprisController.loopState !== MprisLoopState.None ? "#ffffff" : Qt.rgba(1, 1, 1, 0.5) + opacity: MprisController.loopSupported ? 1.0 : 0.35 + layer.enabled: true + layer.effect: BgShadow {} + MouseArea { + anchors.fill: parent + anchors.margins: -5 + cursorShape: Qt.PointingHandCursor + enabled: MprisController.loopSupported + onClicked: { + if (MprisController.loopState === MprisLoopState.Playlist) + MprisController.setLoopState(MprisLoopState.Track); + else if (MprisController.loopState === MprisLoopState.Track) + MprisController.setLoopState(MprisLoopState.None); + else + MprisController.setLoopState(MprisLoopState.Playlist); + } + } + } + } + } + } + } + } + + // Player icon lookup (same as CompactPlayer) + function getPlayerIcon(player) { + if (!player) + return Icons.player; + const dbusName = (player.dbusName || "").toLowerCase(); + const desktopEntry = (player.desktopEntry || "").toLowerCase(); + const identity = (player.identity || "").toLowerCase(); + if (dbusName.includes("spotify") || desktopEntry.includes("spotify") || identity.includes("spotify")) + return Icons.spotify; + if (dbusName.includes("chromium") || dbusName.includes("chrome") || desktopEntry.includes("chromium") || desktopEntry.includes("chrome")) + return Icons.chromium; + if (dbusName.includes("firefox") || desktopEntry.includes("firefox")) + return Icons.firefox; + if (dbusName.includes("telegram") || desktopEntry.includes("telegram") || identity.includes("telegram")) + return Icons.telegram; + return Icons.player; + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/NetworkWidget.qml b/modules/retroshell/files/modules/widgets/desktop/NetworkWidget.qml new file mode 100644 index 00000000..d50d97be --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/NetworkWidget.qml @@ -0,0 +1,41 @@ +import QtQuick +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config +import qs.modules.widgets.desktop + +// Desktop network widget — 2x2 (160x160) network & bandwidth monitor. +WidgetHost { + id: root + + implicitWidth: 160 + implicitHeight: 160 + contentMargins: 0 + + readonly property bool hideIp: (root.widgetData && root.widgetData.hideIp !== undefined) + ? root.widgetData.hideIp === true : true + + property string variant: "compact" + + contentComponent: Component { + NetworkCard { + anchors.fill: parent + variant: root.variant + interfaceName: NetworkMetrics.interfaceName + ssid: NetworkService.networkName + isConnected: NetworkService.wifiStatus === "connected" || NetworkService.ethernet + wifiStrength: NetworkService.networkStrength + wifiEnabled: NetworkService.wifiEnabled + downloadMbps: NetworkMetrics.downloadMbps + uploadMbps: NetworkMetrics.uploadMbps + localIp: NetworkMetrics.localIp + publicIp: NetworkMetrics.publicIp + publicIpValid: NetworkMetrics.publicIpValid + hideIp: root.hideIp + downloadHistory: NetworkMetrics.downloadHistory + uploadHistory: NetworkMetrics.uploadHistory + onToggleWifiRequested: NetworkService.toggleWifi() + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/NoteWidget.qml b/modules/retroshell/files/modules/widgets/desktop/NoteWidget.qml new file mode 100644 index 00000000..5b927604 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/NoteWidget.qml @@ -0,0 +1,210 @@ +import QtQuick +import Quickshell +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config +import qs.modules.widgets.desktop + +// Desktop note widget — 2x2 (160x160) Apple Notes-style pinned note. +// Supports markdown (.md) and rich-text (.html) notes from the shared store. +WidgetHost { + id: root + + implicitWidth: 160 + implicitHeight: 160 + contentMargins: 0 + + readonly property string noteId: (root.widgetData && root.widgetData.noteId) + ? String(root.widgetData.noteId) : "" + readonly property real fontSize: (root.widgetData && root.widgetData.fontSize) + ? Number(root.widgetData.fontSize) : 13 + readonly property bool openFullscreen: (root.widgetData && root.widgetData.openFullscreen !== undefined) + ? root.widgetData.openFullscreen === true : false + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + NotesStore { id: store } + + property string currentId: "" + property string currentTitle: "" + property string currentContent: "" + property string currentBody: "" + property string currentModified: "" + property bool currentIsMarkdown: false + property bool currentTitleStripped: false + property string lastModified: "" + property real lastUserEdit: 0 + + // Light markdown → HTML for the formatted review view. + function mdToHtml(text) { + var s = String(text || ""); + s = s.replace(/&/g, "&").replace(//g, ">"); + s = s.replace(/^### (.*)$/gm, "$1"); + s = s.replace(/^## (.*)$/gm, "$1"); + s = s.replace(/^# (.*)$/gm, "$1"); + s = s.replace(/\*\*([^*\n]+)\*\*/g, "$1"); + s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2"); + s = s.replace(/~~([^~\n]+)~~/g, "$1"); + s = s.replace(/`([^`\n]+)`/g, "$1"); + s = s.replace(/\n/g, "
"); + return s; + } + + readonly property string currentRichBody: content.currentIsMarkdown + ? content.mdToHtml(content.currentBody) + : content.currentContent + + function formatDate(iso) { + if (!iso) return ""; + var d = new Date(iso); + return (d.getMonth() + 1) + "/" + d.getDate() + "/" + (d.getFullYear() % 100); + } + + // Body shown/edited in the card: markdown loses a matching leading + // "# Title" line (the header shows the title), html gets tags stripped. + function computeBody(raw, title, isMarkdown) { + var text = String(raw || ""); + if (isMarkdown) { + var lines = text.split("\n"); + if (lines.length && lines[0].trim() === "# " + title) { + content.currentTitleStripped = true; + lines.shift(); + return lines.join("\n").replace(/^\n+/, ""); + } + content.currentTitleStripped = false; + return text; + } + content.currentTitleStripped = false; + return text.replace(/<[^>]*>/g, ""); + } + + function loadTarget() { + if (store.notes.length === 0) { + content.currentId = ""; + content.currentTitle = ""; + content.currentContent = ""; + content.currentBody = ""; + content.currentModified = ""; + content.lastModified = ""; + return; + } + var target = null; + if (root.noteId) { + for (var i = 0; i < store.notes.length; i++) + if (store.notes[i].id === root.noteId) { target = store.notes[i]; break; } + } + if (!target) target = store.notes[0]; + + if (content.currentId !== target.id) { + content.currentId = target.id; + content.currentTitle = target.title; + content.currentIsMarkdown = target.isMarkdown || false; + content.currentModified = target.modified || ""; + content.currentContent = ""; + content.currentBody = ""; + content.lastModified = target.modified || ""; + store.readNote(target.id, target.isMarkdown); + } else if (content.currentContent === "") { + content.lastModified = target.modified || ""; + store.readNote(target.id, target.isMarkdown); + } + } + + // Re-read the current note when its modified timestamp changed + // (edits made in the launcher / elsewhere), unless the user is + // actively typing here. + function checkModified() { + if (!content.currentId) return; + var n = store.findNote(content.currentId); + if (!n) return; + if (Date.now() - content.lastUserEdit < 3000) return; + if (n.modified && n.modified !== content.lastModified) { + content.currentTitle = n.title; + content.currentIsMarkdown = n.isMarkdown || false; + content.currentModified = n.modified; + content.currentContent = ""; + content.currentBody = ""; + content.lastModified = n.modified; + store.readNote(n.id, n.isMarkdown); + } + } + + Timer { + interval: 30000 + repeat: true + running: true + onTriggered: store.refresh() + } + + Connections { + target: store + function onNotesRefreshed() { + content.loadTarget(); + content.checkModified(); + } + function onNoteLoaded(noteId, noteContent) { + if (noteId === content.currentId) { + content.currentContent = noteContent; + content.currentBody = content.computeBody(noteContent, content.currentTitle, content.currentIsMarkdown); + content.lastModified = content.currentModified; + } + } + function onNoteCreated(noteId) { + content.currentId = noteId; + content.loadTarget(); + } + function onNoteDeleted(noteId) { + if (content.currentId === noteId) { + content.currentId = ""; + content.loadTarget(); + } + } + function onNoteSaved(noteId) { + if (noteId === content.currentId) { + var n = store.findNote(noteId); + if (n) content.lastModified = n.modified; + } + } + } + + Connections { + target: root + function onNoteIdChanged() { content.loadTarget(); } + } + + NoteCard { + anchors.fill: parent + noteTitle: content.currentTitle + noteBody: content.currentBody + richBody: content.currentRichBody + hasNotes: store.notes.length > 0 + dateText: content.formatDate(content.currentModified) + openFullscreen: root.openFullscreen + fontSize: root.fontSize + onSaveRequested: function (bodyText) { + if (!content.currentId) return; + content.lastUserEdit = Date.now(); + var raw; + if (content.currentIsMarkdown) { + raw = content.currentTitleStripped + ? "# " + content.currentTitle + "\n\n" + bodyText + : bodyText; + } else { + raw = bodyText; + } + store.saveNote(content.currentId, raw, content.currentIsMarkdown); + } + onOpenFullscreenRequested: { + // Open the full notes editor (launcher notes tab). + GlobalShortcuts.toggleLauncherWithPrefix(4, Config.prefix.notes + " "); + } + } + + Component.onCompleted: store.refresh() + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/NotesStore.qml b/modules/retroshell/files/modules/widgets/desktop/NotesStore.qml new file mode 100644 index 00000000..406c3130 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/NotesStore.qml @@ -0,0 +1,268 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +// Shared file-based notes store for the desktop notes widgets. Reads/writes the +// same store as the dashboard notes tab (~/.local/share/retroshell-notes). +Item { + id: store + + readonly property string notesDir: (Quickshell.env("XDG_DATA_HOME") || (Quickshell.env("HOME") + "/.local/share")) + "/retroshell-notes" + readonly property string indexPath: notesDir + "/index.json" + readonly property string notesPath: notesDir + "/notes" + + property var notes: [] + signal notesRefreshed() + signal noteLoaded(string noteId, string content) + signal noteCreated(string noteId) + signal noteDeleted(string noteId) + signal noteSaved(string noteId) + + function generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + var r = Math.random() * 16 | 0; + var v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + + function getCurrentTimestamp() { + return new Date().toISOString(); + } + + function noteExtension(isMarkdown) { + return isMarkdown ? ".md" : ".html"; + } + + function stripHtml(text) { + return String(text).replace(/<[^>]*>/g, ""); + } + + function findNote(noteId) { + for (var i = 0; i < store.notes.length; i++) + if (store.notes[i].id === noteId) + return store.notes[i]; + return null; + } + + // ── Index helpers ── + function buildIndexData() { + var order = []; + var notesMap = {}; + for (var i = 0; i < store.notes.length; i++) { + var n = store.notes[i]; + order.push(n.id); + notesMap[n.id] = { + title: n.title, + created: n.created, + modified: n.modified, + isMarkdown: n.isMarkdown || false + }; + } + return { order: order, notes: notesMap }; + } + + function writeIndex() { + saveIndexProc.command = ["sh", "-c", "printf '%s' '" + JSON.stringify(store.buildIndexData(), null, 2).replace(/'/g, "'\\''") + "' > '" + indexPath + "'"]; + saveIndexProc.running = true; + } + + function touchModified(noteId) { + var n = store.findNote(noteId); + if (n) n.modified = store.getCurrentTimestamp(); + } + + function refresh() { + initDirProc.running = true; + } + + function readNote(noteId, isMarkdown) { + readNoteProc.noteId = noteId; + readNoteProc.command = ["cat", notesPath + "/" + noteId + noteExtension(isMarkdown)]; + readNoteProc.running = true; + } + + function saveNote(noteId, content, isMarkdown) { + saveNoteProc.noteId = noteId; + saveNoteProc.command = ["sh", "-c", "printf '%s' '" + String(content).replace(/'/g, "'\\''") + "' > '" + notesPath + "/" + noteId + noteExtension(isMarkdown) + "'"]; + saveNoteProc.running = true; + } + + function createNote(title) { + var noteId = store.generateUUID(); + var noteTitle = title && title.length > 0 ? title : "Untitled Note"; + var initialContent = "# " + noteTitle + "\n\n"; + createNoteProc.noteId = noteId; + createNoteProc.noteTitle = noteTitle; + createNoteProc.command = ["sh", "-c", "mkdir -p '" + notesPath + "' && printf '%s' '" + initialContent.replace(/'/g, "'\\''") + "' > '" + notesPath + "/" + noteId + ".md'"]; + createNoteProc.running = true; + } + + function deleteNote(noteId, isMarkdown) { + deleteNoteProc.noteId = noteId; + deleteNoteProc.command = ["rm", "-f", notesPath + "/" + noteId + noteExtension(isMarkdown)]; + deleteNoteProc.running = true; + } + + function updateTitle(noteId, newTitle) { + readIndexForTitle.noteId = noteId; + readIndexForTitle.newTitle = newTitle; + readIndexForTitle.command = ["cat", indexPath]; + readIndexForTitle.running = true; + } + + // Keep the store in sync when the dashboard (or anything else) edits notes. + FileView { + path: store.indexPath + watchChanges: true + printErrors: false + onFileChanged: store.refresh() + } + + // ── Processes ── + Process { + id: initDirProc + command: ["sh", "-c", + "mkdir -p '" + store.notesPath + "' && [ -f '" + store.indexPath + "' ] || printf '{\"order\":[],\"notes\":{}}' > '" + store.indexPath + "'"] + running: false + onExited: function (code) { + if (code === 0) readIndexProc.running = true; + } + } + + Process { + id: readIndexProc + command: ["cat", store.indexPath] + running: false + property string stdoutData: "" + stdout: SplitParser { onRead: data => readIndexProc.stdoutData += data + "\n" } + onExited: function (code) { + var raw = readIndexProc.stdoutData.trim(); + readIndexProc.stdoutData = ""; + var order = []; + var map = {}; + try { + var data = JSON.parse(raw); + order = data.order || []; + map = data.notes || {}; + } catch (e) {} + var list = []; + for (var i = 0; i < order.length; i++) { + var meta = map[order[i]]; + if (!meta) continue; + list.push({ + id: order[i], + title: meta.title || "Untitled", + created: meta.created || "", + modified: meta.modified || "", + isMarkdown: meta.isMarkdown || false + }); + } + store.notes = list; + store.notesRefreshed(); + } + } + + Process { + id: readNoteProc + running: false + property string noteId: "" + property string stdoutData: "" + stdout: SplitParser { onRead: data => readNoteProc.stdoutData += data + "\n" } + onExited: function (code) { + var id = readNoteProc.noteId; + var content = readNoteProc.stdoutData.replace(/\n$/, ""); + readNoteProc.stdoutData = ""; + readNoteProc.noteId = ""; + store.noteLoaded(id, content); + } + } + + Process { + id: saveNoteProc + running: false + property string noteId: "" + onExited: function (code) { + if (code === 0 && saveNoteProc.noteId) { + store.touchModified(saveNoteProc.noteId); + store.writeIndex(); + store.noteSaved(saveNoteProc.noteId); + saveNoteProc.noteId = ""; + } + } + } + + Process { + id: createNoteProc + running: false + property string noteId: "" + property string noteTitle: "" + onExited: function (code) { + if (code === 0 && createNoteProc.noteId) { + var now = store.getCurrentTimestamp(); + store.notes = [{ + id: createNoteProc.noteId, + title: createNoteProc.noteTitle || "Untitled Note", + created: now, + modified: now, + isMarkdown: true + }].concat(store.notes); + store.writeIndex(); + store.noteCreated(createNoteProc.noteId); + } + createNoteProc.noteId = ""; + createNoteProc.noteTitle = ""; + } + } + + Process { + id: deleteNoteProc + running: false + property string noteId: "" + onExited: function (code) { + if (code === 0 && deleteNoteProc.noteId) { + store.notes = store.notes.filter(function (n) { return n.id !== deleteNoteProc.noteId; }); + store.writeIndex(); + store.noteDeleted(deleteNoteProc.noteId); + } + deleteNoteProc.noteId = ""; + } + } + + Process { + id: readIndexForTitle + running: false + property string noteId: "" + property string newTitle: "" + property string stdoutData: "" + stdout: SplitParser { onRead: data => readIndexForTitle.stdoutData += data + "\n" } + onExited: function (code) { + var raw = readIndexForTitle.stdoutData.trim(); + readIndexForTitle.stdoutData = ""; + var order = []; + var map = {}; + try { + var data = JSON.parse(raw); + order = data.order || []; + map = data.notes || {}; + } catch (e) {} + if (map[readIndexForTitle.noteId]) { + map[readIndexForTitle.noteId].title = readIndexForTitle.newTitle; + map[readIndexForTitle.noteId].modified = store.getCurrentTimestamp(); + } + saveIndexProc.command = ["sh", "-c", "printf '%s' '" + JSON.stringify({ order: order, notes: map }, null, 2).replace(/'/g, "'\\''") + "' > '" + store.indexPath + "'"]; + saveIndexProc.running = true; + var n = store.findNote(readIndexForTitle.noteId); + if (n) { n.title = readIndexForTitle.newTitle; n.modified = store.getCurrentTimestamp(); } + readIndexForTitle.noteId = ""; + readIndexForTitle.newTitle = ""; + store.notesRefreshed(); + } + } + + Process { + id: saveIndexProc + running: false + onExited: function (code) {} + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/PhotoWidget.qml b/modules/retroshell/files/modules/widgets/desktop/PhotoWidget.qml new file mode 100644 index 00000000..202142a5 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/PhotoWidget.qml @@ -0,0 +1,117 @@ +import QtQuick +import qs.modules.theme +import qs.modules.components +import qs.config +import qs.modules.widgets.desktop + +// Desktop photo widget — displays a user-chosen image in a StyledRect frame. +// Sizes: 2x2 (160x160), 2x4 (320x160), 4x2 (320x160). +// Border: pane outer → internalbg inner (same pattern as Calendar). +WidgetHost { + id: root + + implicitWidth: 160 + implicitHeight: 160 + contentMargins: 0 + cardBorder: false + + readonly property string imagePath: (root.widgetData && root.widgetData.imagePath) + ? String(root.widgetData.imagePath) : "" + readonly property bool showBorder: (root.widgetData && root.widgetData.showBorder !== undefined) + ? root.widgetData.showBorder === true : true + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + // With border: pane → internalbg nesting. Without: plain surface. + StyledRect { + anchors.fill: parent + anchors.margins: root.showBorder ? 4 : 0 + variant: root.showBorder ? "pane" : "internalbg" + radius: Styling.radius(root.showBorder ? 4 : 0) + + StyledRect { + anchors.fill: parent + anchors.margins: root.showBorder ? 3 : 0 + variant: "internalbg" + radius: Styling.radius(0) + clip: true + visible: root.showBorder + + Image { + anchors.fill: parent + visible: root.imagePath.length > 0 + source: root.imagePath + fillMode: Image.PreserveAspectCrop + cache: false + asynchronous: true + mipmap: true + } + + Column { + anchors.centerIn: parent + visible: root.imagePath.length === 0 + spacing: 6 + + Image { + anchors.horizontalCenter: parent.horizontalCenter + mipmap: true + source: Qt.resolvedUrl("../../../../assets/retro/retro-logo.svg") + opacity: 0.2 + sourceSize.width: 48 + sourceSize.height: 48 + fillMode: Image.PreserveAspectFit + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Photo" + color: Qt.rgba(1, 1, 1, 0.3) + font.family: Config.theme.font + font.pixelSize: 9 + font.weight: Font.Medium + } + } + } + + // Borderless mode: image fills the entire widget. + Image { + anchors.fill: parent + visible: !root.showBorder && root.imagePath.length > 0 + source: root.imagePath + fillMode: Image.PreserveAspectCrop + cache: false + asynchronous: true + mipmap: true + } + + Column { + anchors.centerIn: parent + visible: !root.showBorder && root.imagePath.length === 0 + spacing: 6 + + Image { + anchors.horizontalCenter: parent.horizontalCenter + mipmap: true + source: Qt.resolvedUrl("../../../../assets/retro/retro-logo.svg") + opacity: 0.2 + sourceSize.width: 48 + sourceSize.height: 48 + fillMode: Image.PreserveAspectFit + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Photo" + color: Qt.rgba(1, 1, 1, 0.3) + font.family: Config.theme.font + font.pixelSize: 9 + font.weight: Font.Medium + } + } + } + } + } +} diff --git a/modules/retroshell/files/modules/widgets/desktop/PowerWidget.qml b/modules/retroshell/files/modules/widgets/desktop/PowerWidget.qml new file mode 100644 index 00000000..e6af17d3 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/PowerWidget.qml @@ -0,0 +1,81 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config +import qs.modules.widgets.desktop + +// Desktop power widget — 2x2 (160x160) power profiles & thermals. +WidgetHost { + id: root + + implicitWidth: 160 + implicitHeight: 160 + contentMargins: 0 + + property string variant: "compact" + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + property var wattMap: ({}) + + function refreshWatt() { + wattProc.command = ["bash", Quickshell.env("RETRO_DIR") + "/scripts/power_core.sh", "--list"]; + wattProc.running = true; + } + + function currentWatts() { + var src = Battery.isPluggedIn ? "ac" : "bat"; + var prf = (PowerProfile.currentProfile || "balanced").replace("power-", ""); + var v = content.wattMap[src + "_" + prf]; + return (v !== undefined && v > 0) ? v : 0; + } + + Process { + id: wattProc + running: false + stdout: StdioCollector { + onStreamFinished: { + var map = {}; + var lines = text.trim().split("\n"); + for (var i = 0; i < lines.length; i++) { + var parts = lines[i].split(": "); + if (parts.length === 2) { + map[parts[0].replace("PWR_", "").toLowerCase()] = parseInt(parts[1]); + } + } + content.wattMap = map; + } + } + } + + Connections { + target: PowerProfile + function onCurrentProfileChanged() { content.refreshWatt(); } + } + + Component.onCompleted: content.refreshWatt() + + PowerCard { + anchors.fill: parent + variant: root.variant + activeProfile: PowerProfile.currentProfile + powerDrawWatts: content.currentWatts() + cpuTempC: SystemResources.cpuTemp + gpuTempC: SystemResources.gpuTemp + fanSpeedRpm: SystemResources.fanRpm + profiles: [ + { id: "power-saver", label: "Saver", icon: Icons.powerSave }, + { id: "balanced", label: "Balanced", icon: Icons.balanced }, + { id: "performance", label: "Perf", icon: Icons.performance } + ] + onProfileSelected: function (id) { PowerProfile.setProfile(id); } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/StorageWidget.qml b/modules/retroshell/files/modules/widgets/desktop/StorageWidget.qml new file mode 100644 index 00000000..5c65610b --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/StorageWidget.qml @@ -0,0 +1,105 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config +import qs.modules.widgets.desktop + +// Desktop storage widget — 2x2 (160x160) Apple-style device storage card. +// Shows real usage for the configured storage device (settings → Desktop → +// Device Storage → pick device). Falls back to sample data until measured. +WidgetHost { + id: root + + implicitWidth: 160 + implicitHeight: 160 + contentMargins: 0 + + readonly property string device: (root.widgetData && root.widgetData.device) + ? String(root.widgetData.device) : "" + + property bool wide: false + property bool showBorder: true + + property var sampleSegments: [ + { label: "System", sizeGB: 120, color: "#FF3B30", striped: false }, + { label: "Home", sizeGB: 180, color: "#34C759", striped: false }, + { label: "Apps", sizeGB: 80, color: "#007AFF", striped: false }, + { label: "Cache", sizeGB: 45, color: "#FF9500", striped: false }, + { label: "Root", sizeGB: 30, color: "#D1D1D6", striped: true }, + { label: "Other", sizeGB: 57, color: "#8E8E93", striped: true } + ] + property real sampleTotal: 512 + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + property var segments: root.sampleSegments + property real totalGB: root.sampleTotal + property real usedGB: -1 + + function refresh() { + var dev = root.device; + if (!dev && SystemResources.validDisks.length > 0) + dev = SystemResources.validDisks[0]; + if (!dev) dev = "/"; + storageProc.command = ["bash", "-c", + "timeout 45 python3 " + Quickshell.shellDir + "/scripts/storage_buckets.py '" + String(dev).replace(/'/g, "") + "'"]; + storageProc.running = true; + } + + Process { + id: storageProc + running: false + stdout: StdioCollector { + onStreamFinished: { + var out = text.trim(); + if (!out) return; + try { + var d = JSON.parse(out); + if (d.buckets && d.buckets.length > 0) { + content.segments = d.buckets; + if (d.totalGB) content.totalGB = d.totalGB; + if (d.usedGB) content.usedGB = d.usedGB; + } + } catch (e) { + console.warn("StorageWidget: parse error", e); + } + } + } + } + + // Re-measure periodically (sizes change over time) + Timer { + id: refreshTimer + interval: 600000 + onTriggered: content.refresh() + } + + Connections { + target: root + function onWidgetDataChanged() { content.refresh(); } + } + + Component.onCompleted: { + content.refresh(); + refreshTimer.start(); + } + + DeviceStorageCard { + anchors.fill: parent + wide: root.wide + showBorder: root.showBorder + title: "Device Storage" + totalGB: content.totalGB + usedGB: content.usedGB + segments: content.segments + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/SystemMonitorWidget.qml b/modules/retroshell/files/modules/widgets/desktop/SystemMonitorWidget.qml new file mode 100644 index 00000000..62027b4f --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/SystemMonitorWidget.qml @@ -0,0 +1,172 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.modules.services +import qs.modules.components +import qs.config +import qs.modules.widgets.desktop + +// Desktop system monitor — 2x2 (160x160) card showing CPU, RAM and disk usage. +WidgetHost { + id: root + + implicitWidth: 160 + implicitHeight: 160 + contentMargins: 0 + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + readonly property string diskLabel: SystemResources.validDisks.length > 0 ? SystemResources.validDisks[0] : "/" + + // Card background + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: Colors.surfaceContainer } + GradientStop { position: 1.0; color: Colors.surfaceContainerLow } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 7 + + // CPU + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Text { + text: Icons.cpu + font.family: Icons.font + font.pixelSize: 14 + color: Colors.outline + } + Text { + Layout.fillWidth: true + text: "CPU" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 12 + font.weight: Font.Bold + } + Text { + text: Math.round(SystemResources.cpuUsage) + "%" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 12 + font.weight: Font.Bold + } + Item { + Layout.preferredWidth: 44 + Layout.preferredHeight: 6 + Rectangle { + anchors.fill: parent + radius: 3 + color: Colors.surfaceContainerHighest + } + Rectangle { + width: parent.width * (SystemResources.cpuUsage / 100) + height: parent.height + radius: 3 + color: Colors.primary + } + } + } + + // RAM + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Text { + text: Icons.ram + font.family: Icons.font + font.pixelSize: 14 + color: Colors.outline + } + Text { + Layout.fillWidth: true + text: "RAM" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 12 + font.weight: Font.Bold + } + Text { + text: Math.round(SystemResources.ramUsage) + "%" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 12 + font.weight: Font.Bold + } + Item { + Layout.preferredWidth: 44 + Layout.preferredHeight: 6 + Rectangle { + anchors.fill: parent + radius: 3 + color: Colors.surfaceContainerHighest + } + Rectangle { + width: parent.width * (SystemResources.ramUsage / 100) + height: parent.height + radius: 3 + color: Colors.primary + } + } + } + + // Disk + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Text { + text: Icons.disk + font.family: Icons.font + font.pixelSize: 14 + color: Colors.outline + } + Text { + Layout.fillWidth: true + text: "Disk" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 12 + font.weight: Font.Bold + } + Text { + text: (SystemResources.diskUsage[content.diskLabel] !== undefined + ? Math.round(SystemResources.diskUsage[content.diskLabel]) + : 0) + "%" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 12 + font.weight: Font.Bold + } + Item { + Layout.preferredWidth: 44 + Layout.preferredHeight: 6 + Rectangle { + anchors.fill: parent + radius: 3 + color: Colors.surfaceContainerHighest + } + Rectangle { + width: parent.width * (SystemResources.diskUsage[content.diskLabel] !== undefined + ? SystemResources.diskUsage[content.diskLabel] : 0) / 100 + height: parent.height + radius: 3 + color: Colors.tertiary + } + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/SystemPulseWidget.qml b/modules/retroshell/files/modules/widgets/desktop/SystemPulseWidget.qml new file mode 100644 index 00000000..1c2fc416 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/SystemPulseWidget.qml @@ -0,0 +1,65 @@ +import QtQuick +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config +import qs.modules.widgets.desktop + +// Desktop system monitor widget — 2x4 (320x160). +WidgetHost { + id: root + + implicitWidth: 320 + implicitHeight: 160 + contentMargins: 0 + + readonly property bool showCpu: (root.widgetData && root.widgetData.showCpu !== undefined) + ? root.widgetData.showCpu === true : true + readonly property bool showGpu: (root.widgetData && root.widgetData.showGpu !== undefined) + ? root.widgetData.showGpu === true : true + readonly property bool showRam: (root.widgetData && root.widgetData.showRam !== undefined) + ? root.widgetData.showRam === true : true + readonly property bool showDisk: (root.widgetData && root.widgetData.showDisk !== undefined) + ? root.widgetData.showDisk === true : false + readonly property bool showCpuTemp: (root.widgetData && root.widgetData.showCpuTemp !== undefined) + ? root.widgetData.showCpuTemp === true : true + readonly property bool showGpuTemp: (root.widgetData && root.widgetData.showGpuTemp !== undefined) + ? root.widgetData.showGpuTemp === true : true + readonly property bool showDiskTemp: (root.widgetData && root.widgetData.showDiskTemp !== undefined) + ? root.widgetData.showDiskTemp === true : false + + readonly property string diskLabel: SystemResources.validDisks.length > 0 ? SystemResources.validDisks[0] : "/" + + contentComponent: Component { + Item { + anchors.fill: parent + + SystemPulseCard { + anchors.fill: parent + + cpuUsage: SystemResources.cpuUsage + cpuTemp: SystemResources.cpuTemp + gpuUsage: SystemResources.gpuUsage + gpuTemp: SystemResources.gpuTemp + ramUsage: SystemResources.ramUsage + diskUsage: SystemResources.diskUsage[root.diskLabel] !== undefined ? SystemResources.diskUsage[root.diskLabel] : 0 + diskTemp: SystemResources.diskTemp + + cpuHistory: SystemResources.cpuHistory + gpuHistory: SystemResources.gpuHistories.length > 0 ? SystemResources.gpuHistories[0] : [] + ramHistory: SystemResources.ramHistory + cpuTempHistory: SystemResources.cpuTempHistory + gpuTempHistory: SystemResources.gpuTempHistories.length > 0 ? SystemResources.gpuTempHistories[0] : [] + diskTempHistory: SystemResources.diskTempHistory + + showCpu: root.showCpu + showGpu: root.showGpu + showRam: root.showRam + showDisk: root.showDisk + showCpuTemp: root.showCpuTemp + showGpuTemp: root.showGpuTemp + showDiskTemp: root.showDiskTemp + } + } + } +} diff --git a/modules/retroshell/files/modules/widgets/desktop/WeatherWidget.qml b/modules/retroshell/files/modules/widgets/desktop/WeatherWidget.qml new file mode 100644 index 00000000..c9029c42 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/WeatherWidget.qml @@ -0,0 +1,104 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.modules.services +import qs.modules.components +import qs.config +import qs.modules.widgets.desktop +import qs.modules.widgets.dashboard.widgets + +// Desktop weather widget — animated weather scene + 7-day forecast card. +WidgetHost { + id: root + + implicitWidth: 320 + implicitHeight: 240 + + contentComponent: Component { + ColumnLayout { + anchors.fill: parent + spacing: 4 + + WeatherWidget { + id: scene + Layout.fillWidth: true + Layout.preferredHeight: 140 + showDebugControls: false + animationsEnabled: true + } + + // 7-day forecast row + Item { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + visible: WeatherService.dataAvailable && WeatherService.forecast.length > 0 + + StyledRect { + id: forecastCard + variant: "pane" + anchors.fill: parent + radius: Styling.radius(2) + + Row { + anchors.centerIn: parent + spacing: 4 + + Repeater { + model: WeatherService.forecast.slice(0, 5) + + Row { + id: fdayRow + required property var modelData + required property int index + spacing: 4 + + Column { + spacing: 1 + width: (scene.width - 12 - (4 * 4) - (4 * 6)) / 5 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: fdayRow.modelData.dayName + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.Medium + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: fdayRow.modelData.emoji + font.family: Config.emojiFont + font.pixelSize: Styling.fontSize(2) + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: (Math.round(fdayRow.modelData.maxTemp) >= 0 ? "+" : "") + Math.round(fdayRow.modelData.maxTemp) + "\u00B0" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.Bold + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: (Math.round(fdayRow.modelData.minTemp) >= 0 ? "+" : "") + Math.round(fdayRow.modelData.minTemp) + "\u00B0" + color: Colors.outline + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.Normal + } + } + + Separator { + vert: true + visible: fdayRow.index < 4 + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + } + } + } + } +} diff --git a/modules/retroshell/files/modules/widgets/desktop/WeatherWidget2x2.qml b/modules/retroshell/files/modules/widgets/desktop/WeatherWidget2x2.qml new file mode 100644 index 00000000..38390371 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/WeatherWidget2x2.qml @@ -0,0 +1,21 @@ +import QtQuick +import qs.modules.widgets.desktop +import qs.modules.widgets.dashboard.widgets + +// Desktop weather widget — square (2x2) card, 160x160. Just the animated +// weather scene filling the whole card, nothing else. +WidgetHost { + id: root + + implicitWidth: 160 + implicitHeight: 160 + contentMargins: 0 + + contentComponent: Component { + WeatherWidget { + anchors.fill: parent + showDebugControls: false + animationsEnabled: true + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/WeatherWidget2x4.qml b/modules/retroshell/files/modules/widgets/desktop/WeatherWidget2x4.qml new file mode 100644 index 00000000..bac51c01 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/WeatherWidget2x4.qml @@ -0,0 +1,100 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.modules.services +import qs.modules.components +import qs.config +import qs.modules.widgets.desktop +import qs.modules.widgets.dashboard.widgets + +// Desktop weather widget — wide (2x4) card, 320x160. Animated weather scene +// fills the whole card; an entire-week forecast strip overlays the bottom. +WidgetHost { + id: root + + implicitWidth: 320 + implicitHeight: 160 + contentMargins: 0 + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + // Animated weather scene filling the whole card + WeatherWidget { + anchors.fill: parent + showDebugControls: false + animationsEnabled: true + } + + // Bottom fade so the forecast reads over the scene + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 66 + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0) } + GradientStop { position: 0.5; color: Qt.rgba(0, 0, 0, 0.4) } + GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.62) } + } + } + + // Entire-week forecast strip (7 days) + Row { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 8 + spacing: 2 + + Repeater { + model: WeatherService.dataAvailable ? WeatherService.forecast : [] + + Item { + required property var modelData + required property int index + + width: (parent.width - (7 - 1) * 2) / 7 + height: 56 + + Column { + anchors.fill: parent + anchors.topMargin: 4 + spacing: 1 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData.dayName + color: Qt.rgba(1, 1, 1, 0.85) + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + layer.enabled: true + layer.effect: BgShadow {} + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData.emoji + font.family: Config.emojiFont + font.pixelSize: Styling.fontSize(1) + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: Math.round(modelData.maxTemp) + "°" + color: "#ffffff" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + font.weight: Font.Bold + layer.enabled: true + layer.effect: BgShadow {} + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/WidgetHost.qml b/modules/retroshell/files/modules/widgets/desktop/WidgetHost.qml new file mode 100644 index 00000000..b69caff6 --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/WidgetHost.qml @@ -0,0 +1,186 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.modules.theme +import qs.modules.components +import qs.config + +// Draggable desktop widget card. Fills its positioned delegate; the delegate +// (DesktopWidgets) owns the x/y placement. In edit mode the whole card can be +// dragged; the card reports normalized (0..1) position changes which the +// delegate commits to the widget file. Each card can be locked so it cannot +// be dragged. +Item { + id: root + + // The {id,type,x,y,width,height,locked} entry for this widget. + property var widgetData: ({}) + // Component to render inside the card. + property Component contentComponent: null + // The desktop layer this widget is placed on (used for drag size math). + property Item widgetLayer: null + + // Whether this widget is locked (cannot be dragged). + property bool locked: root.widgetData && root.widgetData.locked === true + + // Inner margin around the content. Set to 0 to let content bleed to the + // card edges (e.g. album-art backgrounds clipped to the rounded corners). + property real contentMargins: 8 + + // Whether the host card draws its theme border (off for borderless cards). + property bool cardBorder: true + + // Fractional position (0..1) within the layer. + property real normX: root.widgetData && root.widgetData.x !== undefined ? root.widgetData.x : 0.5 + property real normY: root.widgetData && root.widgetData.y !== undefined ? root.widgetData.y : 0.5 + + // Live position change (every move) and final commit (on release). + signal positionChanged(real nx, real ny) + signal positionCommitted(real nx, real ny) + signal requestRemove() + signal lockToggled(bool newLocked) + + readonly property real layerW: widgetLayer ? widgetLayer.width : 0 + readonly property real layerH: widgetLayer ? widgetLayer.height : 0 + + StyledRect { + id: card + anchors.fill: parent + variant: "pane" + radius: Styling.radius(6) + enableShadow: true + enableBorder: root.cardBorder + + Loader { + id: contentLoader + anchors.fill: parent + anchors.margins: root.contentMargins + clip: true + sourceComponent: root.contentComponent + } + } + + // Whole-card drag area, active in edit mode and only when unlocked. + MouseArea { + id: dragArea + anchors.fill: parent + enabled: Config.desktop.editMode && !root.locked + cursorShape: enabled ? Qt.ClosedHandCursor : Qt.ArrowCursor + + onPressed: (mouse) => root.dragStart(mouse.x, mouse.y) + onPositionChanged: (mouse) => root.dragMove(mouse.x, mouse.y) + onReleased: (mouse) => root.dragEnd() + } + + // Edit-mode chrome (border + lock + remove) + Item { + id: editChrome + anchors.fill: parent + visible: Config.desktop.editMode + z: 10 + + Rectangle { + anchors.fill: parent + color: "transparent" + radius: card.radius + border.color: Styling.srItem("overprimary") + border.width: 2 + opacity: 0.8 + } + + // Lock toggle (top-left) + StyledRect { + id: lockBtn + variant: root.locked ? "primary" : "common" + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: 6 + width: 22 + height: 22 + radius: 11 + z: 20 + + Text { + anchors.centerIn: parent + text: root.locked ? Icons.lock : Icons.unpin + font.family: Icons.font + font.pixelSize: 12 + color: Styling.srItem(root.locked ? "primary" : "common") + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + root.locked = !root.locked; + root.lockToggled(root.locked); + } + } + } + + // Remove button (top-right) + StyledRect { + id: closeBtn + variant: "error" + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 6 + width: 22 + height: 22 + radius: 11 + z: 20 + + Text { + anchors.centerIn: parent + text: Icons.cancel + font.family: Icons.font + font.pixelSize: 12 + color: Styling.srItem("error") + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.requestRemove() + } + } + } + + // ── Drag logic (grab offset in the stationary layer's coordinate space) ── + property bool dragging: false + // Offset (in layer coords) from the widget center to the grab point. + property real grabDX: 0 + property real grabDY: 0 + + function dragStart(lx, ly) { + dragging = true; + if (root.widgetLayer) { + var center = root.widgetLayer.mapFromItem(root, root.width / 2, root.height / 2); + var grab = root.widgetLayer.mapFromItem(root, lx, ly); + grabDX = grab.x - center.x; + grabDY = grab.y - center.y; + } else { + grabDX = 0; + grabDY = 0; + } + } + + function dragMove(lx, ly) { + if (!dragging) return; + if (root.layerW <= 0 || root.layerH <= 0) return; + var cur = root.widgetLayer ? root.widgetLayer.mapFromItem(root, lx, ly) : Qt.point(lx, ly); + var nx = (cur.x - grabDX) / root.layerW; + var ny = (cur.y - grabDY) / root.layerH; + nx = Math.max(0, Math.min(1, nx)); + ny = Math.max(0, Math.min(1, ny)); + root.normX = nx; + root.normY = ny; + root.positionChanged(nx, ny); + } + + function dragEnd() { + if (!dragging) return; + dragging = false; + root.positionCommitted(root.normX, root.normY); + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/WorldClockWidget.qml b/modules/retroshell/files/modules/widgets/desktop/WorldClockWidget.qml new file mode 100644 index 00000000..7936c68f --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/WorldClockWidget.qml @@ -0,0 +1,166 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Io +import qs.modules.theme +import qs.config +import qs.modules.widgets.desktop + +// Desktop world clock — 2x4 (320x160) showing up to 4 timezones. +// Timezones are configured from Settings (stored in the widget entry as +// "timezones": ["UTC", "America/New_York", ...]). Offsets are resolved once +// (and refreshed every 10 min to account for DST changes). +WidgetHost { + id: root + + implicitWidth: 320 + implicitHeight: 80 + contentMargins: 0 + + readonly property var timezones: (root.widgetData && root.widgetData.timezones && root.widgetData.timezones.length > 0) + ? root.widgetData.timezones : ["UTC"] + + contentComponent: Component { + Item { + id: content + anchors.fill: parent + + property int tick: 0 + property var offsets: ({}) + + function parseOffset(str) { + str = (str || "").trim(); + if (str.length < 5) return 0; + var sign = str[0] === "-" ? -1 : 1; + var hh = parseInt(str.substring(1, 3), 10) || 0; + var mm = parseInt(str.substring(3, 5), 10) || 0; + return sign * (hh * 60 + mm); + } + + function shortName(zone) { + var parts = String(zone).split("/"); + var last = parts[parts.length - 1].replace(/_/g, " "); + return last.length > 11 ? last.substring(0, 10) + "…" : last; + } + + function offsetFor(zone) { + var off = content.offsets[zone]; + return off !== undefined ? off : 0; + } + + function timeFor(zone) { + content.tick; + var t = new Date(Date.now() + content.offsetFor(zone) * 60000); + var hh = t.getUTCHours(); + var mm = t.getUTCMinutes(); + return (hh < 10 ? "0" : "") + hh + ":" + (mm < 10 ? "0" : "") + mm; + } + + function dayFor(zone) { + content.tick; + var days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + var t = new Date(Date.now() + content.offsetFor(zone) * 60000); + return days[t.getUTCDay()]; + } + + function refreshOffsets() { + if (!root.timezones || root.timezones.length === 0) return; + var cmd = "for z in"; + for (var i = 0; i < root.timezones.length; i++) { + cmd += " '" + String(root.timezones[i]).replace(/'/g, "") + "'"; + } + cmd += "; do echo \"$z|$(TZ=$z date +%z)\"; done"; + tzProc.command = ["bash", "-c", cmd]; + tzProc.running = true; + } + + Process { + id: tzProc + running: false + stdout: StdioCollector { + onStreamFinished: { + var out = text.trim(); + if (!out) return; + var lines = out.split("\n"); + for (var i = 0; i < lines.length; i++) { + var parts = lines[i].split("|"); + if (parts.length === 2) { + content.offsets[parts[0]] = content.parseOffset(parts[1]); + } + } + tzTimer.restart(); + } + } + } + + // Re-resolve offsets periodically (DST changes etc.) + Timer { + id: tzTimer + interval: 600000 + onTriggered: content.refreshOffsets() + } + + Timer { + interval: 1000; running: true; repeat: true; triggeredOnStart: true + onTriggered: content.tick++ + } + + Component.onCompleted: content.refreshOffsets() + + Connections { + target: root + function onWidgetDataChanged() { + content.refreshOffsets(); + } + } + + RowLayout { + anchors.fill: parent + anchors.margins: 6 + spacing: 6 + + Repeater { + model: root.timezones + delegate: Item { + required property string modelData + Layout.fillWidth: true + Layout.fillHeight: true + + ColumnLayout { + anchors.centerIn: parent + spacing: 2 + + Text { + text: content.shortName(modelData) + color: Colors.outline + font.family: Config.theme.font + font.pixelSize: 10 + elide: Text.ElideRight + maximumLineCount: 1 + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + } + + Text { + text: content.timeFor(modelData) + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 20 + font.weight: Font.Bold + Layout.alignment: Qt.AlignHCenter + } + + Text { + text: content.dayFor(modelData) + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: 10 + opacity: 0.7 + Layout.alignment: Qt.AlignHCenter + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/retroshell/files/modules/widgets/desktop/layout.js b/modules/retroshell/files/modules/widgets/desktop/layout.js new file mode 100644 index 00000000..fa7388ae --- /dev/null +++ b/modules/retroshell/files/modules/widgets/desktop/layout.js @@ -0,0 +1,118 @@ +var weekDays = [ // MONDAY IS THE FIRST DAY OF THE WEEK :HESRIGHTYOUKNOW: + { day: 'Mo', today: 0 }, + { day: 'Tu', today: 0 }, + { day: 'We', today: 0 }, + { day: 'Th', today: 0 }, + { day: 'Fr', today: 0 }, + { day: 'Sa', today: 0 }, + { day: 'Su', today: 0 }, +] + +function checkLeapYear(year) { + return ( + year % 400 == 0 || + (year % 4 == 0 && year % 100 != 0)); +} + +function getMonthDays(month, year) { + const leapYear = checkLeapYear(year); + if ((month <= 7 && month % 2 == 1) || (month >= 8 && month % 2 == 0)) return 31; + if (month == 2 && leapYear) return 29; + if (month == 2 && !leapYear) return 28; + return 30; +} + +function getNextMonthDays(month, year) { + const leapYear = checkLeapYear(year); + if (month == 1 && leapYear) return 29; + if (month == 1 && !leapYear) return 28; + if (month == 12) return 31; + if ((month <= 7 && month % 2 == 1) || (month >= 8 && month % 2 == 0)) return 30; + return 31; +} + +function getPrevMonthDays(month, year) { + const leapYear = checkLeapYear(year); + if (month == 3 && leapYear) return 29; + if (month == 3 && !leapYear) return 28; + if (month == 1) return 31; + if ((month <= 7 && month % 2 == 1) || (month >= 8 && month % 2 == 0)) return 30; + return 31; +} + +function getDateInXMonthsTime(x, baseDate) { + var currentDate = baseDate || new Date(); // Get the current date + if (x == 0) return currentDate; // If x is 0, return the current date + + var targetMonth = currentDate.getMonth() + x; // Calculate the target month + var targetYear = currentDate.getFullYear(); // Get the current year + + // Adjust the year and month if necessary + targetYear += Math.floor(targetMonth / 12); + targetMonth = (targetMonth % 12 + 12) % 12; + + // Create a new date object with the target year and month + var targetDate = new Date(targetYear, targetMonth, 1); + + // Set the day to the last day of the month to get the desired date + // targetDate.setDate(0); + + return targetDate; +} + +function getCalendarLayout(dateObject, highlight) { + if (!dateObject) dateObject = new Date(); + const weekday = (dateObject.getDay() + 6) % 7; // MONDAY IS THE FIRST DAY OF THE WEEK + const day = dateObject.getDate(); + const month = dateObject.getMonth() + 1; + const year = dateObject.getFullYear(); + const weekdayOfMonthFirst = (weekday + 35 - (day - 1)) % 7; + const daysInMonth = getMonthDays(month, year); + const daysInNextMonth = getNextMonthDays(month, year); + const daysInPrevMonth = getPrevMonthDays(month, year); + + // Fill + var monthDiff = (weekdayOfMonthFirst == 0 ? 0 : -1); + var toFill, dim; + if(weekdayOfMonthFirst == 0) { + toFill = 1; + dim = daysInMonth; + } + else { + toFill = (daysInPrevMonth - (weekdayOfMonthFirst - 1)); + dim = daysInPrevMonth; + } + var calendar = [...Array(6)].map(() => Array(7)); + var i = 0, j = 0; + var currentWeekRow = -1; + while (i < 6 && j < 7) { + calendar[i][j] = { + "day": toFill, + "today": ((toFill == day && monthDiff == 0 && highlight) ? 1 : ( + monthDiff == 0 ? 0 : + -1 + )) + }; + if (toFill == day && monthDiff == 0 && highlight) { + currentWeekRow = i; + } + // Increment + toFill++; + if (toFill > dim) { // Next month? + monthDiff++; + if (monthDiff == 0) + dim = daysInMonth; + else if (monthDiff == 1) + dim = daysInNextMonth; + toFill = 1; + } + // Next tile + j++; + if (j == 7) { + j = 0; + i++; + } + + } + return { calendar: calendar, currentWeekRow: currentWeekRow }; +} diff --git a/modules/retroshell/files/modules/widgets/presets/PresetsTab.qml b/modules/retroshell/files/modules/widgets/presets/PresetsTab.qml index 03d42de4..a56deb6e 100644 --- a/modules/retroshell/files/modules/widgets/presets/PresetsTab.qml +++ b/modules/retroshell/files/modules/widgets/presets/PresetsTab.qml @@ -23,7 +23,7 @@ Item { readonly property string activePreset: PresetsService.activePreset // Available config files - readonly property var availableConfigFiles: ["bar.js", "desktop.js", "dock.js", "compositor.js", "lockscreen.js", "notch.js", "overview.js", "performance.js", "theme.js", "workspaces.js"] + readonly property var availableConfigFiles: ["bar.js", "desktop.js", "desktop_widgets.js", "dock.js", "compositor.js", "lockscreen.js", "notch.js", "overview.js", "performance.js", "theme.js", "workspaces.js"] // List model ListModel { diff --git a/modules/retroshell/files/modules/widgets/tools/ToolsMenu.qml b/modules/retroshell/files/modules/widgets/tools/ToolsMenu.qml index 823606bc..ebe041f3 100644 --- a/modules/retroshell/files/modules/widgets/tools/ToolsMenu.qml +++ b/modules/retroshell/files/modules/widgets/tools/ToolsMenu.qml @@ -79,13 +79,15 @@ ActionGrid { tooltip: "Webcam Overlay", command: "" }; + case "docker": + return { icon: Icons.docker, tooltip: "Docker Manager", command: "" }; } return null; } actions: { const order = (Config.bar && Config.bar.toolboxOrder) - ? Config.bar.toolboxOrder : ["screenshot", "screenshots", "separator", "recorder", "recordings", "separator", "colorpicker", "ocr", "qr", "lens", "shazam", "webcam"]; + ? Config.bar.toolboxOrder : ["screenshot", "screenshots", "separator", "recorder", "recordings", "separator", "colorpicker", "ocr", "qr", "lens", "shazam", "webcam", "docker"]; const result = []; for (let i = 0; i < order.length; i++) { const item = itemFor(order[i]); @@ -203,6 +205,9 @@ ActionGrid { root.itemSelected(); } else if (action.tooltip === "Webcam Overlay") { GlobalStates.webcamOverlayVisible = !GlobalStates.webcamOverlayVisible; + } else if (action.tooltip === "Docker Manager") { + DockerService.refresh(); + root.itemSelected(); } } } diff --git a/modules/retroshell/files/scripts/cache_format.json b/modules/retroshell/files/scripts/cache_format.json new file mode 100644 index 00000000..a39b837e --- /dev/null +++ b/modules/retroshell/files/scripts/cache_format.json @@ -0,0 +1,3 @@ +{ + "version": "4" +} diff --git a/modules/retroshell/files/scripts/feed_fetch.py b/modules/retroshell/files/scripts/feed_fetch.py new file mode 100755 index 00000000..1e111daf --- /dev/null +++ b/modules/retroshell/files/scripts/feed_fetch.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Fetch top articles for the desktop feed widget. + +Usage: feed_fetch.py [apiKey] + source: devto | hackernews | dailydev + +Outputs a JSON array: + [{id, title, url, readTime, upvotes, source, author, image}] +Cached for 30 minutes per (source, query). Offline falls back to cache. +""" + +import json +import os +import sys +import time +import urllib.parse +import urllib.request + +CACHE_DIR = os.path.expanduser("~/.cache/retro") +MAX_AGE = 30 * 60 + +HEADERS = {"User-Agent": "RetroLinux-Shell/1.0", "Accept": "application/json"} + + +def cache_path(source, query): + return os.path.join(CACHE_DIR, "feed_%s_%s.json" % (source, query or "all")) + + +def get_json(url, headers=None, data=None): + req = urllib.request.Request(url, headers=headers or HEADERS, data=data) + with urllib.request.urlopen(req, timeout=12) as r: + return json.load(r) + + +def fetch_devto(query, count): + url = "https://dev.to/api/articles?per_page=%d&top=1" % count + if query: + url += "&tag=%s" % urllib.parse.quote(query) + data = get_json(url) + out = [] + for a in data: + out.append({ + "id": str(a.get("id", "")), + "title": a.get("title", ""), + "url": a.get("url", ""), + "readTime": a.get("reading_time_minutes", 0), + "upvotes": a.get("public_reactions_count", 0), + "source": "dev.to", + "author": (a.get("user") or {}).get("username", ""), + "image": a.get("cover_image") or a.get("social_image") or "", + }) + return out + + +def fetch_hackernews(query, count): + url = ("https://hn.algolia.com/api/v1/search?query=%s&tags=story&hitsPerPage=%d" + % (urllib.parse.quote(query or "programming"), count)) + data = get_json(url) + out = [] + for h in data.get("hits", []): + if not h.get("title"): + continue + oid = str(h.get("objectID", "")) + out.append({ + "id": oid, + "title": h.get("title", ""), + "url": h.get("url") or ("https://news.ycombinator.com/item?id=%s" % oid), + "readTime": 0, + "upvotes": h.get("points", 0), + "source": "Hacker News", + "author": h.get("author", ""), + "image": "", + }) + return out + + +def fetch_dailydev(query, api_key, count): + url = "https://api.daily.dev/graphql" + gql = ('query { searchPosts(query: "%s", first: %d) { edges { node { ' + 'id title permalink readTime numUpvotes image source { name } } } } }' + % (query.replace('"', ""), count)) + body = json.dumps({"query": gql}).encode() + headers = dict(HEADERS) + headers["Content-Type"] = "application/json" + if api_key: + headers["Authorization"] = "Bearer %s" % api_key + data = get_json(url, headers=headers, data=body) + out = [] + for e in (data.get("data", {}).get("searchPosts", {}).get("edges") or []): + n = e.get("node") or {} + out.append({ + "id": str(n.get("id", "")), + "title": n.get("title", ""), + "url": n.get("permalink", ""), + "readTime": n.get("readTime", 0) or 0, + "upvotes": n.get("numUpvotes", 0) or 0, + "source": (n.get("source") or {}).get("name", "daily.dev"), + "author": "", + "image": n.get("image", ""), + }) + return out + + +def read_cache(source, query): + try: + with open(cache_path(source, query)) as f: + return f.read().strip() + except OSError: + return None + + +def main(): + source = (sys.argv[1] if len(sys.argv) > 1 else "devto").strip() + query = (sys.argv[2] if len(sys.argv) > 2 else "").strip() + api_key = (sys.argv[3] if len(sys.argv) > 3 else "").strip() + try: + count = max(3, min(10, int(sys.argv[4] if len(sys.argv) > 4 else 5))) + except ValueError: + count = 5 + + cached = read_cache(source, query) + if cached: + try: + if time.time() - os.path.getmtime(cache_path(source, query)) < MAX_AGE: + print(cached) + return + except OSError: + pass + + try: + if source == "hackernews": + articles = fetch_hackernews(query, count) + elif source == "dailydev": + articles = fetch_dailydev(query, api_key, count) + else: + articles = fetch_devto(query, count) + try: + os.makedirs(CACHE_DIR, exist_ok=True) + with open(cache_path(source, query), "w") as f: + json.dump(articles, f) + except OSError: + pass + print(json.dumps(articles)) + except Exception: + if cached: + print(cached) + else: + print("[]") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/modules/retroshell/files/scripts/network_monitor.py b/modules/retroshell/files/scripts/network_monitor.py new file mode 100755 index 00000000..24148926 --- /dev/null +++ b/modules/retroshell/files/scripts/network_monitor.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Stream real-time network bandwidth + IP info for the Network widget. + +Emits one JSON line per second: + {"interface", "download", "upload", "localIp", "publicIp", "publicIpValid"} + +download/upload are Mbps computed from /proc/net/dev byte deltas. The public +IP is fetched once in a background thread (and retried on failure / refreshed +periodically); until it resolves ``publicIp`` is empty and ``publicIpValid`` +is false. +""" + +import json +import re +import subprocess +import threading +import time +import urllib.request + +INTERVAL = 1.0 +PUBLIC_RETRY_SEC = 300 + + +def _read_dev(): + data = {} + try: + with open("/proc/net/dev", "r") as f: + lines = f.read().splitlines()[2:] + except OSError: + return data + for line in lines: + if ":" not in line: + continue + iface, rest = line.split(":", 1) + vals = rest.split() + if len(vals) >= 9: + try: + data[iface.strip()] = (int(vals[0]), int(vals[8])) # rx, tx bytes + except ValueError: + pass + return data + + +def _active_interface(): + try: + out = subprocess.run( + ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "d", "status"], + capture_output=True, + text=True, + timeout=3, + ).stdout + for line in out.splitlines(): + parts = line.split(":") + if len(parts) >= 3 and parts[2] == "connected" and parts[1] in ("wifi", "ethernet"): + return parts[0] + except Exception: + pass + return "wlan0" + + +def _local_ip(iface): + try: + out = subprocess.run( + ["ip", "-4", "-o", "addr", "show", iface], + capture_output=True, + text=True, + timeout=3, + ).stdout + m = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", out) + if m: + return m.group(1) + except Exception: + pass + return "" + + +_public_ip = "" +_public_valid = False + + +def _fetch_public(): + global _public_ip, _public_valid + try: + with urllib.request.urlopen("https://api.ipify.org", timeout=5) as r: + v = r.read().decode().strip() + if re.match(r"^\d+\.\d+\.\d+\.\d+$", v): + _public_ip = v + _public_valid = True + except Exception: + pass + + +def _schedule_public(): + threading.Thread(target=_fetch_public, daemon=True).start() + + +def main(): + iface = _active_interface() + local = _local_ip(iface) + prev = _read_dev() + last = time.time() + last_public = 0 + last_recheck = time.time() + _schedule_public() + + while True: + time.sleep(INTERVAL) + cur = _read_dev() + now = time.time() + dt = max(0.001, now - last) + last = now + + now2 = time.time() + if now2 - last_recheck >= 30: + new_iface = _active_interface() + if new_iface != iface: + iface = new_iface + prev = cur + local = _local_ip(iface) + last_recheck = now2 + + prev_rx, prev_tx = prev.get(iface, (0, 0)) + cur_rx, cur_tx = cur.get(iface, (0, 0)) + down = max(0.0, (cur_rx - prev_rx) * 8 / dt / 1e6) + up = max(0.0, (cur_tx - prev_tx) * 8 / dt / 1e6) + + if not local: + local = _local_ip(iface) + + if not _public_valid and now - last_public >= 60: + _schedule_public() + last_public = now + elif _public_valid and now - last_public >= PUBLIC_RETRY_SEC: + _schedule_public() + last_public = now + + print(json.dumps({ + "interface": iface, + "download": round(down, 2), + "upload": round(up, 2), + "localIp": local, + "publicIp": _public_ip, + "publicIpValid": _public_valid, + }), flush=True) + prev = cur + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/modules/retroshell/files/scripts/slice_audio.py b/modules/retroshell/files/scripts/slice_audio.py new file mode 100755 index 00000000..f432d0b8 --- /dev/null +++ b/modules/retroshell/files/scripts/slice_audio.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +import os +import sys +import json +import argparse +import subprocess +import shutil + + +CACHE_FORMAT_PATH = os.path.join(os.path.dirname(__file__), "cache_format.json") +with open(CACHE_FORMAT_PATH, "r", encoding="utf-8") as f: + CACHE_FORMAT_VERSION = str(json.load(f)["version"]) + +DEFAULT_PACK_GAIN = 2.0 + + +def transcode_to_qsoundeffect_wav(source_path, destination_path, gain): + """Write a conservative WAV format supported by Qt's QSoundEffect.""" + cmd = [ + "ffmpeg", "-y", "-i", source_path, + "-map", "0:a:0", "-ar", "44100", "-ac", "1", + "-c:a", "pcm_s16le", + ] + if gain != 1.0: + cmd.extend(["-af", f"volume={gain},alimiter=limit=1.0"]) + cmd.append(destination_path) + result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + if result.returncode != 0: + error = result.stderr.decode("utf-8", errors="ignore").strip() + raise RuntimeError(f"ffmpeg failed for {source_path}: {error}") + +def main(): + parser = argparse.ArgumentParser(description="Slice Mechvibes sound pack into individual key audio files.") + parser.add_argument("--pack-dir", required=True, help="Absolute path to the sound pack directory") + parser.add_argument("--cache-dir", required=True, help="Absolute path to the cache output directory") + parser.add_argument("--pack-gain", type=float, default=DEFAULT_PACK_GAIN, + help="Linear gain before limiting (default: 2.0)") + args = parser.parse_args() + + pack_dir = os.path.abspath(args.pack_dir) + cache_dir = os.path.abspath(args.cache_dir) + config_path = os.path.join(pack_dir, "config.json") + + if not os.path.exists(config_path): + print(f"Error: config.json not found in {pack_dir}", file=sys.stderr) + sys.exit(1) + + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + except Exception as e: + print(f"Error parsing config.json: {e}", file=sys.stderr) + sys.exit(1) + + os.makedirs(cache_dir, exist_ok=True) + + key_define_type = config.get("key_define_type", "single") + defines = config.get("defines", {}) + + if not defines: + print("Error: No 'defines' found in config.json", file=sys.stderr) + sys.exit(1) + + if key_define_type == "single": + sound_file_name = config.get("sound") + if not sound_file_name: + print("Error: 'sound' key missing for single define type", file=sys.stderr) + sys.exit(1) + + sound_path = os.path.join(pack_dir, sound_file_name) + if not os.path.exists(sound_path): + print(f"Error: Sound file {sound_path} not found", file=sys.stderr) + sys.exit(1) + + # Slice each key individually — a single multi-output ffmpeg command + # breaks because per-output -ss/-af on a shared decoder produces + # silent or corrupted WAVs. + keys_to_slice = [(k, d) for k, d in defines.items() + if d and len(d) >= 2] + total = len(keys_to_slice) + print(f"Slicing {total} keys from {sound_path} to {cache_dir}...") + failed = 0 + for idx, (keycode, define) in enumerate(keys_to_slice, 1): + offset_ms, duration_ms = define[0], define[1] + out_file = os.path.join(cache_dir, f"{keycode}.wav") + + cmd = [ + "ffmpeg", "-y", + "-ss", f"{offset_ms / 1000.0}", + "-i", sound_path, + "-t", f"{duration_ms / 1000.0}", + "-map", "0:a:0", "-ar", "44100", "-ac", "1", + "-c:a", "pcm_s16le", + ] + if args.pack_gain != 1.0: + cmd.extend(["-af", f"volume={args.pack_gain},alimiter=limit=1.0"]) + cmd.append(out_file) + + res = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + if res.returncode != 0: + err = res.stderr.decode('utf-8', errors='ignore').strip() + print(f" [{idx}/{total}] keycode {keycode}: {err}", file=sys.stderr) + failed += 1 + + if failed == total: + print("Error: All slices failed", file=sys.stderr) + sys.exit(1) + if failed: + print(f"Warning: {failed}/{total} slices failed", file=sys.stderr) + + elif key_define_type == "multi": + print(f"Copying/converting multi-file sound pack from {pack_dir}...") + normalized_sources = {} + for keycode, file_name in defines.items(): + if not file_name: + continue + src_path = os.path.join(pack_dir, file_name) + if not os.path.exists(src_path): + continue + + dest_path = os.path.join(cache_dir, f"{keycode}.wav") + # Do not symlink/copy source WAVs: QSoundEffect accepts only a + # conservative subset of WAV variants. Normalize every file. + try: + # Old caches used symlinks to the source pack. Remove the + # link itself before ffmpeg writes, never follow it. + if os.path.lexists(dest_path): + os.remove(dest_path) + if src_path in normalized_sources: + shutil.copyfile(normalized_sources[src_path], dest_path) + else: + transcode_to_qsoundeffect_wav(src_path, dest_path, args.pack_gain) + normalized_sources[src_path] = dest_path + except RuntimeError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + # Create marker file + try: + with open(os.path.join(cache_dir, ".complete"), "w", encoding="utf-8") as f: + f.write(CACHE_FORMAT_VERSION) + except Exception as e: + print(f"Warning: Could not write marker file: {e}", file=sys.stderr) + + print("Success: Sound pack prepared successfully.") + +if __name__ == "__main__": + main() diff --git a/modules/retroshell/files/scripts/storage_buckets.py b/modules/retroshell/files/scripts/storage_buckets.py new file mode 100755 index 00000000..bd3a6e95 --- /dev/null +++ b/modules/retroshell/files/scripts/storage_buckets.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Measure real storage usage for the Device Storage widget. + +Usage: storage_buckets.py [mount] +Outputs a JSON object with the device's total/used (via statvfs) plus the +Linux bucket sizes measured with ``du`` (best-effort, timeout guarded): + + - System : /usr, /opt, /snap, /nix + - Home : $HOME excluding cache/container/trash paths + - Cache : ~/.cache, ~/.var (flatpak), docker, libvirt images + - Apps : ~/.local/share (Steam, Kiwix, game launchers, etc.) + - Root : /var/log, /tmp, swap + - Other : gap between measured buckets and actual disk usage +""" + +import json +import os +import subprocess +import sys + +DU_TIMEOUT = 12 + + +def _du(path): + """Directory size in bytes (best-effort, single filesystem).""" + if not path or not os.path.exists(path): + return 0 + try: + out = subprocess.run( + ["du", "-s", "-x", "--", path], + capture_output=True, + text=True, + timeout=DU_TIMEOUT, + ) + if out.returncode == 0 and out.stdout: + return int(out.stdout.split("\t")[0].strip()) * 1024 + except (OSError, ValueError, subprocess.TimeoutExpired): + pass + return 0 + + +def _du_home(home): + """Home directory size excluding cache/container/trash buckets.""" + if not home or not os.path.exists(home): + return 0 + try: + out = subprocess.run( + [ + "du", "-s", "-x", + "--exclude=.cache", "--exclude=.var", "--exclude=Trash", + "--", home, + ], + capture_output=True, + text=True, + timeout=DU_TIMEOUT + 8, + ) + if out.returncode == 0 and out.stdout: + return int(out.stdout.split("\t")[0].strip()) * 1024 + except (OSError, ValueError, subprocess.TimeoutExpired): + pass + return _du(home) + + +def _swap_size(): + total = 0 + try: + with open("/proc/swaps", "r") as f: + next(f, None) + for line in f: + parts = line.split() + if len(parts) >= 3 and parts[0] != "Filename": + try: + total += int(parts[2]) # KB + except ValueError: + pass + except OSError: + pass + return total * 1024 + + +def _gb(b): + return round(b / (1024 ** 3), 1) + + +def main(): + mount = sys.argv[1] if len(sys.argv) > 1 else "/" + home = os.path.expanduser("~") + + total_gb = used_gb = 0.0 + try: + st = os.statvfs(mount) + total = st.f_blocks * st.f_frsize + used = total - (st.f_bavail * st.f_frsize) + total_gb = _gb(total) + used_gb = _gb(used) + except OSError: + pass + + # Only measure individual buckets when viewing the root filesystem — + # the hardcoded paths (/usr, $HOME, etc.) don't apply to other mounts. + if mount == "/": + sys_apps = sum(_du(p) for p in ("/usr", "/opt", "/snap", "/nix")) + cache_cont = ( + _du(os.path.join(home, ".cache")) + + _du(os.path.join(home, ".var")) + + _du("/var/lib/docker") + + _du("/var/lib/libvirt/images") + ) + home_dir = _du_home(home) + app_data = _du(os.path.join(home, ".local", "share")) + sys_root = _du("/var/log") + _du("/tmp") + _swap_size() + + buckets = [ + {"label": "System", "sizeGB": _gb(sys_apps), "color": "#FF3B30", "striped": False}, + {"label": "Home", "sizeGB": _gb(home_dir), "color": "#34C759", "striped": False}, + {"label": "Apps", "sizeGB": _gb(app_data), "color": "#007AFF", "striped": False}, + {"label": "Cache", "sizeGB": _gb(cache_cont), "color": "#FF9500", "striped": False}, + {"label": "Root", "sizeGB": _gb(sys_root), "color": "#D1D1D6", "striped": True}, + ] + + # Add "Other" for the gap between measured buckets and actual disk usage + # (filesystem metadata, journal, deleted-open files, unlisted dirs, etc.) + bucket_sum = sum(b["sizeGB"] for b in buckets) + other_gb = round(used_gb - bucket_sum, 1) + if other_gb > 0.5: + buckets.append({"label": "Other", "sizeGB": other_gb, "color": "#8E8E93", "striped": True}) + else: + # Non-root mount: show a single "Used" bucket from statvfs. + buckets = [ + {"label": "Used", "sizeGB": used_gb, "color": "#FF9500", "striped": False}, + ] + + print(json.dumps({ + "device": mount, + "totalGB": total_gb, + "usedGB": used_gb, + "buckets": buckets, + })) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/modules/retroshell/files/scripts/system_monitor.py b/modules/retroshell/files/scripts/system_monitor.py index e7d2f285..67548c6d 100755 --- a/modules/retroshell/files/scripts/system_monitor.py +++ b/modules/retroshell/files/scripts/system_monitor.py @@ -168,6 +168,51 @@ def get_cpu_temp(self): continue return -1 + def get_disk_temp(self): + """NVMe / SATA drive temperature in °C (best-effort).""" + base = "/sys/class/hwmon" + if not os.path.exists(base): + return -1 + for hwmon in os.listdir(base): + path = os.path.join(base, hwmon) + try: + with open(os.path.join(path, "name"), "r") as f: + name = f.read().strip() + if name in ["nvme", "drivetemp", "scsi"]: + for item in os.listdir(path): + if item.startswith("temp") and item.endswith("_input"): + with open(os.path.join(path, item), "r") as f: + val = int(f.read().strip()) + if 10000 < val < 120000: + return val // 1000 + except Exception: + continue + return -1 + + def get_fan_speed(self): + """Max fan RPM across hwmon fan inputs (best-effort).""" + base = "/sys/class/hwmon" + if not os.path.exists(base): + return -1 + best = -1 + try: + for hwmon in os.listdir(base): + path = os.path.join(base, hwmon) + if not os.path.isdir(path): + continue + for item in os.listdir(path): + if item.startswith("fan") and item.endswith("_input"): + try: + with open(os.path.join(path, item), "r") as f: + val = int(f.read().strip()) + if val > best: + best = val + except (OSError, ValueError): + continue + except OSError: + return -1 + return best + def get_mem(self): try: mem_total = 0 @@ -317,6 +362,8 @@ def get_gpu_stats(self): while True: cpu_usage = monitor.get_cpu() cpu_temp = monitor.get_cpu_temp() + fan_rpm = monitor.get_fan_speed() + disk_temp = monitor.get_disk_temp() ram_usage, ram_total, ram_used, ram_avail = monitor.get_mem() disk_usage = monitor.get_disk_usage(disks) gpu_usages, gpu_temps = monitor.get_gpu_stats() @@ -325,6 +372,8 @@ def get_gpu_stats(self): json.dumps( { "cpu": {"usage": cpu_usage, "temp": cpu_temp}, + "fan_rpm": fan_rpm, + "disk_temp": disk_temp, "ram": { "usage": ram_usage, "total": ram_total, diff --git a/modules/retroshell/files/shell.qml b/modules/retroshell/files/shell.qml index 187063f0..21c851c4 100644 --- a/modules/retroshell/files/shell.qml +++ b/modules/retroshell/files/shell.qml @@ -45,7 +45,7 @@ ShellRoot { Loader { id: desktopLoader - active: Config.desktop.enabled && SuspendManager.wakeReady + active: Config.desktopLayerActive && SuspendManager.wakeReady required property ShellScreen modelData sourceComponent: Desktop { screen: desktopLoader.modelData @@ -288,6 +288,7 @@ ShellRoot { _ = GlobalShortcuts.appId; // Force init (IPC pipe listener) _ = Shazam.available; // Force init (availability probe) _ = Tesseract.available; // Force init (availability probe) + _ = TypingSoundsService.enabled; // Force init (typing sounds daemon) }); } } diff --git a/modules/retroshell/files/version b/modules/retroshell/files/version new file mode 100644 index 00000000..3eefcb9d --- /dev/null +++ b/modules/retroshell/files/version @@ -0,0 +1 @@ +1.0.0 diff --git a/modules/retroshell/packages.sh b/modules/retroshell/packages.sh index 5585c59d..e16f4e31 100755 --- a/modules/retroshell/packages.sh +++ b/modules/retroshell/packages.sh @@ -1,5 +1,6 @@ quickshell qt6-base qt6-declarative qt6-wayland qt6-svg + qt6-imageformats qt6-multimedia qt6-shadertools ttf-phosphor-icons ttf-league-gothic diff --git a/modules/tmux/files/tmux.conf b/modules/tmux/files/tmux.conf index 3acab95d..758d27d3 100644 --- a/modules/tmux/files/tmux.conf +++ b/modules/tmux/files/tmux.conf @@ -12,6 +12,8 @@ set -g history-limit 1000000 # increase history size (from 2,000) set -g set-clipboard on # use system clipboard set -g status-position top # macOS / darwin style set -g default-terminal "${TERM}" +set -g repeat-time 0 +set -g mouse on # Index set -g base-index 1 @@ -123,4 +125,4 @@ set -g status-right-length 150 set -g status-right "#{?SSH_CLIENT,#[bg=red,fg=white,bold] 󰒋 #H #[bg=default] ,}#[bg=brightblack,fg=yellow] #(~/.config/tmux/scripts/cpu.sh)#(~/.config/tmux/scripts/battery.sh)#[bg=brightblack,fg=blue] 󰉋 #[fg=white]#{b:pane_current_path} #[bg=default] #[bg=brightblack,fg=cyan] 󰥔 #[fg=white]%H:%M #[bg=default]" # Initialize TMUX plugin manager (keep this line at the very bottom of tmux.conf) -run '~/.tmux/plugins/tpm/tpm' +run '~/.config/tmux/plugins/tpm/tpm' diff --git a/modules/tmux/install.sh b/modules/tmux/install.sh index cd9172c8..aedde661 100755 --- a/modules/tmux/install.sh +++ b/modules/tmux/install.sh @@ -4,6 +4,7 @@ source "$RETRO_DIR/lib/log.sh" install_tmux() { local tpm_dir="${XDG_CONFIG_HOME:-$HOME/.config}/tmux/plugins/tpm" + local tmux_conf="${XDG_CONFIG_HOME:-$HOME/.config}/tmux/tmux.conf" if [[ -d $tpm_dir ]]; then rx_log "info" "tpm already installed — skipping clone" @@ -14,7 +15,18 @@ install_tmux() { fi rx_log "info" "Installing tmux plugins..." - "$tpm_dir/bin/install_plugins" + + export TMUX_PLUGIN_MANAGER_PATH="${XDG_CONFIG_HOME:-$HOME/.config}/tmux/plugins/" + + if [[ -f $tmux_conf ]]; then + tmux start-server + tmux source-file "$tmux_conf" + "$tpm_dir/bin/install_plugins" + tmux kill-server + else + "$tpm_dir/bin/install_plugins" || true + fi + rx_log "success" "tmux plugins installed" } diff --git a/modules/tmux/packages.sh b/modules/tmux/packages.sh old mode 100644 new mode 100755 diff --git a/modules/typing-sounds/packages.sh b/modules/typing-sounds/packages.sh new file mode 100644 index 00000000..34d4ea29 --- /dev/null +++ b/modules/typing-sounds/packages.sh @@ -0,0 +1 @@ +libinput-tools evtest diff --git a/modules/typing-sounds/properties.json b/modules/typing-sounds/properties.json new file mode 100644 index 00000000..57933c39 --- /dev/null +++ b/modules/typing-sounds/properties.json @@ -0,0 +1,10 @@ +{ + "title": "Typing Sounds", + "description": "Simulate the sound of a mechanical keyboard by playing custom sounds while typing.", + "type": "extra", + "access": "user", + "defaults": false, + "mode": "mirror", + "dependencies": ["retroshell"], + "overwrite": false +} diff --git a/modules/zsh/files/.zshrc b/modules/zsh/files/.zshrc index e2901ee8..75d284a8 100644 --- a/modules/zsh/files/.zshrc +++ b/modules/zsh/files/.zshrc @@ -77,3 +77,13 @@ eval "$(zoxide init zsh)" # To customize prompt, run `p10k configure` or edit ~/.p10k.zsh. [[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh + +# Retro PATH +export PATH="/usr/local/bin:$PATH" +export RETRO_DIR="/opt/retrolinux" +export RETRO_CONFIG="/home/vlad/.config/retro" + +export MANPAGER="nvim +Man!" + +# Include custom settings +source ~/.zsh_settings diff --git a/modules/zsh/packages.sh b/modules/zsh/packages.sh old mode 100644 new mode 100755 diff --git a/scripts/audio_core.sh b/scripts/audio_core.sh index b46468ed..e140ae54 100755 --- a/scripts/audio_core.sh +++ b/scripts/audio_core.sh @@ -8,7 +8,7 @@ get_default_sink() { } get_default_source() { - wpctl status 2>/dev/null | sed -n '/^Audio$/,/^Video$/p' | awk '/Sources:/,/Streams:/ {for(i=1;i<=NF;i++) if($i ~ /^[0-9]+\.$/ && match($(i-1),/\*/)) {gsub(/\./,"",$i); print $i; exit}}' + wpctl status 2>/dev/null | sed -n '/^Audio$/,/^Video$/p' | awk '/Sources:/,/Filters:/ {for(i=1;i<=NF;i++) if($i ~ /^[0-9]+\.$/ && match($(i-1),/\*/)) {gsub(/\./,"",$i); print $i; exit}}' } get_sink_volume() { diff --git a/scripts/disk_core.sh b/scripts/disk_core.sh index 61538b5f..eb1ff1bf 100755 --- a/scripts/disk_core.sh +++ b/scripts/disk_core.sh @@ -301,6 +301,37 @@ case "$1" in _umount_device "$path" ;; + "--auto-mount-persist") + dev="$2" + path="$3" + [[ -z $dev ]] && echo "ERR|missing_device" && exit 1 + + uuid=$(blkid -s UUID -o value "/dev/${dev}" 2>/dev/null) + fstype=$(blkid -s TYPE -o value "/dev/${dev}" 2>/dev/null) + [[ -z $uuid || -z $fstype ]] && echo "ERR|cannot_get_uuid_fstype" && exit 1 + + if [[ -z $path ]]; then + label=$(blkid -s LABEL -o value "/dev/${dev}" 2>/dev/null || echo "") + [[ -z $label ]] && label="${dev}" + path="/mnt/${label// /_}" + fi + + if grep -q "UUID=${uuid}" /etc/fstab 2>/dev/null; then + echo "OK|already_exists" + exit 0 + fi + + $SUDO_CMD mkdir -p "$path" 2>/dev/null + echo "UUID=${uuid} ${path} ${fstype} defaults,nofail 0 2" | $SUDO_CMD tee -a /etc/fstab >/dev/null + + $SUDO_CMD mount "/dev/${dev}" "$path" 2>/dev/null + if [[ $? -eq 0 ]]; then + echo "OK|persisted=${path}" + else + echo "ERR|fstab_updated_but_mount_failed" + fi + ;; + "--btrfs-list") btrfs_path="${2:-/}" _is_btrfs "$btrfs_path" || { diff --git a/scripts/network_core.sh b/scripts/network_core.sh index 58798f8d..f6000569 100755 --- a/scripts/network_core.sh +++ b/scripts/network_core.sh @@ -101,6 +101,9 @@ wifi_connect() { nmcli connection modify "$ssid" 802-11-wireless.cloned-mac-address "random" 2>/dev/null nmcli connection up "$ssid" 2>/dev/null + nmcli connection modify "$ssid" ipv4.dns "1.1.1.1 1.0.0.1" 2>/dev/null + nmcli connection modify "$ssid" ipv4.ignore-auto-dns yes 2>/dev/null + return 0 fi @@ -490,10 +493,189 @@ check_internet() { ping -c 1 -W 3 1.1.1.1 &>/dev/null } +set_dns_provider() { + local provider="$1" + local iface="${2:-$(get_wifi_interfaces | head -1)}" + + [[ -z $provider ]] && echo "result=error|reason=provider_required" && return 1 + + local dns1="" dns2="" + + case "$provider" in + cloudflare) dns1="1.1.1.1"; dns2="1.0.0.1" ;; + google) dns1="8.8.8.8"; dns2="8.8.4.4" ;; + cloud9) dns1="9.9.9.9"; dns2="149.112.112.112" ;; + dhcp) + local conn_name + conn_name=$(nmcli -t -f NAME,DEVICE connection show --active 2>/dev/null | grep ":$iface$" | cut -d: -f1) + if [[ -n $conn_name ]]; then + nmcli connection modify "$conn_name" ipv4.method auto >/dev/null 2>&1 + nmcli connection modify "$conn_name" ipv4.dns "" >/dev/null 2>&1 + nmcli connection modify "$conn_name" ipv4.ignore-auto-dns no >/dev/null 2>&1 + nmcli connection down "$conn_name" >/dev/null 2>&1 + ip addr flush dev "$iface" 2>/dev/null + sleep 1 + nmcli connection up "$conn_name" >/dev/null 2>&1 + fi + echo "result=success|provider=dhcp|iface=$iface" + return 0 + ;; + *) + echo "result=error|reason=unknown_provider|provider=$provider" + return 1 + ;; + esac + + local conn_name + conn_name=$(nmcli -t -f NAME,DEVICE connection show --active 2>/dev/null | grep ":$iface$" | cut -d: -f1) + + if [[ -n $conn_name ]]; then + nmcli connection modify "$conn_name" ipv4.dns "$dns1 $dns2" >/dev/null 2>&1 + nmcli connection modify "$conn_name" ipv4.ignore-auto-dns yes >/dev/null 2>&1 + nmcli connection down "$conn_name" >/dev/null 2>&1 + ip addr flush dev "$iface" 2>/dev/null + sleep 1 + nmcli connection up "$conn_name" >/dev/null 2>&1 + fi + + echo "result=success|provider=$provider|dns1=$dns1|dns2=$dns2|iface=$iface" +} + +get_dns_provider() { + local iface="${1:-$(get_wifi_interfaces | head -1)}" + local conn_name + conn_name=$(nmcli -t -f NAME,DEVICE connection show --active 2>/dev/null | grep ":$iface$" | cut -d: -f1) + + if [[ -n $conn_name ]]; then + local ignore_dns + ignore_dns=$(nmcli -t -f ipv4.ignore-auto-dns connection show "$conn_name" 2>/dev/null | sed 's/ipv4.ignore-auto-dns://') + + if [[ "$ignore_dns" == "no" || -z "$ignore_dns" ]]; then + echo "provider=dhcp" + return 0 + fi + + local dns_servers + dns_servers=$(nmcli -t -f ipv4.dns connection show "$conn_name" 2>/dev/null | sed 's/ipv4.dns://') + + if echo "$dns_servers" | grep -q "1.1.1.1"; then + echo "provider=cloudflare" + elif echo "$dns_servers" | grep -q "8.8.8.8"; then + echo "provider=google" + elif echo "$dns_servers" | grep -q "9.9.9.9"; then + echo "provider=cloud9" + else + echo "provider=custom|dns=$dns_servers" + fi + else + echo "provider=dhcp" + fi +} + +net_stats() { + local DEV=$(ip route show default 2>/dev/null | awk '/default/{print $5}' | head -1) + [[ -z "$DEV" ]] && exit 1 + + local PING=$(ping -c 1 -W 2 8.8.8.8 2>/dev/null | grep 'time=' | sed 's/.*time=\([0-9.]*\).*/\1/') + local LOSS=$(ping -c 3 -W 2 8.8.8.8 2>/dev/null | grep -o '[0-9]*% packet loss' | grep -o '[0-9]*') + local RX=$(cat "/sys/class/net/$DEV/statistics/rx_bytes" 2>/dev/null || echo 0) + local TX=$(cat "/sys/class/net/$DEV/statistics/tx_bytes" 2>/dev/null || echo 0) + local IP=$(ip -4 addr show "$DEV" 2>/dev/null | grep -oP 'inet \K[0-9.]+' | head -1) + local GW=$(ip route show default 2>/dev/null | awk '{print $3}' | head -1) + + local STATE_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/retro" + mkdir -p "$STATE_DIR" 2>/dev/null + local STATE_FILE="$STATE_DIR/net_stats.json" + + local CUM_RX=$RX CUM_TX=$TX + if [[ -f "$STATE_FILE" ]]; then + local prev_rx prev_tx prev_dev + prev_dev=$(grep '^dev=' "$STATE_FILE" 2>/dev/null | cut -d= -f2) + prev_rx=$(grep '^rx=' "$STATE_FILE" 2>/dev/null | cut -d= -f2) + prev_tx=$(grep '^tx=' "$STATE_FILE" 2>/dev/null | cut -d= -f2) + + if [[ -n "$prev_dev" && -n "$prev_rx" && -n "$prev_tx" ]]; then + if [[ "$prev_dev" != "$DEV" ]]; then + CUM_RX=$RX + CUM_TX=$TX + elif (( RX < prev_rx || TX < prev_tx )); then + CUM_RX=$(( prev_rx + RX )) + CUM_TX=$(( prev_tx + TX )) + else + CUM_RX=$RX + CUM_TX=$TX + fi + fi + fi + + cat > "$STATE_FILE" </dev/null | grep ":802-11-wireless" | sed 's/:802-11-wireless//' } +wifi_qr() { + local iface="${1:-$(get_wifi_interfaces | head -1)}" + + local ssid + ssid=$(nmcli -t -f GENERAL.CONNECTION device show "$iface" 2>/dev/null | grep "GENERAL.CONNECTION:" | sed 's/GENERAL.CONNECTION://') + [[ -z "$ssid" || "$ssid" == "--" ]] && exit 1 + + local password + password=$(nmcli -s -t -f 802-11-wireless-security.psk connection show "$ssid" 2>/dev/null | sed 's/802-11-wireless-security.psk://') + [[ -z "$password" ]] && exit 1 + + local ssid_escaped password_escaped + ssid_escaped=$(printf '%s' "$ssid" | sed 's/\\/\\\\/g; s/;/\\;/g; s/:/\\:/g') + password_escaped=$(printf '%s' "$password" | sed 's/\\/\\\\/g; s/;/\\;/g; s/:/\\:/g') + + local qr_string="WIFI:T:WPA;S:${ssid_escaped};P:${password_escaped};;" + + local tmp_png tmp_rgb output_dir output + tmp_png=$(mktemp /tmp/wifi_qr_XXXXXX.png) + tmp_rgb=$(mktemp /tmp/wifi_qr_XXXXXX.rgb) + + qrencode -o "$tmp_png" -s 8 -m 2 "$qr_string" 2>/dev/null + [[ $? -ne 0 ]] && rm -f "$tmp_png" "$tmp_rgb" && exit 1 + + python3 -c " +from PIL import Image +import sys +img = Image.open(sys.argv[1]).convert('RGB') +img.save(sys.argv[2], 'RGB') +" "$tmp_png" "$tmp_rgb" 2>/dev/null + + output_dir="${XDG_CACHE_HOME:-$HOME/.cache}/retro" + mkdir -p "$output_dir" 2>/dev/null + output="$output_dir/wifi_qr.png" + + python3 -c " +from PIL import Image +import sys +img = Image.open(sys.argv[1]).convert('RGB') +img.save(sys.argv[2], 'PNG') +" "$tmp_rgb" "$output" 2>/dev/null + + rm -f "$tmp_png" "$tmp_rgb" + echo "$output" +} + +speed_test() { + local script="$RETRO_DIR/scripts/speedtest_stream.py" + if [[ ! -f "$script" ]]; then + echo "result=error|reason=speedtest_script_not_found" + return 1 + fi + python3 "$script" +} + wifi_forget() { local name="$1" [[ -z $name ]] && echo "result=error|reason=name_required" && return 1 @@ -561,6 +743,8 @@ case "$1" in "--wifi-status") wifi_status "$2" ;; "--wifi-autoconnect") wifi_autoconnect ;; "--wifi-saved") wifi_saved ;; + "--wifi-qr") wifi_qr "$2" ;; + "--speed-test") speed_test ;; "--wifi-forget") wifi_forget "$2" ;; "--ethernet-status") ethernet_status "$2" ;; "--network-status") network_status ;; @@ -573,4 +757,7 @@ case "$1" in "--set-dns") set_dns "$2" "$3" "$4" ;; "--interface-info") interface_info "$2" ;; "--check-internet") check_internet && echo "result=online" || echo "result=offline" ;; + "--net-stats") net_stats ;; + "--set-dns-provider") set_dns_provider "$2" "$3" ;; + "--get-dns-provider") get_dns_provider "$2" ;; esac \ No newline at end of file diff --git a/scripts/power_core.sh b/scripts/power_core.sh index edbca9c4..9d98cba4 100755 --- a/scripts/power_core.sh +++ b/scripts/power_core.sh @@ -25,7 +25,7 @@ readonly INTEL_DB=( "1360P|15,28,64|10,15,28" "1260P|15,28,64|10,15,28" "1355U|10,15,25|7,10,15" "1255U|10,15,25|7,10,15" "11800H|25,45,95|15,25,45" "11370H|15,28,50|10,18,28" "10875H|25,45,95|15,25,45" "10750H|20,45,75|12,20,35" "14900K|65,125,253|35,65,95" - "13900K|65,125,253|35,65,95" "12900K|65,125,241|35,65,95" "11900K|65,125,250|35,65,95" + "13900K|65,125,253|35,65,95" "12900KS|60,150,241|45,65,95" "12900K|65,125,241|35,65,95" "11900K|65,125,250|35,65,95" "10900K|65,125,250|35,65,95" "9900K|65,95,210|35,65,95" "8700K|65,95,140|35,65,95" "10300H|15,35,50|10,18,30" "1135G7|12,20,32|8,12,18" "1235U|7,15,25|5,10,15" "6700HQ|15,35,45|12,25,35" @@ -65,6 +65,26 @@ get_pwr_var() { echo "$val" } +rapl_write_limit() { + local dir="$1" + local idx="$2" + local uw="$3" + + local limit_file="$dir/constraint_${idx}_power_limit_uw" + [[ -f $limit_file ]] || return 0 + + local max_uw=$(cat "$dir/constraint_${idx}_max_power_uw" 2>/dev/null) + if [[ $max_uw =~ ^[0-9]+$ && $max_uw -gt 0 && $uw -gt $max_uw ]]; then + rx_log_file "warn" "${dir##*/} PL${idx}: requested $((uw / 1000000))W exceeds hardware max ($((max_uw / 1000000))W), clamping" + uw=$max_uw + fi + + if ! echo "$uw" >"$limit_file" 2>/dev/null; then + rx_log_file "warn" "Failed to set ${dir##*/} PL${idx} to $((uw / 1000000))W" + return 1 + fi +} + sync_hardware_power() { local state="$1" @@ -147,6 +167,11 @@ set_profile() { fi set_var "PWR_CURRENT" "$profile" + if [[ $profile != "saver" ]] && [[ $(get_var "BAT_SAVER_FORCED") == "true" ]]; then + set_var "BAT_SAVER_FORCED" "false" + set_var "BAT_SAVER_ACTIVE" "false" + fi + local watts=$(get_pwr_var "$profile") local microwatts=$((watts * 1000000)) @@ -171,8 +196,8 @@ set_profile() { for epp in /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference; do echo "performance" >"$epp" 2>/dev/null; done if [[ -d /sys/class/powercap/intel-rapl:0 ]]; then - echo "$microwatts" >/sys/class/powercap/intel-rapl:0/constraint_0_power_limit_uw 2>/dev/null - echo "$microwatts" >/sys/class/powercap/intel-rapl:0/constraint_1_power_limit_uw 2>/dev/null + rapl_write_limit "/sys/class/powercap/intel-rapl:0" 0 "$microwatts" + rapl_write_limit "/sys/class/powercap/intel-rapl:0" 1 "$microwatts" echo "27983872" >/sys/class/powercap/intel-rapl:0/constraint_0_time_window_us 2>/dev/null fi ;; @@ -183,8 +208,8 @@ set_profile() { for epp in /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference; do echo "balance_performance" >"$epp" 2>/dev/null; done if [[ -d /sys/class/powercap/intel-rapl:0 ]]; then - echo "$microwatts" >/sys/class/powercap/intel-rapl:0/constraint_0_power_limit_uw 2>/dev/null - echo "$((microwatts + 5000000))" >/sys/class/powercap/intel-rapl:0/constraint_1_power_limit_uw 2>/dev/null + rapl_write_limit "/sys/class/powercap/intel-rapl:0" 0 "$microwatts" + rapl_write_limit "/sys/class/powercap/intel-rapl:0" 1 "$((microwatts + 5000000))" echo "976" >/sys/class/powercap/intel-rapl:0/constraint_0_time_window_us 2>/dev/null fi ;; @@ -195,18 +220,13 @@ set_profile() { for epp in /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference; do echo "power" >"$epp" 2>/dev/null; done if [[ -d /sys/class/powercap/intel-rapl:0 ]]; then - echo "$microwatts" >/sys/class/powercap/intel-rapl:0/constraint_0_power_limit_uw 2>/dev/null - echo "$microwatts" >/sys/class/powercap/intel-rapl:0/constraint_1_power_limit_uw 2>/dev/null + rapl_write_limit "/sys/class/powercap/intel-rapl:0" 0 "$microwatts" + rapl_write_limit "/sys/class/powercap/intel-rapl:0" 1 "$microwatts" echo "976" >/sys/class/powercap/intel-rapl:0/constraint_0_time_window_us 2>/dev/null fi - if [[ -f /sys/class/powercap/intel-rapl:1/constraint_0_power_limit_uw ]]; then - echo "$microwatts" >/sys/class/powercap/intel-rapl:1/constraint_0_power_limit_uw 2>/dev/null - fi - - if [[ -f /sys/class/powercap/intel-rapl:0:1/constraint_0_power_limit_uw ]]; then - echo "3000000" >/sys/class/powercap/intel-rapl:0:1/constraint_0_power_limit_uw 2>/dev/null - fi + rapl_write_limit "/sys/class/powercap/intel-rapl:1" 0 "$microwatts" + rapl_write_limit "/sys/class/powercap/intel-rapl:0:1" 0 "3000000" set_var "BAT_SAVER_FORCED" "true" set_var "BAT_SAVER_ACTIVE" "true" @@ -231,9 +251,7 @@ set_profile() { echo "$amd_boost" >/sys/devices/system/cpu/cpufreq/boost 2>/dev/null fi - if [[ -f /sys/class/powercap/intel-rapl:0/constraint_0_power_limit_uw ]]; then - echo "$microwatts" >/sys/class/powercap/intel-rapl:0/constraint_0_power_limit_uw 2>/dev/null - fi + rapl_write_limit "/sys/class/powercap/intel-rapl:0" 0 "$microwatts" fi return 0 diff --git a/scripts/shell_core.sh b/scripts/shell_core.sh index 86d26b71..0510ac2a 100755 --- a/scripts/shell_core.sh +++ b/scripts/shell_core.sh @@ -8,19 +8,26 @@ run_cmd() { local cmd="$1" [[ -z $cmd ]] && echo "ERR|no_command" && return 1 + # Primary: QuickShell built-in IPC (fast, no blocking) + local pid + pid=$(cat "$PID_FILE" 2>/dev/null) + if [[ -n $pid ]] && kill -0 "$pid" 2>/dev/null; then + if qs ipc --pid "$pid" call retroshell run "$cmd" 2>/dev/null; then + echo "OK|$cmd" + return 0 + fi + fi + + # Fallback: FIFO pipe (with timeout in case no reader) if [[ -p $PIPE ]]; then - echo "$cmd" >"$PIPE" & - echo "OK|$cmd" - else - local pid - pid=$(cat "$PID_FILE" 2>/dev/null) - if [[ -n $pid ]] && kill -0 "$pid" 2>/dev/null; then - qs ipc --pid "$pid" call retroshell run "$cmd" 2>/dev/null && echo "OK|$cmd" || echo "ERR|ipc_failed" - else - echo "ERR|not_running" - return 1 + if timeout 1 bash -c "echo '$cmd' > '$PIPE'" 2>/dev/null; then + echo "OK|$cmd" + return 0 fi fi + + echo "ERR|not_running" + return 1 } case "$1" in @@ -36,6 +43,7 @@ case "$1" in fi rm -f "$PID_FILE" fi + rm -rf "${HOME}/.cache/quickshell/qmlcache" "${HOME}/.cache/quickshell/qtpipelinecache-"* 2>/dev/null export QS_ICON_THEME=$(gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | tr -d "'") export QT_QPA_PLATFORMTHEME=qt6ct nohup qs -p "$SHELL_DIR/shell.qml" >/dev/null 2>&1 & @@ -91,6 +99,7 @@ case "$1" in "--restart") bash "$0" --stop sleep 0.5 + rm -rf "${HOME}/.cache/quickshell/qmlcache" "${HOME}/.cache/quickshell/qtpipelinecache-"* 2>/dev/null bash "$0" --start ;; *) diff --git a/scripts/speedtest_stream.py b/scripts/speedtest_stream.py new file mode 100644 index 00000000..afe64394 --- /dev/null +++ b/scripts/speedtest_stream.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +import json, time, os, subprocess, threading, sys + +def get_dev(): + r = subprocess.run(["ip", "route", "show", "default"], capture_output=True, text=True) + return r.stdout.split()[4] if r.stdout.strip() else None + +def get_bytes(dev, direction): + try: + with open(f"/sys/class/net/{dev}/statistics/{direction}_bytes") as f: + return int(f.read().strip()) + except: + return 0 + +def measure_speed(dev, direction, stop_event, callback): + prev = get_bytes(dev, direction) + prev_time = time.time() + while not stop_event.is_set(): + time.sleep(0.5) + now = time.time() + elapsed = now - prev_time + if elapsed <= 0: + continue + cur = get_bytes(dev, direction) + speed = max(0, (cur - prev) / elapsed) * 8 / 1_000_000 + if speed >= 0.1: + callback(round(speed, 1)) + prev = cur + prev_time = now + +def do_download(stop_event, speed_callback): + urls = [ + "https://speed.cloudflare.com/__down?bytes=25000000", + "https://proof.ovh.net/files/100mb.dat", + "http://speedtest.tele2.net/10MB.zip", + ] + for url in urls: + try: + proc = subprocess.Popen( + ["curl", "-s", "-o", "/dev/null", "--max-time", "15", url], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + proc.wait() + if proc.returncode == 0: + return True + except: + pass + return False + +def do_upload(stop_event, speed_callback): + data = os.urandom(10_000_000) + tmp = "/tmp/_speedtest_upload.bin" + with open(tmp, "wb") as f: + f.write(data) + try: + urls = [ + "https://speed.cloudflare.com/__up", + "https://proof.ovh.net/files/100mb.dat", + ] + for url in urls: + try: + proc = subprocess.Popen( + ["curl", "-s", "-o", "/dev/null", "--max-time", "15", + "-X", "POST", "-d", f"@{tmp}", url], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + proc.wait() + if proc.returncode == 0: + return True + except: + pass + return False + finally: + try: + os.unlink(tmp) + except: + pass + +def main(): + dev = get_dev() + if not dev: + print(json.dumps({"type": "error", "message": "no network device"}), flush=True) + return + + # Ping + print(json.dumps({"type": "phase", "phase": "ping"}), flush=True) + try: + r = subprocess.run(["ping", "-c", "3", "-W", "3", "1.1.1.1"], + capture_output=True, text=True, timeout=10) + for line in r.stdout.splitlines(): + if "avg" in line: + avg = line.split("=")[1].split("/")[1] + print(json.dumps({"type": "ping", "value": round(float(avg), 1)}), flush=True) + break + else: + print(json.dumps({"type": "ping", "value": 0}), flush=True) + except: + print(json.dumps({"type": "ping", "value": 0}), flush=True) + + # Download test + print(json.dumps({"type": "phase", "phase": "download"}), flush=True) + stop_dl = threading.Event() + dl_speeds = [] + + def on_dl_speed(s): + dl_speeds.append(s) + print(json.dumps({"type": "download", "value": s}), flush=True) + + t_dl = threading.Thread(target=measure_speed, args=(dev, "rx", stop_dl, on_dl_speed), daemon=True) + t_dl.start() + do_download(stop_dl, on_dl_speed) + stop_dl.set() + t_dl.join(timeout=2) + + avg_dl = round(sum(dl_speeds) / len(dl_speeds), 2) if dl_speeds else 0 + print(json.dumps({"type": "download_done", "value": avg_dl}), flush=True) + + # Upload test + print(json.dumps({"type": "phase", "phase": "upload"}), flush=True) + stop_ul = threading.Event() + ul_speeds = [] + + def on_ul_speed(s): + ul_speeds.append(s) + print(json.dumps({"type": "upload", "value": s}), flush=True) + + t_ul = threading.Thread(target=measure_speed, args=(dev, "tx", stop_ul, on_ul_speed), daemon=True) + t_ul.start() + do_upload(stop_ul, on_ul_speed) + stop_ul.set() + t_ul.join(timeout=2) + + avg_ul = round(sum(ul_speeds) / len(ul_speeds), 2) if ul_speeds else 0 + print(json.dumps({"type": "upload_done", "value": avg_ul}), flush=True) + + print(json.dumps({"type": "done"}), flush=True) + +if __name__ == "__main__": + main()