10 Commits

Author SHA1 Message Date
cc48f874ac add trash icon to contexmenu remove bookmark 2025-08-15 08:49:46 +02:00
27f74e6a77 add update installer on custom_file_dialog 2025-08-14 13:14:02 +02:00
ba38ea4b87 - Added window on custom_file_dialog to query if there is
no other folder in the selected folder. So that the folder
   can still be entered

 - Fixes multi and dir mode in custom_file_dialog

 - Add "select" in MessageDialog on list for Button and add grab_set()
   after update_idletasks() to fix Error Traceback
2025-08-14 12:59:07 +02:00
ff1aede356 fix multiselect on icon view in mode="multi" 2025-08-14 00:44:02 +02:00
f565132074 feat(cfd): Implementiert Multi-Auswahl in der Icon-Ansicht 2025-08-14 00:39:35 +02:00
d548b545e3 add update url in cfd_app_config 2025-08-14 00:27:42 +02:00
66202310ec fix(cfd): Behebt UnboundLocalError beim UI-Aufbau 2025-08-14 00:11:58 +02:00
d79e4c9e01 refactor(cfd): Entkoppelt die Update-Konfiguration und bereinigt Imports 2025-08-14 00:06:34 +02:00
0ef94de077 feat(cfd): Integriert Gitea-Update-Prüfung für die Bibliothek 2025-08-13 23:48:37 +02:00
fbc3c8e051 feat(cfd): Implementiert 'dir' Modus und vervollständigt 'multi' Modus 2025-08-13 23:45:17 +02:00
12 changed files with 1215 additions and 536 deletions

View File

@@ -4,6 +4,27 @@ Changelog for shared_libs
- -
### Added
14.08.2025
- Added window on custom_file_dialog to query if there is
no other folder in the selected folder. So that the folder
can still be entered
- Fixes multi and dir mode in custom_file_dialog
- Add "select" in MessageDialog on list for Button and add grab_set()
after update_idletasks() to fix Error Traceback
### Added
13.08.2025
- Rename get methode and mode argument in custom_file_dialog
- Add new mode "multi" and "dir" on custom_file_dialog
### Added ### Added
12.08.2025 12.08.2025

View File

@@ -471,10 +471,17 @@ class AnimatedIcon(tk.Canvas):
return return
# Do not animate if a grab is active on a different window. # Do not animate if a grab is active on a different window.
toplevel = self.winfo_toplevel() try:
grab_widget = toplevel.grab_current() toplevel = self.winfo_toplevel()
if grab_widget is not None and grab_widget != toplevel: grab_widget = toplevel.grab_current()
self.after(100, self._animate) # Check again after a short delay if grab_widget is not None and grab_widget != toplevel:
self.after(100, self._animate) # Check again after a short delay
return
except Exception:
# This can happen if a grabbed widget (like a combobox dropdown)
# is destroyed at the exact moment this check runs.
# It's safest to just skip this animation frame.
self.after(30, self._animate)
return return
self.angle += 0.1 self.angle += 0.1

View File

@@ -635,6 +635,8 @@ class IconManager:
'up': '32/arrow-up.png', 'up': '32/arrow-up.png',
'copy': '32/copy.png', 'copy': '32/copy.png',
'stair': '32/stair.png', 'stair': '32/stair.png',
'star': '32/star.png',
'connect': '32/connect.png',
'audio_small': '32/audio.png', 'audio_small': '32/audio.png',
'icon_view': '32/carrel.png', 'icon_view': '32/carrel.png',
'computer_small': '32/computer.png', 'computer_small': '32/computer.png',
@@ -695,6 +697,8 @@ class IconManager:
'up_large': '48/arrow-up.png', 'up_large': '48/arrow-up.png',
'copy_large': '48/copy.png', 'copy_large': '48/copy.png',
'stair_large': '48/stair.png', 'stair_large': '48/stair.png',
'star_large': '48/star.png',
'connect_large': '48/connect.png',
'icon_view_large': '48/carrel.png', 'icon_view_large': '48/carrel.png',
'computer_large': '48/computer.png', 'computer_large': '48/computer.png',
'device_large': '48/device.png', 'device_large': '48/device.png',
@@ -743,7 +747,9 @@ class IconManager:
'forward_extralarge': '64/arrow-right.png', 'forward_extralarge': '64/arrow-right.png',
'up_extralarge': '64/arrow-up.png', 'up_extralarge': '64/arrow-up.png',
'copy_extralarge': '64/copy.png', 'copy_extralarge': '64/copy.png',
'stairextralarge': '64/stair.png', 'stair_extralarge': '64/stair.png',
'star_extralarge': '64/star.png',
'connect_extralarge': '64/connect.png',
'audio_large': '64/audio.png', 'audio_large': '64/audio.png',
'icon_view_extralarge': '64/carrel.png', 'icon_view_extralarge': '64/carrel.png',
'computer_extralarge': '64/computer.png', 'computer_extralarge': '64/computer.png',

View File

@@ -16,7 +16,8 @@ class CfdConfigManager:
Manages CFD-specific settings using a JSON file for flexibility. Manages CFD-specific settings using a JSON file for flexibility.
""" """
# 1 = 1. Year, 09 = Month of the Year, 2924 = Day and Year of the Year # 1 = 1. Year, 09 = Month of the Year, 2924 = Day and Year of the Year
VERSION: str = "v. 1.08.1325" UPDATE_URL: str = "https://git.ilunix.de/api/v1/repos/punix/shared_libs/releases"
VERSION: str = "v. 1.07.0125"
MAX_ITEMS_TO_DISPLAY = 1000 MAX_ITEMS_TO_DISPLAY = 1000
@@ -35,6 +36,7 @@ class CfdConfigManager:
_config: Optional[Dict[str, Any]] = None _config: Optional[Dict[str, Any]] = None
_config_file: Path = CONFIG_DIR / "cfd_settings.json" _config_file: Path = CONFIG_DIR / "cfd_settings.json"
_bookmarks_file: Path = CONFIG_DIR / "cfd_bookmarks.json"
_default_settings: Dict[str, Any] = { _default_settings: Dict[str, Any] = {
"search_icon_pos": "left", # 'left' or 'right' "search_icon_pos": "left", # 'left' or 'right'
"button_box_pos": "left", # 'left' or 'right' "button_box_pos": "left", # 'left' or 'right'
@@ -44,7 +46,8 @@ class CfdConfigManager:
"use_trash": False, # True or False "use_trash": False, # True or False
"confirm_delete": False, # True or False "confirm_delete": False, # True or False
"recursive_search": True, "recursive_search": True,
"use_pillow_animation": True "use_pillow_animation": True,
"keep_bookmarks_on_reset": True # Keep bookmarks when resetting settings
} }
@classmethod @classmethod
@@ -83,6 +86,51 @@ class CfdConfigManager:
except IOError as e: except IOError as e:
print(f"Error saving settings: {e}") print(f"Error saving settings: {e}")
@classmethod
def _ensure_bookmarks_file(cls: Type['CfdConfigManager']) -> None:
"""Ensures the bookmarks file exists."""
if not cls._bookmarks_file.exists():
try:
cls._bookmarks_file.parent.mkdir(parents=True, exist_ok=True)
with open(cls._bookmarks_file, 'w', encoding='utf-8') as f:
json.dump({}, f, indent=4)
except IOError as e:
print(f"Error creating bookmarks file: {e}")
@classmethod
def load_bookmarks(cls: Type['CfdConfigManager']) -> Dict[str, Any]:
"""Loads bookmarks from the JSON file."""
cls._ensure_bookmarks_file()
try:
with open(cls._bookmarks_file, 'r', encoding='utf-8') as f:
return json.load(f)
except (IOError, json.JSONDecodeError):
return {}
@classmethod
def save_bookmarks(cls: Type['CfdConfigManager'], bookmarks: Dict[str, Any]) -> None:
"""Saves the given bookmarks dictionary to the JSON file."""
try:
with open(cls._bookmarks_file, 'w', encoding='utf-8') as f:
json.dump(bookmarks, f, indent=4)
except IOError as e:
print(f"Error saving bookmarks: {e}")
@classmethod
def add_bookmark(cls: Type['CfdConfigManager'], name: str, data: Dict[str, Any]) -> None:
"""Adds or updates a bookmark."""
bookmarks = cls.load_bookmarks()
bookmarks[name] = data
cls.save_bookmarks(bookmarks)
@classmethod
def remove_bookmark(cls: Type['CfdConfigManager'], name: str) -> None:
"""Removes a bookmark by name."""
bookmarks = cls.load_bookmarks()
if name in bookmarks:
del bookmarks[name]
cls.save_bookmarks(bookmarks)
class LocaleStrings: class LocaleStrings:
""" """
@@ -115,6 +163,12 @@ class LocaleStrings:
"not_found": _("not found."), "not_found": _("not found."),
"access_to": _("Access to"), "access_to": _("Access to"),
"denied": _("denied."), "denied": _("denied."),
"items_selected": _("items selected"),
"select_or_enter_title": _("Select or Enter?"),
"select_or_enter_prompt": _("The folder '{folder_name}' contains no subdirectories. Do you want to select this folder or enter it?"),
"select_button": _("Select"),
"enter_button": _("Enter"),
"cancel_button": _("Cancel"),
} }
# Strings from cfd_view_manager.py # Strings from cfd_view_manager.py
@@ -160,6 +214,10 @@ class LocaleStrings:
"no_results_for": _("No results for"), "no_results_for": _("No results for"),
"error_during_search": _("Error during search"), "error_during_search": _("Error during search"),
"search_error": _("Search Error"), "search_error": _("Search Error"),
"install_new_version": _("Install new version {version}"),
"sftp_connection": _("SFTP Connection"),
"sftp_bookmarks": _("SFTP Bookmarks"),
"remove_bookmark": _("Remove Bookmark"),
} }
# Strings from cfd_settings_dialog.py # Strings from cfd_settings_dialog.py
@@ -194,6 +252,11 @@ class LocaleStrings:
"blink": _("Blink"), "blink": _("Blink"),
"deletion_options_info": _("Deletion options are only available in save mode"), "deletion_options_info": _("Deletion options are only available in save mode"),
"reset_to_default": _("Reset to Default"), "reset_to_default": _("Reset to Default"),
"sftp_settings": _("SFTP Settings"),
"paramiko_not_found": _("Paramiko library not found."),
"sftp_disabled": _("SFTP functionality is disabled. Please install 'paramiko'."),
"paramiko_found": _("Paramiko library found. SFTP is enabled."),
"keep_sftp_bookmarks": _("Keep SFTP bookmarks on reset"),
} }
# Strings from cfd_file_operations.py # Strings from cfd_file_operations.py

View File

