Files
shared_libs/custom_file_dialog/cfd_search_manager.py

347 lines
15 KiB
Python

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("<Escape>")
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("<<TreeviewSelect>>", 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("<Double-1>", 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("<ButtonRelease-3>", 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))