diff --git a/__pycache__/cfd_animated_icon.cpython-312.pyc b/__pycache__/cfd_animated_icon.cpython-312.pyc new file mode 100644 index 0000000..918253c Binary files /dev/null and b/__pycache__/cfd_animated_icon.cpython-312.pyc differ diff --git a/__pycache__/cfd_ui_setup.cpython-312.pyc b/__pycache__/cfd_ui_setup.cpython-312.pyc index f6e430a..efbbbde 100644 Binary files a/__pycache__/cfd_ui_setup.cpython-312.pyc and b/__pycache__/cfd_ui_setup.cpython-312.pyc differ diff --git a/__pycache__/custom_file_dialog.cpython-312.pyc b/__pycache__/custom_file_dialog.cpython-312.pyc index fe6cbd2..32a58bc 100644 Binary files a/__pycache__/custom_file_dialog.cpython-312.pyc and b/__pycache__/custom_file_dialog.cpython-312.pyc differ diff --git a/cfd_animated_icon.py b/cfd_animated_icon.py new file mode 100644 index 0000000..6619bb8 --- /dev/null +++ b/cfd_animated_icon.py @@ -0,0 +1,82 @@ +import tkinter as tk +import math + +class AnimatedSearchIcon(tk.Canvas): + def __init__(self, parent, bg_color, style="single", *args, **kwargs): + kwargs.setdefault('width', 22) + kwargs.setdefault('height', 22) + super().__init__(parent, *args, **kwargs) + self.configure(bg=bg_color, highlightthickness=0) + self.width = self.winfo_reqwidth() + self.height = self.winfo_reqheight() + + self.angle1 = 0 + self.angle2 = 0 + self.color_angle = 0 + self.base_color = (81, 149, 255) # #5195ff + self.is_animating = False + self.style = style + + self.draw_initial_state() + + def start_animation(self): + if self.is_animating: + return + self.is_animating = True + self.update_animation() + + def stop_animation(self): + self.is_animating = False + self.draw_initial_state() + + def update_animation(self): + if not self.is_animating: + return + + if self.style == "single": + self.angle1 = (self.angle1 - 6) % 360 + elif self.style == "double": + self.angle1 = (self.angle1 - 6) % 360 + self.angle2 = (self.angle2 + 6) % 360 + + self.color_angle = (self.color_angle + 0.15) % (2 * math.pi) + self.draw_animated_arc() + self.after(25, self.update_animation) + + def get_pulsating_color(self): + factor = 0.5 * (1 + math.sin(self.color_angle)) + r = int(self.base_color[0] + (255 - self.base_color[0]) * factor * 0.6) + g = int(self.base_color[1] + (255 - self.base_color[1]) * factor * 0.6) + b = int(self.base_color[2] + (255 - self.base_color[2]) * factor * 0.6) + return f"#{r:02x}{g:02x}{b:02x}" + + def draw_animated_arc(self): + self.delete("all") + color = self.get_pulsating_color() + if self.style == "single": + x0, y0 = 3, 3 + x1, y1 = self.width - 3, self.height - 3 + self.create_arc(x0, y0, x1, y1, start=self.angle1, extent=300, style=tk.ARC, width=4, outline=color) + elif self.style == "double": + # Outer arc + x0_outer, y0_outer = 3, 3 + x1_outer, y1_outer = self.width - 3, self.height - 3 + self.create_arc(x0_outer, y0_outer, x1_outer, y1_outer, start=self.angle1, extent=150, style=tk.ARC, width=2, outline=color) + # Inner arc + x0_inner, y0_inner = 7, 7 + x1_inner, y1_inner = self.width - 7, self.height - 7 + self.create_arc(x0_inner, y0_inner, x1_inner, y1_inner, start=self.angle2, extent=150, style=tk.ARC, width=2, outline=color) + + def draw_initial_state(self): + self.delete("all") + if self.style == "single": + x0, y0 = 3, 3 + x1, y1 = self.width - 3, self.height - 3 + self.create_oval(x0, y0, x1, y1, outline="#5195ff", width=4, fill=self.cget("bg")) + elif self.style == "double": + x0_outer, y0_outer = 3, 3 + x1_outer, y1_outer = self.width - 3, self.height - 3 + self.create_oval(x0_outer, y0_outer, x1_outer, y1_outer, outline="#5195ff", width=2, fill=self.cget("bg")) + x0_inner, y0_inner = 7, 7 + x1_inner, y1_inner = self.width - 7, self.height - 7 + self.create_oval(x0_inner, y0_inner, x1_inner, y1_inner, outline="#5195ff", width=2, fill=self.cget("bg")) diff --git a/cfd_ui_setup.py b/cfd_ui_setup.py index 36181a1..c3e5d54 100644 --- a/cfd_ui_setup.py +++ b/cfd_ui_setup.py @@ -3,6 +3,7 @@ import shutil import tkinter as tk from tkinter import ttk from shared_libs.common_tools import Tooltip +from cfd_animated_icon import AnimatedSearchIcon def get_xdg_user_dir(dir_key, fallback_name): @@ -357,6 +358,15 @@ class WidgetManager: self.settings_button = ttk.Button(self.action_status_frame, image=self.dialog.icon_manager.get_icon('settings-2_small'), command=self.dialog.open_settings_dialog, style="Bottom.TButton.Borderless.Round") + self.search_animation = AnimatedSearchIcon(self.status_container, + bg_color=self.style_manager.bottom_color, + style="double", + width=23, height=23) + self.search_animation.grid(row=0, column=0, sticky='w', padx=(0, 5), pady=(4,0)) + self.search_animation.bind("", lambda e: self.dialog.activate_search()) + self.search_status_label.grid(row=0, column=1, sticky="w") + + button_box_pos = self.settings.get("button_box_pos", "left") if self.dialog.dialog_mode == "save": @@ -417,7 +427,7 @@ class WidgetManager: self.center_container.grid_columnconfigure(1, weight=1) self.filter_combobox.grid(in_=self.center_container, row=1, column=1, sticky="e", pady=(5, 0)) - self.search_status_label.grid(row=0, column=0, sticky="w", pady=(5, 0), padx=(5, 0)) + #self.search_status_label.grid(row=0, column=0, sticky="w", pady=(5, 0), padx=(5, 0)) diff --git a/custom_file_dialog.py b/custom_file_dialog.py index 4a7f410..126f25c 100644 --- a/custom_file_dialog.py +++ b/custom_file_dialog.py @@ -5,6 +5,7 @@ from tkinter import ttk from datetime import datetime import subprocess import json +import threading from shared_libs.message import MessageDialog from shared_libs.common_tools import IconManager, Tooltip, ConfigManager, LxTools from cfd_app_config import AppConfig, CfdConfigManager @@ -201,6 +202,8 @@ class CustomFileDialog(tk.Toplevel): self.item_path_map = {} self.responsive_buttons_hidden = None # State for responsive buttons self.search_job = None + self.search_thread = None + self.search_process = None self.icon_manager = IconManager() self.style_manager = StyleManager(self) @@ -235,34 +238,112 @@ class CustomFileDialog(tk.Toplevel): + def activate_search(self, event=None): + """Activates the search entry or cancels an ongoing search.""" + if self.widget_manager.search_animation.is_animating: + # If animating, it means a search is active, so cancel it + if self.search_thread and self.search_thread.is_alive(): + if self.search_process: + try: + os.killpg(os.getpgid(self.search_process.pid), 9) # Send SIGKILL to process group + except (ProcessLookupError, AttributeError): + pass # Process might have already finished or not started + self.widget_manager.search_animation.stop_animation() + self.widget_manager.search_status_label.config(text="Suche abgebrochen.") + self.hide_search_bar() # Reset UI after cancellation + else: + # If not animating, activate search entry + self.widget_manager.filename_entry.focus_set() + self.search_mode = True # Ensure search mode is active + self.widget_manager.filename_entry.bind("", self.execute_search) + self.widget_manager.filename_entry.bind("", self.hide_search_bar) + def show_search_bar(self, event=None): - # Ignore key presses if they are coming from an entry widget or have no character if isinstance(event.widget, (ttk.Entry, tk.Entry)) or not event.char.strip(): return - self.search_mode = True self.widget_manager.filename_entry.focus_set() - # Clear the field before inserting the new character to start a fresh search self.widget_manager.filename_entry.delete(0, tk.END) self.widget_manager.filename_entry.insert(0, event.char) self.widget_manager.filename_entry.bind("", self.execute_search) self.widget_manager.filename_entry.bind("", self.hide_search_bar) + # Removed: self.widget_manager.search_animation.start_animation() def hide_search_bar(self, event=None): self.search_mode = False self.widget_manager.filename_entry.delete(0, tk.END) self.widget_manager.search_status_label.config(text="") - # Unbind search-specific events to restore normal behavior self.widget_manager.filename_entry.unbind("") self.widget_manager.filename_entry.unbind("") - # Re-bind the default save action for the save dialog if self.dialog_mode == "save": self.widget_manager.filename_entry.bind("", lambda e: self.on_save()) self.populate_files() + self.widget_manager.search_animation.stop_animation() - def toggle_search_mode(self, event=None): - # This method might not be needed anymore if search is always active in the entry - pass + def execute_search(self, event=None): + if self.search_thread and self.search_thread.is_alive(): + return + search_term = self.widget_manager.filename_entry.get().strip() + if not search_term: + self.hide_search_bar() + return + self.widget_manager.search_status_label.config(text=f"Suche nach '{search_term}'...") + self.widget_manager.search_animation.start_animation() + self.update_idletasks() + self.search_thread = threading.Thread(target=self._perform_search_in_thread, args=(search_term,)) + self.search_thread.start() + + def _perform_search_in_thread(self, search_term): + self.search_results.clear() + search_dirs = [self.current_dir] + home_dir = os.path.expanduser("~") + if os.path.abspath(self.current_dir) == os.path.abspath(home_dir): + xdg_dirs = [get_xdg_user_dir(d, f) for d, f in [("XDG_DOWNLOAD_DIR", "Downloads"), ("XDG_DOCUMENTS_DIR", "Documents"), ("XDG_PICTURES_DIR", "Pictures"), ("XDG_MUSIC_DIR", "Music"), ("XDG_VIDEO_DIR", "Videos")]] + search_dirs.extend([d for d in xdg_dirs if os.path.exists(d) and os.path.abspath(d) != home_dir and d not in search_dirs]) + + try: + all_files = [] + for search_dir in search_dirs: + if not (self.search_thread and self.search_thread.is_alive()): break + if not os.path.exists(search_dir): continue + original_cwd = os.getcwd() + try: + os.chdir(search_dir) + cmd = ['find', '-L', '.', '-iname', f'*{search_term}*'] + if not self.settings.get("recursive_search", True): + cmd.insert(3, '-maxdepth') + cmd.insert(4, '1') + self.search_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, preexec_fn=os.setsid) + stdout, _ = self.search_process.communicate() + if self.search_process.returncode == 0: + all_files.extend([os.path.join(search_dir, f[2:]) for f in stdout.strip().split('\n') if f and f.startswith('./') and os.path.exists(os.path.join(search_dir, f[2:]))]) + finally: + os.chdir(original_cwd) + + if not (self.search_thread and self.search_thread.is_alive()): raise subprocess.SubprocessError("Search cancelled by user") + + seen = set() + unique_files = [x for x in all_files if not (x in seen or seen.add(x))] + search_hidden = self.settings.get("search_hidden_files", False) + self.search_results = [p for p in unique_files if (search_hidden or not any(part.startswith('.') for part in p.split(os.sep))) and (self._matches_filetype(os.path.basename(p)) or os.path.isdir(p))] + + def update_ui(): + if self.search_results: + self.show_search_results_treeview() + folder_count = sum(1 for p in self.search_results if os.path.isdir(p)) + file_count = len(self.search_results) - folder_count + self.widget_manager.search_status_label.config(text=f"{folder_count} Ordner und {file_count} Dateien gefunden.") + else: + self.widget_manager.search_status_label.config(text=f"Keine Ergebnisse für '{search_term}'.") + self.after(0, update_ui) + except Exception as e: + if isinstance(e, subprocess.SubprocessError): + self.after(0, lambda: self.widget_manager.search_status_label.config(text="Suche abgebrochen.")) + else: + self.after(0, lambda: MessageDialog(message_type="error", text=f"Fehler bei der Suche: {e}", title="Suchfehler", master=self).show()) + finally: + self.after(0, self.widget_manager.search_animation.stop_animation) + self.search_process = None def handle_path_entry_return(self, event): """Handles the Enter key in the path entry to navigate. @@ -313,7 +394,7 @@ class CustomFileDialog(tk.Toplevel): # If search was active, reset it to avoid inconsistent state if self.search_mode: - self.toggle_search_mode() # This will correctly reset the UI + self.hide_search_bar() # This will correctly reset the UI self.navigate_to(self.current_dir) @@ -507,134 +588,6 @@ class CustomFileDialog(tk.Toplevel): - def execute_search(self, event=None): - search_term = self.widget_manager.filename_entry.get().strip() - - if not search_term: - self.hide_search_bar() - return - - self.widget_manager.search_status_label.config(text=f"Suche nach '{search_term}'...") - self.update_idletasks() - - # Clear previous search results - self.search_results.clear() - - # Determine search directories - search_dirs = [self.current_dir] - - # If searching from home directory, also include XDG directories - home_dir = os.path.expanduser("~") - if os.path.abspath(self.current_dir) == os.path.abspath(home_dir): - xdg_dirs = [ - get_xdg_user_dir("XDG_DOWNLOAD_DIR", "Downloads"), - get_xdg_user_dir("XDG_DOCUMENTS_DIR", "Documents"), - get_xdg_user_dir("XDG_PICTURES_DIR", "Pictures"), - get_xdg_user_dir("XDG_MUSIC_DIR", "Music"), - get_xdg_user_dir("XDG_VIDEO_DIR", "Videos") - ] - # Add XDG directories that exist and are not already in home - for xdg_dir in xdg_dirs: - if (os.path.exists(xdg_dir) and - os.path.abspath(xdg_dir) != os.path.abspath(home_dir) and - xdg_dir not in search_dirs): - search_dirs.append(xdg_dir) - - try: - all_files = [] - - # Search in each directory - for search_dir in search_dirs: - if not os.path.exists(search_dir): - continue - - # Change to directory and use relative paths to avoid path issues - original_cwd = os.getcwd() - try: - os.chdir(search_dir) - - # Build find command based on recursive setting (use . for current directory) - if self.settings.get("recursive_search", True): - # Find both files and directories, following symlinks - find_cmd = ['find', '-L', '.', '-iname', f'*{search_term}*'] - else: - # Find both files and directories, but only in the current level, following symlinks - find_cmd = ['find', '-L', '.', '-maxdepth', '1', - '-iname', f'*{search_term}*'] - - result = subprocess.run( - find_cmd, capture_output=True, text=True, timeout=30) - - if result.returncode == 0: - files = result.stdout.strip().split('\n') - # Convert relative paths back to absolute paths - directory_files = [] - for f in files: - if f and f.startswith('./'): - abs_path = os.path.join( - search_dir, f[2:]) # Remove './' prefix - # Check if the path exists, as it might be a broken symlink or deleted - if os.path.exists(abs_path): - directory_files.append(abs_path) - all_files.extend(directory_files) - - finally: - os.chdir(original_cwd) - - # Remove duplicates while preserving order - seen = set() - unique_files = [] - for file_path in all_files: - if file_path not in seen: - seen.add(file_path) - unique_files.append(file_path) - - # Filter based on currently selected filter pattern and hidden file setting - self.search_results = [] - search_hidden = self.settings.get("search_hidden_files", False) - - for file_path in unique_files: - # Check if path contains a hidden component (e.g., /.config/ or /some/path/to/.hidden_file) - if not search_hidden: - if any(part.startswith('.') for part in file_path.split(os.sep)): - continue # Skip hidden files/files in hidden directories - - # Check if the path exists (it might have been deleted during the search) - if os.path.exists(file_path): - filename = os.path.basename(file_path) - if self._matches_filetype(filename) or os.path.isdir(file_path): - self.search_results.append(file_path) - - # Show search results in TreeView - if self.search_results: - self.show_search_results_treeview() - folder_count = sum(1 for p in self.search_results if os.path.isdir(p)) - file_count = len(self.search_results) - folder_count - self.widget_manager.search_status_label.config(text=f"{folder_count} Ordner und {file_count} Dateien gefunden.") - else: - self.widget_manager.search_status_label.config(text=f"Keine Ergebnisse für '{search_term}'.") - MessageDialog( - message_type="info", - text=f"Keine Dateien mit '{search_term}' gefunden.", - title="Suche", - master=self - ).show() - - except subprocess.TimeoutExpired: - MessageDialog( - message_type="error", - text="Suche dauert zu lange und wurde abgebrochen.", - title="Suche", - master=self - ).show() - except Exception as e: - MessageDialog( - message_type="error", - text=f"Fehler bei der Suche: {e}", - title="Suchfehler", - master=self - ).show() - def show_search_results_treeview(self): """Show search results in TreeView format""" # Clear current file list and replace with search results @@ -969,13 +922,13 @@ class CustomFileDialog(tk.Toplevel): Tooltip(item_frame, name) for widget in [item_frame, icon_label, name_label]: - widget.bind("", lambda e, + widget.bind("", lambda e, p=path: self.on_item_double_click(p)) - widget.bind("", lambda e, p=path, + widget.bind("", lambda e, p=path, f=item_frame: self.on_item_select(p, f)) - widget.bind("", lambda e, + widget.bind("", lambda e, p=path: self._show_context_menu(e, p)) - widget.bind("", lambda e, p=path, + widget.bind("", lambda e, p=path, f=item_frame: self.on_rename_request(e, p, f)) if name == item_to_select: @@ -1387,7 +1340,7 @@ class CustomFileDialog(tk.Toplevel): filename = os.path.basename(file_path) if self.search_mode: - self.toggle_search_mode() + self.hide_search_bar() self.navigate_to(directory) self.after(100, lambda: self._select_file_in_view(filename)) @@ -1578,4 +1531,4 @@ class CustomFileDialog(tk.Toplevel): except Exception as e: print(f"Error getting mounted devices: {e}") - return devices + return devices \ No newline at end of file diff --git a/mainwindow.py b/mainwindow.py index c7d103a..3368df5 100755 --- a/mainwindow.py +++ b/mainwindow.py @@ -55,7 +55,7 @@ if __name__ == "__main__": style = ttk.Style(root) root.tk.call('source', f"{theme_path}/water.tcl") try: - root.tk.call('set_theme', 'dark') + root.tk.call('set_theme', 'light') except tk.TclError: pass root.mainloop()