Files
shared_libs/custom_file_dialog/custom_file_dialog.py

557 lines
23 KiB
Python

import os
import shutil
import tkinter as tk
import subprocess
import json
import threading
from typing import Optional, List, Tuple, Dict, Union
from shared_libs.common_tools import IconManager, Tooltip, LxTools
from .cfd_app_config import CfdConfigManager, LocaleStrings
from .cfd_ui_setup import StyleManager, WidgetManager
from shared_libs.animated_icon import AnimatedIcon
from .cfd_settings_dialog import SettingsDialog
from .cfd_file_operations import FileOperationsManager
from .cfd_search_manager import SearchManager
from .cfd_navigation_manager import NavigationManager
from .cfd_view_manager import ViewManager
class CustomFileDialog(tk.Toplevel):
"""
A custom file dialog window that provides functionalities for file selection,
directory navigation, search, and file operations.
"""
def _initialize_managers(self) -> None:
"""Initializes or re-initializes all the manager classes."""
self.style_manager: StyleManager = StyleManager(self)
self.file_op_manager: FileOperationsManager = FileOperationsManager(self)
self.search_manager: SearchManager = SearchManager(self)
self.navigation_manager: NavigationManager = NavigationManager(self)
self.view_manager: ViewManager = ViewManager(self)
self.widget_manager: WidgetManager = WidgetManager(self, self.settings)
def __init__(self, parent: tk.Widget, initial_dir: Optional[str] = None,
filetypes: Optional[List[Tuple[str, str]]] = None,
mode: str = "open", title: str = LocaleStrings.CFD["title"]):
"""
Initializes the CustomFileDialog.
Args:
parent: The parent widget.
initial_dir: The initial directory to display.
filetypes: A list of filetype tuples, e.g., [('Text files', '*.txt')].
mode: The dialog mode. Can be "open" (select single file),
"save" (select single file for saving),
"multi" (select multiple files/directories),
or "dir" (select a single directory).
title: The title of the dialog window.
"""
super().__init__(parent)
self.my_tool_tip: Optional[Tooltip] = None
self.dialog_mode: str = mode
self.load_settings()
self.geometry(self.settings["window_size_preset"])
min_width, min_height = self.get_min_size_from_preset(
self.settings["window_size_preset"])
self.minsize(min_width, min_height)
self.title(title)
self.image: IconManager = IconManager()
width, height = map(
int, self.settings["window_size_preset"].split('x'))
LxTools.center_window_cross_platform(self, width, height)
self.parent: tk.Widget = parent
self.transient(parent)
self.grab_set()
self.result: Optional[Union[str, List[str]]] = None
self.current_dir: str = os.path.abspath(
initial_dir) if initial_dir else os.path.expanduser("~")
self.filetypes: List[Tuple[str, str]] = filetypes if filetypes else [
(LocaleStrings.CFD["all_files"], "*.* ")]
self.current_filter_pattern: str = self.filetypes[0][1]
self.history: List[str] = []
self.history_pos: int = -1
self.view_mode: tk.StringVar = tk.StringVar(
value=self.settings.get("default_view_mode", "icons"))
self.show_hidden_files: tk.BooleanVar = tk.BooleanVar(value=False)
self.resize_job: Optional[str] = None
self.last_width: int = 0
self.search_results: List[str] = []
self.search_mode: bool = False
self.original_path_text: str = ""
self.items_to_load_per_batch: int = 250
self.item_path_map: Dict[int, str] = {}
self.responsive_buttons_hidden: Optional[bool] = None
self.search_job: Optional[str] = None
self.search_thread: Optional[threading.Thread] = None
self.search_process: Optional[subprocess.Popen] = None
self.icon_manager: IconManager = IconManager()
self._initialize_managers()
self.widget_manager.filename_entry.bind(
"<Return>", self.search_manager.execute_search)
self.update_animation_settings()
self.view_manager._update_view_mode_buttons()
def initial_load() -> None:
"""Performs the initial loading and UI setup."""
self.update_idletasks()
self.last_width = self.widget_manager.file_list_frame.winfo_width()
self._handle_responsive_buttons(self.winfo_width())
self.navigation_manager.navigate_to(self.current_dir)
self.after(10, initial_load)
self.widget_manager.path_entry.bind(
"<Return>", self.navigation_manager.handle_path_entry_return)
self.bind("<Key>", self.search_manager.show_search_bar)
if self.dialog_mode == "save":
self.bind("<Delete>", self.file_op_manager.delete_selected_item)
def load_settings(self) -> None:
"""Loads settings from the configuration file."""
self.settings = CfdConfigManager.load()
size_preset = self.settings.get("window_size_preset", "1050x850")
self.settings["window_size_preset"] = size_preset
if hasattr(self, 'view_mode'):
self.view_mode.set(self.settings.get("default_view_mode", "icons"))
def get_min_size_from_preset(self, preset: str) -> Tuple[int, int]:
"""
Calculates the minimum window size based on a preset string.
Args:
preset: The size preset string (e.g., "1050x850").
Returns:
A tuple containing the minimum width and height.
"""
w, h = map(int, preset.split('x'))
return max(650, w - 400), max(450, h - 400)
def reload_config_and_rebuild_ui(self) -> None:
"""Reloads the configuration and rebuilds the entire UI."""
self.load_settings()
self.geometry(self.settings["window_size_preset"])
min_width, min_height = self.get_min_size_from_preset(
self.settings["window_size_preset"])
self.minsize(min_width, min_height)
width, height = map(
int, self.settings["window_size_preset"].split('x'))
LxTools.center_window_cross_platform(self, width, height)
for widget in self.winfo_children():
widget.destroy()
self._initialize_managers()
self.widget_manager.filename_entry.bind(
"<Return>", self.search_manager.execute_search)
self.view_manager._update_view_mode_buttons()
self.responsive_buttons_hidden = None
self.update_idletasks()
self._handle_responsive_buttons(self.winfo_width())
self.update_animation_settings()
if self.search_mode:
self.search_manager.show_search_results_treeview()
else:
self.navigation_manager.navigate_to(self.current_dir)
def open_settings_dialog(self) -> None:
"""Opens the settings dialog."""
SettingsDialog(self, dialog_mode=self.dialog_mode)
def update_animation_settings(self) -> None:
"""Updates the search animation icon based on current settings."""
use_pillow = self.settings.get('use_pillow_animation', False)
anim_type = self.settings.get('animation_type', 'double')
is_running = self.widget_manager.search_animation.running
if is_running:
self.widget_manager.search_animation.stop()
self.widget_manager.search_animation.destroy()
self.widget_manager.search_animation = AnimatedIcon(
self.widget_manager.status_container,
width=23,
height=23,
use_pillow=use_pillow,
animation_type=anim_type,
color="#2a6fde",
highlight_color="#5195ff",
bg=self.style_manager.bottom_color
)
self.widget_manager.search_animation.grid(
row=0, column=0, sticky='w', padx=(0, 5), pady=(4, 0))
self.widget_manager.search_animation.bind(
"<Button-1>", lambda e: self.search_manager.activate_search())
self.my_tool_tip = Tooltip(
self.widget_manager.search_animation,
text=lambda: LocaleStrings.UI["cancel_search"] if self.widget_manager.search_animation.running else LocaleStrings.UI["start_search"]
)
if is_running:
self.widget_manager.search_animation.start()
def get_file_icon(self, filename: str, size: str = 'large') -> tk.PhotoImage:
"""
Gets the appropriate icon for a given filename.
Args:
filename: The name of the file.
size: The desired icon size ('large' or 'small').
Returns:
A PhotoImage object for the corresponding file type.
"""
ext = os.path.splitext(filename)[1].lower()
if ext == '.py':
return self.icon_manager.get_icon(f'python_{size}')
if ext == '.pdf':
return self.icon_manager.get_icon(f'pdf_{size}')
if ext in ['.tar', '.zip', '.rar', '.7z', '.gz']:
return self.icon_manager.get_icon(f'archive_{size}')
if ext in ['.mp3', '.wav', '.ogg', '.flac']:
return self.icon_manager.get_icon(f'audio_{size}')
if ext in ['.mp4', '.mkv', '.avi', '.mov']:
return self.icon_manager.get_icon(f'video_{size}') if size == 'large' else self.icon_manager.get_icon(
'video_small_file')
if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.svg']:
return self.icon_manager.get_icon(f'picture_{size}')
if ext == '.iso':
return self.icon_manager.get_icon(f'iso_{size}')
return self.icon_manager.get_icon(f'file_{size}')
def on_window_resize(self, event: tk.Event) -> None:
"""
Handles the window resize event.
Args:
event: The event object.
"""
if event.widget is self:
if self.view_mode.get() == "icons" and not self.search_mode:
new_width = self.widget_manager.file_list_frame.winfo_width()
if abs(new_width - self.last_width) > 50:
if self.resize_job:
self.after_cancel(self.resize_job)
def repopulate_icons() -> None:
"""Repopulates the file list icons."""
self.update_idletasks()
self.view_manager.populate_files()
self.resize_job = self.after(150, repopulate_icons)
self.last_width = new_width
self._handle_responsive_buttons(event.width)
def _handle_responsive_buttons(self, window_width: int) -> None:
"""
Shows or hides buttons based on the window width.
Args:
window_width: The current width of the window.
"""
threshold = 850
container = self.widget_manager.responsive_buttons_container
more_button = self.widget_manager.more_button
should_be_hidden = window_width < threshold
if should_be_hidden != self.responsive_buttons_hidden:
if should_be_hidden:
container.pack_forget()
more_button.pack(side="left", padx=5)
else:
more_button.pack_forget()
container.pack(side="left")
self.responsive_buttons_hidden = should_be_hidden
def show_more_menu(self) -> None:
"""Displays a 'more options' menu."""
more_menu = tk.Menu(self, tearoff=0, background=self.style_manager.header, foreground=self.style_manager.color_foreground,
activebackground=self.style_manager.selection_color, activeforeground=self.style_manager.color_foreground, relief='flat', borderwidth=0)
is_writable = os.access(self.current_dir, os.W_OK)
creation_state = tk.NORMAL if is_writable and self.dialog_mode != "open" else tk.DISABLED
more_menu.add_command(label=LocaleStrings.UI["new_folder"], command=self.file_op_manager.create_new_folder,
image=self.icon_manager.get_icon('new_folder_small'), compound='left', state=creation_state)
more_menu.add_command(label=LocaleStrings.UI["new_document"], command=self.file_op_manager.create_new_file,
image=self.icon_manager.get_icon('new_document_small'), compound='left', state=creation_state)
more_menu.add_separator()
more_menu.add_command(label=LocaleStrings.VIEW["icon_view"], command=self.view_manager.set_icon_view,
image=self.icon_manager.get_icon('icon_view'), compound='left')
more_menu.add_command(label=LocaleStrings.VIEW["list_view"], command=self.view_manager.set_list_view,
image=self.icon_manager.get_icon('list_view'), compound='left')
more_menu.add_separator()
hidden_files_label = LocaleStrings.UI["hide_hidden_files"] if self.show_hidden_files.get(
) else LocaleStrings.UI["show_hidden_files"]
hidden_files_icon = self.icon_manager.get_icon(
'unhide') if self.show_hidden_files.get() else self.icon_manager.get_icon('hide')
more_menu.add_command(label=hidden_files_label, command=self.view_manager.toggle_hidden_files,
image=hidden_files_icon, compound='left')
more_button = self.widget_manager.more_button
x = more_button.winfo_rootx()
y = more_button.winfo_rooty() + more_button.winfo_height()
more_menu.tk_popup(x, y)
def on_sidebar_resize(self, event: tk.Event) -> None:
"""
Handles the sidebar resize event, adjusting button text visibility.
Args:
event: The event object.
"""
current_width = event.width
threshold_width = 100
if current_width < threshold_width:
for btn, original_text in self.widget_manager.sidebar_buttons:
btn.config(text="", compound="top")
for btn, original_text in self.widget_manager.device_buttons:
btn.config(text="", compound="top")
else:
for btn, original_text in self.widget_manager.sidebar_buttons:
btn.config(text=original_text, compound="left")
for btn, original_text in self.widget_manager.device_buttons:
btn.config(text=original_text, compound="left")
def _on_devices_enter(self, event: tk.Event) -> None:
"""
Shows the scrollbar when the mouse enters the devices area.
Args:
event: The event object.
"""
self.widget_manager.devices_scrollbar.grid(
row=1, column=1, sticky="ns")
def _on_devices_leave(self, event: tk.Event) -> None:
"""
Hides the scrollbar when the mouse leaves the devices area.
Args:
event: The event object.
"""
x, y = event.x_root, event.y_root
widget_x = self.widget_manager.devices_canvas.winfo_rootx()
widget_y = self.widget_manager.devices_canvas.winfo_rooty()
widget_width = self.widget_manager.devices_canvas.winfo_width()
widget_height = self.widget_manager.devices_canvas.winfo_height()
buffer = 5
if not (widget_x - buffer <= x <= widget_x + widget_width + buffer and
widget_y - buffer <= y <= widget_y + widget_height + buffer):
self.widget_manager.devices_scrollbar.grid_remove()
def toggle_recursive_search(self) -> None:
"""
Toggles the recursive search option on or off."""
self.widget_manager.recursive_search.set(
not self.widget_manager.recursive_search.get())
if self.widget_manager.recursive_search.get():
self.widget_manager.recursive_button.configure(
style="Header.TButton.Active.Round")
else:
self.widget_manager.recursive_button.configure(
style="Header.TButton.Borderless.Round")
def update_status_bar(self, status_info: Optional[str] = None) -> None:
"""
Updates the status bar with disk usage and selected item information.
Args:
status_info: The path of the currently selected item or a custom string.
"""
try:
total, used, free = shutil.disk_usage(self.current_dir)
free_str = self._format_size(free)
self.widget_manager.storage_label.config(
text=f"{LocaleStrings.CFD['free_space']}: {free_str}")
self.widget_manager.storage_bar['value'] = (used / total) * 100
status_text = ""
if status_info and os.path.exists(status_info):
selected_path = status_info
if os.path.isdir(selected_path):
content_count = self.view_manager._get_folder_content_count(
selected_path)
if content_count is not None:
status_text = f
def on_open(self) -> None:
"""Handles the 'Open' action, closing the dialog if a file is selected."""
if self.result and isinstance(self.result, str) and os.path.isfile(self.result):
self.destroy()
def on_save(self) -> None:
"""Handles the 'Save' action, setting the selected file and closing the dialog."""
file_name = self.widget_manager.filename_entry.get()
if file_name:
self.result = os.path.join(self.current_dir, file_name)
self.destroy()
def on_cancel(self) -> None:
"""Handles the 'Cancel' action, clearing the selection and closing the dialog."""
self.result = None
self.destroy()
def get_result(self) -> Optional[Union[str, List[str]]]:
"""
Returns the result of the dialog.
Returns:
- A string containing a single path for modes 'open', 'save', 'dir'.
- A list of strings for mode 'multi'.
- None if the dialog was cancelled.
"""
return self.result
def update_action_buttons_state(self) -> None:
"""Updates the state of action buttons (e.g., 'New Folder') based on directory permissions."""
is_writable = os.access(self.current_dir, os.W_OK)
state = tk.NORMAL if is_writable and self.dialog_mode != "open" else tk.DISABLED
self.widget_manager.new_folder_button.config(state=state)
self.widget_manager.new_file_button.config(state=state)
def _matches_filetype(self, filename: str) -> bool:
"""
Checks if a filename matches the current filetype filter.
Args:
filename: The name of the file to check.
Returns:
True if the file matches the filter, False otherwise.
"""
if self.current_filter_pattern == "*.*":
return True
patterns = self.current_filter_pattern.lower().split()
fn_lower = filename.lower()
for p in patterns:
if p.startswith('*.'):
if fn_lower.endswith(p[1:]):
return True
elif p.startswith('.'):
if fn_lower.endswith(p):
return True
else:
if fn_lower == p:
return True
return False
def _format_size(self, size_bytes: Optional[int]) -> str:
"""
Formats a size in bytes into a human-readable string (KB, MB, GB).
Args:
size_bytes: The size in bytes.
Returns:
A formatted string representing the size.
"""
if size_bytes is None:
return ""
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1024**2:
return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1024**3:
return f"{size_bytes/1024**2:.1f} MB"
return f"{size_bytes/1024**3:.1f} GB"
def shorten_text(self, text: str, max_len: int) -> str:
"""
Shortens a string to a maximum length, adding '...' if truncated.
Args:
text: The text to shorten.
max_len: The maximum allowed length.
Returns:
The shortened text.
"""
return text if len(text) <= max_len else text[:max_len-3] + "..."
def _get_mounted_devices(self) -> List[Tuple[str, str, bool]]:
"""
Retrieves a list of mounted devices on the system.
Returns:
A list of tuples, where each tuple contains the display name,
mount point, and a boolean indicating if it's removable.
"""
devices: List[Tuple[str, str, bool]] = []
root_disk_name: Optional[str] = None
try:
result = subprocess.run(['lsblk', '-J', '-o', 'NAME,MOUNTPOINT,FSTYPE,SIZE,RO,RM,TYPE,LABEL,PKNAME'],
capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
for block_device in data.get('blockdevices', []):
if 'children' in block_device:
for child_device in block_device['children']:
if child_device.get('mountpoint') == '/':
root_disk_name = block_device.get('name')
break
if root_disk_name:
break
for block_device in data.get('blockdevices', []):
if (block_device.get('mountpoint') and
block_device.get('type') not in ['loop', 'rom'] and
block_device.get('mountpoint') != '/'):
if block_device.get('name').startswith(root_disk_name) and not block_device.get('rm', False):
pass
else:
name = block_device.get('name')
mountpoint = block_device.get('mountpoint')
label = block_device.get('label')
removable = block_device.get('rm', False)
display_name = label if label else name
devices.append((display_name, mountpoint, removable))
if 'children' in block_device:
for child_device in block_device['children']:
if (child_device.get('mountpoint') and
child_device.get('type') not in ['loop', 'rom'] and
child_device.get('mountpoint') != '/'):
if block_device.get('name') == root_disk_name and not child_device.get('rm', False):
pass
else:
name = child_device.get('name')
mountpoint = child_device.get('mountpoint')
label = child_device.get('label')
removable = child_device.get('rm', False)
display_name = label if label else name
devices.append(
(display_name, mountpoint, removable))
except Exception as e:
print(f"Error getting mounted devices: {e}")
return devices