@@ -41,7 +41,45 @@ class FileOperationsManager:
Args: Args:
event: The event that triggered the deletion (optional). event: The event that triggered the deletion (optional).
""" """
if not self.dialog.selected_file or not os.path.exists(self.dialog.selected_file): if not self.dialog.result or not isinstance(self.dialog.result, str):
return
selected_path = self.dialog.result
if self.dialog.current_fs_type == 'sftp':
item_name = os.path.basename(selected_path)
dialog = MessageDialog(
master=self.dialog,
title=LocaleStrings.FILE["confirm_delete_title"],
text=f"{LocaleStrings.FILE['are_you_sure']} '{item_name}' {LocaleStrings.FILE['delete_permanently']}?",
message_type="question"
)
if not dialog.show():
return
try:
if self.dialog.sftp_manager.path_is_dir(selected_path):
success, msg = self.dialog.sftp_manager.rm_recursive(selected_path)
else:
success, msg = self.dialog.sftp_manager.rm(selected_path)
if not success:
raise OSError(msg)
self.dialog.view_manager.populate_files()
self.dialog.widget_manager.search_status_label.config(
text=f"'{item_name}' {LocaleStrings.FILE['was_successfully_removed']}")
except Exception as e:
MessageDialog(
master=self.dialog,
title=LocaleStrings.FILE["error_title"],
text=f"{LocaleStrings.FILE['error_removing']} '{item_name}':\n{e}",
message_type="error"
).show()
return
# Local deletion logic
if not os.path.exists(selected_path):
return return
use_trash = self.dialog.settings.get( use_trash = self.dialog.settings.get(
@@ -49,7 +87,7 @@ class FileOperationsManager:
confirm = self.dialog.settings.get("confirm_delete", False) confirm = self.dialog.settings.get("confirm_delete", False)
action_text = LocaleStrings.FILE["move_to_trash"] if use_trash else LocaleStrings.FILE["delete_permanently"] action_text = LocaleStrings.FILE["move_to_trash"] if use_trash else LocaleStrings.FILE["delete_permanently"]
item_name = os.path.basename(self.dialog.selected_file) item_name = os.path.basename(selected_path)
if not confirm: if not confirm:
dialog = MessageDialog( dialog = MessageDialog(
@@ -63,12 +101,12 @@ class FileOperationsManager:
try: try:
if use_trash: if use_trash:
send2trash.send2trash(self.dialog.selected_file) send2trash.send2trash(selected_path)
else: else:
if os.path.isdir(self.dialog.selected_file): if os.path.isdir(selected_path):
shutil.rmtree(self.dialog.selected_file) shutil.rmtree(selected_path)
else: else:
os.remove(self.dialog.selected_file) os.remove(selected_path)
self.dialog.view_manager.populate_files() self.dialog.view_manager.populate_files()
self.dialog.widget_manager.search_status_label.config( self.dialog.widget_manager.search_status_label.config(
@@ -104,10 +142,20 @@ class FileOperationsManager:
new_path = os.path.join(self.dialog.current_dir, new_name) new_path = os.path.join(self.dialog.current_dir, new_name)
try: try:
if is_folder: if self.dialog.current_fs_type == 'sftp':
os.mkdir(new_path) if is_folder:
success, msg = self.dialog.sftp_manager.mkdir(new_path)
if not success:
raise OSError(msg)
else:
success, msg = self.dialog.sftp_manager.touch(new_path)
if not success:
raise OSError(msg)
else: else:
open(new_path, 'a').close() if is_folder:
os.mkdir(new_path)
else:
open(new_path, 'a').close()
self.dialog.view_manager.populate_files(item_to_rename=new_name) self.dialog.view_manager.populate_files(item_to_rename=new_name)
except Exception as e: except Exception as e:
self.dialog.widget_manager.search_status_label.config( self.dialog.widget_manager.search_status_label.config(
@@ -129,7 +177,10 @@ class FileOperationsManager:
name, ext = os.path.splitext(base_name) name, ext = os.path.splitext(base_name)
counter = 1 counter = 1
new_name = base_name new_name = base_name
while os.path.exists(os.path.join(self.dialog.current_dir, new_name)):
path_exists = self.dialog.sftp_manager.exists if self.dialog.current_fs_type == 'sftp' else os.path.exists
while path_exists(os.path.join(self.dialog.current_dir, new_name)):
counter += 1 counter += 1
new_name = f"{name} {counter}{ext}" new_name = f"{name} {counter}{ext}"
return new_name return new_name
@@ -327,16 +378,32 @@ class FileOperationsManager:
self.dialog.view_manager.populate_files(item_to_select=old_name) self.dialog.view_manager.populate_files(item_to_select=old_name)
return return
if os.path.exists(new_path): if self.dialog.current_fs_type == 'sftp':
self.dialog.widget_manager.search_status_label.config( if self.dialog.sftp_manager.exists(new_path):
text=f"'{new_name}' {LocaleStrings.FILE['folder_exists_error']}") self.dialog.widget_manager.search_status_label.config(
self.dialog.view_manager.populate_files(item_to_select=old_name) text=f"'{new_name}' {LocaleStrings.FILE['folder_exists_error']}")
return self.dialog.view_manager.populate_files(item_to_select=old_name)
return
try:
success, msg = self.dialog.sftp_manager.rename(old_path, new_path)
if not success:
raise OSError(msg)
self.dialog.view_manager.populate_files(item_to_select=new_name)
except Exception as e:
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.FILE['error_renaming']}: {e}")
self.dialog.view_manager.populate_files()
else:
if os.path.exists(new_path):
self.dialog.widget_manager.search_status_label.config(
text=f"'{new_name}' {LocaleStrings.FILE['folder_exists_error']}")
self.dialog.view_manager.populate_files(item_to_select=old_name)
return
try: try:
os.rename(old_path, new_path) os.rename(old_path, new_path)
self.dialog.view_manager.populate_files(item_to_select=new_name) self.dialog.view_manager.populate_files(item_to_select=new_name)
except Exception as e: except Exception as e:
self.dialog.widget_manager.search_status_label.config( self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.FILE['error_renaming']}: {e}") text=f"{LocaleStrings.FILE['error_renaming']}: {e}")
self.dialog.view_manager.populate_files() self.dialog.view_manager.populate_files()

View File

@@ -76,10 +76,14 @@ class NavigationManager:
self.dialog.widget_manager.search_animation.stop() self.dialog.widget_manager.search_animation.stop()
# Clear previous selection state before populating new view
self.dialog.selected_item_frames.clear()
self.dialog.result = None
self.dialog.view_manager.populate_files( self.dialog.view_manager.populate_files(
item_to_select=file_to_select) item_to_select=file_to_select)
self.update_nav_buttons() self.update_nav_buttons()
self.dialog.update_status_bar() self.dialog.update_selection_info() # Use the new central update method
self.dialog.update_action_buttons_state() self.dialog.update_action_buttons_state()
except Exception as e: except Exception as e:
self.dialog.widget_manager.search_status_label.config( self.dialog.widget_manager.search_status_label.config(
@@ -101,15 +105,22 @@ class NavigationManager:
def go_up_level(self) -> None: def go_up_level(self) -> None:
"""Navigates to the parent directory of the current directory.""" """Navigates to the parent directory of the current directory."""
new_path = os.path.dirname(self.dialog.current_dir) if self.dialog.current_fs_type == "sftp":
if new_path != self.dialog.current_dir: if self.dialog.current_dir and self.dialog.current_dir != "/":
self.navigate_to(new_path) new_path = self.dialog.current_dir.rsplit('/', 1)[0]
if not new_path:
new_path = "/"
self.navigate_to(new_path)
else:
new_path = os.path.dirname(self.dialog.current_dir)
if new_path != self.dialog.current_dir:
self.navigate_to(new_path)
def _update_ui_after_navigation(self) -> None: def _update_ui_after_navigation(self) -> None:
"""Updates all necessary UI components after a navigation action.""" """Updates all necessary UI components after a navigation action."""
self.dialog.view_manager.populate_files() self.dialog.view_manager.populate_files()
self.update_nav_buttons() self.update_nav_buttons()
self.dialog.update_status_bar() self.dialog.update_selection_info()
self.dialog.update_action_buttons_state() self.dialog.update_action_buttons_state()
def update_nav_buttons(self) -> None: def update_nav_buttons(self) -> None:

View File

@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
from .cfd_app_config import CfdConfigManager, LocaleStrings from .cfd_app_config import CfdConfigManager, LocaleStrings
from shared_libs.animated_icon import PIL_AVAILABLE from shared_libs.animated_icon import PIL_AVAILABLE
from .cfd_sftp_manager import PARAMIKO_AVAILABLE
if TYPE_CHECKING: if TYPE_CHECKING:
from custom_file_dialog import CustomFileDialog from custom_file_dialog import CustomFileDialog
@@ -56,6 +57,8 @@ class SettingsDialog(tk.Toplevel):
value=self.settings.get("use_pillow_animation", False)) value=self.settings.get("use_pillow_animation", False))
self.animation_type = tk.StringVar( self.animation_type = tk.StringVar(
value=self.settings.get("animation_type", "double_arc")) value=self.settings.get("animation_type", "double_arc"))
self.keep_bookmarks_on_reset = tk.BooleanVar(
value=self.settings.get("keep_bookmarks_on_reset", True))
# --- UI Elements --- # --- UI Elements ---
main_frame = ttk.Frame(self, padding=10) main_frame = ttk.Frame(self, padding=10)
@@ -140,6 +143,24 @@ class SettingsDialog(tk.Toplevel):
ttk.Radiobutton(anim_type_frame, text=LocaleStrings.SET["blink"], variable=self.animation_type, ttk.Radiobutton(anim_type_frame, text=LocaleStrings.SET["blink"], variable=self.animation_type,
value="blink").pack(side="left", padx=5) value="blink").pack(side="left", padx=5)
# SFTP Settings
sftp_frame = ttk.LabelFrame(
main_frame, text=LocaleStrings.SET["sftp_settings"], padding=10)
sftp_frame.pack(fill="x", pady=5)
if not PARAMIKO_AVAILABLE:
ttk.Label(sftp_frame, text=LocaleStrings.SET["paramiko_not_found"],
font=("TkDefaultFont", 9, "italic")).pack(anchor="w")
ttk.Label(sftp_frame, text=LocaleStrings.SET["sftp_disabled"],
font=("TkDefaultFont", 9, "italic")).pack(anchor="w")
else:
ttk.Label(sftp_frame, text=LocaleStrings.SET["paramiko_found"],
font=("TkDefaultFont", 9)).pack(anchor="w")
self.keep_bookmarks_checkbutton = ttk.Checkbutton(sftp_frame, text=LocaleStrings.SET["keep_sftp_bookmarks"],
variable=self.keep_bookmarks_on_reset)
self.keep_bookmarks_checkbutton.pack(anchor="w", pady=(5,0))
# Disable deletion options in "open" mode # Disable deletion options in "open" mode
if not self.dialog_mode == "save": if not self.dialog_mode == "save":
self.use_trash_checkbutton.config(state=tk.DISABLED) self.use_trash_checkbutton.config(state=tk.DISABLED)
@@ -174,7 +195,8 @@ class SettingsDialog(tk.Toplevel):
"use_trash": self.use_trash.get(), "use_trash": self.use_trash.get(),
"confirm_delete": self.confirm_delete.get(), "confirm_delete": self.confirm_delete.get(),
"use_pillow_animation": self.use_pillow_animation.get(), "use_pillow_animation": self.use_pillow_animation.get(),
"animation_type": self.animation_type.get() "animation_type": self.animation_type.get(),
"keep_bookmarks_on_reset": self.keep_bookmarks_on_reset.get()
} }
CfdConfigManager.save(new_settings) CfdConfigManager.save(new_settings)
self.master.reload_config_and_rebuild_ui() self.master.reload_config_and_rebuild_ui()
@@ -193,3 +215,4 @@ class SettingsDialog(tk.Toplevel):
self.use_pillow_animation.set(defaults.get( self.use_pillow_animation.set(defaults.get(
"use_pillow_animation", True) and PIL_AVAILABLE) "use_pillow_animation", True) and PIL_AVAILABLE)
self.animation_type.set(defaults.get("animation_type", "counter_arc")) self.animation_type.set(defaults.get("animation_type", "counter_arc"))
self.keep_bookmarks_on_reset.set(defaults.get("keep_bookmarks_on_reset", True))

View File

@@ -0,0 +1,151 @@
# cfd_sftp_manager.py
try:
import paramiko
PARAMIKO_AVAILABLE = True
except ImportError:
paramiko = None
PARAMIKO_AVAILABLE = False
class SFTPManager:
def __init__(self):
self.client = None
self.sftp = None
def connect(self, host, port, username, password=None, key_file=None, passphrase=None):
if not PARAMIKO_AVAILABLE:
raise ImportError("Paramiko library is not installed. SFTP functionality is disabled.")
try:
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(
hostname=host,
port=port,
username=username,
password=password,
key_filename=key_file,
passphrase=passphrase,
timeout=10
)
self.sftp = self.client.open_sftp()
return True, "Connection successful."
except Exception as e:
self.client = None
self.sftp = None
return False, str(e)
def disconnect(self):
if self.sftp:
self.sftp.close()
self.sftp = None
if self.client:
self.client.close()
self.client = None
def list_directory(self, path):
if not self.sftp:
return [], "Not connected."
try:
items = self.sftp.listdir_attr(path)
# Sort directories first, then files
items.sort(key=lambda x: (not self.item_is_dir(x), x.filename.lower()))
return items, None
except Exception as e:
return [], str(e)
def item_is_dir(self, item):
# Helper to check if an SFTP attribute object is a directory
import stat
return stat.S_ISDIR(item.st_mode)
def path_is_dir(self, path):
if not self.sftp:
return False
try:
import stat
return stat.S_ISDIR(self.sftp.stat(path).st_mode)
except Exception:
return False
def exists(self, path):
if not self.sftp:
return False
try:
self.sftp.stat(path)
return True
except FileNotFoundError:
return False
except Exception:
return False
def mkdir(self, path):
if not self.sftp:
return False, "Not connected."
try:
self.sftp.mkdir(path)
return True, ""
except Exception as e:
return False, str(e)
def rmdir(self, path):
if not self.sftp:
return False, "Not connected."
try:
self.sftp.rmdir(path)
return True, ""
except Exception as e:
return False, str(e)
def rm(self, path):
if not self.sftp:
return False, "Not connected."
try:
self.sftp.remove(path)
return True, ""
except Exception as e:
return False, str(e)
def rename(self, old_path, new_path):
if not self.sftp:
return False, "Not connected."
try:
self.sftp.rename(old_path, new_path)
return True, ""
except Exception as e:
return False, str(e)
def touch(self, path):
if not self.sftp:
return False, "Not connected."
try:
with self.sftp.open(path, 'w') as f:
pass
return True, ""
except Exception as e:
return False, str(e)
def rm_recursive(self, path):
if not self.sftp:
return False, "Not connected."
try:
items = self.sftp.listdir_attr(path)
for item in items:
remote_path = f"{path}/{item.filename}"
if self.item_is_dir(item):
success, msg = self.rm_recursive(remote_path)
if not success:
return False, msg
else:
self.sftp.remove(remote_path)
self.sftp.rmdir(path)
return True, ""
except Exception as e:
return False, str(e)
@property
def is_connected(self):
return self.sftp is not None

View File

@@ -10,21 +10,14 @@ if TYPE_CHECKING:
from custom_file_dialog import CustomFileDialog from custom_file_dialog import CustomFileDialog
from shared_libs.common_tools import Tooltip from shared_libs.common_tools import Tooltip
from shared_libs.animated_icon import AnimatedIcon from shared_libs.animated_icon import AnimatedIcon, PIL_AVAILABLE
from .cfd_app_config import LocaleStrings from .cfd_app_config import LocaleStrings
from .cfd_sftp_manager import PARAMIKO_AVAILABLE
def get_xdg_user_dir(dir_key: str, fallback_name: str) -> str: def get_xdg_user_dir(dir_key: str, fallback_name: str) -> str:
""" """
Retrieves a user directory path from the XDG user-dirs.dirs config file. Retrieves a user directory path from the XDG user-dirs.dirs config file.
Args:
dir_key (str): The key for the directory (e.g., "XDG_DOWNLOAD_DIR").
fallback_name (str): The name of the directory to use as a fallback
if the key is not found (e.g., "Downloads").
Returns:
str: The absolute path to the user directory.
""" """
home = os.path.expanduser("~") home = os.path.expanduser("~")
fallback_path = os.path.join(home, fallback_name) fallback_path = os.path.join(home, fallback_name)
@@ -52,19 +45,10 @@ class StyleManager:
"""Manages the visual styling of the application using ttk styles.""" """Manages the visual styling of the application using ttk styles."""
def __init__(self, dialog: 'CustomFileDialog') -> None: def __init__(self, dialog: 'CustomFileDialog') -> None:
"""
Initializes the StyleManager.
Args:
dialog: The main CustomFileDialog instance.
"""
self.dialog = dialog self.dialog = dialog
self.setup_styles() self.setup_styles()
def setup_styles(self) -> None: def setup_styles(self) -> None:
"""
Configures all the ttk styles for the dialog based on a light or dark theme.
"""
style = ttk.Style(self.dialog) style = ttk.Style(self.dialog)
base_bg = self.dialog.cget('background') base_bg = self.dialog.cget('background')
self.is_dark = sum(self.dialog.winfo_rgb(base_bg)) / 3 < 32768 self.is_dark = sum(self.dialog.winfo_rgb(base_bg)) / 3 < 32768
@@ -129,40 +113,29 @@ class StyleManager:
('selected', "black" if not self.is_dark else "white")]) ('selected', "black" if not self.is_dark else "white")])
style.configure("TButton.Borderless.Round", anchor="w") style.configure("TButton.Borderless.Round", anchor="w")
style.configure("Small.Horizontal.TProgressbar", thickness=8) style.configure("Small.Horizontal.TProgressbar", thickness=8)
style.configure("Bottom.TButton.Borderless.Round", style.configure("Bottom.TButton.Borderless.Round",
background=self.bottom_color) background=self.bottom_color)
style.map("Bottom.TButton.Borderless.Round", style.map("Bottom.TButton.Borderless.Round", background=[
background=[('active', self.hover_extrastyle)]) ('active', self.hover_extrastyle)])
style.layout("Bottom.TButton.Borderless.Round", style.layout("Bottom.TButton.Borderless.Round",
style.layout("Header.TButton.Borderless.Round") style.layout("Header.TButton.Borderless.Round"))
)
class WidgetManager: class WidgetManager:
"""Manages the creation, layout, and management of all widgets in the dialog.""" """Manages the creation, layout, and management of all widgets in the dialog."""
def __init__(self, dialog: 'CustomFileDialog', settings: Dict[str, Any]) -> None: def __init__(self, dialog: 'CustomFileDialog', settings: Dict[str, Any]) -> None:
"""
Initializes the WidgetManager.
Args:
dialog: The main CustomFileDialog instance.
settings (dict): The application settings.
"""
self.dialog = dialog self.dialog = dialog
self.style_manager = dialog.style_manager self.style_manager = dialog.style_manager
self.settings = settings self.settings = settings
self.setup_widgets() self.setup_widgets()
def _setup_top_bar(self, parent_frame: ttk.Frame) -> None: def _setup_top_bar(self, parent_frame: ttk.Frame) -> None:
"""Sets up the top bar with navigation and control buttons."""
top_bar = ttk.Frame( top_bar = ttk.Frame(
parent_frame, style='Accent.TFrame', padding=(0, 5, 0, 5)) parent_frame, style='Accent.TFrame', padding=(0, 5, 0, 5))
top_bar.grid(row=0, column=0, columnspan=2, sticky="ew") top_bar.grid(row=0, column=0, columnspan=2, sticky="ew")
top_bar.grid_columnconfigure(1, weight=1) top_bar.grid_columnconfigure(1, weight=1)
# Left navigation
left_nav_container = ttk.Frame(top_bar, style='Accent.TFrame') left_nav_container = ttk.Frame(top_bar, style='Accent.TFrame')
left_nav_container.grid(row=0, column=0, sticky="w") left_nav_container.grid(row=0, column=0, sticky="w")
left_nav_container.grid_propagate(False) left_nav_container.grid_propagate(False)
@@ -187,23 +160,29 @@ class WidgetManager:
self.home_button.pack(side="left", padx=(5, 10)) self.home_button.pack(side="left", padx=(5, 10))
Tooltip(self.home_button, LocaleStrings.UI["home"]) Tooltip(self.home_button, LocaleStrings.UI["home"])
# Path and search
path_search_container = ttk.Frame(top_bar, style='Accent.TFrame') path_search_container = ttk.Frame(top_bar, style='Accent.TFrame')
path_search_container.grid(row=0, column=1, sticky="ew") path_search_container.grid(row=0, column=1, sticky="ew")
path_search_container.grid_columnconfigure(0, weight=1)
self.path_entry = ttk.Entry(path_search_container) self.path_entry = ttk.Entry(path_search_container)
self.path_entry.grid(row=0, column=0, sticky="ew")
self.path_entry.bind( self.path_entry.bind(
"<Return>", lambda e: self.dialog.navigation_manager.navigate_to(self.path_entry.get())) "<Return>", lambda e: self.dialog.navigation_manager.navigate_to(self.path_entry.get()))
search_icon_pos = self.settings.get("search_icon_pos", "left") self.update_animation_icon = AnimatedIcon(
if search_icon_pos == 'left': path_search_container,
path_search_container.grid_columnconfigure(1, weight=1) width=20,
self.path_entry.grid(row=0, column=1, sticky="ew") height=20,
else: # right animation_type="blink",
path_search_container.grid_columnconfigure(0, weight=1) use_pillow=PIL_AVAILABLE,
self.path_entry.grid(row=0, column=0, sticky="ew") bg=self.style_manager.header
)
self.update_animation_icon.grid(
row=0, column=1, sticky='e', padx=(5, 0))
self.update_animation_icon.grid_remove() # Initially hidden
# Right controls right_controls_container = ttk.Frame(
right_controls_container = ttk.Frame(top_bar, style='Accent.TFrame') top_bar, style='Accent.TFrame')
right_controls_container.grid(row=0, column=2, sticky="e") right_controls_container.grid(row=0, column=2, sticky="e")
self.responsive_buttons_container = ttk.Frame( self.responsive_buttons_container = ttk.Frame(
right_controls_container, style='Accent.TFrame') right_controls_container, style='Accent.TFrame')
@@ -219,6 +198,19 @@ class WidgetManager:
self.new_file_button.pack(side="left", padx=5) self.new_file_button.pack(side="left", padx=5)
Tooltip(self.new_file_button, LocaleStrings.UI["new_document"]) Tooltip(self.new_file_button, LocaleStrings.UI["new_document"])
sftp_icon = self.dialog.icon_manager.get_icon('connect')
if sftp_icon:
self.sftp_button = ttk.Button(self.responsive_buttons_container, image=sftp_icon,
command=self.dialog.open_sftp_dialog, style="Header.TButton.Borderless.Round")
else:
self.sftp_button = ttk.Button(self.responsive_buttons_container, text="SFTP",
command=self.dialog.open_sftp_dialog, style="Header.TButton.Borderless.Round")
self.sftp_button.pack(side="left", padx=5)
Tooltip(self.sftp_button, LocaleStrings.UI["sftp_connection"])
if not PARAMIKO_AVAILABLE:
self.sftp_button.config(state=tk.DISABLED)
if self.dialog.dialog_mode == "open": if self.dialog.dialog_mode == "open":
self.new_folder_button.config(state=tk.DISABLED) self.new_folder_button.config(state=tk.DISABLED)
self.new_file_button.config(state=tk.DISABLED) self.new_file_button.config(state=tk.DISABLED)
@@ -246,13 +238,12 @@ class WidgetManager:
command=self.dialog.show_more_menu, style="Header.TButton.Borderless.Round", width=3) command=self.dialog.show_more_menu, style="Header.TButton.Borderless.Round", width=3)
def _setup_sidebar(self, parent_paned_window: ttk.PanedWindow) -> None: def _setup_sidebar(self, parent_paned_window: ttk.PanedWindow) -> None:
"""Sets up the sidebar with bookmarks and devices."""
sidebar_frame = ttk.Frame( sidebar_frame = ttk.Frame(
parent_paned_window, style="Sidebar.TFrame", padding=(0, 0, 0, 0), width=200) parent_paned_window, style="Sidebar.TFrame", padding=(0, 0, 0, 0), width=200)
sidebar_frame.grid_propagate(False) sidebar_frame.grid_propagate(False)
sidebar_frame.bind("<Configure>", self.dialog.on_sidebar_resize) sidebar_frame.bind("<Configure>", self.dialog.on_sidebar_resize)
parent_paned_window.add(sidebar_frame, weight=0) parent_paned_window.add(sidebar_frame, weight=0)
sidebar_frame.grid_rowconfigure(2, weight=1) sidebar_frame.grid_rowconfigure(4, weight=1)
self._setup_sidebar_bookmarks(sidebar_frame) self._setup_sidebar_bookmarks(sidebar_frame)
@@ -265,10 +256,14 @@ class WidgetManager:
tk.Frame(sidebar_frame, height=1, bg=separator_color).grid( tk.Frame(sidebar_frame, height=1, bg=separator_color).grid(
row=3, column=0, sticky="ew", padx=20, pady=15) row=3, column=0, sticky="ew", padx=20, pady=15)
self._setup_sidebar_sftp_bookmarks(sidebar_frame)
tk.Frame(sidebar_frame, height=1, bg=separator_color).grid(
row=5, column=0, sticky="ew", padx=20, pady=15)
self._setup_sidebar_storage(sidebar_frame) self._setup_sidebar_storage(sidebar_frame)
def _setup_sidebar_bookmarks(self, sidebar_frame: ttk.Frame) -> None: def _setup_sidebar_bookmarks(self, sidebar_frame: ttk.Frame) -> None:
"""Sets up the bookmark buttons in the sidebar."""
sidebar_buttons_frame = ttk.Frame( sidebar_buttons_frame = ttk.Frame(
sidebar_frame, style="Sidebar.TFrame", padding=(0, 15, 0, 0)) sidebar_frame, style="Sidebar.TFrame", padding=(0, 15, 0, 0))
sidebar_buttons_frame.grid(row=0, column=0, sticky="nsew") sidebar_buttons_frame.grid(row=0, column=0, sticky="nsew")
@@ -293,8 +288,47 @@ class WidgetManager:
btn.pack(fill="x", pady=1) btn.pack(fill="x", pady=1)
self.sidebar_buttons.append((btn, f" {config['name']}")) self.sidebar_buttons.append((btn, f" {config['name']}"))
def _setup_sidebar_sftp_bookmarks(self, sidebar_frame: ttk.Frame) -> None:
self.sftp_bookmarks_frame = ttk.Frame(
sidebar_frame, style="Sidebar.TFrame")
self.sftp_bookmarks_frame.grid(row=4, column=0, sticky="nsew", padx=10)
self.sftp_bookmarks_frame.grid_columnconfigure(0, weight=1)
bookmarks = self.dialog.config_manager.load_bookmarks()
if not bookmarks:
return
ttk.Label(self.sftp_bookmarks_frame, text=LocaleStrings.UI["sftp_bookmarks"], background=self.style_manager.sidebar_color,
foreground=self.style_manager.color_foreground).grid(row=0, column=0, sticky="ew", padx=10, pady=(5, 0))
self.sftp_bookmark_buttons = []
sftp_bookmark_icon = self.dialog.icon_manager.get_icon('star')
row_counter = 1
for name, data in bookmarks.items():
if sftp_bookmark_icon:
btn = ttk.Button(self.sftp_bookmarks_frame, text=f" {name}", image=sftp_bookmark_icon, compound="left",
command=lambda d=data: self.dialog.connect_sftp_bookmark(d), style="Dark.TButton.Borderless")
else:
btn = ttk.Button(self.sftp_bookmarks_frame, text=f" {name}", compound="left",
command=lambda d=data: self.dialog.connect_sftp_bookmark(d), style="Dark.TButton.Borderless")
btn.grid(row=row_counter, column=0, sticky="ew")
row_counter += 1
btn.bind("<Button-3>", lambda event,
n=name: self._show_sftp_bookmark_context_menu(event, n))
self.sftp_bookmark_buttons.append(btn)
def _show_sftp_bookmark_context_menu(self, event, name):
context_menu = tk.Menu(self.dialog, tearoff=0)
trash_icon = self.dialog.icon_manager.get_icon('trash_small2')
context_menu.add_command(
label=LocaleStrings.UI["remove_bookmark"],
image=trash_icon,
compound=tk.LEFT,
command=lambda: self.dialog.remove_sftp_bookmark(name))
context_menu.tk_popup(event.x_root, event.y_root)
def _setup_sidebar_devices(self, sidebar_frame: ttk.Frame) -> None: def _setup_sidebar_devices(self, sidebar_frame: ttk.Frame) -> None:
"""Sets up the mounted devices section in the sidebar."""
mounted_devices_frame = ttk.Frame( mounted_devices_frame = ttk.Frame(
sidebar_frame, style="Sidebar.TFrame") sidebar_frame, style="Sidebar.TFrame")
mounted_devices_frame.grid(row=2, column=0, sticky="nsew", padx=10) mounted_devices_frame.grid(row=2, column=0, sticky="nsew", padx=10)
@@ -316,13 +350,6 @@ class WidgetManager:
self.devices_canvas_window = self.devices_canvas.create_window( self.devices_canvas_window = self.devices_canvas.create_window(
(0, 0), window=self.devices_scrollable_frame, anchor="nw") (0, 0), window=self.devices_scrollable_frame, anchor="nw")
self.devices_canvas.bind("<Enter>", self.dialog._on_devices_enter)
self.devices_canvas.bind("<Leave>", self.dialog._on_devices_leave)
self.devices_scrollable_frame.bind(
"<Enter>", self.dialog._on_devices_enter)
self.devices_scrollable_frame.bind(
"<Leave>", self.dialog._on_devices_leave)
def _configure_devices_canvas(event: tk.Event) -> None: def _configure_devices_canvas(event: tk.Event) -> None:
self.devices_canvas.configure( self.devices_canvas.configure(
scrollregion=self.devices_canvas.bbox("all")) scrollregion=self.devices_canvas.bbox("all"))
@@ -365,8 +392,6 @@ class WidgetManager:
w.bind("<MouseWheel>", _on_devices_mouse_wheel) w.bind("<MouseWheel>", _on_devices_mouse_wheel)
w.bind("<Button-4>", _on_devices_mouse_wheel) w.bind("<Button-4>", _on_devices_mouse_wheel)
w.bind("<Button-5>", _on_devices_mouse_wheel) w.bind("<Button-5>", _on_devices_mouse_wheel)
w.bind("<Enter>", self.dialog._on_devices_enter)
w.bind("<Leave>", self.dialog._on_devices_leave)
try: try:
total, used, _ = shutil.disk_usage(mount_point) total, used, _ = shutil.disk_usage(mount_point)
@@ -378,24 +403,20 @@ class WidgetManager:
w.bind("<MouseWheel>", _on_devices_mouse_wheel) w.bind("<MouseWheel>", _on_devices_mouse_wheel)
w.bind("<Button-4>", _on_devices_mouse_wheel) w.bind("<Button-4>", _on_devices_mouse_wheel)
w.bind("<Button-5>", _on_devices_mouse_wheel) w.bind("<Button-5>", _on_devices_mouse_wheel)
w.bind("<Enter>", self.dialog._on_devices_enter)
w.bind("<Leave>", self.dialog._on_devices_leave)
except (FileNotFoundError, PermissionError): except (FileNotFoundError, PermissionError):
pass pass
def _setup_sidebar_storage(self, sidebar_frame: ttk.Frame) -> None: def _setup_sidebar_storage(self, sidebar_frame: ttk.Frame) -> None:
"""Sets up the storage indicator in the sidebar."""
storage_frame = ttk.Frame(sidebar_frame, style="Sidebar.TFrame") storage_frame = ttk.Frame(sidebar_frame, style="Sidebar.TFrame")
storage_frame.grid(row=5, column=0, sticky="sew", padx=10, pady=10) storage_frame.grid(row=6, column=0, sticky="sew", padx=10, pady=10)
self.storage_label = ttk.Label( self.storage_label = ttk.Label(
storage_frame, text=f"{LocaleStrings.CFD["free_space"]}:", background=self.style_manager.freespace_background) storage_frame, text=f"{LocaleStrings.CFD['free_space']}", background=self.style_manager.freespace_background)
self.storage_label.pack(fill="x", padx=10) self.storage_label.pack(fill="x", padx=10)
self.storage_bar = ttk.Progressbar( self.storage_bar = ttk.Progressbar(
storage_frame, orient="horizontal", length=100, mode="determinate") storage_frame, orient="horizontal", length=100, mode="determinate")
self.storage_bar.pack(fill="x", pady=(2, 5), padx=15) self.storage_bar.pack(fill="x", pady=(2, 5), padx=15)
def _setup_bottom_bar(self) -> None: def _setup_bottom_bar(self) -> None:
"""Sets up the bottom bar including filename entry, action buttons, and status info."""
self.action_status_frame = ttk.Frame( self.action_status_frame = ttk.Frame(
self.content_frame, style="AccentBottom.TFrame") self.content_frame, style="AccentBottom.TFrame")
self.action_status_frame.grid( self.action_status_frame.grid(
@@ -423,7 +444,6 @@ class WidgetManager:
sticky="ew", pady=(4, 0)) sticky="ew", pady=(4, 0))
self.status_container.grid(row=2, column=0, columnspan=3, sticky='ew') self.status_container.grid(row=2, column=0, columnspan=3, sticky='ew')
# --- Define Widgets ---
self.search_status_label = ttk.Label( self.search_status_label = ttk.Label(
self.status_container, text="", style="AccentBottom.TLabel") self.status_container, text="", style="AccentBottom.TLabel")
self.filename_entry = ttk.Entry(self.center_container) self.filename_entry = ttk.Entry(self.center_container)
@@ -450,7 +470,7 @@ class WidgetManager:
self.cancel_button = ttk.Button( self.cancel_button = ttk.Button(
self.action_status_frame, text=LocaleStrings.SET["cancel_button"], command=self.dialog.on_cancel) self.action_status_frame, text=LocaleStrings.SET["cancel_button"], command=self.dialog.on_cancel)
self.filter_combobox = ttk.Combobox(self.center_container, values=[ self.filter_combobox = ttk.Combobox(self.center_container, values=[
ft[0] for ft in self.dialog.filetypes], state="readonly") ft[0] for ft in self.dialog.filetypes], state="readonly")
self.filter_combobox.bind( self.filter_combobox.bind(
"<<ComboboxSelected>>", self.dialog.view_manager.on_filter_change) "<<ComboboxSelected>>", self.dialog.view_manager.on_filter_change)
self.filter_combobox.set(self.dialog.filetypes[0][0]) self.filter_combobox.set(self.dialog.filetypes[0][0])
@@ -466,7 +486,7 @@ class WidgetManager:
self.cancel_button = ttk.Button( self.cancel_button = ttk.Button(
self.action_status_frame, text=LocaleStrings.CFD["cancel"], command=self.dialog.on_cancel) self.action_status_frame, text=LocaleStrings.CFD["cancel"], command=self.dialog.on_cancel)
self.filter_combobox = ttk.Combobox(self.center_container, values=[ self.filter_combobox = ttk.Combobox(self.center_container, values=[
ft[0] for ft in self.dialog.filetypes], state="readonly") ft[0] for ft in self.dialog.filetypes], state="readonly")
self.filter_combobox.bind( self.filter_combobox.bind(
"<<ComboboxSelected>>", self.dialog.view_manager.on_filter_change) "<<ComboboxSelected>>", self.dialog.view_manager.on_filter_change)
self.filter_combobox.set(self.dialog.filetypes[0][0]) self.filter_combobox.set(self.dialog.filetypes[0][0])
@@ -478,21 +498,16 @@ class WidgetManager:
self._layout_bottom_buttons(button_box_pos) self._layout_bottom_buttons(button_box_pos)
def _layout_bottom_buttons(self, button_box_pos: str) -> None: def _layout_bottom_buttons(self, button_box_pos: str) -> None:
"""Lays out the bottom action buttons based on user settings."""
# Configure container weights
self.left_container.grid_rowconfigure(0, weight=1) self.left_container.grid_rowconfigure(0, weight=1)
self.right_container.grid_rowconfigure(0, weight=1) self.right_container.grid_rowconfigure(0, weight=1)
# Determine action button and its container
action_button = self.save_button if self.dialog.dialog_mode == "save" else self.open_button action_button = self.save_button if self.dialog.dialog_mode == "save" else self.open_button
action_container = self.left_container if button_box_pos == 'left' else self.right_container action_container = self.left_container if button_box_pos == 'left' else self.right_container
other_container = self.right_container if button_box_pos == 'left' else self.left_container other_container = self.right_container if button_box_pos == 'left' else self.left_container
# Place main action buttons
action_button.grid(in_=action_container, row=0, column=0, pady=(0, 5)) action_button.grid(in_=action_container, row=0, column=0, pady=(0, 5))
self.cancel_button.grid(in_=action_container, row=1, column=0) self.cancel_button.grid(in_=action_container, row=1, column=0)
# Place settings and trash buttons
if button_box_pos == 'left': if button_box_pos == 'left':
self.settings_button.grid( self.settings_button.grid(
in_=other_container, row=0, column=0, sticky="ne") in_=other_container, row=0, column=0, sticky="ne")
@@ -506,7 +521,6 @@ class WidgetManager:
self.trash_button.grid( self.trash_button.grid(
in_=other_container, row=0, column=0, sticky="sw") in_=other_container, row=0, column=0, sticky="sw")
# Layout for the center container (filename, filter, status)
if button_box_pos == 'left': if button_box_pos == 'left':
self.center_container.grid_columnconfigure(0, weight=1) self.center_container.grid_columnconfigure(0, weight=1)
self.filter_combobox.grid( self.filter_combobox.grid(
@@ -517,8 +531,6 @@ class WidgetManager:
in_=self.center_container, row=1, column=1, sticky="e", pady=(5, 0)) in_=self.center_container, row=1, column=1, sticky="e", pady=(5, 0))
def setup_widgets(self) -> None: def setup_widgets(self) -> None:
"""Creates and arranges all widgets in the main dialog window."""
# Main container
main_frame = ttk.Frame(self.dialog, style='Accent.TFrame') main_frame = ttk.Frame(self.dialog, style='Accent.TFrame')
main_frame.pack(fill="both", expand=True) main_frame.pack(fill="both", expand=True)
main_frame.grid_rowconfigure(2, weight=1) main_frame.grid_rowconfigure(2, weight=1)
@@ -526,12 +538,10 @@ class WidgetManager:
self._setup_top_bar(main_frame) self._setup_top_bar(main_frame)
# Horizontal separator
separator_color = "#000000" if self.style_manager.is_dark else "#9c9c9c" separator_color = "#000000" if self.style_manager.is_dark else "#9c9c9c"
tk.Frame(main_frame, height=1, bg=separator_color).grid( tk.Frame(main_frame, height=1, bg=separator_color).grid(
row=1, column=0, columnspan=2, sticky="ew") row=1, column=0, columnspan=2, sticky="ew")
# PanedWindow for resizable sidebar and content
paned_window = ttk.PanedWindow( paned_window = ttk.PanedWindow(
main_frame, orient=tk.HORIZONTAL, style="Sidebar.TFrame") main_frame, orient=tk.HORIZONTAL, style="Sidebar.TFrame")
paned_window.grid(row=2, column=0, columnspan=2, sticky="nsew") paned_window.grid(row=2, column=0, columnspan=2, sticky="nsew")
@@ -549,5 +559,4 @@ class WidgetManager:
self.file_list_frame.grid(row=0, column=0, sticky="nsew") self.file_list_frame.grid(row=0, column=0, sticky="nsew")
self.dialog.bind("<Configure>", self.dialog.on_window_resize) self.dialog.bind("<Configure>", self.dialog.on_window_resize)
# --- Bottom Bar ---
self._setup_bottom_bar() self._setup_bottom_bar()

