Compare commits
10 Commits
1d05f5088f
...
cc48f874ac
Author | SHA1 | Date | |
---|---|---|---|
cc48f874ac | |||
27f74e6a77 | |||
ba38ea4b87 | |||
ff1aede356 | |||
f565132074 | |||
d548b545e3 | |||
66202310ec | |||
d79e4c9e01 | |||
0ef94de077 | |||
fbc3c8e051 |
21
Changelog
21
Changelog
@@ -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
|
||||
12.08.2025
|
||||
|
||||
|
@@ -471,11 +471,18 @@ class AnimatedIcon(tk.Canvas):
|
||||
return
|
||||
|
||||
# Do not animate if a grab is active on a different window.
|
||||
try:
|
||||
toplevel = self.winfo_toplevel()
|
||||
grab_widget = toplevel.grab_current()
|
||||
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
|
||||
|
||||
self.angle += 0.1
|
||||
if self.angle > 2 * pi:
|
||||
|
@@ -635,6 +635,8 @@ class IconManager:
|
||||
'up': '32/arrow-up.png',
|
||||
'copy': '32/copy.png',
|
||||
'stair': '32/stair.png',
|
||||
'star': '32/star.png',
|
||||
'connect': '32/connect.png',
|
||||
'audio_small': '32/audio.png',
|
||||
'icon_view': '32/carrel.png',
|
||||
'computer_small': '32/computer.png',
|
||||
@@ -695,6 +697,8 @@ class IconManager:
|
||||
'up_large': '48/arrow-up.png',
|
||||
'copy_large': '48/copy.png',
|
||||
'stair_large': '48/stair.png',
|
||||
'star_large': '48/star.png',
|
||||
'connect_large': '48/connect.png',
|
||||
'icon_view_large': '48/carrel.png',
|
||||
'computer_large': '48/computer.png',
|
||||
'device_large': '48/device.png',
|
||||
@@ -743,7 +747,9 @@ class IconManager:
|
||||
'forward_extralarge': '64/arrow-right.png',
|
||||
'up_extralarge': '64/arrow-up.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',
|
||||
'icon_view_extralarge': '64/carrel.png',
|
||||
'computer_extralarge': '64/computer.png',
|
||||
|
@@ -16,7 +16,8 @@ class CfdConfigManager:
|
||||
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
|
||||
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
|
||||
|
||||
@@ -35,6 +36,7 @@ class CfdConfigManager:
|
||||
|
||||
_config: Optional[Dict[str, Any]] = None
|
||||
_config_file: Path = CONFIG_DIR / "cfd_settings.json"
|
||||
_bookmarks_file: Path = CONFIG_DIR / "cfd_bookmarks.json"
|
||||
_default_settings: Dict[str, Any] = {
|
||||
"search_icon_pos": "left", # 'left' or 'right'
|
||||
"button_box_pos": "left", # 'left' or 'right'
|
||||
@@ -44,7 +46,8 @@ class CfdConfigManager:
|
||||
"use_trash": False, # True or False
|
||||
"confirm_delete": False, # True or False
|
||||
"recursive_search": True,
|
||||
"use_pillow_animation": True
|
||||
"use_pillow_animation": True,
|
||||
"keep_bookmarks_on_reset": True # Keep bookmarks when resetting settings
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -83,6 +86,51 @@ class CfdConfigManager:
|
||||
except IOError as 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:
|
||||
"""
|
||||
@@ -115,6 +163,12 @@ class LocaleStrings:
|
||||
"not_found": _("not found."),
|
||||
"access_to": _("Access to"),
|
||||
"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
|
||||
@@ -160,6 +214,10 @@ class LocaleStrings:
|
||||
"no_results_for": _("No results for"),
|
||||
"error_during_search": _("Error during search"),
|
||||
"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
|
||||
@@ -194,6 +252,11 @@ class LocaleStrings:
|
||||
"blink": _("Blink"),
|
||||
"deletion_options_info": _("Deletion options are only available in save mode"),
|
||||
"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
|
||||
|
@@ -41,7 +41,45 @@ class FileOperationsManager:
|
||||
Args:
|
||||
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
|
||||
|
||||
use_trash = self.dialog.settings.get(
|
||||
@@ -49,7 +87,7 @@ class FileOperationsManager:
|
||||
confirm = self.dialog.settings.get("confirm_delete", False)
|
||||
|
||||
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:
|
||||
dialog = MessageDialog(
|
||||
@@ -63,12 +101,12 @@ class FileOperationsManager:
|
||||
|
||||
try:
|
||||
if use_trash:
|
||||
send2trash.send2trash(self.dialog.selected_file)
|
||||
send2trash.send2trash(selected_path)
|
||||
else:
|
||||
if os.path.isdir(self.dialog.selected_file):
|
||||
shutil.rmtree(self.dialog.selected_file)
|
||||
if os.path.isdir(selected_path):
|
||||
shutil.rmtree(selected_path)
|
||||
else:
|
||||
os.remove(self.dialog.selected_file)
|
||||
os.remove(selected_path)
|
||||
|
||||
self.dialog.view_manager.populate_files()
|
||||
self.dialog.widget_manager.search_status_label.config(
|
||||
@@ -104,6 +142,16 @@ class FileOperationsManager:
|
||||
new_path = os.path.join(self.dialog.current_dir, new_name)
|
||||
|
||||
try:
|
||||
if self.dialog.current_fs_type == 'sftp':
|
||||
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:
|
||||
if is_folder:
|
||||
os.mkdir(new_path)
|
||||
else:
|
||||
@@ -129,7 +177,10 @@ class FileOperationsManager:
|
||||
name, ext = os.path.splitext(base_name)
|
||||
counter = 1
|
||||
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
|
||||
new_name = f"{name} {counter}{ext}"
|
||||
return new_name
|
||||
@@ -327,6 +378,22 @@ class FileOperationsManager:
|
||||
self.dialog.view_manager.populate_files(item_to_select=old_name)
|
||||
return
|
||||
|
||||
if self.dialog.current_fs_type == 'sftp':
|
||||
if self.dialog.sftp_manager.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:
|
||||
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']}")
|
||||
|
@@ -76,10 +76,14 @@ class NavigationManager:
|
||||
|
||||
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(
|
||||
item_to_select=file_to_select)
|
||||
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()
|
||||
except Exception as e:
|
||||
self.dialog.widget_manager.search_status_label.config(
|
||||
@@ -101,6 +105,13 @@ class NavigationManager:
|
||||
|
||||
def go_up_level(self) -> None:
|
||||
"""Navigates to the parent directory of the current directory."""
|
||||
if self.dialog.current_fs_type == "sftp":
|
||||
if self.dialog.current_dir and self.dialog.current_dir != "/":
|
||||
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)
|
||||
@@ -109,7 +120,7 @@ class NavigationManager:
|
||||
"""Updates all necessary UI components after a navigation action."""
|
||||
self.dialog.view_manager.populate_files()
|
||||
self.update_nav_buttons()
|
||||
self.dialog.update_status_bar()
|
||||
self.dialog.update_selection_info()
|
||||
self.dialog.update_action_buttons_state()
|
||||
|
||||
def update_nav_buttons(self) -> None:
|
||||
|
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from .cfd_app_config import CfdConfigManager, LocaleStrings
|
||||
from shared_libs.animated_icon import PIL_AVAILABLE
|
||||
from .cfd_sftp_manager import PARAMIKO_AVAILABLE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_file_dialog import CustomFileDialog
|
||||
@@ -56,6 +57,8 @@ class SettingsDialog(tk.Toplevel):
|
||||
value=self.settings.get("use_pillow_animation", False))
|
||||
self.animation_type = tk.StringVar(
|
||||
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 ---
|
||||
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,
|
||||
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
|
||||
if not self.dialog_mode == "save":
|
||||
self.use_trash_checkbutton.config(state=tk.DISABLED)
|
||||
@@ -174,7 +195,8 @@ class SettingsDialog(tk.Toplevel):
|
||||
"use_trash": self.use_trash.get(),
|
||||
"confirm_delete": self.confirm_delete.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)
|
||||
self.master.reload_config_and_rebuild_ui()
|
||||
@@ -193,3 +215,4 @@ class SettingsDialog(tk.Toplevel):
|
||||
self.use_pillow_animation.set(defaults.get(
|
||||
"use_pillow_animation", True) and PIL_AVAILABLE)
|
||||
self.animation_type.set(defaults.get("animation_type", "counter_arc"))
|
||||
self.keep_bookmarks_on_reset.set(defaults.get("keep_bookmarks_on_reset", True))
|
151
custom_file_dialog/cfd_sftp_manager.py
Normal file
151
custom_file_dialog/cfd_sftp_manager.py
Normal 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
|
@@ -10,21 +10,14 @@ if TYPE_CHECKING:
|
||||
from custom_file_dialog import CustomFileDialog
|
||||
|
||||
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_sftp_manager import PARAMIKO_AVAILABLE
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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("~")
|
||||
fallback_path = os.path.join(home, fallback_name)
|
||||
@@ -52,19 +45,10 @@ class StyleManager:
|
||||
"""Manages the visual styling of the application using ttk styles."""
|
||||
|
||||
def __init__(self, dialog: 'CustomFileDialog') -> None:
|
||||
"""
|
||||
Initializes the StyleManager.
|
||||
|
||||
Args:
|
||||
dialog: The main CustomFileDialog instance.
|
||||
"""
|
||||
self.dialog = dialog
|
||||
self.setup_styles()
|
||||
|
||||
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)
|
||||
base_bg = self.dialog.cget('background')
|
||||
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")])
|
||||
style.configure("TButton.Borderless.Round", anchor="w")
|
||||
style.configure("Small.Horizontal.TProgressbar", thickness=8)
|
||||
|
||||
style.configure("Bottom.TButton.Borderless.Round",
|
||||
background=self.bottom_color)
|
||||
style.map("Bottom.TButton.Borderless.Round",
|
||||
background=[('active', self.hover_extrastyle)])
|
||||
style.map("Bottom.TButton.Borderless.Round", background=[
|
||||
('active', self.hover_extrastyle)])
|
||||
style.layout("Bottom.TButton.Borderless.Round",
|
||||
style.layout("Header.TButton.Borderless.Round")
|
||||
)
|
||||
style.layout("Header.TButton.Borderless.Round"))
|
||||
|
||||
|
||||
class WidgetManager:
|
||||
"""Manages the creation, layout, and management of all widgets in the dialog."""
|
||||
|
||||
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.style_manager = dialog.style_manager
|
||||
self.settings = settings
|
||||
self.setup_widgets()
|
||||
|
||||
def _setup_top_bar(self, parent_frame: ttk.Frame) -> None:
|
||||
"""Sets up the top bar with navigation and control buttons."""
|
||||
top_bar = ttk.Frame(
|
||||
parent_frame, style='Accent.TFrame', padding=(0, 5, 0, 5))
|
||||
top_bar.grid(row=0, column=0, columnspan=2, sticky="ew")
|
||||
top_bar.grid_columnconfigure(1, weight=1)
|
||||
|
||||
# Left navigation
|
||||
left_nav_container = ttk.Frame(top_bar, style='Accent.TFrame')
|
||||
left_nav_container.grid(row=0, column=0, sticky="w")
|
||||
left_nav_container.grid_propagate(False)
|
||||
@@ -187,23 +160,29 @@ class WidgetManager:
|
||||
self.home_button.pack(side="left", padx=(5, 10))
|
||||
Tooltip(self.home_button, LocaleStrings.UI["home"])
|
||||
|
||||
# Path and search
|
||||
path_search_container = ttk.Frame(top_bar, style='Accent.TFrame')
|
||||
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.grid(row=0, column=0, sticky="ew")
|
||||
self.path_entry.bind(
|
||||
"<Return>", lambda e: self.dialog.navigation_manager.navigate_to(self.path_entry.get()))
|
||||
|
||||
search_icon_pos = self.settings.get("search_icon_pos", "left")
|
||||
if search_icon_pos == 'left':
|
||||
path_search_container.grid_columnconfigure(1, weight=1)
|
||||
self.path_entry.grid(row=0, column=1, sticky="ew")
|
||||
else: # right
|
||||
path_search_container.grid_columnconfigure(0, weight=1)
|
||||
self.path_entry.grid(row=0, column=0, sticky="ew")
|
||||
self.update_animation_icon = AnimatedIcon(
|
||||
path_search_container,
|
||||
width=20,
|
||||
height=20,
|
||||
animation_type="blink",
|
||||
use_pillow=PIL_AVAILABLE,
|
||||
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(top_bar, style='Accent.TFrame')
|
||||
right_controls_container = ttk.Frame(
|
||||
top_bar, style='Accent.TFrame')
|
||||
right_controls_container.grid(row=0, column=2, sticky="e")
|
||||
self.responsive_buttons_container = ttk.Frame(
|
||||
right_controls_container, style='Accent.TFrame')
|
||||
@@ -219,6 +198,19 @@ class WidgetManager:
|
||||
self.new_file_button.pack(side="left", padx=5)
|
||||
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":
|
||||
self.new_folder_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)
|
||||
|
||||
def _setup_sidebar(self, parent_paned_window: ttk.PanedWindow) -> None:
|
||||
"""Sets up the sidebar with bookmarks and devices."""
|
||||
sidebar_frame = ttk.Frame(
|
||||
parent_paned_window, style="Sidebar.TFrame", padding=(0, 0, 0, 0), width=200)
|
||||
sidebar_frame.grid_propagate(False)
|
||||
sidebar_frame.bind("<Configure>", self.dialog.on_sidebar_resize)
|
||||
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)
|
||||
|
||||
@@ -265,10 +256,14 @@ class WidgetManager:
|
||||
tk.Frame(sidebar_frame, height=1, bg=separator_color).grid(
|
||||
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)
|
||||
|
||||
def _setup_sidebar_bookmarks(self, sidebar_frame: ttk.Frame) -> None:
|
||||
"""Sets up the bookmark buttons in the sidebar."""
|
||||
sidebar_buttons_frame = ttk.Frame(
|
||||
sidebar_frame, style="Sidebar.TFrame", padding=(0, 15, 0, 0))
|
||||
sidebar_buttons_frame.grid(row=0, column=0, sticky="nsew")
|
||||
@@ -293,8 +288,47 @@ class WidgetManager:
|
||||
btn.pack(fill="x", pady=1)
|
||||
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:
|
||||
"""Sets up the mounted devices section in the sidebar."""
|
||||
mounted_devices_frame = ttk.Frame(
|
||||
sidebar_frame, style="Sidebar.TFrame")
|
||||
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(
|
||||
(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:
|
||||
self.devices_canvas.configure(
|
||||
scrollregion=self.devices_canvas.bbox("all"))
|
||||
@@ -365,8 +392,6 @@ class WidgetManager:
|
||||
w.bind("<MouseWheel>", _on_devices_mouse_wheel)
|
||||
w.bind("<Button-4>", _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:
|
||||
total, used, _ = shutil.disk_usage(mount_point)
|
||||
@@ -378,24 +403,20 @@ class WidgetManager:
|
||||
w.bind("<MouseWheel>", _on_devices_mouse_wheel)
|
||||
w.bind("<Button-4>", _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):
|
||||
pass
|
||||
|
||||
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.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(
|
||||
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_bar = ttk.Progressbar(
|
||||
storage_frame, orient="horizontal", length=100, mode="determinate")
|
||||
self.storage_bar.pack(fill="x", pady=(2, 5), padx=15)
|
||||
|
||||
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.content_frame, style="AccentBottom.TFrame")
|
||||
self.action_status_frame.grid(
|
||||
@@ -423,7 +444,6 @@ class WidgetManager:
|
||||
sticky="ew", pady=(4, 0))
|
||||
self.status_container.grid(row=2, column=0, columnspan=3, sticky='ew')
|
||||
|
||||
# --- Define Widgets ---
|
||||
self.search_status_label = ttk.Label(
|
||||
self.status_container, text="", style="AccentBottom.TLabel")
|
||||
self.filename_entry = ttk.Entry(self.center_container)
|
||||
@@ -478,21 +498,16 @@ class WidgetManager:
|
||||
self._layout_bottom_buttons(button_box_pos)
|
||||
|
||||
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.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_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
|
||||
|
||||
# Place main action buttons
|
||||
action_button.grid(in_=action_container, row=0, column=0, pady=(0, 5))
|
||||
self.cancel_button.grid(in_=action_container, row=1, column=0)
|
||||
|
||||
# Place settings and trash buttons
|
||||
if button_box_pos == 'left':
|
||||
self.settings_button.grid(
|
||||
in_=other_container, row=0, column=0, sticky="ne")
|
||||
@@ -506,7 +521,6 @@ class WidgetManager:
|
||||
self.trash_button.grid(
|
||||
in_=other_container, row=0, column=0, sticky="sw")
|
||||
|
||||
# Layout for the center container (filename, filter, status)
|
||||
if button_box_pos == 'left':
|
||||
self.center_container.grid_columnconfigure(0, weight=1)
|
||||
self.filter_combobox.grid(
|
||||
@@ -517,8 +531,6 @@ class WidgetManager:
|
||||
in_=self.center_container, row=1, column=1, sticky="e", pady=(5, 0))
|
||||
|
||||
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.pack(fill="both", expand=True)
|
||||
main_frame.grid_rowconfigure(2, weight=1)
|
||||
@@ -526,12 +538,10 @@ class WidgetManager:
|
||||
|
||||
self._setup_top_bar(main_frame)
|
||||
|
||||
# Horizontal separator
|
||||
separator_color = "#000000" if self.style_manager.is_dark else "#9c9c9c"
|
||||
tk.Frame(main_frame, height=1, bg=separator_color).grid(
|
||||
row=1, column=0, columnspan=2, sticky="ew")
|
||||
|
||||
# PanedWindow for resizable sidebar and content
|
||||
paned_window = ttk.PanedWindow(
|
||||
main_frame, orient=tk.HORIZONTAL, style="Sidebar.TFrame")
|
||||
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.dialog.bind("<Configure>", self.dialog.on_window_resize)
|
||||
|
||||
# --- Bottom Bar ---
|
||||
self._setup_bottom_bar()
|
||||
|
@@ -2,7 +2,7 @@ import os
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
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
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -10,6 +10,7 @@ if TYPE_CHECKING:
|
||||
from custom_file_dialog import CustomFileDialog
|
||||
|
||||
from shared_libs.common_tools import Tooltip
|
||||
from shared_libs.message import MessageDialog
|
||||
from .cfd_app_config import CfdConfigManager, LocaleStrings
|
||||
|
||||
|
||||
@@ -25,18 +26,66 @@ class ViewManager:
|
||||
"""
|
||||
self.dialog = dialog
|
||||
|
||||
def _get_file_info_list(self) -> Tuple[List[Dict], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Gets a sorted list of file information dictionaries from the current source.
|
||||
"""
|
||||
if self.dialog.current_fs_type == "sftp":
|
||||
items, error = self.dialog.sftp_manager.list_directory(self.dialog.current_dir)
|
||||
if error:
|
||||
return [], error, None
|
||||
|
||||
file_info_list = []
|
||||
import stat
|
||||
for item in items:
|
||||
if item.filename in ['.', '..']:
|
||||
continue
|
||||
is_dir = stat.S_ISDIR(item.st_mode)
|
||||
# Manually construct SFTP path to ensure forward slashes
|
||||
path = f"{self.dialog.current_dir}/{item.filename}".replace("//", "/")
|
||||
file_info_list.append({
|
||||
'name': item.filename,
|
||||
'path': path,
|
||||
'is_dir': is_dir,
|
||||
'size': item.st_size,
|
||||
'modified': item.st_mtime
|
||||
})
|
||||
return file_info_list, None, None
|
||||
|
||||
else:
|
||||
try:
|
||||
items = list(os.scandir(self.dialog.current_dir))
|
||||
num_items = len(items)
|
||||
warning_message = None
|
||||
if num_items > CfdConfigManager.MAX_ITEMS_TO_DISPLAY:
|
||||
warning_message = f"{LocaleStrings.CFD['showing']} {CfdConfigManager.MAX_ITEMS_TO_DISPLAY} {LocaleStrings.CFD['of']} {num_items} {LocaleStrings.CFD['entries']}."
|
||||
items = items[:CfdConfigManager.MAX_ITEMS_TO_DISPLAY]
|
||||
|
||||
items.sort(key=lambda e: (not e.is_dir(), e.name.lower()))
|
||||
|
||||
file_info_list = []
|
||||
for item in items:
|
||||
try:
|
||||
stat_result = item.stat()
|
||||
file_info_list.append({
|
||||
'name': item.name,
|
||||
'path': item.path,
|
||||
'is_dir': item.is_dir(),
|
||||
'size': stat_result.st_size,
|
||||
'modified': stat_result.st_mtime
|
||||
})
|
||||
except (FileNotFoundError, PermissionError):
|
||||
continue
|
||||
|
||||
return file_info_list, None, warning_message
|
||||
except PermissionError:
|
||||
return ([], LocaleStrings.CFD["access_denied"], None)
|
||||
except FileNotFoundError:
|
||||
return ([], LocaleStrings.CFD["directory_not_found"], None)
|
||||
|
||||
def populate_files(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None:
|
||||
"""
|
||||
Populates the main file display area.
|
||||
|
||||
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()
|
||||
|
||||
@@ -46,46 +95,19 @@ class ViewManager:
|
||||
self.dialog.widget_manager.path_entry.insert(
|
||||
0, self.dialog.current_dir)
|
||||
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":
|
||||
self.populate_list_view(item_to_rename, item_to_select)
|
||||
else:
|
||||
self.populate_icon_view(item_to_rename, item_to_select)
|
||||
|
||||
def _get_sorted_items(self) -> Tuple[List[str], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Gets a sorted list of items from the current directory.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple containing (list of items, error message, warning message).
|
||||
"""
|
||||
try:
|
||||
items = os.listdir(self.dialog.current_dir)
|
||||
num_items = len(items)
|
||||
warning_message = None
|
||||
if num_items > CfdConfigManager.MAX_ITEMS_TO_DISPLAY:
|
||||
warning_message = f"{LocaleStrings.CFD['showing']} {CfdConfigManager.MAX_ITEMS_TO_DISPLAY} {LocaleStrings.CFD['of']} {num_items} {LocaleStrings.CFD['entries']}."
|
||||
items = items[:CfdConfigManager.MAX_ITEMS_TO_DISPLAY]
|
||||
dirs = sorted([d for d in items if os.path.isdir(
|
||||
os.path.join(self.dialog.current_dir, d))], key=str.lower)
|
||||
files = sorted([f for f in items if not os.path.isdir(
|
||||
os.path.join(self.dialog.current_dir, f))], key=str.lower)
|
||||
return (dirs + files, None, warning_message)
|
||||
except PermissionError:
|
||||
return ([], LocaleStrings.CFD["access_denied"], None)
|
||||
except FileNotFoundError:
|
||||
return ([], LocaleStrings.CFD["directory_not_found"], None)
|
||||
|
||||
def _get_folder_content_count(self, folder_path: str) -> Optional[int]:
|
||||
"""
|
||||
Counts the number of items in a given folder.
|
||||
|
||||
Args:
|
||||
folder_path (str): The path to the folder.
|
||||
|
||||
Returns:
|
||||
int or None: The number of items, or None if an error occurs.
|
||||
"""
|
||||
if self.dialog.current_fs_type == "sftp":
|
||||
return None
|
||||
try:
|
||||
if not os.path.isdir(folder_path) or not os.access(folder_path, os.R_OK):
|
||||
return None
|
||||
@@ -102,17 +124,21 @@ class ViewManager:
|
||||
def _get_item_path_from_widget(self, widget: tk.Widget) -> Optional[str]:
|
||||
"""
|
||||
Traverses up the widget hierarchy to find the item_path attribute.
|
||||
|
||||
Args:
|
||||
widget: The widget to start from.
|
||||
|
||||
Returns:
|
||||
str or None: The associated file path, or None if not found.
|
||||
"""
|
||||
while widget and not hasattr(widget, 'item_path'):
|
||||
widget = widget.master
|
||||
return getattr(widget, 'item_path', None)
|
||||
|
||||
def _is_dir(self, path: str) -> bool:
|
||||
"""Checks if a given path is a directory, supporting both local and SFTP."""
|
||||
if self.dialog.current_fs_type == 'sftp':
|
||||
for item in self.dialog.all_items:
|
||||
if item['path'] == path:
|
||||
return item['is_dir']
|
||||
return False
|
||||
else:
|
||||
return os.path.isdir(path)
|
||||
|
||||
def _handle_icon_click(self, event: tk.Event) -> None:
|
||||
"""Handles a single click on an icon view item."""
|
||||
item_path = self._get_item_path_from_widget(event.widget)
|
||||
@@ -120,7 +146,7 @@ class ViewManager:
|
||||
item_frame = event.widget
|
||||
while not hasattr(item_frame, 'item_path'):
|
||||
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:
|
||||
"""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:
|
||||
"""
|
||||
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.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]:
|
||||
"""
|
||||
Loads a batch of items into the icon view.
|
||||
|
||||
Args:
|
||||
container: The parent widget for the items.
|
||||
scroll_handler: The function to handle mouse wheel events.
|
||||
item_to_rename (str, optional): Item to enter rename mode.
|
||||
item_to_select (str, optional): Item to select.
|
||||
|
||||
Returns:
|
||||
The widget that was focused (renamed or selected), or None.
|
||||
"""
|
||||
start_index = self.dialog.currently_loaded_count
|
||||
end_index = min(len(self.dialog.all_items), start_index +
|
||||
@@ -250,11 +263,13 @@ class ViewManager:
|
||||
widget_to_focus = None
|
||||
|
||||
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('.'):
|
||||
continue
|
||||
path = os.path.join(self.dialog.current_dir, name)
|
||||
is_dir = os.path.isdir(path)
|
||||
if not is_dir and not self.dialog._matches_filetype(name):
|
||||
continue
|
||||
|
||||
@@ -283,7 +298,7 @@ class ViewManager:
|
||||
widget.bind("<Double-Button-1>", lambda e,
|
||||
p=path: self._handle_item_double_click(p))
|
||||
widget.bind("<Button-1>", lambda e, p=path,
|
||||
f=item_frame: self.on_item_select(p, f))
|
||||
f=item_frame: self.on_item_select(p, f, e))
|
||||
widget.bind("<ButtonRelease-3>", lambda e,
|
||||
p=path: self.dialog.file_op_manager._show_context_menu(e, p))
|
||||
widget.bind("<F2>", lambda e, p=path,
|
||||
@@ -309,12 +324,8 @@ class ViewManager:
|
||||
def populate_list_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None:
|
||||
"""
|
||||
Populates the file display with items in a list (Treeview) layout.
|
||||
|
||||
Args:
|
||||
item_to_rename (str, optional): Item to enter rename mode.
|
||||
item_to_select (str, optional): Item to select.
|
||||
"""
|
||||
self.dialog.all_items, error, warning = self._get_sorted_items()
|
||||
self.dialog.all_items, error, warning = self._get_file_info_list()
|
||||
self.dialog.currently_loaded_count = 0
|
||||
|
||||
tree_frame = ttk.Frame(self.dialog.widget_manager.file_list_frame)
|
||||
@@ -326,6 +337,9 @@ class ViewManager:
|
||||
self.dialog.tree = ttk.Treeview(
|
||||
tree_frame, columns=columns, show="tree headings")
|
||||
|
||||
if self.dialog.dialog_mode == 'multi':
|
||||
self.dialog.tree.config(selectmode="extended")
|
||||
|
||||
self.dialog.tree.heading(
|
||||
"#0", text=LocaleStrings.VIEW["name"], anchor="w")
|
||||
self.dialog.tree.column("#0", anchor="w", width=250, stretch=True)
|
||||
@@ -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:
|
||||
"""
|
||||
Loads a batch of items into the list view.
|
||||
|
||||
Args:
|
||||
item_to_rename (str, optional): Item to enter rename mode.
|
||||
item_to_select (str, optional): Item to select.
|
||||
|
||||
Returns:
|
||||
bool: True if the item to rename/select was found and processed.
|
||||
"""
|
||||
start_index = self.dialog.currently_loaded_count
|
||||
end_index = min(len(self.dialog.all_items), start_index +
|
||||
@@ -398,25 +405,28 @@ class ViewManager:
|
||||
|
||||
item_found = False
|
||||
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('.'):
|
||||
continue
|
||||
path = os.path.join(self.dialog.current_dir, name)
|
||||
is_dir = os.path.isdir(path)
|
||||
if not is_dir and not self.dialog._matches_filetype(name):
|
||||
continue
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
modified_time = datetime.fromtimestamp(
|
||||
stat.st_mtime).strftime('%d.%m.%Y %H:%M')
|
||||
file_info['modified']).strftime('%d.%m.%Y %H:%M')
|
||||
if is_dir:
|
||||
icon, file_type, size = self.dialog.icon_manager.get_icon(
|
||||
'folder_small'), LocaleStrings.FILE["folder"], ""
|
||||
else:
|
||||
icon, file_type, size = self.dialog.get_file_icon(
|
||||
name, 'small'), LocaleStrings.FILE["file"], self.dialog._format_size(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=(
|
||||
size, file_type, modified_time))
|
||||
self.dialog.item_path_map[item_id] = path # Store path for later retrieval
|
||||
|
||||
if name == item_to_rename:
|
||||
self.dialog.tree.selection_set(item_id)
|
||||
self.dialog.tree.focus(item_id)
|
||||
@@ -434,14 +444,48 @@ class ViewManager:
|
||||
self.dialog.currently_loaded_count = end_index
|
||||
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.
|
||||
|
||||
Args:
|
||||
path (str): The path of the selected item.
|
||||
item_frame: The widget frame of the selected item.
|
||||
"""
|
||||
if self.dialog.dialog_mode == 'dir' and not self._is_dir(path):
|
||||
return
|
||||
|
||||
if self.dialog.dialog_mode == 'multi':
|
||||
ctrl_pressed = (event.state & 0x4) != 0 if event else False
|
||||
|
||||
if ctrl_pressed:
|
||||
if item_frame in self.dialog.selected_item_frames:
|
||||
item_frame.state(['!selected'])
|
||||
for child in item_frame.winfo_children():
|
||||
if isinstance(child, ttk.Label):
|
||||
child.state(['!selected'])
|
||||
self.dialog.selected_item_frames.remove(item_frame)
|
||||
else:
|
||||
item_frame.state(['selected'])
|
||||
for child in item_frame.winfo_children():
|
||||
if isinstance(child, ttk.Label):
|
||||
child.state(['selected'])
|
||||
self.dialog.selected_item_frames.append(item_frame)
|
||||
else:
|
||||
for f in self.dialog.selected_item_frames:
|
||||
f.state(['!selected'])
|
||||
for child in f.winfo_children():
|
||||
if isinstance(child, ttk.Label):
|
||||
child.state(['!selected'])
|
||||
self.dialog.selected_item_frames.clear()
|
||||
|
||||
item_frame.state(['selected'])
|
||||
for child in item_frame.winfo_children():
|
||||
if isinstance(child, ttk.Label):
|
||||
child.state(['selected'])
|
||||
self.dialog.selected_item_frames.append(item_frame)
|
||||
|
||||
selected_paths = [frame.item_path for frame in self.dialog.selected_item_frames]
|
||||
self.dialog.result = selected_paths
|
||||
self.dialog.update_selection_info()
|
||||
|
||||
else: # Single selection mode
|
||||
if hasattr(self.dialog, 'selected_item_frame') and self.dialog.selected_item_frame.winfo_exists():
|
||||
self.dialog.selected_item_frame.state(['!selected'])
|
||||
for child in self.dialog.selected_item_frame.winfo_children():
|
||||
@@ -452,52 +496,106 @@ class ViewManager:
|
||||
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.update_selection_info(path)
|
||||
|
||||
self.dialog.search_manager.show_search_ready()
|
||||
if not os.path.isdir(path):
|
||||
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
|
||||
self.dialog.widget_manager.filename_entry.insert(
|
||||
0, os.path.basename(path))
|
||||
|
||||
def on_list_select(self, event: tk.Event) -> None:
|
||||
"""Handles the selection of an item in the list view."""
|
||||
if not self.dialog.tree.selection():
|
||||
"""
|
||||
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
|
||||
item_id = self.dialog.tree.selection()[0]
|
||||
item_text = self.dialog.tree.item(item_id, 'text').strip()
|
||||
path = os.path.join(self.dialog.current_dir, item_text)
|
||||
self.dialog.selected_file = path
|
||||
self.dialog.update_status_bar(path)
|
||||
self.dialog.search_manager.show_search_ready()
|
||||
if not os.path.isdir(self.dialog.selected_file):
|
||||
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
|
||||
self.dialog.widget_manager.filename_entry.insert(0, item_text)
|
||||
|
||||
if self.dialog.dialog_mode == 'multi':
|
||||
selected_paths = []
|
||||
for item_id in selections:
|
||||
path = self.dialog.item_path_map.get(item_id)
|
||||
if path:
|
||||
selected_paths.append(path)
|
||||
self.dialog.result = selected_paths
|
||||
self.dialog.update_selection_info()
|
||||
else:
|
||||
item_id = selections[0]
|
||||
path = self.dialog.item_path_map.get(item_id)
|
||||
if not path:
|
||||
return
|
||||
|
||||
if self.dialog.dialog_mode == 'dir' and not self._is_dir(path):
|
||||
self.dialog.result = None
|
||||
self.dialog.tree.selection_remove(item_id)
|
||||
self.dialog.update_selection_info()
|
||||
return
|
||||
|
||||
self.dialog.update_selection_info(path)
|
||||
|
||||
def on_list_context_menu(self, event: tk.Event) -> str:
|
||||
"""Shows the context menu for a list view item."""
|
||||
"""
|
||||
Shows the context menu for a list view item.
|
||||
"""
|
||||
iid = self.dialog.tree.identify_row(event.y)
|
||||
if not iid:
|
||||
return "break"
|
||||
self.dialog.tree.selection_set(iid)
|
||||
item_text = self.dialog.tree.item(iid, "text").strip()
|
||||
item_path = os.path.join(self.dialog.current_dir, item_text)
|
||||
self.dialog.file_op_manager._show_context_menu(event, item_path)
|
||||
path = self.dialog.item_path_map.get(iid)
|
||||
if path:
|
||||
self.dialog.file_op_manager._show_context_menu(event, path)
|
||||
return "break"
|
||||
|
||||
def _handle_item_double_click(self, path: str) -> None:
|
||||
"""
|
||||
Handles the logic for a double-click on any item, regardless of view.
|
||||
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):
|
||||
if self.dialog.dialog_mode == 'dir':
|
||||
has_subdirs = False
|
||||
try:
|
||||
if self.dialog.current_fs_type == "sftp":
|
||||
import stat
|
||||
items, _ = self.dialog.sftp_manager.list_directory(path)
|
||||
for item in items:
|
||||
if item.filename not in ['.', '..'] and stat.S_ISDIR(item.st_mode):
|
||||
has_subdirs = True
|
||||
break
|
||||
else:
|
||||
for item in os.listdir(path):
|
||||
if os.path.isdir(os.path.join(path, item)) and not item.startswith('.'):
|
||||
has_subdirs = True
|
||||
break
|
||||
except OSError:
|
||||
self.dialog.navigation_manager.navigate_to(path)
|
||||
elif self.dialog.dialog_mode == "open":
|
||||
self.dialog.selected_file = path
|
||||
self.dialog.destroy()
|
||||
return
|
||||
|
||||
if has_subdirs:
|
||||
self.dialog.navigation_manager.navigate_to(path)
|
||||
else:
|
||||
dialog = MessageDialog(
|
||||
master=self.dialog,
|
||||
message_type="ask",
|
||||
title=LocaleStrings.CFD["select_or_enter_title"],
|
||||
text=LocaleStrings.CFD["select_or_enter_prompt"].format(folder_name=os.path.basename(path)),
|
||||
buttons=[
|
||||
LocaleStrings.CFD["select_button"],
|
||||
LocaleStrings.CFD["enter_button"],
|
||||
LocaleStrings.CFD["cancel_button"],
|
||||
]
|
||||
)
|
||||
choice = dialog.show()
|
||||
|
||||
if choice is True:
|
||||
self.dialog.result = path
|
||||
self.dialog.on_open()
|
||||
elif choice is False:
|
||||
self.dialog.navigation_manager.navigate_to(path)
|
||||
else:
|
||||
self.dialog.navigation_manager.navigate_to(path)
|
||||
|
||||
elif self.dialog.dialog_mode in ["open", "multi"]:
|
||||
self.dialog.result = path
|
||||
self.dialog.on_open()
|
||||
elif self.dialog.dialog_mode == "save":
|
||||
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
|
||||
self.dialog.widget_manager.filename_entry.insert(
|
||||
@@ -505,24 +603,24 @@ class ViewManager:
|
||||
self.dialog.on_save()
|
||||
|
||||
def on_list_double_click(self, event: tk.Event) -> None:
|
||||
"""Handles a double-click on a list view item."""
|
||||
if not self.dialog.tree.selection():
|
||||
"""
|
||||
Handles a double-click on a list view item.
|
||||
"""
|
||||
selection = self.dialog.tree.selection()
|
||||
if not selection:
|
||||
return
|
||||
item_id = self.dialog.tree.selection()[0]
|
||||
item_text = self.dialog.tree.item(item_id, 'text').strip()
|
||||
path = os.path.join(self.dialog.current_dir, item_text)
|
||||
item_id = selection[0]
|
||||
path = self.dialog.item_path_map.get(item_id)
|
||||
if path:
|
||||
self._handle_item_double_click(path)
|
||||
|
||||
def _select_file_in_view(self, filename: str) -> None:
|
||||
"""
|
||||
Programmatically selects a file in the current view.
|
||||
|
||||
Args:
|
||||
filename (str): The name of the file to select.
|
||||
"""
|
||||
if self.dialog.view_mode.get() == "list":
|
||||
for item_id in self.dialog.tree.get_children():
|
||||
if self.dialog.tree.item(item_id, "text").strip() == filename:
|
||||
for item_id, path in self.dialog.item_path_map.items():
|
||||
if os.path.basename(path) == filename:
|
||||
self.dialog.tree.selection_set(item_id)
|
||||
self.dialog.tree.focus(item_id)
|
||||
self.dialog.tree.see(item_id)
|
||||
|
@@ -4,8 +4,13 @@ import tkinter as tk
|
||||
import subprocess
|
||||
import json
|
||||
import threading
|
||||
import webbrowser
|
||||
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_ui_setup import StyleManager, WidgetManager
|
||||
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_navigation_manager import NavigationManager
|
||||
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):
|
||||
@@ -22,35 +29,23 @@ class CustomFileDialog(tk.Toplevel):
|
||||
directory navigation, search, and file operations.
|
||||
"""
|
||||
|
||||
def _initialize_managers(self) -> None:
|
||||
"""Initializes or re-initializes all the manager classes."""
|
||||
self.style_manager: StyleManager = StyleManager(self)
|
||||
self.file_op_manager: FileOperationsManager = FileOperationsManager(self)
|
||||
self.search_manager: SearchManager = SearchManager(self)
|
||||
self.navigation_manager: NavigationManager = NavigationManager(self)
|
||||
self.view_manager: ViewManager = ViewManager(self)
|
||||
self.widget_manager: WidgetManager = WidgetManager(self, self.settings)
|
||||
|
||||
def __init__(self, parent: tk.Widget, initial_dir: Optional[str] = None,
|
||||
filetypes: Optional[List[Tuple[str, str]]] = None,
|
||||
mode: str = "open", title: str = LocaleStrings.CFD["title"]):
|
||||
"""
|
||||
Initializes the CustomFileDialog.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
initial_dir: The initial directory to display.
|
||||
filetypes: A list of filetype tuples, e.g., [('Text files', '*.txt')].
|
||||
mode: The dialog mode. Can be "open" (select single file),
|
||||
"save" (select single file for saving),
|
||||
"multi" (select multiple files/directories),
|
||||
or "dir" (select a single directory).
|
||||
title: The title of the dialog window.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.current_fs_type = "local" # "local" or "sftp"
|
||||
|
||||
self.sftp_manager = SFTPManager()
|
||||
self.config_manager = CfdConfigManager()
|
||||
|
||||
self.my_tool_tip: Optional[Tooltip] = None
|
||||
self.dialog_mode: str = mode
|
||||
self.gitea_api_url = CfdConfigManager.UPDATE_URL
|
||||
self.lib_version = CfdConfigManager.VERSION
|
||||
self.update_status: str = ""
|
||||
|
||||
self.load_settings()
|
||||
|
||||
@@ -81,6 +76,7 @@ class CustomFileDialog(tk.Toplevel):
|
||||
self.show_hidden_files: tk.BooleanVar = tk.BooleanVar(value=False)
|
||||
self.resize_job: Optional[str] = None
|
||||
self.last_width: int = 0
|
||||
self.selected_item_frames: List[ttk.Frame] = []
|
||||
self.search_results: List[str] = []
|
||||
self.search_mode: bool = False
|
||||
self.original_path_text: str = ""
|
||||
@@ -113,11 +109,28 @@ class CustomFileDialog(tk.Toplevel):
|
||||
self.widget_manager.path_entry.bind(
|
||||
"<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)
|
||||
|
||||
if self.dialog_mode == "save":
|
||||
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:
|
||||
"""Loads settings from the configuration file."""
|
||||
self.settings = CfdConfigManager.load()
|
||||
@@ -129,12 +142,6 @@ class CustomFileDialog(tk.Toplevel):
|
||||
def get_min_size_from_preset(self, preset: str) -> Tuple[int, int]:
|
||||
"""
|
||||
Calculates the minimum window size based on a preset string.
|
||||
|
||||
Args:
|
||||
preset: The size preset string (e.g., "1050x850").
|
||||
|
||||
Returns:
|
||||
A tuple containing the minimum width and height.
|
||||
"""
|
||||
w, h = map(int, preset.split('x'))
|
||||
return max(650, w - 400), max(450, h - 400)
|
||||
@@ -175,6 +182,89 @@ class CustomFileDialog(tk.Toplevel):
|
||||
"""Opens the settings dialog."""
|
||||
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:
|
||||
"""Updates the search animation icon based on current settings."""
|
||||
use_pillow = self.settings.get('use_pillow_animation', False)
|
||||
@@ -207,16 +297,57 @@ class CustomFileDialog(tk.Toplevel):
|
||||
if is_running:
|
||||
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:
|
||||
"""
|
||||
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()
|
||||
|
||||
@@ -240,9 +371,6 @@ class CustomFileDialog(tk.Toplevel):
|
||||
def on_window_resize(self, event: tk.Event) -> None:
|
||||
"""
|
||||
Handles the window resize event.
|
||||
|
||||
Args:
|
||||
event: The event object.
|
||||
"""
|
||||
if event.widget is self:
|
||||
if self.view_mode.get() == "icons" and not self.search_mode:
|
||||
@@ -264,9 +392,6 @@ class CustomFileDialog(tk.Toplevel):
|
||||
def _handle_responsive_buttons(self, window_width: int) -> None:
|
||||
"""
|
||||
Shows or hides buttons based on the window width.
|
||||
|
||||
Args:
|
||||
window_width: The current width of the window.
|
||||
"""
|
||||
threshold = 850
|
||||
container = self.widget_manager.responsive_buttons_container
|
||||
@@ -317,9 +442,6 @@ class CustomFileDialog(tk.Toplevel):
|
||||
def on_sidebar_resize(self, event: tk.Event) -> None:
|
||||
"""
|
||||
Handles the sidebar resize event, adjusting button text visibility.
|
||||
|
||||
Args:
|
||||
event: The event object.
|
||||
"""
|
||||
current_width = event.width
|
||||
threshold_width = 100
|
||||
@@ -338,9 +460,6 @@ class CustomFileDialog(tk.Toplevel):
|
||||
def _on_devices_enter(self, event: tk.Event) -> None:
|
||||
"""
|
||||
Shows the scrollbar when the mouse enters the devices area.
|
||||
|
||||
Args:
|
||||
event: The event object.
|
||||
"""
|
||||
self.widget_manager.devices_scrollbar.grid(
|
||||
row=1, column=1, sticky="ns")
|
||||
@@ -348,9 +467,6 @@ class CustomFileDialog(tk.Toplevel):
|
||||
def _on_devices_leave(self, event: tk.Event) -> None:
|
||||
"""
|
||||
Hides the scrollbar when the mouse leaves the devices area.
|
||||
|
||||
Args:
|
||||
event: The event object.
|
||||
"""
|
||||
x, y = event.x_root, event.y_root
|
||||
widget_x = self.widget_manager.devices_canvas.winfo_rootx()
|
||||
@@ -375,32 +491,82 @@ class CustomFileDialog(tk.Toplevel):
|
||||
self.widget_manager.recursive_button.configure(
|
||||
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:
|
||||
status_info: The path of the currently selected item or a custom string.
|
||||
"""
|
||||
if self.dialog_mode == 'multi':
|
||||
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:
|
||||
total, used, free = shutil.disk_usage(self.current_dir)
|
||||
free_str = self._format_size(free)
|
||||
self.widget_manager.storage_label.config(
|
||||
text=f"{LocaleStrings.CFD['free_space']}: {free_str}")
|
||||
self.widget_manager.storage_bar['value'] = (used / total) * 100
|
||||
|
||||
status_text = ""
|
||||
if status_info and os.path.exists(status_info):
|
||||
selected_path = status_info
|
||||
if os.path.isdir(selected_path):
|
||||
content_count = self.view_manager._get_folder_content_count(
|
||||
selected_path)
|
||||
if content_count is not None:
|
||||
status_text = f
|
||||
except FileNotFoundError:
|
||||
self.widget_manager.storage_label.config(
|
||||
text=f"{LocaleStrings.CFD['free_space']}: {LocaleStrings.CFD['unknown']}")
|
||||
self.widget_manager.storage_bar['value'] = 0
|
||||
self.widget_manager.search_status_label.config(
|
||||
text=LocaleStrings.CFD["directory_not_found"])
|
||||
|
||||
def on_open(self) -> None:
|
||||
"""Handles the 'Open' action, closing the dialog if a file is selected."""
|
||||
if self.result and isinstance(self.result, str) and os.path.isfile(self.result):
|
||||
"""Handles the 'Open' or 'OK' action based on the dialog mode."""
|
||||
if self.dialog_mode == 'multi':
|
||||
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:
|
||||
@@ -416,32 +582,42 @@ class CustomFileDialog(tk.Toplevel):
|
||||
self.destroy()
|
||||
|
||||
def get_result(self) -> Optional[Union[str, List[str]]]:
|
||||
"""
|
||||
Returns the result of the dialog.
|
||||
|
||||
Returns:
|
||||
- A string containing a single path for modes 'open', 'save', 'dir'.
|
||||
- A list of strings for mode 'multi'.
|
||||
- None if the dialog was cancelled.
|
||||
"""
|
||||
"""Returns the result of the dialog."""
|
||||
return self.result
|
||||
|
||||
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."""
|
||||
new_folder_state = tk.DISABLED
|
||||
new_file_state = tk.DISABLED
|
||||
trash_state = tk.DISABLED
|
||||
|
||||
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)
|
||||
state = tk.NORMAL if is_writable and self.dialog_mode != "open" else tk.DISABLED
|
||||
self.widget_manager.new_folder_button.config(state=state)
|
||||
self.widget_manager.new_file_button.config(state=state)
|
||||
|
||||
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:
|
||||
"""
|
||||
Checks if a filename matches the current filetype filter.
|
||||
|
||||
Args:
|
||||
filename: The name of the file to check.
|
||||
|
||||
Returns:
|
||||
True if the file matches the filter, False otherwise.
|
||||
"""
|
||||
if self.current_filter_pattern == "*.*":
|
||||
return True
|
||||
@@ -464,12 +640,6 @@ class CustomFileDialog(tk.Toplevel):
|
||||
def _format_size(self, size_bytes: Optional[int]) -> str:
|
||||
"""
|
||||
Formats a size in bytes into a human-readable string (KB, MB, GB).
|
||||
|
||||
Args:
|
||||
size_bytes: The size in bytes.
|
||||
|
||||
Returns:
|
||||
A formatted string representing the size.
|
||||
"""
|
||||
if size_bytes is None:
|
||||
return ""
|
||||
@@ -484,23 +654,12 @@ class CustomFileDialog(tk.Toplevel):
|
||||
def shorten_text(self, text: str, max_len: int) -> str:
|
||||
"""
|
||||
Shortens a string to a maximum length, adding '...' if truncated.
|
||||
|
||||
Args:
|
||||
text: The text to shorten.
|
||||
max_len: The maximum allowed length.
|
||||
|
||||
Returns:
|
||||
The shortened text.
|
||||
"""
|
||||
return text if len(text) <= max_len else text[:max_len-3] + "..."
|
||||
|
||||
def _get_mounted_devices(self) -> List[Tuple[str, str, bool]]:
|
||||
"""
|
||||
Retrieves a list of mounted devices on the system.
|
||||
|
||||
Returns:
|
||||
A list of tuples, where each tuple contains the display name,
|
||||
mount point, and a boolean indicating if it's removable.
|
||||
"""
|
||||
devices: List[Tuple[str, str, bool]] = []
|
||||
root_disk_name: Optional[str] = None
|
||||
@@ -519,7 +678,8 @@ class CustomFileDialog(tk.Toplevel):
|
||||
break
|
||||
|
||||
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('mountpoint') != '/'):
|
||||
|
||||
@@ -536,7 +696,8 @@ class CustomFileDialog(tk.Toplevel):
|
||||
|
||||
if 'children' in block_device:
|
||||
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('mountpoint') != '/'):
|
||||
|
||||
|
426
message.py
426
message.py
@@ -12,95 +12,7 @@ except (ModuleNotFoundError, NameError):
|
||||
class MessageDialog:
|
||||
"""
|
||||
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__(
|
||||
self,
|
||||
message_type: str = "info",
|
||||
@@ -113,23 +25,20 @@ class MessageDialog:
|
||||
font: tuple = 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.buttons = buttons
|
||||
self.master = master
|
||||
self.result: bool = False # Default is False
|
||||
self.result: bool = False
|
||||
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
# Window creation
|
||||
self.window = tk.Toplevel(master)
|
||||
self.window.grab_set()
|
||||
self.window.resizable(False, False)
|
||||
ttk.Style().configure("TButton")
|
||||
self.buttons_widgets = []
|
||||
self.current_button_index = 0
|
||||
|
||||
# Load icons using IconManager
|
||||
icon_manager = IconManager()
|
||||
self.icons = {
|
||||
"error": icon_manager.get_icon("error_extralarge"),
|
||||
@@ -138,36 +47,27 @@ class MessageDialog:
|
||||
"ask": icon_manager.get_icon("question_mark_extralarge"),
|
||||
}
|
||||
|
||||
# Handle custom icon override
|
||||
if self.icon:
|
||||
if isinstance(self.icon, str) and os.path.exists(self.icon):
|
||||
# If it's a path, load it
|
||||
try:
|
||||
self.icons[self.message_type] = tk.PhotoImage(
|
||||
file=self.icon)
|
||||
self.icons[self.message_type] = tk.PhotoImage(file=self.icon)
|
||||
except tk.TclError as e:
|
||||
print(
|
||||
f"Error loading custom icon from path '{self.icon}': {e}")
|
||||
print(f"Error loading custom icon from path '{self.icon}': {e}")
|
||||
elif isinstance(self.icon, tk.PhotoImage):
|
||||
# If it's already a PhotoImage, use it directly
|
||||
self.icons[self.message_type] = self.icon
|
||||
|
||||
# Window title and icon
|
||||
self.window.title(self._get_title() if not self.title else self.title)
|
||||
window_icon = self.icons.get(self.message_type)
|
||||
if window_icon:
|
||||
self.window.iconphoto(False, window_icon)
|
||||
|
||||
# Layout
|
||||
frame = ttk.Frame(self.window)
|
||||
frame.pack(expand=True, fill="both", padx=15, pady=8)
|
||||
|
||||
# Grid-Configuration
|
||||
frame.grid_rowconfigure(0, weight=1)
|
||||
frame.grid_columnconfigure(0, weight=1)
|
||||
frame.grid_columnconfigure(1, weight=3)
|
||||
|
||||
# Icon and Text
|
||||
icon_label = ttk.Label(frame, image=self.icons.get(self.message_type))
|
||||
pady_value = 5 if self.icon is not None else 15
|
||||
icon_label.grid(
|
||||
@@ -183,118 +83,280 @@ class MessageDialog:
|
||||
font=font if font else ("Helvetica", 12),
|
||||
pady=20,
|
||||
)
|
||||
text_label.grid(
|
||||
row=0,
|
||||
column=1,
|
||||
padx=(10, 20),
|
||||
sticky="nsew",
|
||||
)
|
||||
text_label.grid(row=0, column=1, padx=(10, 20), sticky="nsew")
|
||||
|
||||
# Create button frame
|
||||
self.button_frame = ttk.Frame(frame)
|
||||
self.button_frame.grid(row=1, columnspan=2, pady=(8, 10))
|
||||
|
||||
for i, btn_text in enumerate(buttons):
|
||||
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:
|
||||
# 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(
|
||||
buttons) == 2 else 10
|
||||
btn.pack(side="left" if i == 0 else "right",
|
||||
padx=padx_value, pady=5)
|
||||
btn.focus_set() if i == 0 else None # Set focus on first button
|
||||
padx_value = 50 if self.icon is not None and len(buttons) == 2 else 10
|
||||
btn.pack(side="left" if i == 0 else "right", padx=padx_value, pady=5)
|
||||
if i == 0: btn.focus_set()
|
||||
self.buttons_widgets.append(btn)
|
||||
|
||||
self.window.bind("<Return>", lambda event: self._on_enter_pressed())
|
||||
self.window.bind("<Left>", lambda event: self._navigate_left())
|
||||
self.window.bind("<Right>", lambda event: self._navigate_right())
|
||||
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.update() # Window update before centering!
|
||||
LxTools.center_window_cross_platform(
|
||||
self.window, self.window.winfo_width(), self.window.winfo_height()
|
||||
)
|
||||
|
||||
# Close Window on Cancel
|
||||
self.window.protocol(
|
||||
"WM_DELETE_WINDOW", lambda: self._on_button_click("Cancel")
|
||||
)
|
||||
self.window.update()
|
||||
LxTools.center_window_cross_platform(self.window, self.window.winfo_width(), self.window.winfo_height())
|
||||
self.window.protocol("WM_DELETE_WINDOW", lambda: self._on_button_click("Cancel"))
|
||||
|
||||
def _get_title(self) -> str:
|
||||
return {
|
||||
"error": "Error",
|
||||
"info": "Info",
|
||||
"ask": "Question",
|
||||
"warning": "Warning",
|
||||
}[self.message_type]
|
||||
return {"error": "Error", "info": "Info", "ask": "Question", "warning": "Warning"}[self.message_type]
|
||||
|
||||
def _navigate_left(self):
|
||||
if not self.buttons_widgets:
|
||||
return
|
||||
self.current_button_index = (self.current_button_index - 1) % len(
|
||||
self.buttons_widgets
|
||||
)
|
||||
if not self.buttons_widgets: return
|
||||
self.current_button_index = (self.current_button_index - 1) % len(self.buttons_widgets)
|
||||
self.buttons_widgets[self.current_button_index].focus_set()
|
||||
|
||||
def _navigate_right(self):
|
||||
if not self.buttons_widgets:
|
||||
return
|
||||
self.current_button_index = (self.current_button_index + 1) % len(
|
||||
self.buttons_widgets
|
||||
)
|
||||
if not self.buttons_widgets: return
|
||||
self.current_button_index = (self.current_button_index + 1) % len(self.buttons_widgets)
|
||||
self.buttons_widgets[self.current_button_index].focus_set()
|
||||
|
||||
def _on_enter_pressed(self):
|
||||
focused = self.window.focus_get()
|
||||
if isinstance(focused, ttk.Button):
|
||||
focused.invoke()
|
||||
if isinstance(focused, ttk.Button): focused.invoke()
|
||||
|
||||
def _on_button_click(self, button_text: str) -> None:
|
||||
"""
|
||||
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",
|
||||
]:
|
||||
if len(self.buttons) >= 3 and button_text.lower() in ["cancel", "abort", "exit"]:
|
||||
self.result = None
|
||||
# Check: Button text is "Yes", "Ok", "Continue", "Next", or "Start"
|
||||
elif button_text.lower() in ["yes", "ok", "continue", "next", "start"]:
|
||||
elif button_text.lower() in ["yes", "ok", "continue", "next", "start", "select"]:
|
||||
self.result = True
|
||||
else:
|
||||
# Fallback for all other cases (e.g., "No", closing with X, or fewer than 3 buttons)
|
||||
self.result = False
|
||||
|
||||
self.window.destroy()
|
||||
|
||||
def show(self) -> Optional[bool]:
|
||||
"""
|
||||
Displays the dialog window and waits for user interaction.
|
||||
|
||||
Returns:
|
||||
bool or None:
|
||||
- `True` if "Yes", "Ok", etc. was clicked.
|
||||
- `False` if "No" was clicked, or the window was closed with X (when there are 2 buttons).
|
||||
- `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).
|
||||
"""
|
||||
self.window.wait_window()
|
||||
return self.result
|
||||
|
||||
|
||||
class CredentialsDialog:
|
||||
"""
|
||||
A dialog for securely entering SSH/SFTP credentials.
|
||||
"""
|
||||
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()
|
||||
return self.result
|
||||
|
Reference in New Issue
Block a user