From f11841cfab32cebf9112cd9e870a497c281bb824 Mon Sep 17 00:00:00 2001 From: Nick007 Date: Tue, 21 Oct 2025 18:01:18 +0800 Subject: [PATCH] feat: add window switcher --- Dockerfile | 22 +- root/defaults/autostart | 2 +- root/scripts/start.sh | 7 + root/scripts/window_switcher.py | 383 ++++++++++++++++++++++++++++++++ 4 files changed, 403 insertions(+), 11 deletions(-) create mode 100755 root/scripts/start.sh create mode 100644 root/scripts/window_switcher.py diff --git a/Dockerfile b/Dockerfile index eb6837a..eb19f54 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,17 +27,9 @@ RUN apt-get update && \ libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 \ libxcomposite1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 \ libxss1 libxtst6 libatomic1 libxcomposite1 libxrender1 libxrandr2 libxkbcommon-x11-0 \ - libfontconfig1 libdbus-1-3 libnss3 libx11-xcb1 + libfontconfig1 libdbus-1-3 libnss3 libx11-xcb1 python3-tk -# Clean up -RUN apt-get purge -y --autoremove -RUN apt-get autoclean && \ - rm -rf \ - /config/.cache \ - /config/.npm \ - /var/lib/apt/lists/* \ - /var/tmp/* \ - /tmp/* +RUN pip install --no-cache-dir python-xlib # Install WeChat based on target architecture RUN case "$TARGETPLATFORM" in \ @@ -59,6 +51,16 @@ RUN case "$TARGETPLATFORM" in \ rm -f wechat.deb && \ echo "✅ WeChat installation completed for $WECHAT_ARCH" +# Clean up +RUN apt-get purge -y --autoremove +RUN apt-get autoclean && \ + rm -rf \ + /config/.cache \ + /config/.npm \ + /var/lib/apt/lists/* \ + /var/tmp/* \ + /tmp/* + # set app name ENV TITLE="WeChat-Selkies" ENV TZ="Asia/Shanghai" diff --git a/root/defaults/autostart b/root/defaults/autostart index 61e8068..10982d6 100644 --- a/root/defaults/autostart +++ b/root/defaults/autostart @@ -1 +1 @@ -/scripts/wechat/wechat-start.sh +/scripts/start.sh diff --git a/root/scripts/start.sh b/root/scripts/start.sh new file mode 100755 index 0000000..a08f548 --- /dev/null +++ b/root/scripts/start.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# start WeChat application in the background if wechat exists +if [ -f /usr/bin/wechat ]; then nohup /usr/bin/wechat > /dev/null 2>&1 & fi + +# start window switcher application in the background +nohup sleep 2 && python /scripts/window_switcher.py > /dev/null 2>&1 & \ No newline at end of file diff --git a/root/scripts/window_switcher.py b/root/scripts/window_switcher.py new file mode 100644 index 0000000..a794d83 --- /dev/null +++ b/root/scripts/window_switcher.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 + +import tkinter as tk +from tkinter import ttk +import threading +import time +from Xlib import X, display +from Xlib.ext import shape +import re + +class Tooltip: + def __init__(self, widget): + self.widget = widget + self.tipwindow = None + self.id = None + self.x = self.y = 0 + + def showtip(self, text): + "Display text in tooltip window" + self.text = text + if self.tipwindow or not self.text: + return + x = self.widget.winfo_rootx() + 20 + y = self.widget.winfo_rooty() + 20 + self.tipwindow = tw = tk.Toplevel(self.widget) + tw.wm_overrideredirect(1) + tw.wm_geometry("+%d+%d" % (x, y)) + label = tk.Label(tw, text=self.text, justify=tk.LEFT, + background="#ffffe0", relief=tk.SOLID, borderwidth=1, + font=("tahoma", "8", "normal")) + label.pack(ipadx=1) + + def hidetip(self): + tw = self.tipwindow + self.tipwindow = None + if tw: + tw.destroy() + +def CreateTooltip(widget, text): + tooltip = Tooltip(widget) + def enter(event): + tooltip.showtip(text) + def leave(event): + tooltip.hidetip() + widget.bind('', enter) + widget.bind('', leave) + +class WindowSwitcher: + def __init__(self): + self.root = tk.Tk() + self.root.title("Window Switcher") + self.root.overrideredirect(True) # No border + self.root.attributes('-topmost', True) # Always on top + self.root.configure(bg='#2c2c2c') + + # Set up window style + self.setup_ui() + + # Initialize window list + self.current_windows = [] + self.buttons = [] + + # Create X11 connection + self.d = display.Display() + self.root_win = self.d.screen().root + + # Enable event listening (with lower permission mask) + self.enable_event_listening() + + # Initial window list + self.update_window_list() + + # Bind drag events + self.is_dragging = False + self.drag_threshold = 5 # Minimum pixel movement to consider as drag + self.start_x = 0 + self.start_y = 0 + self.drag_start_x = 0 + self.drag_start_y = 0 + self.bind_drag_events() + + def setup_ui(self): + # Button container + self.button_frame = tk.Frame(self.root, bg='#2c2c2c') + self.button_frame.pack(fill='both', expand=True, padx=1, pady=1) + + def enable_event_listening(self): + """Enable X11 event listening (with lower permission mask)""" + try: + # Use PropertyChangeMask and FocusChangeMask + self.root_win.change_attributes( + event_mask=X.PropertyChangeMask | + X.FocusChangeMask + ) + + # Start event listening thread + self.event_thread = threading.Thread(target=self.event_loop, daemon=True) + self.event_thread.start() + except Exception as e: + print(f"Cannot set up event listening: {e}") + print("Using polling mode as fallback") + # If event listening fails, start polling thread + self.polling_thread = threading.Thread(target=self.polling_loop, daemon=True) + self.polling_thread.start() + + def bind_drag_events(self): + def start_move(event): + # Only start dragging if clicked on the background, not on a button + if str(event.widget) == str(self.root): + self.is_dragging = False # Reset drag state + self.start_x = event.x_root + self.start_y = event.y_root + self.drag_start_x = self.root.winfo_x() + self.drag_start_y = self.root.winfo_y() + + def do_move(event): + # Calculate distance moved + current_x = event.x_root + current_y = event.y_root + dx = abs(current_x - self.start_x) + dy = abs(current_y - self.start_y) + + # If movement exceeds threshold, start dragging + if not self.is_dragging and (dx > self.drag_threshold or dy > self.drag_threshold): + self.is_dragging = True + + if self.is_dragging: + x = self.drag_start_x + (current_x - self.start_x) + y = self.drag_start_y + (current_y - self.start_y) + self.root.geometry(f"+{x}+{y}") + + def end_move(event): + # After drag is complete, reset dragging state + self.is_dragging = False + + # Bind events to root window + self.root.bind("", start_move) + self.root.bind("", do_move) + self.root.bind("", end_move) + + def get_current_desktop_windows(self): + """Get all windows on current desktop (including minimized windows)""" + try: + # Get current desktop number + current_desktop_prop = self.root_win.get_full_property( + self.d.intern_atom("_NET_CURRENT_DESKTOP"), X.AnyPropertyType + ) + current_desktop = current_desktop_prop.value[0] if current_desktop_prop else 0 + + # Get current active window + active_window_prop = self.root_win.get_full_property( + self.d.intern_atom("_NET_ACTIVE_WINDOW"), X.AnyPropertyType + ) + active_wid = active_window_prop.value[0] if active_window_prop else None + + # Get all client windows + client_list_prop = self.root_win.get_full_property( + self.d.intern_atom("_NET_CLIENT_LIST"), X.AnyPropertyType + ) + if not client_list_prop: + return [] + + window_ids = client_list_prop.value + windows = [] + + for wid in window_ids: + try: + win = self.d.create_resource_object('window', wid) + + # Skip current active window + if wid == active_wid: + continue + + # Get window type, exclude dock, desktop, etc. + wtype_prop = win.get_full_property( + self.d.intern_atom("_NET_WM_WINDOW_TYPE"), X.AnyPropertyType + ) + if wtype_prop: + wtype_atoms = wtype_prop.value + skip_types = [ + self.d.intern_atom("_NET_WM_WINDOW_TYPE_DOCK"), + self.d.intern_atom("_NET_WM_WINDOW_TYPE_DESKTOP"), + self.d.intern_atom("_NET_WM_WINDOW_TYPE_SPLASH"), + self.d.intern_atom("_NET_WM_WINDOW_TYPE_TOOLBAR"), + self.d.intern_atom("_NET_WM_WINDOW_TYPE_MENU") + ] + + if any(atom in wtype_atoms for atom in skip_types): + continue + + # Get window's desktop + desktop_prop = win.get_full_property( + self.d.intern_atom("_NET_WM_DESKTOP"), X.AnyPropertyType + ) + if desktop_prop and desktop_prop.value[0] != current_desktop: + continue + + # Get window name + name_prop = win.get_full_property( + self.d.intern_atom("_NET_WM_NAME"), X.AnyPropertyType + ) or win.get_full_property( + self.d.intern_atom("WM_NAME"), X.AnyPropertyType + ) + + title = name_prop.value.decode() if name_prop and name_prop.value else "Unknown" + + # Check if window is minimized + state_prop = win.get_full_property( + self.d.intern_atom("_NET_WM_STATE"), X.AnyPropertyType + ) + is_minimized = False + if state_prop: + state_atoms = state_prop.value + minimized_atom = self.d.intern_atom("_NET_WM_STATE_HIDDEN") + if minimized_atom in state_atoms: + is_minimized = True + + # Show non-active windows (including minimized windows) + windows.append((wid, title, is_minimized)) + except Exception as e: + print(f"Error processing window {wid}: {e}") + continue + + # Return at most 10 windows + return windows[:10] + except Exception as e: + print(f"Error getting window list: {e}") + return [] + + def update_window_list(self): + """Update window list""" + new_windows = self.get_current_desktop_windows() + if new_windows != self.current_windows: + self.current_windows = new_windows + # Update UI in main thread + self.root.after(0, self.refresh_ui) + + def activate_window(self, wid): + """Activate specified window only if not dragging""" + # Activate only if not dragging + if not self.is_dragging: + try: + win = self.d.create_resource_object('window', wid) + + # Build ClientMessage event + from Xlib.protocol import event + atom = self.d.intern_atom("_NET_ACTIVE_WINDOW") + + # Create ClientMessage event + msg = event.ClientMessage( + window=win, + client_type=atom, + data=(32, [1, X.CurrentTime, 0, 0, 0]) + ) + + # Send activate window message + self.root_win.send_event( + msg, + event_mask=X.SubstructureRedirectMask | X.SubstructureNotifyMask + ) + self.d.flush() + + # Bring window to top + win.configure(stack_mode=X.Above) + self.root.attributes('-topmost', True) # Keep switcher on top + except Exception as e: + print(f"Error activating window: {e}") + + def get_display_text(self, title): + """Determine display text based on character type""" + if not title: + return "?" + + # Check if it contains Chinese characters + has_chinese = bool(re.search(r'[\u4e00-\u9fff]', title)) + + if has_chinese: + # If contains Chinese, show first character only + return title[0] + else: + # If only English, show first 2-3 characters, but not exceeding title length + return title[:3] + + def refresh_ui(self): + """Refresh UI, update window list""" + # Clear old buttons + for btn in self.buttons: + btn.destroy() + self.buttons.clear() + + # Adjust window size based on number of windows + num_windows = len(self.current_windows) + if num_windows == 0: + # If no windows, hide entire window + self.root.withdraw() + else: + # Calculate window width (each cell 30px, plus borders and spacing) + width = num_windows * 30 + (num_windows - 1) * 2 # 30px cell + 2px border + self.root.geometry(f"{width}x30") + self.root.deiconify() # Ensure window is visible + + # Create new buttons + for wid, title, is_minimized in self.current_windows: + # Get text to display + display_text = self.get_display_text(title) + + # Create fixed-size frame container + container = tk.Frame(self.button_frame, width=30, height=30, bg='#4a4a4a') + container.pack_propagate(False) # Prevent container size from changing with content + container.pack(side='left', padx=1, pady=1) + + # Create button inside container + btn = tk.Button( + container, + text=display_text, + command=lambda w=wid: self.activate_window(w), + bg='#4a4a4a', + fg='#ffffff', + activebackground='#5a5a5a', + activeforeground='#ffffff', + relief='flat', + font=('Arial', 10, 'bold'), + bd=0 # No border + ) + + # Add tooltip + CreateTooltip(btn, f"{title} {'(Minimized)' if is_minimized else ''}") + + # Fill container with button + btn.pack(fill='both', expand=True) + + self.buttons.append(container) + + def event_loop(self): + """Event listening loop""" + # Event mask constants + PROPERTY_CHANGE = 28 # X.PropertyChangeMask + FOCUS_IN = 9 # X.FocusIn + FOCUS_OUT = 10 # X.FocusOut + + while True: + try: + event = self.d.next_event() + + # Check if window list update is needed + should_update = False + + # Compare using event type values + if event.type == PROPERTY_CHANGE: + # Window property changed, check if it's window state or title change + atom_name = self.d.get_atom_name(event.atom) + if atom_name in ["_NET_WM_NAME", "WM_NAME", "_NET_WM_STATE", + "_NET_ACTIVE_WINDOW", "_NET_CLIENT_LIST"]: + should_update = True + elif event.type == FOCUS_IN or event.type == FOCUS_OUT: + # Focus changed + should_update = True + + if should_update: + # Add delay to prevent continuous triggering + time.sleep(0.1) + self.update_window_list() + + except Exception as e: + print(f"Event loop error: {e}") + time.sleep(0.1) # Delay on error + + def polling_loop(self): + """Polling loop (fallback)""" + while True: + try: + self.update_window_list() + time.sleep(2) # Poll every 2 seconds + except Exception as e: + print(f"Polling loop error: {e}") + time.sleep(2) + + def run(self): + self.root.mainloop() + +if __name__ == "__main__": + app = WindowSwitcher() + app.run()