import os import threading import subprocess from datetime import datetime import tkinter as tk from tkinter import ttk from typing import Optional, TYPE_CHECKING from shared_libs.message import MessageDialog from .cfd_ui_setup import get_xdg_user_dir from .cfd_app_config import LocaleStrings if TYPE_CHECKING: from custom_file_dialog import CustomFileDialog class SearchManager: """Manages the file search functionality, including UI and threading.""" def __init__(self, dialog: 'CustomFileDialog') -> None: """ Initializes the SearchManager. Args: dialog: The main CustomFileDialog instance. """ self.dialog = dialog def show_search_ready(self, event: Optional[tk.Event] = None) -> None: """Shows the static 'full circle' to indicate search is ready.""" if not self.dialog.search_mode: self.dialog.widget_manager.search_animation.show_full_circle() def activate_search(self, event: Optional[tk.Event] = None) -> None: """ Activates the search entry or cancels an ongoing search. If a search is running, it cancels it. Otherwise, it executes a new search only if there is a search term present. """ if self.dialog.widget_manager.search_animation.running: if self.dialog.search_thread and self.dialog.search_thread.is_alive(): self.dialog.search_thread.cancelled = True self.dialog.widget_manager.search_animation.stop() self.dialog.widget_manager.search_status_label.config( text=LocaleStrings.UI["cancel_search"]) else: # Only execute search if there is text in the entry if self.dialog.widget_manager.filename_entry.get().strip(): self.execute_search() def show_search_bar(self, event: tk.Event) -> None: """ Activates search mode and displays the search bar upon user typing. Args: event: The key press event that triggered the search. """ if isinstance(event.widget, (ttk.Entry, tk.Entry)) or not event.char.strip(): return self.dialog.search_mode = True self.dialog.widget_manager.filename_entry.focus_set() self.dialog.widget_manager.filename_entry.delete(0, tk.END) self.dialog.widget_manager.filename_entry.insert(0, event.char) self.dialog.widget_manager.search_animation.show_full_circle() def hide_search_bar(self, event: Optional[tk.Event] = None) -> None: """ Deactivates search mode, clears the search bar, and restores the file view. """ self.dialog.search_mode = False self.dialog.widget_manager.filename_entry.delete(0, tk.END) self.dialog.widget_manager.search_status_label.config(text="") self.dialog.widget_manager.filename_entry.unbind("") self.dialog.view_manager.populate_files() self.dialog.widget_manager.search_animation.hide() def execute_search(self, event: Optional[tk.Event] = None) -> None: """ Initiates a file search in a background thread. Prevents starting a new search if one is already running. """ if self.dialog.search_thread and self.dialog.search_thread.is_alive(): return search_term = self.dialog.widget_manager.filename_entry.get().strip() if not search_term: self.hide_search_bar() return self.dialog.widget_manager.search_status_label.config( text=f"{LocaleStrings.UI['searching_for']} '{search_term}'...") self.dialog.widget_manager.search_animation.start(pulse=False) self.dialog.update_idletasks() self.dialog.search_thread = threading.Thread( target=self._perform_search_in_thread, args=(search_term,)) self.dialog.search_thread.start() def _perform_search_in_thread(self, search_term: str) -> None: """ Performs the actual file search in a background thread. Searches the current directory and relevant XDG user directories. Handles recursive/non-recursive and hidden/non-hidden file searches. Updates the UI with results upon completion. Args: search_term (str): The term to search for. """ self.dialog.search_results.clear() search_dirs = [self.dialog.current_dir] home_dir = os.path.expanduser("~") if os.path.abspath(self.dialog.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]) search_successful = False try: all_files = [] is_recursive = self.dialog.settings.get("recursive_search", True) search_hidden = self.dialog.settings.get( "search_hidden_files", False) search_term_lower = search_term.lower() for search_dir in search_dirs: if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()): break if not os.path.exists(search_dir): continue is_home_search = os.path.abspath(search_dir) == home_dir follow_links = is_recursive and is_home_search if is_recursive: for root, dirs, files in os.walk(search_dir, followlinks=follow_links): if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()): raise InterruptedError( LocaleStrings.UI["search_cancelled_by_user"]) if not search_hidden: dirs[:] = [ d for d in dirs if not d.startswith('.')] files = [f for f in files if not f.startswith('.')] for name in files: if search_term_lower in name.lower() and self.dialog._matches_filetype(name): all_files.append(os.path.join(root, name)) for name in dirs: if search_term_lower in name.lower(): all_files.append(os.path.join(root, name)) else: for name in os.listdir(search_dir): if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()): raise InterruptedError( LocaleStrings.UI["search_cancelled_by_user"]) if not search_hidden and name.startswith('.'): continue path = os.path.join(search_dir, name) is_dir = os.path.isdir(path) if search_term_lower in name.lower(): if is_dir: all_files.append(path) elif self.dialog._matches_filetype(name): all_files.append(path) if is_recursive: break if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()): raise InterruptedError( LocaleStrings.UI["search_cancelled_by_user"]) seen = set() self.dialog.search_results = [ x for x in all_files if not (x in seen or seen.add(x))] def update_ui() -> None: nonlocal search_successful if self.dialog.search_results: search_successful = True self.show_search_results_treeview() folder_count = sum( 1 for p in self.dialog.search_results if os.path.isdir(p)) file_count = len(self.dialog.search_results) - folder_count self.dialog.widget_manager.search_status_label.config( text=f"{folder_count} {LocaleStrings.UI['folders_and']} {file_count} {LocaleStrings.UI['files_found']}") else: search_successful = False self.dialog.widget_manager.search_status_label.config( text=f"{LocaleStrings.UI['no_results_for']} '{search_term}'.") self.dialog.after(0, update_ui) except (Exception, InterruptedError) as e: if isinstance(e, (InterruptedError, subprocess.SubprocessError)): self.dialog.after(0, lambda: self.dialog.widget_manager.search_status_label.config( text=LocaleStrings.UI["cancel_search"])) else: self.dialog.after(0, lambda: MessageDialog( message_type="error", text=f"{LocaleStrings.UI['error_during_search']}: {e}", title=LocaleStrings.UI["search_error"], master=self.dialog).show()) finally: self.dialog.after( 0, lambda: self.dialog.widget_manager.search_animation.stop(status="DISABLE" if not search_successful else None)) self.dialog.search_process = None def show_search_results_treeview(self) -> None: """Displays the search results in a dedicated Treeview.""" if self.dialog.widget_manager.file_list_frame.winfo_exists(): for widget in self.dialog.widget_manager.file_list_frame.winfo_children(): widget.destroy() tree_frame = ttk.Frame(self.dialog.widget_manager.file_list_frame) tree_frame.pack(fill='both', expand=True) tree_frame.grid_rowconfigure(0, weight=1) tree_frame.grid_columnconfigure(0, weight=1) columns = ("path", "size", "modified") search_tree = ttk.Treeview( tree_frame, columns=columns, show="tree headings") search_tree.heading( "#0", text=LocaleStrings.VIEW["filename"], anchor="w") search_tree.column("#0", anchor="w", width=200, stretch=True) search_tree.heading( "path", text=LocaleStrings.VIEW["path"], anchor="w") search_tree.column("path", anchor="w", width=300, stretch=True) search_tree.heading( "size", text=LocaleStrings.VIEW["size"], anchor="e") search_tree.column("size", anchor="e", width=100, stretch=False) search_tree.heading( "modified", text=LocaleStrings.VIEW["date_modified"], anchor="w") search_tree.column("modified", anchor="w", width=160, stretch=False) v_scrollbar = ttk.Scrollbar( tree_frame, orient="vertical", command=search_tree.yview) h_scrollbar = ttk.Scrollbar( tree_frame, orient="horizontal", command=search_tree.xview) search_tree.configure(yscrollcommand=v_scrollbar.set, xscrollcommand=h_scrollbar.set) search_tree.grid(row=0, column=0, sticky='nsew') v_scrollbar.grid(row=0, column=1, sticky='ns') h_scrollbar.grid(row=1, column=0, sticky='ew') for file_path in self.dialog.search_results: try: filename = os.path.basename(file_path) directory = os.path.dirname(file_path) stat = os.stat(file_path) size = self.dialog._format_size(stat.st_size) modified_time = datetime.fromtimestamp( stat.st_mtime).strftime('%d.%m.%Y %H:%M') if os.path.isdir(file_path): icon = self.dialog.icon_manager.get_icon('folder_small') else: icon = self.dialog.get_file_icon(filename, 'small') search_tree.insert("", "end", text=f" {filename}", image=icon, values=(directory, size, modified_time)) except (FileNotFoundError, PermissionError): continue def on_search_select(event: tk.Event) -> None: selection = search_tree.selection() if selection: item = search_tree.item(selection[0]) filename = item['text'].strip() directory = item['values'][0] full_path = os.path.join(directory, filename) try: stat = os.stat(full_path) size_str = self.dialog._format_size(stat.st_size) self.dialog.widget_manager.search_status_label.config( text=f"'{filename}' {LocaleStrings.VIEW['size']}: {size_str}") except (FileNotFoundError, PermissionError): self.dialog.widget_manager.search_status_label.config( text=f"'{filename}' {LocaleStrings.FILE['not_accessible']}") self.dialog.widget_manager.filename_entry.delete(0, tk.END) self.dialog.widget_manager.filename_entry.insert(0, filename) search_tree.bind("<>", on_search_select) def on_search_double_click(event: tk.Event) -> None: selection = search_tree.selection() if not selection: return item = search_tree.item(selection[0]) filename = item['text'].strip() directory = item['values'][0] full_path = os.path.join(directory, filename) if not os.path.exists(full_path): return # Item no longer exists if os.path.isdir(full_path): # For directories, navigate into them self.hide_search_bar() self.dialog.navigation_manager.navigate_to(full_path) else: # For files, select it and close the dialog self.dialog.selected_file = full_path self.dialog.destroy() search_tree.bind("", on_search_double_click) def show_context_menu(event: tk.Event) -> str: iid = search_tree.identify_row(event.y) if not iid: return "break" search_tree.selection_set(iid) item = search_tree.item(iid) filename = item['text'].strip() directory = item['values'][0] full_path = os.path.join(directory, filename) self.dialog.file_op_manager._show_context_menu(event, full_path) return "break" search_tree.bind("", show_context_menu) def _open_file_location(self, search_tree: ttk.Treeview) -> None: """ Navigates to the directory of the selected item in the search results. Args: search_tree: The Treeview widget containing the search results. """ selection = search_tree.selection() if not selection: return item = search_tree.item(selection[0]) filename = item['text'].strip() directory = item['values'][0] self.hide_search_bar() self.dialog.navigation_manager.navigate_to(directory) self.dialog.after( 100, lambda: self.dialog.view_manager._select_file_in_view(filename))