Files
shared_libs/custom_file_dialog/cfd_view_manager.py

614 lines
27 KiB
Python

import os
import tkinter as tk
from tkinter import ttk
from datetime import datetime
from typing import Optional, List, Tuple, Callable, Any
# To avoid circular import with custom_file_dialog.py
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from custom_file_dialog import CustomFileDialog
from shared_libs.common_tools import Tooltip
from .cfd_app_config import CfdConfigManager, LocaleStrings
class ViewManager:
"""Manages the display of files and folders in list and icon views."""
def __init__(self, dialog: 'CustomFileDialog'):
"""
Initializes the ViewManager.
Args:
dialog: The main CustomFileDialog instance.
"""
self.dialog = dialog
def populate_files(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None:
"""
Populates the main file display area.
This method clears the current view and then calls the appropriate
method to populate either the list or icon view.
Args:
item_to_rename (str, optional): The name of an item to immediately
put into rename mode. Defaults to None.
item_to_select (str, optional): The name of an item to select
after populating. Defaults to None.
"""
self._unbind_mouse_wheel_events()
for widget in self.dialog.widget_manager.file_list_frame.winfo_children():
widget.destroy()
self.dialog.widget_manager.path_entry.delete(0, tk.END)
self.dialog.widget_manager.path_entry.insert(
0, self.dialog.current_dir)
self.dialog.result = None
self.dialog.update_status_bar()
if self.dialog.view_mode.get() == "list":
self.populate_list_view(item_to_rename, item_to_select)
else:
self.populate_icon_view(item_to_rename, item_to_select)
def _get_sorted_items(self) -> Tuple[List[str], Optional[str], Optional[str]]:
"""
Gets a sorted list of items from the current directory.
Returns:
tuple: A tuple containing (list of items, error message, warning message).
"""
try:
items = os.listdir(self.dialog.current_dir)
num_items = len(items)
warning_message = None
if num_items > CfdConfigManager.MAX_ITEMS_TO_DISPLAY:
warning_message = f"{LocaleStrings.CFD['showing']} {CfdConfigManager.MAX_ITEMS_TO_DISPLAY} {LocaleStrings.CFD['of']} {num_items} {LocaleStrings.CFD['entries']}."
items = items[:CfdConfigManager.MAX_ITEMS_TO_DISPLAY]
dirs = sorted([d for d in items if os.path.isdir(
os.path.join(self.dialog.current_dir, d))], key=str.lower)
files = sorted([f for f in items if not os.path.isdir(
os.path.join(self.dialog.current_dir, f))], key=str.lower)
return (dirs + files, None, warning_message)
except PermissionError:
return ([], LocaleStrings.CFD["access_denied"], None)
except FileNotFoundError:
return ([], LocaleStrings.CFD["directory_not_found"], None)
def _get_folder_content_count(self, folder_path: str) -> Optional[int]:
"""
Counts the number of items in a given folder.
Args:
folder_path (str): The path to the folder.
Returns:
int or None: The number of items, or None if an error occurs.
"""
try:
if not os.path.isdir(folder_path) or not os.access(folder_path, os.R_OK):
return None
items = os.listdir(folder_path)
if not self.dialog.show_hidden_files.get():
items = [item for item in items if not item.startswith('.')]
return len(items)
except (PermissionError, FileNotFoundError):
return None
def _get_item_path_from_widget(self, widget: tk.Widget) -> Optional[str]:
"""
Traverses up the widget hierarchy to find the item_path attribute.
Args:
widget: The widget to start from.
Returns:
str or None: The associated file path, or None if not found.
"""
while widget and not hasattr(widget, 'item_path'):
widget = widget.master
return getattr(widget, 'item_path', None)
def _handle_icon_click(self, event: tk.Event) -> None:
"""Handles a single click on an icon view item."""
item_path = self._get_item_path_from_widget(event.widget)
if item_path:
item_frame = event.widget
while not hasattr(item_frame, 'item_path'):
item_frame = item_frame.master
self.on_item_select(item_path, item_frame)
def _handle_icon_double_click(self, event: tk.Event) -> None:
"""Handles a double click on an icon view item."""
item_path = self._get_item_path_from_widget(event.widget)
if item_path:
self._handle_item_double_click(item_path)
def _handle_icon_context_menu(self, event: tk.Event) -> None:
"""Handles a context menu request on an icon view item."""
item_path = self._get_item_path_from_widget(event.widget)
if item_path:
self.dialog.file_op_manager._show_context_menu(event, item_path)
def _handle_icon_rename_request(self, event: tk.Event) -> None:
"""Handles a rename request on an icon view item."""
item_path = self._get_item_path_from_widget(event.widget)
if item_path:
item_frame = event.widget
while not hasattr(item_frame, 'item_path'):
item_frame = item_frame.master
self.dialog.file_op_manager.on_rename_request(
event, item_path, item_frame)
def populate_icon_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None:
"""
Populates the file display with items in an icon grid layout.
Args:
item_to_rename (str, optional): Item to enter rename mode.
item_to_select (str, optional): Item to select.
"""
self.dialog.all_items, error, warning = self._get_sorted_items()
self.dialog.currently_loaded_count = 0
self.dialog.icon_canvas = tk.Canvas(self.dialog.widget_manager.file_list_frame,
highlightthickness=0, bg=self.dialog.style_manager.icon_bg_color)
v_scrollbar = ttk.Scrollbar(
self.dialog.widget_manager.file_list_frame, orient="vertical", command=self.dialog.icon_canvas.yview)
self.dialog.icon_canvas.pack(side="left", fill="both", expand=True)
self.dialog.icon_canvas.focus_set()
v_scrollbar.pack(side="right", fill="y")
container_frame = ttk.Frame(
self.dialog.icon_canvas, style="Content.TFrame")
self.dialog.icon_canvas.create_window(
(0, 0), window=container_frame, anchor="nw")
container_frame.bind("<Configure>", lambda e: self.dialog.icon_canvas.configure(
scrollregion=self.dialog.icon_canvas.bbox("all")))
def _on_mouse_wheel(event: tk.Event) -> None:
if event.num == 4:
delta = -1
elif event.num == 5:
delta = 1
else:
delta = -1 * int(event.delta / 120)
self.dialog.icon_canvas.yview_scroll(delta, "units")
if self.dialog.currently_loaded_count < len(self.dialog.all_items) and self.dialog.icon_canvas.yview()[1] > 0.9:
self._load_more_items_icon_view(
container_frame, _on_mouse_wheel)
for widget in [self.dialog.icon_canvas, container_frame]:
widget.bind("<MouseWheel>", _on_mouse_wheel)
widget.bind("<Button-4>", _on_mouse_wheel)
widget.bind("<Button-5>", _on_mouse_wheel)
if warning:
self.dialog.widget_manager.search_status_label.config(text=warning)
if error:
ttk.Label(container_frame, text=error).pack(pady=20)
return
widget_to_focus = None
while self.dialog.currently_loaded_count < len(self.dialog.all_items):
widget_to_focus = self._load_more_items_icon_view(
container_frame, _on_mouse_wheel, item_to_rename, item_to_select)
if widget_to_focus:
break
if not (item_to_rename or item_to_select):
break
if widget_to_focus:
def scroll_to_widget() -> None:
self.dialog.update_idletasks()
if not widget_to_focus.winfo_exists():
return
y = widget_to_focus.winfo_y()
canvas_height = self.dialog.icon_canvas.winfo_height()
scroll_region = self.dialog.icon_canvas.bbox("all")
if not scroll_region:
return
scroll_height = scroll_region[3]
if scroll_height > canvas_height:
fraction = y / scroll_height
self.dialog.icon_canvas.yview_moveto(fraction)
self.dialog.after(100, scroll_to_widget)
def _load_more_items_icon_view(self, container: ttk.Frame, scroll_handler: Callable, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> Optional[ttk.Frame]:
"""
Loads a batch of items into the icon view.
Args:
container: The parent widget for the items.
scroll_handler: The function to handle mouse wheel events.
item_to_rename (str, optional): Item to enter rename mode.
item_to_select (str, optional): Item to select.
Returns:
The widget that was focused (renamed or selected), or None.
"""
start_index = self.dialog.currently_loaded_count
end_index = min(len(self.dialog.all_items), start_index +
self.dialog.items_to_load_per_batch)
if start_index >= end_index:
return None
item_width, item_height = 125, 100
frame_width = self.dialog.widget_manager.file_list_frame.winfo_width()
col_count = max(1, frame_width // item_width - 1)
row = start_index // col_count if col_count > 0 else 0
col = start_index % col_count if col_count > 0 else 0
widget_to_focus = None
for i in range(start_index, end_index):
name = self.dialog.all_items[i]
if not self.dialog.show_hidden_files.get() and name.startswith('.'):
continue
path = os.path.join(self.dialog.current_dir, name)
is_dir = os.path.isdir(path)
if not is_dir and not self.dialog._matches_filetype(name):
continue
item_frame = ttk.Frame(
container, width=item_width, height=item_height, style="Item.TFrame")
item_frame.grid(row=row, column=col, padx=5, ipadx=25, pady=5)
item_frame.grid_propagate(False)
item_frame.item_path = path
if name == item_to_rename:
self.dialog.file_op_manager.start_rename(item_frame, path)
widget_to_focus = item_frame
else:
icon = self.dialog.icon_manager.get_icon(
'folder_large') if is_dir else self.dialog.get_file_icon(name, 'large')
icon_label = ttk.Label(
item_frame, image=icon, style="Icon.TLabel")
icon_label.pack(pady=(10, 5))
name_label = ttk.Label(item_frame, text=self.dialog.shorten_text(
name, 14), anchor="center", style="Item.TLabel")
name_label.pack(fill="x", expand=True)
Tooltip(item_frame, name)
for widget in [item_frame, icon_label, name_label]:
widget.bind("<Double-Button-1>", lambda e,
p=path: self._handle_item_double_click(p))
widget.bind("<Button-1>", lambda e, p=path,
f=item_frame: self.on_item_select(p, f))
widget.bind("<ButtonRelease-3>", lambda e,
p=path: self.dialog.file_op_manager._show_context_menu(e, p))
widget.bind("<F2>", lambda e, p=path,
f=item_frame: self.dialog.file_op_manager.on_rename_request(e, p, f))
widget.bind("<MouseWheel>", scroll_handler)
widget.bind("<Button-4>", scroll_handler)
widget.bind("<Button-5>", scroll_handler)
if name == item_to_select:
self.on_item_select(path, item_frame)
widget_to_focus = item_frame
if col_count > 0:
col = (col + 1) % col_count
if col == 0:
row += 1
else:
row += 1
self.dialog.currently_loaded_count = end_index
return widget_to_focus
def populate_list_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None:
"""
Populates the file display with items in a list (Treeview) layout.
Args:
item_to_rename (str, optional): Item to enter rename mode.
item_to_select (str, optional): Item to select.
"""
self.dialog.all_items, error, warning = self._get_sorted_items()
self.dialog.currently_loaded_count = 0
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 = ("size", "type", "modified")
self.dialog.tree = ttk.Treeview(
tree_frame, columns=columns, show="tree headings")
self.dialog.tree.heading(
"#0", text=LocaleStrings.VIEW["name"], anchor="w")
self.dialog.tree.column("#0", anchor="w", width=250, stretch=True)
self.dialog.tree.heading(
"size", text=LocaleStrings.VIEW["size"], anchor="e")
self.dialog.tree.column("size", anchor="e", width=120, stretch=False)
self.dialog.tree.heading(
"type", text=LocaleStrings.VIEW["type"], anchor="w")
self.dialog.tree.column("type", anchor="w", width=120, stretch=False)
self.dialog.tree.heading(
"modified", text=LocaleStrings.VIEW["date_modified"], anchor="w")
self.dialog.tree.column("modified", anchor="w",
width=160, stretch=False)
v_scrollbar = ttk.Scrollbar(
tree_frame, orient="vertical", command=self.dialog.tree.yview)
h_scrollbar = ttk.Scrollbar(
tree_frame, orient="horizontal", command=self.dialog.tree.xview)
self.dialog.tree.configure(yscrollcommand=v_scrollbar.set,
xscrollcommand=h_scrollbar.set)
self.dialog.tree.grid(row=0, column=0, sticky='nsew')
self.dialog.tree.focus_set()
v_scrollbar.grid(row=0, column=1, sticky='ns')
h_scrollbar.grid(row=1, column=0, sticky='ew')
def _on_scroll(*args: Any) -> None:
if self.dialog.currently_loaded_count < len(self.dialog.all_items) and self.dialog.tree.yview()[1] > 0.9:
self._load_more_items_list_view()
v_scrollbar.set(*args)
self.dialog.tree.configure(yscrollcommand=_on_scroll)
self.dialog.tree.bind("<Double-1>", self.on_list_double_click)
self.dialog.tree.bind("<<TreeviewSelect>>", self.on_list_select)
self.dialog.tree.bind(
"<F2>", self.dialog.file_op_manager.on_rename_request)
self.dialog.tree.bind("<ButtonRelease-3>", self.on_list_context_menu)
if warning:
self.dialog.widget_manager.search_status_label.config(text=warning)
if error:
self.dialog.tree.insert("", "end", text=error, values=())
return
while self.dialog.currently_loaded_count < len(self.dialog.all_items):
item_found = self._load_more_items_list_view(
item_to_rename, item_to_select)
if item_found:
break
if not (item_to_rename or item_to_select):
break
def _load_more_items_list_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> bool:
"""
Loads a batch of items into the list view.
Args:
item_to_rename (str, optional): Item to enter rename mode.
item_to_select (str, optional): Item to select.
Returns:
bool: True if the item to rename/select was found and processed.
"""
start_index = self.dialog.currently_loaded_count
end_index = min(len(self.dialog.all_items), start_index +
self.dialog.items_to_load_per_batch)
if start_index >= end_index:
return False
item_found = False
for i in range(start_index, end_index):
name = self.dialog.all_items[i]
if not self.dialog.show_hidden_files.get() and name.startswith('.'):
continue
path = os.path.join(self.dialog.current_dir, name)
is_dir = os.path.isdir(path)
if not is_dir and not self.dialog._matches_filetype(name):
continue
try:
stat = os.stat(path)
modified_time = datetime.fromtimestamp(
stat.st_mtime).strftime('%d.%m.%Y %H:%M')
if is_dir:
icon, file_type, size = self.dialog.icon_manager.get_icon(
'folder_small'), LocaleStrings.FILE["folder"], ""
else:
icon, file_type, size = self.dialog.get_file_icon(
name, 'small'), LocaleStrings.FILE["file"], self.dialog._format_size(stat.st_size)
item_id = self.dialog.tree.insert("", "end", text=f" {name}", image=icon, values=(
size, file_type, modified_time))
if name == item_to_rename:
self.dialog.tree.selection_set(item_id)
self.dialog.tree.focus(item_id)
self.dialog.tree.see(item_id)
self.dialog.file_op_manager.start_rename(item_id, path)
item_found = True
elif name == item_to_select:
self.dialog.tree.selection_set(item_id)
self.dialog.tree.focus(item_id)
self.dialog.tree.see(item_id)
item_found = True
except (FileNotFoundError, PermissionError):
continue
self.dialog.currently_loaded_count = end_index
return item_found
def on_item_select(self, path: str, item_frame: ttk.Frame) -> None:
"""
Handles the selection of an item in the icon view.
Args:
path (str): The path of the selected item.
item_frame: The widget frame of the selected item.
"""
if hasattr(self.dialog, 'selected_item_frame') and self.dialog.selected_item_frame.winfo_exists():
self.dialog.selected_item_frame.state(['!selected'])
for child in self.dialog.selected_item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['!selected'])
item_frame.state(['selected'])
for child in item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['selected'])
self.dialog.selected_item_frame = item_frame
self.dialog.selected_file = path
self.dialog.update_status_bar(path)
self.dialog.search_manager.show_search_ready()
if not os.path.isdir(path):
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
self.dialog.widget_manager.filename_entry.insert(
0, os.path.basename(path))
def on_list_select(self, event: tk.Event) -> None:
"""Handles the selection of an item in the list view."""
if not self.dialog.tree.selection():
return
item_id = self.dialog.tree.selection()[0]
item_text = self.dialog.tree.item(item_id, 'text').strip()
path = os.path.join(self.dialog.current_dir, item_text)
self.dialog.selected_file = path
self.dialog.update_status_bar(path)
self.dialog.search_manager.show_search_ready()
if not os.path.isdir(self.dialog.selected_file):
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
self.dialog.widget_manager.filename_entry.insert(0, item_text)
def on_list_context_menu(self, event: tk.Event) -> str:
"""Shows the context menu for a list view item."""
iid = self.dialog.tree.identify_row(event.y)
if not iid:
return "break"
self.dialog.tree.selection_set(iid)
item_text = self.dialog.tree.item(iid, "text").strip()
item_path = os.path.join(self.dialog.current_dir, item_text)
self.dialog.file_op_manager._show_context_menu(event, item_path)
return "break"
def _handle_item_double_click(self, path: str) -> None:
"""
Handles the logic for a double-click on any item, regardless of view.
Navigates into directories or selects files.
Args:
path (str): The full path of the double-clicked item.
"""
if os.path.isdir(path):
self.dialog.navigation_manager.navigate_to(path)
elif self.dialog.dialog_mode == "open":
self.dialog.selected_file = path
self.dialog.destroy()
elif self.dialog.dialog_mode == "save":
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
self.dialog.widget_manager.filename_entry.insert(
0, os.path.basename(path))
self.dialog.on_save()
def on_list_double_click(self, event: tk.Event) -> None:
"""Handles a double-click on a list view item."""
if not self.dialog.tree.selection():
return
item_id = self.dialog.tree.selection()[0]
item_text = self.dialog.tree.item(item_id, 'text').strip()
path = os.path.join(self.dialog.current_dir, item_text)
self._handle_item_double_click(path)
def _select_file_in_view(self, filename: str) -> None:
"""
Programmatically selects a file in the current view.
Args:
filename (str): The name of the file to select.
"""
if self.dialog.view_mode.get() == "list":
for item_id in self.dialog.tree.get_children():
if self.dialog.tree.item(item_id, "text").strip() == filename:
self.dialog.tree.selection_set(item_id)
self.dialog.tree.focus(item_id)
self.dialog.tree.see(item_id)
break
elif self.dialog.view_mode.get() == "icons":
if not hasattr(self.dialog, 'icon_canvas') or not self.dialog.icon_canvas.winfo_exists():
return
container_frame = self.dialog.icon_canvas.winfo_children()[0]
target_path = os.path.join(self.dialog.current_dir, filename)
for widget in container_frame.winfo_children():
if hasattr(widget, 'item_path') and widget.item_path == target_path:
self.on_item_select(widget.item_path, widget)
def scroll_to_widget() -> None:
self.dialog.update_idletasks()
if not widget.winfo_exists():
return
y = widget.winfo_y()
canvas_height = self.dialog.icon_canvas.winfo_height()
scroll_region = self.dialog.icon_canvas.bbox("all")
if not scroll_region:
return
scroll_height = scroll_region[3]
if scroll_height > canvas_height:
fraction = y / scroll_height
self.dialog.icon_canvas.yview_moveto(fraction)
self.dialog.after(100, scroll_to_widget)
break
def _update_view_mode_buttons(self) -> None:
"""Updates the visual state of the view mode toggle buttons."""
if self.dialog.view_mode.get() == "icons":
self.dialog.widget_manager.icon_view_button.configure(
style="Header.TButton.Active.Round")
self.dialog.widget_manager.list_view_button.configure(
style="Header.TButton.Borderless.Round")
else:
self.dialog.widget_manager.list_view_button.configure(
style="Header.TButton.Active.Round")
self.dialog.widget_manager.icon_view_button.configure(
style="Header.TButton.Borderless.Round")
def set_icon_view(self) -> None:
"""Switches to icon view and repopulates the files."""
self.dialog.view_mode.set("icons")
self._update_view_mode_buttons()
self.populate_files()
def set_list_view(self) -> None:
"""Switches to list view and repopulates the files."""
self.dialog.view_mode.set("list")
self._update_view_mode_buttons()
self.populate_files()
def toggle_hidden_files(self) -> None:
"""Toggles the visibility of hidden files and refreshes the view."""
self.dialog.show_hidden_files.set(
not self.dialog.show_hidden_files.get())
if self.dialog.show_hidden_files.get():
self.dialog.widget_manager.hidden_files_button.config(
image=self.dialog.icon_manager.get_icon('unhide'))
Tooltip(self.dialog.widget_manager.hidden_files_button,
LocaleStrings.UI["hide_hidden_files"])
else:
self.dialog.widget_manager.hidden_files_button.config(
image=self.dialog.icon_manager.get_icon('hide'))
Tooltip(self.dialog.widget_manager.hidden_files_button,
LocaleStrings.UI["show_hidden_files"])
self.populate_files()
def on_filter_change(self, event: tk.Event) -> None:
"""Handles a change in the file type filter combobox."""
selected_desc = self.dialog.widget_manager.filter_combobox.get()
for desc, pattern in self.dialog.filetypes:
if desc == selected_desc:
self.dialog.current_filter_pattern = pattern
break
self.populate_files()
def _unbind_mouse_wheel_events(self) -> None:
"""Unbinds all mouse wheel events from the dialog."""
self.dialog.unbind_all("<MouseWheel>")
self.dialog.unbind_all("<Button-4>")
self.dialog.unbind_all("<Button-5>")