View File

@@ -2,7 +2,7 @@ import os
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk
from datetime import datetime from datetime import datetime
from typing import Optional, List, Tuple, Callable, Any from typing import Optional, List, Tuple, Callable, Any, Dict
# To avoid circular import with custom_file_dialog.py # To avoid circular import with custom_file_dialog.py
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -10,6 +10,7 @@ if TYPE_CHECKING:
from custom_file_dialog import CustomFileDialog from custom_file_dialog import CustomFileDialog
from shared_libs.common_tools import Tooltip from shared_libs.common_tools import Tooltip
from shared_libs.message import MessageDialog
from .cfd_app_config import CfdConfigManager, LocaleStrings from .cfd_app_config import CfdConfigManager, LocaleStrings
@@ -25,18 +26,66 @@ class ViewManager:
""" """
self.dialog = dialog 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: def populate_files(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None:
""" """
Populates the main file display area. 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() self._unbind_mouse_wheel_events()
@@ -46,46 +95,19 @@ class ViewManager:
self.dialog.widget_manager.path_entry.insert( self.dialog.widget_manager.path_entry.insert(
0, self.dialog.current_dir) 0, self.dialog.current_dir)
self.dialog.result = None self.dialog.result = None
self.dialog.update_status_bar() self.dialog.selected_item_frames.clear()
self.dialog.update_selection_info()
if self.dialog.view_mode.get() == "list": if self.dialog.view_mode.get() == "list":
self.populate_list_view(item_to_rename, item_to_select) self.populate_list_view(item_to_rename, item_to_select)
else: else:
self.populate_icon_view(item_to_rename, item_to_select) 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]: def _get_folder_content_count(self, folder_path: str) -> Optional[int]:
""" """
Counts the number of items in a given folder. 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.
""" """
if self.dialog.current_fs_type == "sftp":
return None
try: try:
if not os.path.isdir(folder_path) or not os.access(folder_path, os.R_OK): if not os.path.isdir(folder_path) or not os.access(folder_path, os.R_OK):
return None return None
@@ -102,17 +124,21 @@ class ViewManager:
def _get_item_path_from_widget(self, widget: tk.Widget) -> Optional[str]: def _get_item_path_from_widget(self, widget: tk.Widget) -> Optional[str]:
""" """
Traverses up the widget hierarchy to find the item_path attribute. 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'): while widget and not hasattr(widget, 'item_path'):
widget = widget.master widget = widget.master
return getattr(widget, 'item_path', None) 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: def _handle_icon_click(self, event: tk.Event) -> None:
"""Handles a single click on an icon view item.""" """Handles a single click on an icon view item."""
item_path = self._get_item_path_from_widget(event.widget) item_path = self._get_item_path_from_widget(event.widget)
@@ -120,7 +146,7 @@ class ViewManager:
item_frame = event.widget item_frame = event.widget
while not hasattr(item_frame, 'item_path'): while not hasattr(item_frame, 'item_path'):
item_frame = item_frame.master item_frame = item_frame.master
self.on_item_select(item_path, item_frame) self.on_item_select(item_path, item_frame, event)
def _handle_icon_double_click(self, event: tk.Event) -> None: def _handle_icon_double_click(self, event: tk.Event) -> None:
"""Handles a double click on an icon view item.""" """Handles a double click on an icon view item."""
@@ -147,12 +173,8 @@ class ViewManager:
def populate_icon_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None: 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. 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.all_items, error, warning = self._get_file_info_list()
self.dialog.currently_loaded_count = 0 self.dialog.currently_loaded_count = 0
self.dialog.icon_canvas = tk.Canvas(self.dialog.widget_manager.file_list_frame, self.dialog.icon_canvas = tk.Canvas(self.dialog.widget_manager.file_list_frame,
@@ -223,15 +245,6 @@ class ViewManager:
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]: 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. 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 start_index = self.dialog.currently_loaded_count
end_index = min(len(self.dialog.all_items), start_index + end_index = min(len(self.dialog.all_items), start_index +
@@ -250,11 +263,13 @@ class ViewManager:
widget_to_focus = None widget_to_focus = None
for i in range(start_index, end_index): for i in range(start_index, end_index):
name = self.dialog.all_items[i] 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('.'): if not self.dialog.show_hidden_files.get() and name.startswith('.'):
continue 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): if not is_dir and not self.dialog._matches_filetype(name):
continue continue
@@ -283,7 +298,7 @@ class ViewManager:
widget.bind("<Double-Button-1>", lambda e, widget.bind("<Double-Button-1>", lambda e,
p=path: self._handle_item_double_click(p)) p=path: self._handle_item_double_click(p))
widget.bind("<Button-1>", lambda e, p=path, widget.bind("<Button-1>", lambda e, p=path,
f=item_frame: self.on_item_select(p, f)) f=item_frame: self.on_item_select(p, f, e))
widget.bind("<ButtonRelease-3>", lambda e, widget.bind("<ButtonRelease-3>", lambda e,
p=path: self.dialog.file_op_manager._show_context_menu(e, p)) p=path: self.dialog.file_op_manager._show_context_menu(e, p))
widget.bind("<F2>", lambda e, p=path, widget.bind("<F2>", lambda e, p=path,
@@ -309,12 +324,8 @@ class ViewManager:
def populate_list_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None: 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. 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.all_items, error, warning = self._get_file_info_list()
self.dialog.currently_loaded_count = 0 self.dialog.currently_loaded_count = 0
tree_frame = ttk.Frame(self.dialog.widget_manager.file_list_frame) tree_frame = ttk.Frame(self.dialog.widget_manager.file_list_frame)
@@ -325,6 +336,9 @@ class ViewManager:
columns = ("size", "type", "modified") columns = ("size", "type", "modified")
self.dialog.tree = ttk.Treeview( self.dialog.tree = ttk.Treeview(
tree_frame, columns=columns, show="tree headings") tree_frame, columns=columns, show="tree headings")
if self.dialog.dialog_mode == 'multi':
self.dialog.tree.config(selectmode="extended")
self.dialog.tree.heading( self.dialog.tree.heading(
"#0", text=LocaleStrings.VIEW["name"], anchor="w") "#0", text=LocaleStrings.VIEW["name"], anchor="w")
@@ -381,13 +395,6 @@ class ViewManager:
def _load_more_items_list_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> bool: 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. 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 start_index = self.dialog.currently_loaded_count
end_index = min(len(self.dialog.all_items), start_index + end_index = min(len(self.dialog.all_items), start_index +
@@ -398,25 +405,28 @@ class ViewManager:
item_found = False item_found = False
for i in range(start_index, end_index): for i in range(start_index, end_index):
name = self.dialog.all_items[i] 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('.'): if not self.dialog.show_hidden_files.get() and name.startswith('.'):
continue 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): if not is_dir and not self.dialog._matches_filetype(name):
continue continue
try: try:
stat = os.stat(path)
modified_time = datetime.fromtimestamp( modified_time = datetime.fromtimestamp(
stat.st_mtime).strftime('%d.%m.%Y %H:%M') file_info['modified']).strftime('%d.%m.%Y %H:%M')
if is_dir: if is_dir:
icon, file_type, size = self.dialog.icon_manager.get_icon( icon, file_type, size = self.dialog.icon_manager.get_icon(
'folder_small'), LocaleStrings.FILE["folder"], "" 'folder_small'), LocaleStrings.FILE["folder"], ""
else: else:
icon, file_type, size = self.dialog.get_file_icon( icon, file_type, size = self.dialog.get_file_icon(
name, 'small'), LocaleStrings.FILE["file"], self.dialog._format_size(stat.st_size) 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=( item_id = self.dialog.tree.insert("", "end", text=f" {name}", image=icon, values=(
size, file_type, modified_time)) size, file_type, modified_time))
self.dialog.item_path_map[item_id] = path # Store path for later retrieval
if name == item_to_rename: if name == item_to_rename:
self.dialog.tree.selection_set(item_id) self.dialog.tree.selection_set(item_id)
self.dialog.tree.focus(item_id) self.dialog.tree.focus(item_id)
@@ -434,70 +444,158 @@ class ViewManager:
self.dialog.currently_loaded_count = end_index self.dialog.currently_loaded_count = end_index
return item_found return item_found
def on_item_select(self, path: str, item_frame: ttk.Frame) -> None: 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. 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(): if self.dialog.dialog_mode == 'dir' and not self._is_dir(path):
self.dialog.selected_item_frame.state(['!selected']) return
for child in self.dialog.selected_item_frame.winfo_children():
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): if isinstance(child, ttk.Label):
child.state(['!selected']) child.state(['selected'])
item_frame.state(['selected']) self.dialog.selected_item_frame = item_frame
for child in item_frame.winfo_children(): self.dialog.update_selection_info(path)
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() 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: 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(): 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 return
item_id = self.dialog.tree.selection()[0]
item_text = self.dialog.tree.item(item_id, 'text').strip() if self.dialog.dialog_mode == 'multi':
path = os.path.join(self.dialog.current_dir, item_text) selected_paths = []
self.dialog.selected_file = path for item_id in selections:
self.dialog.update_status_bar(path) path = self.dialog.item_path_map.get(item_id)
self.dialog.search_manager.show_search_ready() if path:
if not os.path.isdir(self.dialog.selected_file): selected_paths.append(path)
self.dialog.widget_manager.filename_entry.delete(0, tk.END) self.dialog.result = selected_paths
self.dialog.widget_manager.filename_entry.insert(0, item_text) 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: def on_list_context_menu(self, event: tk.Event) -> str:
"""Shows the context menu for a list view item.""" """
Shows the context menu for a list view item.
"""
iid = self.dialog.tree.identify_row(event.y) iid = self.dialog.tree.identify_row(event.y)
if not iid: if not iid:
return "break" return "break"
self.dialog.tree.selection_set(iid) self.dialog.tree.selection_set(iid)
item_text = self.dialog.tree.item(iid, "text").strip() path = self.dialog.item_path_map.get(iid)
item_path = os.path.join(self.dialog.current_dir, item_text) if path:
self.dialog.file_op_manager._show_context_menu(event, item_path) self.dialog.file_op_manager._show_context_menu(event, path)
return "break" return "break"
def _handle_item_double_click(self, path: str) -> None: def _handle_item_double_click(self, path: str) -> None:
""" """
Handles the logic for a double-click on any item, regardless of view. 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): if self._is_dir(path):
self.dialog.navigation_manager.navigate_to(path) if self.dialog.dialog_mode == 'dir':
elif self.dialog.dialog_mode == "open": has_subdirs = False
self.dialog.selected_file = path try:
self.dialog.destroy() 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": elif self.dialog.dialog_mode == "save":
self.dialog.widget_manager.filename_entry.delete(0, tk.END) self.dialog.widget_manager.filename_entry.delete(0, tk.END)
self.dialog.widget_manager.filename_entry.insert( self.dialog.widget_manager.filename_entry.insert(
@@ -505,24 +603,24 @@ class ViewManager:
self.dialog.on_save() self.dialog.on_save()
def on_list_double_click(self, event: tk.Event) -> None: 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(): Handles a double-click on a list view item.
"""
selection = self.dialog.tree.selection()
if not selection:
return return
item_id = self.dialog.tree.selection()[0] item_id = selection[0]
item_text = self.dialog.tree.item(item_id, 'text').strip() path = self.dialog.item_path_map.get(item_id)
path = os.path.join(self.dialog.current_dir, item_text) if path:
self._handle_item_double_click(path) self._handle_item_double_click(path)
def _select_file_in_view(self, filename: str) -> None: def _select_file_in_view(self, filename: str) -> None:
""" """
Programmatically selects a file in the current view. 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": if self.dialog.view_mode.get() == "list":
for item_id in self.dialog.tree.get_children(): for item_id, path in self.dialog.item_path_map.items():
if self.dialog.tree.item(item_id, "text").strip() == filename: if os.path.basename(path) == filename:
self.dialog.tree.selection_set(item_id) self.dialog.tree.selection_set(item_id)
self.dialog.tree.focus(item_id) self.dialog.tree.focus(item_id)
self.dialog.tree.see(item_id) self.dialog.tree.see(item_id)
@@ -610,4 +708,4 @@ class ViewManager:
"""Unbinds all mouse wheel events from the dialog.""" """Unbinds all mouse wheel events from the dialog."""
self.dialog.unbind_all("<MouseWheel>") self.dialog.unbind_all("<MouseWheel>")
self.dialog.unbind_all("<Button-4>") self.dialog.unbind_all("<Button-4>")
self.dialog.unbind_all("<Button-5>") self.dialog.unbind_all("<Button-5>")

View File

@@ -4,8 +4,13 @@ import tkinter as tk
import subprocess import subprocess
import json import json
import threading import threading
import webbrowser
from typing import Optional, List, Tuple, Dict, Union from typing import Optional, List, Tuple, Dict, Union
from shared_libs.common_tools import IconManager, Tooltip, LxTools
import requests
from shared_libs.common_tools import IconManager, Tooltip, LxTools, message_box_animation
from shared_libs.gitea import GiteaUpdater, GiteaApiUrlError, GiteaVersionParseError, GiteaApiResponseError
from .cfd_app_config import CfdConfigManager, LocaleStrings from .cfd_app_config import CfdConfigManager, LocaleStrings
from .cfd_ui_setup import StyleManager, WidgetManager from .cfd_ui_setup import StyleManager, WidgetManager
from shared_libs.animated_icon import AnimatedIcon from shared_libs.animated_icon import AnimatedIcon
@@ -14,6 +19,8 @@ from .cfd_file_operations import FileOperationsManager
from .cfd_search_manager import SearchManager from .cfd_search_manager import SearchManager
from .cfd_navigation_manager import NavigationManager from .cfd_navigation_manager import NavigationManager
from .cfd_view_manager import ViewManager from .cfd_view_manager import ViewManager
from .cfd_sftp_manager import SFTPManager, PARAMIKO_AVAILABLE
from shared_libs.message import CredentialsDialog, MessageDialog, InputDialog
class CustomFileDialog(tk.Toplevel): class CustomFileDialog(tk.Toplevel):
@@ -22,35 +29,23 @@ class CustomFileDialog(tk.Toplevel):
directory navigation, search, and file operations. 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, def __init__(self, parent: tk.Widget, initial_dir: Optional[str] = None,
filetypes: Optional[List[Tuple[str, str]]] = None, filetypes: Optional[List[Tuple[str, str]]] = None,
mode: str = "open", title: str = LocaleStrings.CFD["title"]): mode: str = "open", title: str = LocaleStrings.CFD["title"]):
""" """
Initializes the CustomFileDialog. 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) super().__init__(parent)
self.current_fs_type = "local" # "local" or "sftp"
self.sftp_manager = SFTPManager()
self.config_manager = CfdConfigManager()
self.my_tool_tip: Optional[Tooltip] = None self.my_tool_tip: Optional[Tooltip] = None
self.dialog_mode: str = mode self.dialog_mode: str = mode
self.gitea_api_url = CfdConfigManager.UPDATE_URL
self.lib_version = CfdConfigManager.VERSION
self.update_status: str = ""
self.load_settings() self.load_settings()
@@ -81,6 +76,7 @@ class CustomFileDialog(tk.Toplevel):
self.show_hidden_files: tk.BooleanVar = tk.BooleanVar(value=False) self.show_hidden_files: tk.BooleanVar = tk.BooleanVar(value=False)
self.resize_job: Optional[str] = None self.resize_job: Optional[str] = None
self.last_width: int = 0 self.last_width: int = 0
self.selected_item_frames: List[ttk.Frame] = []
self.search_results: List[str] = [] self.search_results: List[str] = []
self.search_mode: bool = False self.search_mode: bool = False
self.original_path_text: str = "" self.original_path_text: str = ""
@@ -113,11 +109,28 @@ class CustomFileDialog(tk.Toplevel):
self.widget_manager.path_entry.bind( self.widget_manager.path_entry.bind(
"<Return>", self.navigation_manager.handle_path_entry_return) "<Return>", self.navigation_manager.handle_path_entry_return)
self.widget_manager.home_button.config(command=self.go_to_local_home)
self.bind("<Key>", self.search_manager.show_search_bar) self.bind("<Key>", self.search_manager.show_search_bar)
if self.dialog_mode == "save": if self.dialog_mode == "save":
self.bind("<Delete>", self.file_op_manager.delete_selected_item) self.bind("<Delete>", self.file_op_manager.delete_selected_item)
if self.gitea_api_url and self.lib_version:
self.update_thread = threading.Thread(
target=self.check_for_updates, daemon=True)
self.update_thread.start()
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 load_settings(self) -> None: def load_settings(self) -> None:
"""Loads settings from the configuration file.""" """Loads settings from the configuration file."""
self.settings = CfdConfigManager.load() self.settings = CfdConfigManager.load()
@@ -129,12 +142,6 @@ class CustomFileDialog(tk.Toplevel):
def get_min_size_from_preset(self, preset: str) -> Tuple[int, int]: def get_min_size_from_preset(self, preset: str) -> Tuple[int, int]:
""" """
Calculates the minimum window size based on a preset string. 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')) w, h = map(int, preset.split('x'))
return max(650, w - 400), max(450, h - 400) return max(650, w - 400), max(450, h - 400)
@@ -175,6 +182,89 @@ class CustomFileDialog(tk.Toplevel):
"""Opens the settings dialog.""" """Opens the settings dialog."""
SettingsDialog(self, dialog_mode=self.dialog_mode) SettingsDialog(self, dialog_mode=self.dialog_mode)
def open_sftp_dialog(self):
if not PARAMIKO_AVAILABLE:
MessageDialog(message_type="error",
text="Paramiko library is not installed.").show()
return
dialog = CredentialsDialog(self)
credentials = dialog.show()
if credentials:
self.connect_sftp(credentials, is_new_connection=True)
def connect_sftp(self, credentials, is_new_connection: bool = False):
self.config(cursor="watch")
self.update_idletasks()
success, message = self.sftp_manager.connect(
host=credentials['host'],
port=credentials['port'],
username=credentials['username'],
password=credentials['password'],
key_file=credentials['key_file'],
passphrase=credentials['passphrase']
)
self.config(cursor="")
if success:
self.current_fs_type = "sftp"
self.widget_manager.sftp_button.config(
command=self.disconnect_sftp, style="Header.TButton.Active.Round")
initial_path = credentials.get("initial_path", "/")
self.navigation_manager.navigate_to(initial_path)
if is_new_connection:
save_bookmark = MessageDialog(
message_type="ask",
text="Connection successful. Save as bookmark?",
buttons=["Yes", "No"]
).show()
if save_bookmark:
bookmark_name = InputDialog(
self,
title="Save Bookmark",
prompt="Enter a name for the bookmark:",
initial_value=credentials['host']
).show()
if bookmark_name:
self.config_manager.add_bookmark(
bookmark_name, credentials)
self.reload_config_and_rebuild_ui()
else:
MessageDialog(message_type="error",
text=f"Connection failed: {message}").show()
def connect_sftp_bookmark(self, data):
self.connect_sftp(data, is_new_connection=False)
def remove_sftp_bookmark(self, name):
confirm = MessageDialog(
message_type="ask",
text=f"Remove bookmark '{name}'?",
buttons=["Yes", "No"]).show()
if confirm:
self.config_manager.remove_bookmark(name)
self.reload_config_and_rebuild_ui()
def disconnect_sftp(self):
self.sftp_manager.disconnect()
self.current_fs_type = "local"
self.widget_manager.sftp_button.config(
command=self.open_sftp_dialog, style="Header.TButton.Borderless.Round")
self.navigation_manager.navigate_to(os.path.expanduser("~"))
def go_to_local_home(self):
if self.current_fs_type == "sftp":
self.disconnect_sftp()
else:
self.navigation_manager.navigate_to(os.path.expanduser("~"))
def update_animation_settings(self) -> None: def update_animation_settings(self) -> None:
"""Updates the search animation icon based on current settings.""" """Updates the search animation icon based on current settings."""
use_pillow = self.settings.get('use_pillow_animation', False) use_pillow = self.settings.get('use_pillow_animation', False)
@@ -207,16 +297,57 @@ class CustomFileDialog(tk.Toplevel):
if is_running: if is_running:
self.widget_manager.search_animation.start() self.widget_manager.search_animation.start()
def check_for_updates(self) -> None:
"""Checks for library updates via the Gitea API in a background thread."""
try:
new_version = GiteaUpdater.check_for_update(
self.gitea_api_url,
self.lib_version,
)
self.after(0, self.update_ui_for_update, new_version)
except (requests.exceptions.RequestException, GiteaApiUrlError, GiteaVersionParseError, GiteaApiResponseError):
self.after(0, self.update_ui_for_update, "ERROR")
except Exception:
self.after(0, self.update_ui_for_update, "ERROR")
def _run_installer(self, event: Optional[tk.Event] = None) -> None:
"""Runs the LxTools installer if it exists."""
installer_path = '/usr/local/bin/lxtools_installer'
if os.path.exists(installer_path):
try:
subprocess.Popen([installer_path])
self.widget_manager.search_status_label.config(
text="Installer started...")
except OSError as e:
self.widget_manager.search_status_label.config(
text=f"Error starting installer: {e}")
else:
self.widget_manager.search_status_label.config(
text=f"Installer not found at {installer_path}")
def update_ui_for_update(self, new_version: Optional[str]) -> None:
"""
Updates the UI based on the result of the library update check.
"""
self.update_status = new_version
icon = self.widget_manager.update_animation_icon
icon.grid_remove()
icon.hide()
if new_version is None or new_version == "ERROR":
return
icon.grid(row=0, column=2, sticky='e', padx=(10, 5))
tooltip_msg = LocaleStrings.UI["install_new_version"].format(
version=new_version)
icon.start()
icon.bind("<Button-1>", self._run_installer)
Tooltip(icon, tooltip_msg)
def get_file_icon(self, filename: str, size: str = 'large') -> tk.PhotoImage: def get_file_icon(self, filename: str, size: str = 'large') -> tk.PhotoImage:
""" """
Gets the appropriate icon for a given filename. 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() ext = os.path.splitext(filename)[1].lower()
@@ -240,9 +371,6 @@ class CustomFileDialog(tk.Toplevel):
def on_window_resize(self, event: tk.Event) -> None: def on_window_resize(self, event: tk.Event) -> None:
""" """
Handles the window resize event. Handles the window resize event.
Args:
event: The event object.
""" """
if event.widget is self: if event.widget is self:
if self.view_mode.get() == "icons" and not self.search_mode: if self.view_mode.get() == "icons" and not self.search_mode:
@@ -264,9 +392,6 @@ class CustomFileDialog(tk.Toplevel):
def _handle_responsive_buttons(self, window_width: int) -> None: def _handle_responsive_buttons(self, window_width: int) -> None:
""" """
Shows or hides buttons based on the window width. Shows or hides buttons based on the window width.
Args:
window_width: The current width of the window.
""" """
threshold = 850 threshold = 850
container = self.widget_manager.responsive_buttons_container container = self.widget_manager.responsive_buttons_container
@@ -317,9 +442,6 @@ class CustomFileDialog(tk.Toplevel):
def on_sidebar_resize(self, event: tk.Event) -> None: def on_sidebar_resize(self, event: tk.Event) -> None:
""" """
Handles the sidebar resize event, adjusting button text visibility. Handles the sidebar resize event, adjusting button text visibility.
Args:
event: The event object.
""" """
current_width = event.width current_width = event.width
threshold_width = 100 threshold_width = 100
@@ -338,9 +460,6 @@ class CustomFileDialog(tk.Toplevel):
def _on_devices_enter(self, event: tk.Event) -> None: def _on_devices_enter(self, event: tk.Event) -> None:
""" """
Shows the scrollbar when the mouse enters the devices area. Shows the scrollbar when the mouse enters the devices area.
Args:
event: The event object.
""" """
self.widget_manager.devices_scrollbar.grid( self.widget_manager.devices_scrollbar.grid(
row=1, column=1, sticky="ns") row=1, column=1, sticky="ns")
@@ -348,9 +467,6 @@ class CustomFileDialog(tk.Toplevel):
def _on_devices_leave(self, event: tk.Event) -> None: def _on_devices_leave(self, event: tk.Event) -> None:
""" """
Hides the scrollbar when the mouse leaves the devices area. Hides the scrollbar when the mouse leaves the devices area.
Args:
event: The event object.
""" """
x, y = event.x_root, event.y_root x, y = event.x_root, event.y_root
widget_x = self.widget_manager.devices_canvas.winfo_rootx() widget_x = self.widget_manager.devices_canvas.winfo_rootx()
@@ -375,33 +491,83 @@ class CustomFileDialog(tk.Toplevel):
self.widget_manager.recursive_button.configure( self.widget_manager.recursive_button.configure(
style="Header.TButton.Borderless.Round") style="Header.TButton.Borderless.Round")
def update_status_bar(self, status_info: Optional[str] = None) -> None: def update_selection_info(self, status_info: Optional[str] = None) -> None:
""" """
Updates the status bar with disk usage and selected item information. Updates status bar, filename entry, and result based on current selection.
"""
self._update_disk_usage()
status_text = ""
Args: if self.dialog_mode == 'multi':
status_info: The path of the currently selected item or a custom string. selected_paths = self.result if isinstance(
""" self.result, list) else []
self.widget_manager.filename_entry.delete(0, tk.END)
if selected_paths:
filenames = [
f'"{os.path.basename(p)}"' for p in selected_paths]
self.widget_manager.filename_entry.insert(
0, " ".join(filenames))
count = len(selected_paths)
status_text = f"{count} {LocaleStrings.CFD['items_selected']}"
else:
status_text = ""
else:
if status_info and (self.current_fs_type == 'sftp' or os.path.exists(status_info)):
self.result = status_info
self.widget_manager.filename_entry.delete(0, tk.END)
self.widget_manager.filename_entry.insert(
0, os.path.basename(status_info))
if self.view_manager._is_dir(status_info):
content_count = self.view_manager._get_folder_content_count(
status_info)
if content_count is not None:
status_text = f"'{os.path.basename(status_info)}' ({content_count} {LocaleStrings.CFD['entries']})"
else:
status_text = f"'{os.path.basename(status_info)}'"
else:
status_text = f"'{os.path.basename(status_info)}'"
elif status_info:
status_text = status_info
self.widget_manager.search_status_label.config(text=status_text)
self.update_action_buttons_state()
def _update_disk_usage(self) -> None:
"""Updates only the disk usage part of the status bar."""
if self.current_fs_type == "sftp":
self.widget_manager.storage_label.config(text="SFTP Storage: N/A")
self.widget_manager.storage_bar['value'] = 0
return
try: try:
total, used, free = shutil.disk_usage(self.current_dir) total, used, free = shutil.disk_usage(self.current_dir)
free_str = self._format_size(free) free_str = self._format_size(free)
self.widget_manager.storage_label.config( self.widget_manager.storage_label.config(
text=f"{LocaleStrings.CFD['free_space']}: {free_str}") text=f"{LocaleStrings.CFD['free_space']}: {free_str}")
self.widget_manager.storage_bar['value'] = (used / total) * 100 self.widget_manager.storage_bar['value'] = (used / total) * 100
except FileNotFoundError:
status_text = "" self.widget_manager.storage_label.config(
if status_info and os.path.exists(status_info): text=f"{LocaleStrings.CFD['free_space']}: {LocaleStrings.CFD['unknown']}")
selected_path = status_info self.widget_manager.storage_bar['value'] = 0
if os.path.isdir(selected_path): self.widget_manager.search_status_label.config(
content_count = self.view_manager._get_folder_content_count( text=LocaleStrings.CFD["directory_not_found"])
selected_path)
if content_count is not None:
status_text = f
def on_open(self) -> None: def on_open(self) -> None:
"""Handles the 'Open' action, closing the dialog if a file is selected.""" """Handles the 'Open' or 'OK' action based on the dialog mode."""
if self.result and isinstance(self.result, str) and os.path.isfile(self.result): if self.dialog_mode == 'multi':
self.destroy() if self.result and isinstance(self.result, list) and self.result:
self.destroy()
return
selected_path = self.result
if not selected_path or not isinstance(selected_path, str):
return
if self.dialog_mode == 'dir':
if self.view_manager._is_dir(selected_path):
self.destroy()
elif self.dialog_mode == 'open':
if not self.view_manager._is_dir(selected_path):
self.destroy()
def on_save(self) -> None: def on_save(self) -> None:
"""Handles the 'Save' action, setting the selected file and closing the dialog.""" """Handles the 'Save' action, setting the selected file and closing the dialog."""
@@ -416,32 +582,42 @@ class CustomFileDialog(tk.Toplevel):
self.destroy() self.destroy()
def get_result(self) -> Optional[Union[str, List[str]]]: def get_result(self) -> Optional[Union[str, List[str]]]:
""" """Returns the result of the dialog."""
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 return self.result
def update_action_buttons_state(self) -> None: def update_action_buttons_state(self) -> None:
"""Updates the state of action buttons (e.g., 'New Folder') based on directory permissions.""" """Updates the state of action buttons based on current context."""
is_writable = os.access(self.current_dir, os.W_OK) new_folder_state = tk.DISABLED
state = tk.NORMAL if is_writable and self.dialog_mode != "open" else tk.DISABLED new_file_state = tk.DISABLED
self.widget_manager.new_folder_button.config(state=state) trash_state = tk.DISABLED
self.widget_manager.new_file_button.config(state=state)
is_writable = False
if self.dialog_mode != "open":
if self.current_fs_type == 'sftp':
is_writable = True
else:
is_writable = os.access(self.current_dir, os.W_OK)
if is_writable:
new_folder_state = tk.NORMAL
new_file_state = tk.NORMAL
if self.dialog_mode == "save":
trash_state = tk.NORMAL
if hasattr(self.widget_manager, 'new_folder_button'):
self.widget_manager.new_folder_button.config(
state=new_folder_state)
if hasattr(self.widget_manager, 'new_file_button'):
self.widget_manager.new_file_button.config(state=new_file_state)
if hasattr(self.widget_manager, 'trash_button'):
self.widget_manager.trash_button.config(state=trash_state)
def _matches_filetype(self, filename: str) -> bool: def _matches_filetype(self, filename: str) -> bool:
""" """
Checks if a filename matches the current filetype filter. 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 == "*.*": if self.current_filter_pattern == "*.*":
return True return True
@@ -464,12 +640,6 @@ class CustomFileDialog(tk.Toplevel):
def _format_size(self, size_bytes: Optional[int]) -> str: def _format_size(self, size_bytes: Optional[int]) -> str:
""" """
Formats a size in bytes into a human-readable string (KB, MB, GB). 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: if size_bytes is None:
return "" return ""
@@ -484,23 +654,12 @@ class CustomFileDialog(tk.Toplevel):
def shorten_text(self, text: str, max_len: int) -> str: def shorten_text(self, text: str, max_len: int) -> str:
""" """
Shortens a string to a maximum length, adding '...' if truncated. 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] + "..." return text if len(text) <= max_len else text[:max_len-3] + "..."
def _get_mounted_devices(self) -> List[Tuple[str, str, bool]]: def _get_mounted_devices(self) -> List[Tuple[str, str, bool]]:
""" """
Retrieves a list of mounted devices on the system. 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]] = [] devices: List[Tuple[str, str, bool]] = []
root_disk_name: Optional[str] = None root_disk_name: Optional[str] = None
@@ -519,7 +678,8 @@ class CustomFileDialog(tk.Toplevel):
break break
for block_device in data.get('blockdevices', []): for block_device in data.get('blockdevices', []):
if (block_device.get('mountpoint') and if (
block_device.get('mountpoint') and
block_device.get('type') not in ['loop', 'rom'] and block_device.get('type') not in ['loop', 'rom'] and
block_device.get('mountpoint') != '/'): block_device.get('mountpoint') != '/'):
@@ -536,7 +696,8 @@ class CustomFileDialog(tk.Toplevel):
if 'children' in block_device: if 'children' in block_device:
for child_device in block_device['children']: for child_device in block_device['children']:
if (child_device.get('mountpoint') and if (
child_device.get('mountpoint') and
child_device.get('type') not in ['loop', 'rom'] and child_device.get('type') not in ['loop', 'rom'] and
child_device.get('mountpoint') != '/'): child_device.get('mountpoint') != '/'):
@@ -554,4 +715,4 @@ class CustomFileDialog(tk.Toplevel):
except Exception as e: except Exception as e:
print(f"Error getting mounted devices: {e}") print(f"Error getting mounted devices: {e}")
return devices return devices

View File

@@ -12,95 +12,7 @@ except (ModuleNotFoundError, NameError):
class MessageDialog: class MessageDialog:
""" """
A customizable message dialog window using tkinter for user interaction. A customizable message dialog window using tkinter for user interaction.
This class creates modal dialogs for displaying information, warnings, errors,
or questions to the user. It supports multiple button configurations, custom
icons, keyboard navigation, and command binding. The dialog is centered on the
screen and handles user interactions with focus management and accessibility.
Attributes:
message_type (str): Type of message ("info", "error", "warning", "ask").
text (str): Main message content to display.
buttons (List[str]): List of button labels (e.g., ["OK", "Cancel"]).
result (bool or None):
- True for positive actions (Yes, OK)
- False for negative actions (No, Cancel)
- None if "Cancel" was clicked with ≥3 buttons
icons: Dictionary mapping message types to tkinter.PhotoImage objects
Parameters:
message_type: Type of message dialog (default: "info")
text: Message content to display
buttons: List of button labels (default: ["OK"])
master: Parent tkinter window (optional)
commands: List of callables for each button (default: [None])
icon: Custom icon path (overrides default icons if provided)
title: Window title (default: derived from message_type)
font: Font tuple for text styling
wraplength: Text wrapping width in pixels
Methods:
_get_title(): Returns the default window title based on message type.
_on_button_click(button_text): Sets result and closes the dialog.
show(): Displays the dialog and waits for user response.
Example Usage:
1. Basic Info Dialog:
>>> MessageDialog(
... text="This is an information message.")
>>> result = dialog.show()
>>> print("User clicked OK:", result)
Notes:
My Favorite Example,
for simply information message:
>>> MessageDialog(text="This is an information message.")
>>> result = MessageDialog(text="This is an information message.").show()
Example Usage:
2. Error Dialog with Custom Command:
>>> def on_retry():
... print("User selected Retry")
>>> dialog = MessageDialog(
... message_type="error",
... text="An error occurred during processing.",
... buttons=["Retry", "Cancel"],
... commands=[on_retry, None],
... title="Critical Error"
... )
>>> result = dialog.show()
>>> print("User selected Retry:", result)
Example Usage:
3. And a special example for a "open link" button:
Be careful not to forget to import it into the script in which
this dialog is used!!! import webbrowser from functools import partial
>>> MessageDialog(
... "info"
... text="This is an information message.",
... buttons=["Yes", "Go to Exapmle"],
... commands=[
... None, # Default on "OK"
... partial(webbrowser.open, "https://exapmle.com"),
... ],
... icon="/pathh/to/custom/icon.png",
... title="Example",
... )
Notes:
- Returns None if "Cancel" was clicked with ≥3 buttons
- Supports keyboard navigation (Left/Right arrows and Enter)
- Dialog automatically centers on screen
- Result is False for window close (X) with 2 buttons
- Font and wraplength parameters enable text styling
""" """
def __init__( def __init__(
self, self,
message_type: str = "info", message_type: str = "info",
@@ -113,23 +25,20 @@ class MessageDialog:
font: tuple = None, font: tuple = None,
wraplength: int = None, wraplength: int = None,
): ):
self.message_type = message_type or "info" # Default is "info" self.message_type = message_type or "info"
self.text = text self.text = text
self.buttons = buttons self.buttons = buttons
self.master = master self.master = master
self.result: bool = False # Default is False self.result: bool = False
self.icon = icon self.icon = icon
self.title = title self.title = title
# Window creation
self.window = tk.Toplevel(master) self.window = tk.Toplevel(master)
self.window.grab_set()
self.window.resizable(False, False) self.window.resizable(False, False)
ttk.Style().configure("TButton") ttk.Style().configure("TButton")
self.buttons_widgets = [] self.buttons_widgets = []
self.current_button_index = 0 self.current_button_index = 0
# Load icons using IconManager
icon_manager = IconManager() icon_manager = IconManager()
self.icons = { self.icons = {
"error": icon_manager.get_icon("error_extralarge"), "error": icon_manager.get_icon("error_extralarge"),
@@ -138,36 +47,27 @@ class MessageDialog:
"ask": icon_manager.get_icon("question_mark_extralarge"), "ask": icon_manager.get_icon("question_mark_extralarge"),
} }
# Handle custom icon override
if self.icon: if self.icon:
if isinstance(self.icon, str) and os.path.exists(self.icon): if isinstance(self.icon, str) and os.path.exists(self.icon):
# If it's a path, load it
try: try:
self.icons[self.message_type] = tk.PhotoImage( self.icons[self.message_type] = tk.PhotoImage(file=self.icon)
file=self.icon)
except tk.TclError as e: except tk.TclError as e:
print( print(f"Error loading custom icon from path '{self.icon}': {e}")
f"Error loading custom icon from path '{self.icon}': {e}")
elif isinstance(self.icon, tk.PhotoImage): elif isinstance(self.icon, tk.PhotoImage):
# If it's already a PhotoImage, use it directly
self.icons[self.message_type] = self.icon self.icons[self.message_type] = self.icon
# Window title and icon
self.window.title(self._get_title() if not self.title else self.title) self.window.title(self._get_title() if not self.title else self.title)
window_icon = self.icons.get(self.message_type) window_icon = self.icons.get(self.message_type)
if window_icon: if window_icon:
self.window.iconphoto(False, window_icon) self.window.iconphoto(False, window_icon)
# Layout
frame = ttk.Frame(self.window) frame = ttk.Frame(self.window)
frame.pack(expand=True, fill="both", padx=15, pady=8) frame.pack(expand=True, fill="both", padx=15, pady=8)
# Grid-Configuration
frame.grid_rowconfigure(0, weight=1) frame.grid_rowconfigure(0, weight=1)
frame.grid_columnconfigure(0, weight=1) frame.grid_columnconfigure(0, weight=1)
frame.grid_columnconfigure(1, weight=3) frame.grid_columnconfigure(1, weight=3)
# Icon and Text
icon_label = ttk.Label(frame, image=self.icons.get(self.message_type)) icon_label = ttk.Label(frame, image=self.icons.get(self.message_type))
pady_value = 5 if self.icon is not None else 15 pady_value = 5 if self.icon is not None else 15
icon_label.grid( icon_label.grid(
@@ -183,118 +83,280 @@ class MessageDialog:
font=font if font else ("Helvetica", 12), font=font if font else ("Helvetica", 12),
pady=20, pady=20,
) )
text_label.grid( text_label.grid(row=0, column=1, padx=(10, 20), sticky="nsew")
row=0,
column=1,
padx=(10, 20),
sticky="nsew",
)
# Create button frame
self.button_frame = ttk.Frame(frame) self.button_frame = ttk.Frame(frame)
self.button_frame.grid(row=1, columnspan=2, pady=(8, 10)) self.button_frame.grid(row=1, columnspan=2, pady=(8, 10))
for i, btn_text in enumerate(buttons): for i, btn_text in enumerate(buttons):
if commands and len(commands) > i and commands[i] is not None: if commands and len(commands) > i and commands[i] is not None:
# Button with individual command btn = ttk.Button(self.button_frame, text=btn_text, command=commands[i])
btn = ttk.Button(
self.button_frame,
text=btn_text,
command=commands[i],
)
else: else:
# Default button set self.result and close window btn = ttk.Button(self.button_frame, text=btn_text, command=lambda t=btn_text: self._on_button_click(t))
btn = ttk.Button(
self.button_frame,
text=btn_text,
command=lambda t=btn_text: self._on_button_click(t),
)
padx_value = 50 if self.icon is not None and len( padx_value = 50 if self.icon is not None and len(buttons) == 2 else 10
buttons) == 2 else 10 btn.pack(side="left" if i == 0 else "right", padx=padx_value, pady=5)
btn.pack(side="left" if i == 0 else "right", if i == 0: btn.focus_set()
padx=padx_value, pady=5)
btn.focus_set() if i == 0 else None # Set focus on first button
self.buttons_widgets.append(btn) self.buttons_widgets.append(btn)
self.window.bind("<Return>", lambda event: self._on_enter_pressed()) self.window.bind("<Return>", lambda event: self._on_enter_pressed())
self.window.bind("<Left>", lambda event: self._navigate_left()) self.window.bind("<Left>", lambda event: self._navigate_left())
self.window.bind("<Right>", lambda event: self._navigate_right()) self.window.bind("<Right>", lambda event: self._navigate_right())
self.window.update_idletasks() self.window.update_idletasks()
self.window.attributes("-alpha", 0.0) # 100% Transparencence self.window.grab_set()
self.window.attributes("-alpha", 0.0)
self.window.after(200, lambda: self.window.attributes("-alpha", 100.0)) self.window.after(200, lambda: self.window.attributes("-alpha", 100.0))
self.window.update() # Window update before centering! self.window.update()
LxTools.center_window_cross_platform( LxTools.center_window_cross_platform(self.window, self.window.winfo_width(), self.window.winfo_height())
self.window, self.window.winfo_width(), self.window.winfo_height() self.window.protocol("WM_DELETE_WINDOW", lambda: self._on_button_click("Cancel"))
)
# Close Window on Cancel
self.window.protocol(
"WM_DELETE_WINDOW", lambda: self._on_button_click("Cancel")
)
def _get_title(self) -> str: def _get_title(self) -> str:
return { return {"error": "Error", "info": "Info", "ask": "Question", "warning": "Warning"}[self.message_type]
"error": "Error",
"info": "Info",
"ask": "Question",
"warning": "Warning",
}[self.message_type]
def _navigate_left(self): def _navigate_left(self):
if not self.buttons_widgets: if not self.buttons_widgets: return
return self.current_button_index = (self.current_button_index - 1) % len(self.buttons_widgets)
self.current_button_index = (self.current_button_index - 1) % len(
self.buttons_widgets
)
self.buttons_widgets[self.current_button_index].focus_set() self.buttons_widgets[self.current_button_index].focus_set()
def _navigate_right(self): def _navigate_right(self):
if not self.buttons_widgets: if not self.buttons_widgets: return
return self.current_button_index = (self.current_button_index + 1) % len(self.buttons_widgets)
self.current_button_index = (self.current_button_index + 1) % len(
self.buttons_widgets
)
self.buttons_widgets[self.current_button_index].focus_set() self.buttons_widgets[self.current_button_index].focus_set()
def _on_enter_pressed(self): def _on_enter_pressed(self):
focused = self.window.focus_get() focused = self.window.focus_get()
if isinstance(focused, ttk.Button): if isinstance(focused, ttk.Button): focused.invoke()
focused.invoke()
def _on_button_click(self, button_text: str) -> None: def _on_button_click(self, button_text: str) -> None:
""" if len(self.buttons) >= 3 and button_text.lower() in ["cancel", "abort", "exit"]:
Sets `self.result` based on the clicked button.
- Returns `None` if the button is "Cancel", "Abort", or "Exit" **and** there are 3 or more buttons.
- Returns `True` if the button is "Yes", "Ok", "Continue", "Next", or "Start".
- Returns `False` in all other cases (e.g., "No", closing with X, or fewer than 3 buttons).
"""
# Check: If there are 3+ buttons and the button text matches "Cancel", "Abort", or "Exit"
if len(self.buttons) >= 3 and button_text.lower() in [
"cancel",
"abort",
"exit",
]:
self.result = None self.result = None
# Check: Button text is "Yes", "Ok", "Continue", "Next", or "Start" elif button_text.lower() in ["yes", "ok", "continue", "next", "start", "select"]:
elif button_text.lower() in ["yes", "ok", "continue", "next", "start"]:
self.result = True self.result = True
else: else:
# Fallback for all other cases (e.g., "No", closing with X, or fewer than 3 buttons)
self.result = False self.result = False
self.window.destroy() self.window.destroy()
def show(self) -> Optional[bool]: def show(self) -> Optional[bool]:
""" self.window.wait_window()
Displays the dialog window and waits for user interaction. return self.result
Returns:
bool or None: class CredentialsDialog:
- `True` if "Yes", "Ok", etc. was clicked. """
- `False` if "No" was clicked, or the window was closed with X (when there are 2 buttons). A dialog for securely entering SSH/SFTP credentials.
- `None` if "Cancel", "Abort", or "Exit" was clicked **and** there are 3+ buttons, """
or the window was closed with X (when there are 3+ buttons). def __init__(self, master: Optional[tk.Tk] = None, title: str = "SFTP Connection"):
""" self.master = master
self.result = None
self.window = tk.Toplevel(master)
self.window.title(title)
self.window.resizable(False, False)
style = ttk.Style(self.window)
style.configure("Creds.TEntry", padding=(5, 2))
frame = ttk.Frame(self.window, padding=15)
frame.pack(expand=True, fill="both")
frame.grid_columnconfigure(1, weight=1)
# Host
ttk.Label(frame, text="Host:").grid(row=0, column=0, sticky="w", pady=2)
self.host_entry = ttk.Entry(frame, width=40, style="Creds.TEntry")
self.host_entry.grid(row=0, column=1, sticky="ew", pady=2)
# Port
ttk.Label(frame, text="Port:").grid(row=1, column=0, sticky="w", pady=2)
self.port_entry = ttk.Entry(frame, width=10, style="Creds.TEntry")
self.port_entry.insert(0, "22")
self.port_entry.grid(row=1, column=1, sticky="w", pady=2)
# Username
ttk.Label(frame, text="Username:").grid(row=2, column=0, sticky="w", pady=2)
self.username_entry = ttk.Entry(frame, width=40, style="Creds.TEntry")
self.username_entry.grid(row=2, column=1, sticky="ew", pady=2)
# Initial Path
ttk.Label(frame, text="Initial Path:").grid(row=3, column=0, sticky="w", pady=2)
self.path_entry = ttk.Entry(frame, width=40, style="Creds.TEntry")
self.path_entry.insert(0, "~")
self.path_entry.grid(row=3, column=1, sticky="ew", pady=2)
# Auth Method
ttk.Label(frame, text="Auth Method:").grid(row=4, column=0, sticky="w", pady=5)
auth_frame = ttk.Frame(frame)
auth_frame.grid(row=4, column=1, sticky="w", pady=2)
self.auth_method = tk.StringVar(value="password")
ttk.Radiobutton(auth_frame, text="Password", variable=self.auth_method, value="password", command=self._toggle_auth_fields).pack(side="left")
ttk.Radiobutton(auth_frame, text="Key File", variable=self.auth_method, value="keyfile", command=self._toggle_auth_fields).pack(side="left", padx=10)
# Password
self.password_label = ttk.Label(frame, text="Password:")
self.password_label.grid(row=5, column=0, sticky="w", pady=2)
self.password_entry = ttk.Entry(frame, show="*", width=40, style="Creds.TEntry")
self.password_entry.grid(row=5, column=1, sticky="ew", pady=2)
# Key File
self.keyfile_label = ttk.Label(frame, text="Key File:")
self.keyfile_label.grid(row=6, column=0, sticky="w", pady=2)
key_frame = ttk.Frame(frame)
key_frame.grid(row=6, column=1, sticky="ew", pady=2)
self.keyfile_entry = ttk.Entry(key_frame, width=36, style="Creds.TEntry")
self.keyfile_entry.pack(side="left", fill="x", expand=True)
self.keyfile_button = ttk.Button(key_frame, text="", width=2, command=self._show_key_menu)
self.keyfile_button.pack(side="left", padx=(2,0))
# Passphrase
self.passphrase_label = ttk.Label(frame, text="Passphrase:")
self.passphrase_label.grid(row=7, column=0, sticky="w", pady=2)
self.passphrase_entry = ttk.Entry(frame, show="*", width=40, style="Creds.TEntry")
self.passphrase_entry.grid(row=7, column=1, sticky="ew", pady=2)
# Buttons
button_frame = ttk.Frame(frame)
button_frame.grid(row=8, column=1, sticky="e", pady=(15, 0))
connect_button = ttk.Button(button_frame, text="Connect", command=self._on_connect)
connect_button.pack(side="left", padx=5)
cancel_button = ttk.Button(button_frame, text="Cancel", command=self._on_cancel)
cancel_button.pack(side="left")
self._toggle_auth_fields()
self.window.bind("<Return>", lambda event: self._on_connect())
self.window.protocol("WM_DELETE_WINDOW", self._on_cancel)
self.window.update_idletasks()
self.window.grab_set()
self.window.attributes("-alpha", 0.0)
self.window.after(200, lambda: self.window.attributes("-alpha", 100.0))
self.window.update()
LxTools.center_window_cross_platform(self.window, self.window.winfo_width(), self.window.winfo_height())
self.host_entry.focus_set()
def _get_ssh_keys(self) -> List[str]:
ssh_path = os.path.expanduser("~/.ssh")
keys = []
if os.path.isdir(ssh_path):
try:
for item in os.listdir(ssh_path):
full_path = os.path.join(ssh_path, item)
if os.path.isfile(full_path) and not item.endswith('.pub') and 'known_hosts' not in item:
keys.append(full_path)
except OSError:
pass
return keys
def _show_key_menu(self):
keys = self._get_ssh_keys()
if not keys:
return
menu = tk.Menu(self.window, tearoff=0)
for key_path in keys:
menu.add_command(label=key_path, command=lambda k=key_path: self._select_key_from_menu(k))
x = self.keyfile_button.winfo_rootx()
y = self.keyfile_button.winfo_rooty() + self.keyfile_button.winfo_height()
menu.tk_popup(x, y)
def _select_key_from_menu(self, key_path):
self.keyfile_entry.delete(0, tk.END)
self.keyfile_entry.insert(0, key_path)
def _toggle_auth_fields(self):
method = self.auth_method.get()
if method == "password":
self.password_label.grid()
self.password_entry.grid()
self.keyfile_label.grid_remove()
self.keyfile_entry.master.grid_remove()
self.passphrase_label.grid_remove()
self.passphrase_entry.grid_remove()
else:
self.password_label.grid_remove()
self.password_entry.grid_remove()
self.keyfile_label.grid()
self.keyfile_entry.master.grid()
self.passphrase_label.grid()
self.passphrase_entry.grid()
self.window.update_idletasks()
self.window.geometry("")
def _on_connect(self):
self.result = {
"host": self.host_entry.get(),
"port": int(self.port_entry.get() or 22),
"username": self.username_entry.get(),
"initial_path": self.path_entry.get() or "/",
"password": self.password_entry.get() if self.auth_method.get() == "password" else None,
"key_file": self.keyfile_entry.get() if self.auth_method.get() == "keyfile" else None,
"passphrase": self.passphrase_entry.get() if self.auth_method.get() == "keyfile" else None,
}
self.window.destroy()
def _on_cancel(self):
self.result = None
self.window.destroy()
def show(self) -> Optional[dict]:
self.window.wait_window()
return self.result
class InputDialog:
"""
A simple dialog for getting a single line of text input from the user.
"""
def __init__(self, parent, title: str, prompt: str, initial_value: str = ""):
self.result = None
self.window = tk.Toplevel(parent)
self.window.title(title)
self.window.transient(parent)
self.window.resizable(False, False)
frame = ttk.Frame(self.window, padding=15)
frame.pack(expand=True, fill="both")
ttk.Label(frame, text=prompt, wraplength=250).pack(pady=(0, 10))
self.entry = ttk.Entry(frame, width=40)
self.entry.insert(0, initial_value)
self.entry.pack(pady=5)
self.entry.focus_set()
self.entry.selection_range(0, tk.END)
button_frame = ttk.Frame(frame)
button_frame.pack(pady=(10, 0))
ok_button = ttk.Button(button_frame, text="OK", command=self._on_ok)
ok_button.pack(side="left", padx=5)
cancel_button = ttk.Button(
button_frame, text="Cancel", command=self._on_cancel)
cancel_button.pack(side="left", padx=5)
self.window.bind("<Return>", lambda e: self._on_ok())
self.window.protocol("WM_DELETE_WINDOW", self._on_cancel)
self.window.update_idletasks()
self.window.grab_set()
self.window.attributes("-alpha", 0.0)
self.window.after(200, lambda: self.window.attributes("-alpha", 100.0))
self.window.update()
LxTools.center_window_cross_platform(
self.window, self.window.winfo_width(), self.window.winfo_height()
)
def _on_ok(self):
self.result = self.entry.get()
if self.result:
self.window.destroy()
def _on_cancel(self):
self.result = None
self.window.destroy()
def show(self) -> Optional[str]:
self.window.wait_window() self.window.wait_window()
return self.result return self.result