import os import tkinter as tk from tkinter import ttk from datetime import datetime from typing import Optional, List, Tuple, Callable, Any, Dict # 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 shared_libs.message import MessageDialog 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 _get_file_info_list(self) -> Tuple[List[Dict], Optional[str], Optional[str]]: """ Gets a sorted list of file information dictionaries from the current source. """ if self.dialog.current_fs_type == "sftp": items, error = self.dialog.sftp_manager.list_directory(self.dialog.current_dir) if error: return [], error, None file_info_list = [] import stat for item in items: if item.filename in ['.', '..']: continue is_dir = stat.S_ISDIR(item.st_mode) # Manually construct SFTP path to ensure forward slashes path = f"{self.dialog.current_dir}/{item.filename}".replace("//", "/") file_info_list.append({ 'name': item.filename, 'path': path, 'is_dir': is_dir, 'size': item.st_size, 'modified': item.st_mtime }) return file_info_list, None, None else: try: items = list(os.scandir(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] items.sort(key=lambda e: (not e.is_dir(), e.name.lower())) file_info_list = [] for item in items: try: stat_result = item.stat() file_info_list.append({ 'name': item.name, 'path': item.path, 'is_dir': item.is_dir(), 'size': stat_result.st_size, 'modified': stat_result.st_mtime }) except (FileNotFoundError, PermissionError): continue return file_info_list, None, warning_message except PermissionError: return ([], LocaleStrings.CFD["access_denied"], None) except FileNotFoundError: return ([], LocaleStrings.CFD["directory_not_found"], None) def populate_files(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None: """ Populates the main file display area. """ 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.selected_item_frames.clear() self.dialog.update_selection_info() 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_folder_content_count(self, folder_path: str) -> Optional[int]: """ Counts the number of items in a given folder, supporting both local and SFTP. """ try: if self.dialog.current_fs_type == "sftp": if not self.dialog.sftp_manager.path_is_dir(folder_path): return None items, error = self.dialog.sftp_manager.list_directory(folder_path) if error: return None else: 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(): # For SFTP, items are attrs, for local they are strings if self.dialog.current_fs_type == "sftp": items = [item for item in items if not item.filename.startswith('.')] else: 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. """ while widget and not hasattr(widget, 'item_path'): widget = widget.master return getattr(widget, 'item_path', None) def _is_dir(self, path: str) -> bool: """Checks if a given path is a directory, supporting both local and SFTP.""" if self.dialog.current_fs_type == 'sftp': for item in self.dialog.all_items: if item['path'] == path: return item['is_dir'] return False else: return os.path.isdir(path) 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, event) 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. """ self.dialog.all_items, error, warning = self._get_file_info_list() 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. """ 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): file_info = self.dialog.all_items[i] name = file_info['name'] path = file_info['path'] is_dir = file_info['is_dir'] if not self.dialog.show_hidden_files.get() and name.startswith('.'): continue 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._handle_item_double_click(p)) widget.bind("", lambda e, p=path, f=item_frame: self.on_item_select(p, f, e)) 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. """ self.dialog.all_items, error, warning = self._get_file_info_list() 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") if self.dialog.dialog_mode == 'multi': self.dialog.tree.config(selectmode="extended") 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. """ 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): file_info = self.dialog.all_items[i] name = file_info['name'] path = file_info['path'] is_dir = file_info['is_dir'] if not self.dialog.show_hidden_files.get() and name.startswith('.'): continue if not is_dir and not self.dialog._matches_filetype(name): continue try: modified_time = datetime.fromtimestamp( file_info['modified']).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(file_info['size']) item_id = self.dialog.tree.insert("", "end", text=f" {name}", image=icon, values=( size, file_type, modified_time)) self.dialog.item_path_map[item_id] = path # Store path for later retrieval 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, event: Optional[tk.Event] = None) -> None: """ Handles the selection of an item in the icon view. """ if self.dialog.dialog_mode == 'dir' and not self._is_dir(path): return if self.dialog.dialog_mode == 'multi': ctrl_pressed = (event.state & 0x4) != 0 if event else False if ctrl_pressed: if item_frame in self.dialog.selected_item_frames: item_frame.state(['!selected']) for child in item_frame.winfo_children(): if isinstance(child, ttk.Label): child.state(['!selected']) self.dialog.selected_item_frames.remove(item_frame) else: item_frame.state(['selected']) for child in item_frame.winfo_children(): if isinstance(child, ttk.Label): child.state(['selected']) self.dialog.selected_item_frames.append(item_frame) else: for f in self.dialog.selected_item_frames: f.state(['!selected']) for child in f.winfo_children(): if isinstance(child, ttk.Label): child.state(['!selected']) self.dialog.selected_item_frames.clear() item_frame.state(['selected']) for child in item_frame.winfo_children(): if isinstance(child, ttk.Label): child.state(['selected']) self.dialog.selected_item_frames.append(item_frame) selected_paths = [frame.item_path for frame in self.dialog.selected_item_frames] self.dialog.result = selected_paths self.dialog.update_selection_info() else: # Single selection mode 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.update_selection_info(path) self.dialog.search_manager.show_search_ready() def on_list_select(self, event: tk.Event) -> None: """ Handles the selection of an item in the list view. """ selections = self.dialog.tree.selection() if not selections: self.dialog.result = [] if self.dialog.dialog_mode == 'multi' else None self.dialog.update_selection_info() return if self.dialog.dialog_mode == 'multi': selected_paths = [] for item_id in selections: path = self.dialog.item_path_map.get(item_id) if path: selected_paths.append(path) self.dialog.result = selected_paths self.dialog.update_selection_info() else: item_id = selections[0] path = self.dialog.item_path_map.get(item_id) if not path: return if self.dialog.dialog_mode == 'dir' and not self._is_dir(path): self.dialog.result = None self.dialog.tree.selection_remove(item_id) self.dialog.update_selection_info() return self.dialog.update_selection_info(path) 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) path = self.dialog.item_path_map.get(iid) if path: self.dialog.file_op_manager._show_context_menu(event, 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. """ if self._is_dir(path): if self.dialog.dialog_mode == 'dir': has_subdirs = False try: if self.dialog.current_fs_type == "sftp": import stat items, _ = self.dialog.sftp_manager.list_directory(path) for item in items: if item.filename not in ['.', '..'] and stat.S_ISDIR(item.st_mode): has_subdirs = True break else: for item in os.listdir(path): if os.path.isdir(os.path.join(path, item)) and not item.startswith('.'): has_subdirs = True break except OSError: self.dialog.navigation_manager.navigate_to(path) return if has_subdirs: self.dialog.navigation_manager.navigate_to(path) else: dialog = MessageDialog( master=self.dialog, message_type="ask", title=LocaleStrings.CFD["select_or_enter_title"], text=LocaleStrings.CFD["select_or_enter_prompt"].format(folder_name=os.path.basename(path)), buttons=[ LocaleStrings.CFD["select_button"], LocaleStrings.CFD["enter_button"], LocaleStrings.CFD["cancel_button"], ] ) choice = dialog.show() if choice is True: self.dialog.result = path self.dialog.on_open() elif choice is False: self.dialog.navigation_manager.navigate_to(path) else: self.dialog.navigation_manager.navigate_to(path) elif self.dialog.dialog_mode in ["open", "multi"]: self.dialog.result = path self.dialog.on_open() 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. """ selection = self.dialog.tree.selection() if not selection: return item_id = selection[0] path = self.dialog.item_path_map.get(item_id) if path: self._handle_item_double_click(path) def _select_file_in_view(self, filename: str) -> None: """ Programmatically selects a file in the current view. """ is_sftp = self.dialog.current_fs_type == "sftp" if self.dialog.view_mode.get() == "list": for item_id, path in self.dialog.item_path_map.items(): basename = path.split('/')[-1] if is_sftp else os.path.basename(path) if basename == 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] if is_sftp: # Ensure forward slashes for SFTP paths target_path = f"{self.dialog.current_dir}/{filename}".replace("//", "/") else: 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("")