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 AppConfig, 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.selected_file = 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 > AppConfig.MAX_ITEMS_TO_DISPLAY: warning_message = f"{LocaleStrings.CFD['showing']} {AppConfig.MAX_ITEMS_TO_DISPLAY} {LocaleStrings.CFD['of']} {num_items} {LocaleStrings.CFD['entries']}." items = items[:AppConfig.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.on_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("", 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("", _on_mouse_wheel) widget.bind("", _on_mouse_wheel) widget.bind("", _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("", lambda e, p=path: self.on_item_double_click(p)) widget.bind("", lambda e, p=path, f=item_frame: self.on_item_select(p, f)) widget.bind("", lambda e, p=path: self.dialog.file_op_manager._show_context_menu(e, p)) widget.bind("", lambda e, p=path, f=item_frame: self.dialog.file_op_manager.on_rename_request(e, p, f)) widget.bind("", scroll_handler) widget.bind("", scroll_handler) widget.bind("", 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("", self.on_list_double_click) self.dialog.tree.bind("<>", self.on_list_select) self.dialog.tree.bind("", self.dialog.file_op_manager.on_rename_request) self.dialog.tree.bind("", 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 on_item_double_click(self, path: str) -> None: """ Handles a double-click on an icon view item. Args: path (str): The 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) 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, item_text) self.dialog.on_save() 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("") self.dialog.unbind_all("") self.dialog.unbind_all("")