142 Commits

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

 - Fixes multi and dir mode in custom_file_dialog

 - Add "select" in MessageDialog on list for Button and add grab_set()
   after update_idletasks() to fix Error Traceback
2025-08-14 12:59:07 +02:00
ff1aede356 fix multiselect on icon view in mode="multi" 2025-08-14 00:44:02 +02:00
f565132074 feat(cfd): Implementiert Multi-Auswahl in der Icon-Ansicht 2025-08-14 00:39:35 +02:00
d548b545e3 add update url in cfd_app_config 2025-08-14 00:27:42 +02:00
66202310ec fix(cfd): Behebt UnboundLocalError beim UI-Aufbau 2025-08-14 00:11:58 +02:00
d79e4c9e01 refactor(cfd): Entkoppelt die Update-Konfiguration und bereinigt Imports 2025-08-14 00:06:34 +02:00
0ef94de077 feat(cfd): Integriert Gitea-Update-Prüfung für die Bibliothek 2025-08-13 23:48:37 +02:00
fbc3c8e051 feat(cfd): Implementiert 'dir' Modus und vervollständigt 'multi' Modus 2025-08-13 23:45:17 +02:00
1d05f5088f feat(cfd): Implementiert multi-select Modus für die Listenansicht 2025-08-13 23:41:22 +02:00
2c2163b936 refactor(cfd): Bereitet den Dialog für neue Modi (multi, dir) vor 2025-08-13 23:39:11 +02:00
16937faf91 feat(cfd): Zeigt DISABLE-Status bei ergebnisloser Suche an 2025-08-13 23:08:05 +02:00
24e8ed1dff fix(cfd): Verhindert das Verschwinden des Such-Icons bei Klick ohne Suchbegriff 2025-08-13 23:06:50 +02:00
c1fe8b62e1 fix(animated_icon): Behebt Fehler bei der Wiederherstellung des DISABLE-Status 2025-08-13 23:05:04 +02:00
c5626073c9 fix(menu_bar): Korrigiert den Tooltip für den Update-Button 2025-08-13 23:03:13 +02:00
0d3eff772e fixes and update on animated icon contexmanager gitea and menu_bar 2025-08-13 22:21:03 +02:00
864ad63bf8 new Gitea handling 2025-08-13 21:15:22 +02:00
eb893d197a Fix: Resolve TclError by managing animated icon clickability via bindings
- Replaced direct 'state' configuration on ttk.Frame (animated_icon_frame)
  with dynamic binding/unbinding of the '<Button-1>' event.
- This resolves the '_tkinter.TclError: unknown option "-state"' as ttk.Frame
  does not support the 'state' option.
- Ensures the animated icon is only clickable when an update is available,
  improving UI behavior and error handling.
2025-08-13 19:56:55 +02:00
b44c7b96d3 Fix: Improve update button and animation behavior in MenuBar
- Ensured the update toggle button remains active regardless of update status.
- Implemented state management for the animated update icon, making it clickable
  only when a new version is available.
- Corrected tooltip synchronization with the animated icon's state.
- Removed redundant update status check in the updater method, as UI state
  now controls interaction.
2025-08-13 19:52:51 +02:00
13f5f1f4fd Docs: Update MenuBar docstrings and remove unused comments
- Updated docstrings for __init__ and update_ui_for_update to reflect
  recent changes in arguments and logic, improving code documentation.
- Removed an unused commented-out line in the __init__ method signature.
2025-08-13 18:36:14 +02:00
fb794538d8 Refactor: Streamline MenuBar arguments and remove redundancy
- Replaced 'app_config' with 'app_version' to directly pass the application version,
  removing the need for 'app_config' as a whole.
- Reverted 'tooltips' argument and instead use 'msg_config' directly,
  aligning with existing project structure where 'Msg' class encapsulates
  both general strings (STR) and tooltips (TTIP).
- Updated all internal references to use 'self.app_version' and 'self.msg_config.TTIP'.
- This refactoring reduces argument redundancy and improves consistency with
  the project's configuration management.
2025-08-13 18:31:17 +02:00
3cf91ba58f Refactor: Externalize remaining MenuBar tooltips
- Replaced hardcoded tooltips for the log and about buttons with references
  to the 'tooltips' dictionary passed during MenuBar initialization.
- This further enhances the flexibility and translatability of the MenuBar.
2025-08-13 17:44:35 +02:00
ab14b4ffa3 Fix: Resolve NameError for Optional in MenuBar
- Added 'Optional' to the import list from the 'typing' module in menu_bar.py
  to resolve a NameError when using Optional as a type hint.
2025-08-13 17:26:07 +02:00
6fe090e9e5 Refactor: Update MenuBar for new GiteaUpdater and flexible tooltips
- Updated MenuBar to use the new GiteaUpdater class for update checks.
- Modified __init__ to accept gitea_api_url and a dictionary of tooltips,
  making the MenuBar more project-independent and translatable.
- Refactored check_for_updates and update_ui_for_update to handle the
  new GiteaUpdater's return values (new version string or None) and
  new states (DISABLED, ERROR).
- Improved updater logic to prevent execution when no updates are available
  or an error occurred.
- Corrected about dialog icon parameter.
2025-08-13 17:18:28 +02:00
8b4068fdc7 add icons in kontex menüs 2025-08-13 01:24:55 +02:00
ba6ef7385a replace tooltip animation with exist tooltip, redundancy reduced, search optimized, add new icons copy and stair (for path folllow) 2025-08-13 01:05:57 +02:00
dc51bf6f2c Reduced redundancy, logviewer fulll removed , add log_window and menu_bar 2025-08-12 22:44:49 +02:00
80aebe3bab Struktur: Integriere CustomFileDialog und AnimatedIcon
Die
      Komponenten des CustomFileDialog wurden aus einem separaten Repository importiert
      und in ein eigenes Verzeichnis verschoben. Die Test-Datei 'mainwindow.py' wurde
      entfernt. 'animated_icon.py' wurde als gemeinsam genutzte Komponente im
      Stammverzeichnis platziert.
2025-08-10 13:31:53 +02:00
941ac4334b Merge remote-tracking branch 'customfiledialog-repo/main' into 4-06-2025 2025-08-10 13:29:26 +02:00
d124c24533 fix fstrings and syntax error 2025-08-10 12:25:10 +02:00
30c2c3b901 add type-hints on cfd_file_operations, cfd_app_config, animated_icon 2025-08-10 12:04:34 +02:00
5a41d6b1fd add doctring and typhint in custom_file_dialog and add type-hints on cfd_view_manager, cfd_ui_setup, cfd_settings_dialog, cfd_search_manager, cfd_navigation_manager 2025-08-10 11:57:43 +02:00
4e7ef8d348 add docstring in cfd_ui_setup, cfd_view_manager 2025-08-10 11:33:19 +02:00
14a50616a3 add dochstring in cfd_file_operations, cfd_navigation_manager, cfd_search_manager, cfd_settings_dialog and fix entryfield on create new folder and new document in iconview 2025-08-10 11:16:48 +02:00
246addc34b add docstrings in animated_icon and cfd_app_config.py 2025-08-10 10:38:45 +02:00
b18bf7fe85 translate strings place in cfd_app_config and replace unhide with new size 2025-08-10 01:26:57 +02:00
b8d46fb547 Refactor: Decompose CustomFileDialog class
The `CustomFileDialog` class had become too large and complex, making it difficult to maintain.

This change refactors the monolithic class into several specialized manager classes, each responsible for a specific area of concern:

- `SettingsDialog`: Moved to its own file, `cfd_settings_dialog.py`.
- `FileOperationsManager`: Handles file/folder creation, deletion, and renaming.
- `SearchManager`: Encapsulates all search-related logic.
- `NavigationManager`: Manages directory navigation and history.
- `ViewManager`: Controls the rendering of file and folder views.

The main `CustomFileDialog` class has been streamlined and now acts as an orchestrator for these managers. This improves readability, separation of concerns, and the overall maintainability of the code.
2025-08-09 11:51:58 +02:00
d392e1e608 add blink animation 2025-08-09 10:39:59 +02:00
f06d1b6652 fix all search problems 2025-08-09 10:16:51 +02:00
f47f18d48c commit 83 2025-08-09 02:13:13 +02:00
497978ef95 commit 82 2025-08-09 01:50:41 +02:00
7956d4e393 commit 81 2025-08-09 01:36:30 +02:00
cb6c513622 commit 80 2025-08-09 01:22:50 +02:00
2f504658a3 commit 79 2025-08-09 00:40:45 +02:00
cef383ca74 commit 78 2025-08-09 00:32:57 +02:00
287ebfd1d0 commit 77 2025-08-09 00:23:25 +02:00
11bcf5cc7a commit 76 search works 2025-08-09 00:00:08 +02:00
9e495cc73c commit 75 2025-08-08 22:39:36 +02:00
482eaae591 commit 74 2025-08-08 19:36:42 +02:00
43ac132ec8 commit 73 2025-08-08 15:28:54 +02:00
de1cc4a03d commit 72 2025-08-08 14:56:53 +02:00
f6d86d7e42 commit 71 2025-08-08 13:59:23 +02:00
fda52d52d4 commit 70 2025-08-08 13:53:40 +02:00
2e934c62e4 69 2025-08-08 13:23:21 +02:00
3d2ffcc69e commit 68 2025-08-08 12:13:02 +02:00
053b0c22c5 commit 67 2025-08-08 00:55:32 +02:00
9c2b72345c commit 66 2025-08-07 19:06:06 +02:00
e5d10dded6 commit 65 2025-08-07 16:16:07 +02:00
b1bd928a76 commit 64 2025-08-07 11:37:03 +02:00
e059efc1cf commit 63 2025-08-07 11:28:50 +02:00
aae8f4431a commit 62 2025-08-07 02:36:01 +02:00
1d6137ed44 commit 61 2025-08-07 00:35:37 +02:00
b71e1cc79c commit 60 2025-08-07 00:30:14 +02:00
bbfdc3efac commit 59 2025-08-06 23:49:16 +02:00
84c5405df7 commit 58 2025-08-06 22:12:22 +02:00
1168ea8ecf feat(ui): Adjust button layout for open/save modes
Modified the button layout methods to place the primary action button (Open/Save) at the top and the Cancel button at the bottom of their respective containers.

This creates a more consistent and predictable user experience across all dialog modes and view settings.
2025-08-06 17:58:14 +02:00
a8a55574f5 refactor(ui): Decompose sidebar setup into smaller methods
Further refactored the _setup_sidebar method by breaking it down into smaller, more focused methods: _setup_sidebar_bookmarks, _setup_sidebar_devices, and _setup_sidebar_storage.

This completes the modularization of the UI setup, resulting in a highly organized and maintainable WidgetManager class.
2025-08-06 15:33:37 +02:00
9252b0d23f Revert "refactor(ui): Decompose sidebar setup into smaller methods"
This reverts commit 2e6eb57b55.
2025-08-06 15:16:37 +02:00
2e6eb57b55 refactor(ui): Decompose sidebar setup into smaller methods
Further refactored the _setup_sidebar method by breaking it down into smaller, more focused methods: _setup_sidebar_bookmarks, _setup_sidebar_devices, and _setup_sidebar_storage.

This completes the modularization of the UI setup, resulting in a highly organized and maintainable WidgetManager class.
2025-08-06 12:13:15 +02:00
b170b4094e refactor(ui): Modularize UI setup into distinct methods
Further refactored the WidgetManager class by extracting the setup logic for the top bar and the sidebar into their own dedicated methods: _setup_top_bar and _setup_sidebar.

This simplifies the main setup_widgets method into a high-level blueprint, significantly improving code organization and readability.
2025-08-06 12:07:14 +02:00
29480a0096 refactor(ui): Encapsulate bottom bar setup in its own method
To improve readability and reduce the size of the main setup_widgets method, the logic for creating and laying out all widgets in the bottom bar has been extracted into a new _setup_bottom_bar method.
2025-08-06 11:38:15 +02:00
f21d09c6a6 refactor(ui): Separate bottom bar layout logic into methods
Extract the layout logic for the different dialog modes (save/open) and button positions (left/right) into dedicated methods within the WidgetManager class.

This improves readability and maintainability, making future changes easier and less error-prone.
2025-08-06 11:20:34 +02:00
c8db431c06 commit 57 2025-08-05 18:29:58 +02:00
160d8acafb disable new folder, new document works in open mode 2025-08-05 13:01:30 +02:00
f2b6c330fa commit 55 2025-08-05 10:14:09 +02:00
3005d17f03 Fix on save mode bottom entry autosize 2025-08-04 13:16:12 +02:00
17fe3455b8 Bottom Buttons UI ok 2025-08-04 12:21:18 +02:00
d20a941c8c commit 54 2025-08-03 23:53:22 +02:00
0e280e30e2 commit 53 2025-08-03 23:35:48 +02:00
6f9a7c922c commit 52 2025-08-03 23:34:04 +02:00
6f8b0b290c commit 51 2025-08-03 23:20:50 +02:00
0d7ab8d73d commit 50 2025-08-03 22:57:50 +02:00
2c28c94961 commit 49 2025-08-03 18:04:48 +02:00
30c200918d commit 48 2025-08-03 18:04:21 +02:00
2880e0d7a1 commit 47 2025-08-03 14:46:55 +02:00
94c32ddd9e commit 46 2025-08-03 02:35:48 +02:00
e211072cc2 commit 45 2025-08-03 02:06:02 +02:00
1ca1264101 commit 44 2025-08-03 01:39:21 +02:00
b1394e0f62 commit 43 2025-08-03 00:56:28 +02:00
369605be8a commit 42 2025-08-02 23:06:02 +02:00
77ae398761 commit 41 2025-08-02 22:38:38 +02:00
2b09721fec commit 40 2025-08-02 21:40:06 +02:00
07751e5c9a commit 39 2025-08-02 20:01:29 +02:00
b350e562fa commit 37 2025-08-02 10:54:37 +02:00
af7dcc31e4 commit 37 2025-08-01 10:52:48 +02:00
e3bb68f7e2 commit 36 mit common tools 2025-08-01 09:29:26 +02:00
2404a60b6c commit 35 2025-07-31 16:06:43 +02:00
13b54fd5c6 commit 34 2025-07-31 14:09:31 +02:00
8536e2c463 commit 33 2025-07-31 11:01:22 +02:00
78b93f66be commit 32 2025-07-31 10:07:28 +02:00
f1f85d36c9 commit 31 2025-07-30 22:41:22 +02:00
c010bd53cb commit 30 2025-07-30 22:29:22 +02:00
8a4d3d70c9 commit 29 2025-07-30 22:18:36 +02:00
4ca52c2dc9 commit 28 2025-07-30 20:36:19 +02:00
e535a42d3e commit 27 2025-07-30 15:09:49 +02:00
0b7a85424a commit 26 2025-07-30 01:55:45 +02:00
98b16e664b commit 25 2025-07-30 01:15:53 +02:00
9314548928 commit 24 2025-07-30 00:20:54 +02:00
592b68eb88 commit 23 2025-07-29 14:26:17 +02:00
80940f6119 commit 22 2025-07-29 13:00:24 +02:00
d97fade936 commit 21 2025-07-29 12:53:05 +02:00
7cc2658433 commit 20 2025-07-29 12:41:31 +02:00
3ddbf026da commit 19 2025-07-29 12:34:20 +02:00
9fd0410e5a commit 18 2025-07-29 12:08:35 +02:00
4757df1710 commit 17 2025-07-29 10:28:13 +02:00
0d82a91e69 commit 16 2025-07-29 08:47:51 +02:00
dfa9b033e8 commit 15 2025-07-28 19:17:52 +02:00
37117fc943 commit 15 2025-07-28 00:43:48 +02:00
49a097c525 commit 14 2025-07-28 00:00:34 +02:00
dbae4dbb88 commit 13 2025-07-27 22:48:35 +02:00
1b7a6b3411 commit 12 2025-07-27 21:38:08 +02:00
7ab63d2a63 fix scrolbar in listview and fix view icons in listview 2025-07-27 21:08:41 +02:00
11f8d0e2fd commit 11 2025-07-27 21:02:22 +02:00
872513f348 bessere anzeige der einzelnen icons 2025-07-27 00:29:55 +02:00
cad67a0c35 fix formartierung 2025-07-26 22:24:48 +02:00
6e83f6499c commit 10 scrollen behoben 2025-07-26 22:04:14 +02:00
a0da1f501c commit imagepaths on /usr/local/share 2025-07-26 21:54:28 +02:00
d7c4c0088b commit seven 2025-07-26 18:34:40 +02:00
1dd22ef0f8 commit six 2025-07-26 18:05:38 +02:00
22f7649d01 commit five 2025-07-26 14:39:28 +02:00
3250410f54 commit 4 2025-07-26 12:52:37 +02:00
f9421fc602 commit three 2025-07-26 12:39:42 +02:00
06774c0653 commit two 2025-07-26 11:44:23 +02:00
ff970973e2 first commit 2025-07-25 23:42:43 +02:00
6faf65ad08 fix about icon 2025-07-09 17:47:12 +02:00
0d694adc2d update size and german translate file 2025-07-09 17:02:36 +02:00
ec76940dca fix f string in row 469 2025-07-09 12:11:31 +02:00
6242dd7b0d update size x and y 2025-07-09 11:10:09 +02:00
703d2dfc4a fix update icon path now direkt coded 2025-07-09 08:29:31 +02:00
52f782b4e8 replace download with open lxtoolsinstaller add translate german for logviewer fix message dialog 2025-06-29 18:08:35 +02:00
7351100e55 fir image links on readme 2025-06-15 15:43:10 +02:00
24 changed files with 5407 additions and 1119 deletions

View File

@@ -2,8 +2,67 @@ Changelog for shared_libs
## [Unreleased]
- add Info Window for user in delete logfile
bevore delete logfile.
-
### 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
- New class loggers, animated icon, methods in common tools
improved and added new methods (contexmanager)
- Own FileDialog added(custom, this is exclusively for linux
An alternative to the X11 file dialogue that is otherwise opened
when working with python
- Reduced redundancy, logviewer fulll removed , add log_window and menu_bar
- replace tooltip animation with exist tooltip, search optimized, add new icons
copy and stair (for path folllow)
### Added
01.08.2025
- Add Icon Class to Central Image Management
- Tooltip Class replace
### Added
09.07.2025
- fix new icon for install Update
### Added
29.06.2025
- add new icon for install Update
- replace download with updater methode
- add methode for open lxtools_installer Appimage
- add german translation for logviewer
### Added
15-06-2025

View File

@@ -4,9 +4,10 @@ Module Project for apps by git.ilunix.de
Examples with a Theme from Projekt Wire-Py
# Screenshots
[![info_example.png](https://fb.ilunix.de/api/public/dl/KtaTPMMq=true)](https://fb.ilunix.de/share/KtaTPMMq)
[![error_example.png](https://fb.ilunix.de/api/public/dl/cRO_ksrM=true)](https://fb.ilunix.de/share/cRO_ksrM)
[![warning_example.png](https://fb.ilunix.de/api/public/dl/1JEdSJcI=true)](https://fb.ilunix.de/share/1JEdSJcI)
[![question_light_example.png](https://fb.ilunix.de/api/public/dl/XxNey7y7=true)](https://fb.ilunix.de/share/1XxNey7y7)
[![question_dark_example.png](https://fb.ilunix.de/api/public/dl/4HCxiNwB=true)](https://fb.ilunix.de/share/4HCxiNwB)
[![example_with_own_title_and_icon.png](https://fb.ilunix.de/api/public/dl/uui8b1xx=true)](https://fb.ilunix.de/share/uui8b1xx)
[![info_example.png](https://fb.ilunix.de/api/public/dl/KtaTPMMq?inline=true)](https://fb.ilunix.de/share/KtaTPMMq)
[![error_example.png](https://fb.ilunix.de/api/public/dl/cRO_ksrM?inline=true)](https://fb.ilunix.de/share/cRO_ksrM)
[![warning_example.png](https://fb.ilunix.de/api/public/dl/1JEdSJcI?inline=true)](https://fb.ilunix.de/share/1JEdSJcI)
[![question_light_example.png](https://fb.ilunix.de/api/public/dl/XxNey7y7?inline=true)](https://fb.ilunix.de/share/1XxNey7y7)
[![question_dark_example.png](https://fb.ilunix.de/api/public/dl/4HCxiNwB?inline=true)](https://fb.ilunix.de/share/4HCxiNwB)
[![example_with_own_title_and_icon.png](https://fb.ilunix.de/api/public/dl/uui8b1xx?inline=true)](https://fb.ilunix.de/share/uui8b1xx)
[![logviewer_example.png](https://fb.ilunix.de/api/public/dl/54OM6wUC?inline=true)](https://fb.ilunix.de/share/54OM6wUC)

View File

@@ -1,3 +1,3 @@
#!/usr/bin/python3

541
animated_icon.py Normal file
View File

@@ -0,0 +1,541 @@
"""
A Tkinter widget for displaying animated icons.
This module provides the AnimatedIcon class, a custom Tkinter Canvas widget
that can display various types of animations. It supports both native Tkinter
drawing and Pillow (PIL) for anti-aliased graphics if available.
"""
import tkinter as tk
from math import sin, cos, pi
from typing import Tuple, Optional
try:
from PIL import Image, ImageDraw, ImageTk
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
def _hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
"""Converts a hex color string to an RGB tuple."""
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
class AnimatedIcon(tk.Canvas):
"""A custom Tkinter Canvas widget for displaying animations."""
def __init__(self, master: tk.Misc, width: int = 20, height: int = 20, animation_type: str = "counter_arc", color: str = "#2a6fde", highlight_color: str = "#5195ff", use_pillow: bool = False, bg: Optional[str] = None) -> None:
"""
Initializes the AnimatedIcon widget.
Args:
master: The parent widget.
width (int): The width of the icon.
height (int): The height of the icon.
animation_type (str): The type of animation to display.
Options: "counter_arc", "double_arc", "line", "blink".
color (str): The primary color of the icon.
highlight_color (str): The highlight color of the icon.
use_pillow (bool): Whether to use Pillow for drawing if available.
bg (str): The background color of the canvas.
"""
if bg is None:
try:
bg = master.cget("background")
except tk.TclError:
bg = "#f0f0f0" # Fallback color
super().__init__(master, width=width, height=height, bg=bg, highlightthickness=0)
self.width = width
self.height = height
self.animation_type = animation_type
self.color = color
self.highlight_color = highlight_color
self.use_pillow = use_pillow and PIL_AVAILABLE
self.running = False
self.is_disabled = False
self.pause_count = 0
self.angle = 0
self.pulse_animation = False
self.color_rgb = _hex_to_rgb(self.color)
self.highlight_color_rgb = _hex_to_rgb(self.highlight_color)
if self.use_pillow:
self.image = Image.new(
"RGBA", (width * 4, height * 4), (0, 0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.photo_image = None
def _draw_frame(self) -> None:
"""Draws a single frame of the animation."""
if self.use_pillow:
self._draw_pillow_frame()
else:
self._draw_canvas_frame()
def _draw_canvas_frame(self) -> None:
"""Draws a frame using native Tkinter canvas methods."""
self.delete("all")
if self.pulse_animation:
self._draw_canvas_pulse()
elif self.animation_type == "line":
self._draw_canvas_line()
elif self.animation_type == "double_arc":
self._draw_canvas_double_arc()
elif self.animation_type == "counter_arc":
self._draw_canvas_counter_arc()
elif self.animation_type == "blink":
self._draw_canvas_blink()
def _draw_canvas_pulse(self) -> None:
"""Draws the pulse animation using canvas methods."""
center_x, center_y = self.width / 2, self.height / 2
alpha = (sin(self.angle * 5) + 1) / 2 # Faster pulse
r = int(
alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0])
g = int(
alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1])
b = int(
alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2])
pulse_color = f"#{r:02x}{g:02x}{b:02x}"
if self.animation_type == "line":
for i in range(8):
angle = i * (pi / 4)
start_x = center_x + cos(angle) * (self.width * 0.2)
start_y = center_y + sin(angle) * (self.height * 0.2)
end_x = center_x + cos(angle) * (self.width * 0.4)
end_y = center_y + sin(angle) * (self.height * 0.4)
self.create_line(start_x, start_y, end_x,
end_y, fill=pulse_color, width=2)
elif self.animation_type == "double_arc":
radius = min(center_x, center_y) * 0.8
bbox = (center_x - radius, center_y - radius,
center_x + radius, center_y + radius)
self.create_arc(bbox, start=0, extent=359.9,
style=tk.ARC, outline=pulse_color, width=2)
elif self.animation_type == "counter_arc":
radius_outer = min(center_x, center_y) * 0.8
bbox_outer = (center_x - radius_outer, center_y - radius_outer,
center_x + radius_outer, center_y + radius_outer)
self.create_arc(bbox_outer, start=0, extent=359.9,
style=tk.ARC, outline=pulse_color, width=2)
radius_inner = min(center_x, center_y) * 0.6
bbox_inner = (center_x - radius_inner, center_y - radius_inner,
center_x + radius_inner, center_y + radius_inner)
self.create_arc(bbox_inner, start=0, extent=359.9,
style=tk.ARC, outline=self.color, width=2)
def _draw_canvas_line(self) -> None:
"""Draws the line animation using canvas methods."""
center_x, center_y = self.width / 2, self.height / 2
for i in range(8):
angle = self.angle + i * (pi / 4)
start_x = center_x + cos(angle) * (self.width * 0.2)
start_y = center_y + sin(angle) * (self.height * 0.2)
end_x = center_x + cos(angle) * (self.width * 0.4)
end_y = center_y + sin(angle) * (self.height * 0.4)
alpha = (cos(self.angle * 2 + i * (pi / 4)) + 1) / 2
r = int(
alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0])
g = int(
alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1])
b = int(
alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2])
color = f"#{r:02x}{g:02x}{b:02x}"
self.create_line(start_x, start_y, end_x,
end_y, fill=color, width=2)
def _draw_canvas_double_arc(self) -> None:
"""Draws the double arc animation using canvas methods."""
center_x, center_y = self.width / 2, self.height / 2
radius = min(center_x, center_y) * 0.8
bbox = (center_x - radius, center_y - radius,
center_x + radius, center_y + radius)
start_angle1 = -self.angle * 180 / pi
extent1 = 120 + 60 * sin(-self.angle)
self.create_arc(bbox, start=start_angle1, extent=extent1,
style=tk.ARC, outline=self.highlight_color, width=2)
start_angle2 = (-self.angle + pi) * 180 / pi
extent2 = 120 + 60 * sin(-self.angle + pi / 2)
self.create_arc(bbox, start=start_angle2, extent=extent2,
style=tk.ARC, outline=self.color, width=2)
def _draw_canvas_counter_arc(self) -> None:
"""Draws the counter arc animation using canvas methods."""
center_x, center_y = self.width / 2, self.height / 2
radius_outer = min(center_x, center_y) * 0.8
bbox_outer = (center_x - radius_outer, center_y - radius_outer,
center_x + radius_outer, center_y + radius_outer)
start_angle1 = -self.angle * 180 / pi
self.create_arc(bbox_outer, start=start_angle1, extent=150,
style=tk.ARC, outline=self.highlight_color, width=2)
radius_inner = min(center_x, center_y) * 0.6
bbox_inner = (center_x - radius_inner, center_y - radius_inner,
center_x + radius_inner, center_y + radius_inner)
start_angle2 = self.angle * 180 / pi + 60
self.create_arc(bbox_inner, start=start_angle2, extent=150,
style=tk.ARC, outline=self.color, width=2)
def _draw_canvas_blink(self) -> None:
"""Draws the blink animation using canvas methods."""
center_x, center_y = self.width / 2, self.height / 2
radius = min(center_x, center_y) * 0.8
alpha = (sin(self.angle * 2) + 1) / 2 # Slower blinking speed
r = int(
alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0])
g = int(
alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1])
b = int(
alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2])
blink_color = f"#{r:02x}{g:02x}{b:02x}"
self.create_arc(center_x - radius, center_y - radius, center_x + radius, center_y +
radius, start=0, extent=359.9, style=tk.ARC, outline=blink_color, width=4)
def _draw_pillow_frame(self) -> None:
"""Draws a frame using Pillow for anti-aliased graphics."""
self.draw.rectangle(
[0, 0, self.width * 4, self.height * 4], fill=(0, 0, 0, 0))
if self.pulse_animation:
self._draw_pillow_pulse()
elif self.animation_type == "line":
self._draw_pillow_line()
elif self.animation_type == "double_arc":
self._draw_pillow_double_arc()
elif self.animation_type == "counter_arc":
self._draw_pillow_counter_arc()
elif self.animation_type == "blink":
self._draw_pillow_blink()
resized_image = self.image.resize(
(self.width, self.height), Image.Resampling.LANCZOS)
self.photo_image = ImageTk.PhotoImage(resized_image)
self.delete("all")
self.create_image(0, 0, anchor="nw", image=self.photo_image)
def _draw_pillow_pulse(self) -> None:
"""Draws the pulse animation using Pillow."""
center_x, center_y = self.width * 2, self.height * 2
alpha = (sin(self.angle * 5) + 1) / 2 # Faster pulse
r = int(
alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0])
g = int(
alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1])
b = int(
alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2])
pulse_color = (r, g, b)
if self.animation_type == "line":
for i in range(12):
angle = i * (pi / 6)
start_x = center_x + cos(angle) * (self.width * 0.8)
start_y = center_y + sin(angle) * (self.height * 0.8)
end_x = center_x + cos(angle) * (self.width * 1.6)
end_y = center_y + sin(angle) * (self.height * 1.6)
self.draw.line([(start_x, start_y), (end_x, end_y)],
fill=pulse_color, width=6, joint="curve")
elif self.animation_type == "double_arc":
radius = min(center_x, center_y) * 0.8
bbox = (center_x - radius, center_y - radius,
center_x + radius, center_y + radius)
self.draw.arc(bbox, start=0, end=360, fill=pulse_color, width=5)
elif self.animation_type == "counter_arc":
radius_outer = min(center_x, center_y) * 0.8
bbox_outer = (center_x - radius_outer, center_y - radius_outer,
center_x + radius_outer, center_y + radius_outer)
self.draw.arc(bbox_outer, start=0, end=360,
fill=pulse_color, width=7)
radius_inner = min(center_x, center_y) * 0.6
bbox_inner = (center_x - radius_inner, center_y - radius_inner,
center_x + radius_inner, center_y + radius_inner)
self.draw.arc(bbox_inner, start=0, end=360,
fill=self.color_rgb, width=7)
def _draw_pillow_line(self) -> None:
"""Draws the line animation using Pillow."""
center_x, center_y = self.width * 2, self.height * 2
for i in range(12):
angle = self.angle + i * (pi / 6)
start_x = center_x + cos(angle) * (self.width * 0.8)
start_y = center_y + sin(angle) * (self.height * 0.8)
end_x = center_x + cos(angle) * (self.width * 1.6)
end_y = center_y + sin(angle) * (self.height * 1.6)
alpha = (cos(self.angle * 2.5 + i * (pi / 6)) + 1) / 2
r = int(
alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0])
g = int(
alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1])
b = int(
alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2])
color = (r, g, b)
self.draw.line([(start_x, start_y), (end_x, end_y)],
fill=color, width=6, joint="curve")
def _draw_pillow_double_arc(self) -> None:
"""Draws the double arc animation using Pillow."""
center_x, center_y = self.width * 2, self.height * 2
radius = min(center_x, center_y) * 0.8
bbox = (center_x - radius, center_y - radius,
center_x + radius, center_y + radius)
start_angle1 = self.angle * 180 / pi
extent1 = 120 + 60 * sin(self.angle)
self.draw.arc(bbox, start=start_angle1, end=start_angle1 +
extent1, fill=self.highlight_color_rgb, width=5)
start_angle2 = (self.angle + pi) * 180 / pi
extent2 = 120 + 60 * sin(self.angle + pi / 2)
self.draw.arc(bbox, start=start_angle2, end=start_angle2 +
extent2, fill=self.color_rgb, width=5)
def _draw_pillow_counter_arc(self) -> None:
"""Draws the counter arc animation using Pillow."""
center_x, center_y = self.width * 2, self.height * 2
radius_outer = min(center_x, center_y) * 0.8
bbox_outer = (center_x - radius_outer, center_y - radius_outer,
center_x + radius_outer, center_y + radius_outer)
start_angle1 = self.angle * 180 / pi
self.draw.arc(bbox_outer, start=start_angle1, end=start_angle1 +
150, fill=self.highlight_color_rgb, width=7)
radius_inner = min(center_x, center_y) * 0.6
bbox_inner = (center_x - radius_inner, center_y - radius_inner,
center_x + radius_inner, center_y + radius_inner)
start_angle2 = -self.angle * 180 / pi + 60
self.draw.arc(bbox_inner, start=start_angle2,
end=start_angle2 + 150, fill=self.color_rgb, width=7)
def _draw_pillow_blink(self) -> None:
"""Draws the blink animation using Pillow."""
center_x, center_y = self.width * 2, self.height * 2
radius = min(center_x, center_y) * 0.8
alpha = (sin(self.angle * 2) + 1) / 2 # Slower blinking speed
r = int(
alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0])
g = int(
alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1])
b = int(
alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2])
blink_color = (r, g, b)
self.draw.arc((center_x - radius, center_y - radius, center_x + radius,
center_y + radius), start=0, end=360, fill=blink_color, width=10)
def _draw_stopped_frame(self) -> None:
"""Draws the icon in its stopped (static) state."""
self.delete("all")
original_highlight_color = self.highlight_color
original_highlight_color_rgb = self.highlight_color_rgb
if self.is_disabled:
self.highlight_color = "#8f99aa"
self.highlight_color_rgb = _hex_to_rgb(self.highlight_color)
try:
if self.use_pillow:
self._draw_pillow_stopped_frame()
else:
self._draw_canvas_stopped_frame()
finally:
if self.is_disabled:
self.highlight_color = original_highlight_color
self.highlight_color_rgb = original_highlight_color_rgb
def _draw_canvas_stopped_frame(self) -> None:
"""Draws the stopped state using canvas methods."""
if self.animation_type == "line":
self._draw_canvas_line_stopped()
elif self.animation_type == "double_arc":
self._draw_canvas_double_arc_stopped()
elif self.animation_type == "counter_arc":
self._draw_canvas_counter_arc_stopped()
elif self.animation_type == "blink":
self._draw_canvas_blink_stopped()
def _draw_canvas_line_stopped(self) -> None:
"""Draws the stopped state for the line animation."""
center_x, center_y = self.width / 2, self.height / 2
for i in range(8):
angle = i * (pi / 4)
start_x = center_x + cos(angle) * (self.width * 0.2)
start_y = center_y + sin(angle) * (self.height * 0.2)
end_x = center_x + cos(angle) * (self.width * 0.4)
end_y = center_y + sin(angle) * (self.height * 0.4)
self.create_line(start_x, start_y, end_x, end_y,
fill=self.highlight_color, width=2)
def _draw_canvas_double_arc_stopped(self) -> None:
"""Draws the stopped state for the double arc animation."""
center_x, center_y = self.width / 2, self.height / 2
radius = min(center_x, center_y) * 0.8
bbox = (center_x - radius, center_y - radius,
center_x + radius, center_y + radius)
self.create_arc(bbox, start=0, extent=359.9, style=tk.ARC,
outline=self.highlight_color, width=2)
def _draw_canvas_counter_arc_stopped(self) -> None:
"""Draws the stopped state for the counter arc animation."""
center_x, center_y = self.width / 2, self.height / 2
radius_outer = min(center_x, center_y) * 0.8
bbox_outer = (center_x - radius_outer, center_y - radius_outer,
center_x + radius_outer, center_y + radius_outer)
self.create_arc(bbox_outer, start=0, extent=359.9,
style=tk.ARC, outline=self.highlight_color, width=2)
radius_inner = min(center_x, center_y) * 0.6
bbox_inner = (center_x - radius_inner, center_y - radius_inner,
center_x + radius_inner, center_y + radius_inner)
self.create_arc(bbox_inner, start=0, extent=359.9,
style=tk.ARC, outline=self.color, width=2)
def _draw_canvas_blink_stopped(self) -> None:
"""Draws the stopped state for the blink animation."""
center_x, center_y = self.width / 2, self.height / 2
radius = min(center_x, center_y) * 0.8
self.create_arc(center_x - radius, center_y - radius, center_x + radius, center_y +
radius, start=0, extent=359.9, style=tk.ARC, outline=self.highlight_color, width=4)
def _draw_pillow_stopped_frame(self) -> None:
"""Draws the stopped state using Pillow."""
self.draw.rectangle(
[0, 0, self.width * 4, self.height * 4], fill=(0, 0, 0, 0))
if self.animation_type == "line":
self._draw_pillow_line_stopped()
elif self.animation_type == "double_arc":
self._draw_pillow_double_arc_stopped()
elif self.animation_type == "counter_arc":
self._draw_pillow_counter_arc_stopped()
elif self.animation_type == "blink":
self._draw_pillow_blink_stopped()
resized_image = self.image.resize(
(self.width, self.height), Image.Resampling.LANCZOS)
self.photo_image = ImageTk.PhotoImage(resized_image)
self.create_image(0, 0, anchor="nw", image=self.photo_image)
def _draw_pillow_line_stopped(self) -> None:
"""Draws the stopped state for the line animation using Pillow."""
center_x, center_y = self.width * 2, self.height * 2
for i in range(12):
angle = i * (pi / 6)
start_x = center_x + cos(angle) * (self.width * 0.8)
start_y = center_y + sin(angle) * (self.height * 0.8)
end_x = center_x + cos(angle) * (self.width * 1.6)
end_y = center_y + sin(angle) * (self.height * 1.6)
self.draw.line([(start_x, start_y), (end_x, end_y)],
fill=self.highlight_color_rgb, width=6, joint="curve")
def _draw_pillow_double_arc_stopped(self) -> None:
"""Draws the stopped state for the double arc animation using Pillow."""
center_x, center_y = self.width * 2, self.height * 2
radius = min(center_x, center_y) * 0.8
bbox = (center_x - radius, center_y - radius,
center_x + radius, center_y + radius)
self.draw.arc(bbox, start=0, end=360,
fill=self.highlight_color_rgb, width=5)
def _draw_pillow_counter_arc_stopped(self) -> None:
"""Draws the stopped state for the counter arc animation using Pillow."""
center_x, center_y = self.width * 2, self.height * 2
radius_outer = min(center_x, center_y) * 0.8
bbox_outer = (center_x - radius_outer, center_y - radius_outer,
center_x + radius_outer, center_y + radius_outer)
self.draw.arc(bbox_outer, start=0, end=360,
fill=self.highlight_color_rgb, width=7)
radius_inner = min(center_x, center_y) * 0.6
bbox_inner = (center_x - radius_inner, center_y - radius_inner,
center_x + radius_inner, center_y + radius_inner)
self.draw.arc(bbox_inner, start=0, end=360,
fill=self.color_rgb, width=7)
def _draw_pillow_blink_stopped(self) -> None:
"""Draws the stopped state for the blink animation using Pillow."""
center_x, center_y = self.width * 2, self.height * 2
radius = min(center_x, center_y) * 0.8
self.draw.arc((center_x - radius, center_y - radius, center_x + radius,
center_y + radius), start=0, end=360, fill=self.highlight_color_rgb, width=10)
def _animate(self) -> None:
"""The main animation loop."""
if self.pause_count > 0 or not self.running or not self.winfo_exists():
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:
self.angle -= 2 * pi
self._draw_frame()
self.after(30, self._animate)
def start(self, pulse: bool = False) -> None:
"""
Starts the animation.
Args:
pulse (bool): If True, plays a pulsing animation instead of the main one.
"""
if not self.winfo_exists():
return
self.running = True
self.is_disabled = False
self.pulse_animation = pulse
if self.pause_count == 0:
self._animate()
def stop(self, status: Optional[str] = None) -> None:
"""Stops the animation and shows the static 'stopped' frame."""
if not self.winfo_exists():
return
self.running = False
self.pulse_animation = False
self.is_disabled = status == "DISABLE"
self._draw_stopped_frame()
def hide(self) -> None:
"""Stops the animation and clears the canvas."""
if not self.winfo_exists():
return
self.running = False
self.pulse_animation = False
self.delete("all")
def pause(self) -> None:
"""Pauses the animation and draws a static frame."""
self.pause_count += 1
self._draw_stopped_frame()
def resume(self) -> None:
"""Resumes the animation if the pause count is zero."""
self.pause_count = max(0, self.pause_count - 1)
if self.pause_count == 0 and self.running:
self._animate()
def show_full_circle(self) -> None:
"""Shows the static 'stopped' frame without starting the animation."""
if not self.winfo_exists():
return
if not self.running:
self._draw_stopped_frame()

View File

@@ -1,16 +1,21 @@
""" Classes Method and Functions for lx Apps """
" Classes Method and Functions for lx Apps "
import logging
import signal
import base64
from contextlib import contextmanager
from .logger import app_logger
from subprocess import CompletedProcess, run
import gettext
import locale
import re
import sys
import shutil
import tkinter as tk
from tkinter import ttk
import os
from typing import Optional, Dict, Any, NoReturn
from pathlib import Path
from tkinter import Toplevel
class CryptoUtil:
@@ -34,14 +39,14 @@ class CryptoUtil:
# Output from Openssl Error
if process.stderr:
logging.error(process.stderr, exc_info=True)
app_logger.log(process.stderr)
if process.returncode == 0:
logging.info("Files successfully decrypted...", exc_info=True)
app_logger.log("Files successfully decrypted...")
else:
logging.error(
f"Error process decrypt: Code {process.returncode}", exc_info=True
app_logger.log(
f"Error process decrypt: Code {process.returncode}"
)
@staticmethod
@@ -58,13 +63,13 @@ class CryptoUtil:
# Output from Openssl Error
if process.stderr:
logging.error(process.stderr, exc_info=True)
app_logger.log(process.stderr)
if process.returncode == 0:
logging.info("Files successfully encrypted...", exc_info=True)
app_logger.log("Files successfully encrypted...")
else:
logging.error(
f"Error process encrypt: Code {process.returncode}", exc_info=True
app_logger.log(
f"Error process encrypt: Code {process.returncode}"
)
@staticmethod
@@ -83,9 +88,8 @@ class CryptoUtil:
return True
elif "False" in process.stdout:
return False
logging.error(
f"Unexpected output from the external script:\nSTDOUT: {process.stdout}\nSTDERR: {process.stderr}",
exc_info=True,
app_logger.log(
f"Unexpected output from the external script:\nSTDOUT: {process.stdout}\nSTDERR: {process.stderr}"
)
return False
@@ -110,7 +114,7 @@ class CryptoUtil:
if len(decoded) != 32: # 32 bytes = 256 bits
return False
except Exception as e:
logging.error(f"Error on decode Base64: {e}", exc_info=True)
app_logger.log(f"Error on decode Base64: {e}")
return False
return True
@@ -273,17 +277,16 @@ class LxTools:
# End program for certain signals, report to others only reception
if signum in (signal.SIGINT, signal.SIGTERM):
exit_code: int = 1
logging.error(
f"\nSignal {signal_name} {signum} received. => Aborting with exit code {exit_code}.",
exc_info=True,
app_logger.log(
f"\nSignal {signal_name} {signum} received. => Aborting with exit code {exit_code}."
)
LxTools.clean_files(file_path, file)
logging.info("Breakdown by user...")
app_logger.log("Breakdown by user...")
sys.exit(exit_code)
else:
logging.info(f"Signal {signum} received and ignored.")
app_logger.log(f"Signal {signum} received and ignored.")
LxTools.clean_files(file_path, file)
logging.error("Process unexpectedly ended...")
app_logger.log("Process unexpectedly ended...")
# Register signal handlers for various signals
signal.signal(signal.SIGINT, signal_handler)
@@ -324,14 +327,14 @@ class ConfigManager:
"""Load the config file and return the config as dict"""
if not cls._config:
try:
lines = Path(cls._config_file).read_text(encoding="utf-8").splitlines()
lines = Path(cls._config_file).read_text(
encoding="utf-8").splitlines()
cls._config = {
"updates": lines[1].strip(),
"theme": lines[3].strip(),
"tooltips": lines[5].strip()
== "True", # is converted here to boolean!!!
"autostart": lines[7].strip() if len(lines) > 7 else "off",
"logfile": lines[9].strip(),
}
except (IndexError, FileNotFoundError):
# DeDefault values in case of error
@@ -340,7 +343,6 @@ class ConfigManager:
"theme": "light",
"tooltips": "True", # Default Value as string!
"autostart": "off",
"logfile": LOG_FILE_PATH,
}
return cls._config
@@ -357,8 +359,6 @@ class ConfigManager:
f"{str(cls._config['tooltips'])}\n",
"# Autostart\n",
f"{cls._config['autostart']}\n",
"# Logfile\n",
f"{cls._config['logfile']}\n",
]
Path(cls._config_file).write_text("".join(lines), encoding="utf-8")
@@ -396,110 +396,463 @@ class ThemeManager:
@staticmethod
def change_theme(root, theme_in_use, theme_name=None):
"""Change application theme centrally"""
"""
Change application theme centrally.
Args:
root: The root Tkinter window.
theme_in_use (str): The name of the theme to apply.
theme_name (Optional[str]): The name of the theme to save in the config.
If None, the theme is not saved.
"""
root.tk.call("set_theme", theme_in_use)
if theme_in_use == theme_name:
ConfigManager.set("theme", theme_in_use)
class Tooltip:
"""Class for Tooltip
from common_tools.py import Tooltip
example: Tooltip(label, "Show tooltip on label")
example: Tooltip(button, "Show tooltip on button")
example: Tooltip(widget, "Text", state_var=tk.BooleanVar())
example: Tooltip(widget, "Text", state_var=tk.BooleanVar(), x_offset=20, y_offset=10)
"""
A flexible tooltip class for Tkinter widgets that supports dynamic activation/deactivation.
info: label and button are parent widgets.
NOTE: When using with state_var, pass the tk.BooleanVar object directly,
NOT its value. For example: use state_var=my_bool_var, NOT state_var=my_bool_var.get()
This class provides customizable tooltips that appear when the mouse hovers over a widget.
It can be used for simple, always-active tooltips or for tooltips whose visibility is
controlled by a `tk.BooleanVar`, allowing for global enable/disable functionality.
Attributes:
widget (tk.Widget): The Tkinter widget to which the tooltip is attached.
text (str): The text to display in the tooltip.
wraplength (int): The maximum line length for the tooltip text before wrapping.
state_var (Optional[tk.BooleanVar]): An optional Tkinter BooleanVar that controls
the visibility of the tooltip. If True, the tooltip
is active; if False, it is inactive. If None, the
tooltip is always active.
tooltip_window (Optional[tk.Toplevel]): The Toplevel window used to display the tooltip.
id (Optional[str]): The ID of the `after` job used to schedule the tooltip display.
Usage Examples:
# 1. Simple Tooltip (always active):
# Tooltip(my_button, "This is a simple tooltip.")
# 2. State-Controlled Tooltip (can be enabled/disabled globally):
# tooltip_state = tk.BooleanVar(value=True)
# Tooltip(my_button, "This tooltip can be turned off!", state_var=tooltip_state)
# # To toggle visibility:
# # tooltip_state.set(False) # Tooltips will hide
# # tooltip_state.set(True) # Tooltips will show again
"""
def __init__(
self,
widget: Any,
text: str,
state_var: Optional[tk.BooleanVar] = None,
x_offset: int = 65,
y_offset: int = 40,
) -> None:
"""Tooltip Class"""
self.widget: Any = widget
self.text: str = text
self.tooltip_window: Optional[Toplevel] = None
def __init__(self, widget, text, wraplength=250, state_var=None):
self.widget = widget
self.text = text
self.wraplength = wraplength
self.state_var = state_var
self.x_offset = x_offset
self.y_offset = y_offset
# Initial binding based on the current state
self.tooltip_window = None
self.id = None
self.update_bindings()
# Add trace to the state_var if provided
if self.state_var is not None:
if self.state_var:
self.state_var.trace_add("write", self.update_bindings)
def update_bindings(self, *args) -> None:
"""Updates the bindings based on the current state"""
# Remove existing bindings first
# Add bindings to the top-level window to hide the tooltip when the
# main window loses focus or is iconified.
toplevel = self.widget.winfo_toplevel()
toplevel.bind("<FocusOut>", self.leave, add="+")
toplevel.bind("<Unmap>", self.leave, add="+")
def update_bindings(self, *args):
"""
Updates the event bindings for the widget based on the current state_var.
If state_var is True or None, the <Enter>, <Leave>, and <ButtonPress> events
are bound to show/hide the tooltip. Otherwise, they are unbound.
"""
self.widget.unbind("<Enter>")
self.widget.unbind("<Leave>")
self.widget.unbind("<ButtonPress>")
# Add new bindings if tooltips are enabled
if self.state_var is None or self.state_var.get():
self.widget.bind("<Enter>", self.show_tooltip)
self.widget.bind("<Leave>", self.hide_tooltip)
self.widget.bind("<Enter>", self.enter)
self.widget.bind("<Leave>", self.leave)
self.widget.bind("<ButtonPress>", self.leave)
def show_tooltip(self, event: Optional[Any] = None) -> None:
"""Shows the tooltip"""
if self.tooltip_window or not self.text:
def enter(self, event=None):
"""
Handles the <Enter> event. Schedules the tooltip to be shown after a delay
if tooltips are enabled (via state_var).
"""
# Do not show tooltips if a grab is active on a different window.
# This prevents tooltips from appearing over other modal dialogs.
toplevel = self.widget.winfo_toplevel()
grab_widget = toplevel.grab_current()
if grab_widget is not None and grab_widget != toplevel:
return
x: int
y: int
cx: int
cy: int
if self.state_var is None or self.state_var.get():
self.schedule()
x, y, cx, cy = self.widget.bbox("insert")
x += self.widget.winfo_rootx() + self.x_offset
y += self.widget.winfo_rooty() + self.y_offset
def leave(self, event=None):
"""
Handles the <Leave> event. Unschedules any pending tooltip display
and immediately hides any visible tooltip.
"""
self.unschedule()
self.hide_tooltip()
def schedule(self):
"""
Schedules the `show_tooltip` method to be called after a short delay.
Cancels any previously scheduled calls to prevent flickering.
"""
self.unschedule()
self.id = self.widget.after(250, self.show_tooltip)
def unschedule(self):
"""
Cancels any pending `show_tooltip` calls.
"""
id = self.id
self.id = None
if id:
self.widget.after_cancel(id)
def show_tooltip(self, event=None):
"""
Displays the tooltip window. The tooltip is a Toplevel window containing a ttk.Label.
It is positioned near the widget and styled for readability.
"""
if self.tooltip_window:
return
text_to_show = self.text() if callable(self.text) else self.text
if not text_to_show:
return
try:
# Position the tooltip just below the widget.
# Using winfo_rootx/y is more reliable than bbox.
x = self.widget.winfo_rootx()
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5
except tk.TclError:
# This can happen if the widget is destroyed while the tooltip is scheduled.
return
self.tooltip_window = tw = tk.Toplevel(self.widget)
tw.wm_overrideredirect(True)
tw.wm_geometry(f"+{x}+{y}")
tw.wm_geometry(f"+" + str(x) + "+" + str(y))
label = ttk.Label(tw, text=text_to_show, justify=tk.LEFT, background="#FFFFE0", foreground="black",
relief=tk.SOLID, borderwidth=1, wraplength=self.wraplength, padding=(4, 2, 4, 2))
label.pack(ipadx=1)
label: tk.Label = tk.Label(
tw,
text=self.text,
background="lightgreen",
foreground="black",
relief="solid",
borderwidth=1,
padx=5,
pady=5,
)
label.grid()
self.tooltip_window.after(2200, lambda: tw.destroy())
def hide_tooltip(self, event: Optional[Any] = None) -> None:
"""Hides the tooltip"""
if self.tooltip_window:
self.tooltip_window.destroy()
self.tooltip_window = None
def hide_tooltip(self):
"""
Hides and destroys the tooltip window if it is currently visible.
"""
tw = self.tooltip_window
self.tooltip_window = None
if tw:
tw.destroy()
class LogConfig:
"""
A static class for configuring application-wide logging.
This class provides a convenient way to set up file-based logging for the application.
It ensures that log messages are written to a specified file with a consistent format.
Methods:
logger(file_path: str) -> None:
Configures the root logger to write messages to the specified file.
Usage Example:
# Assuming LOG_FILE_PATH is defined elsewhere (e.g., in a config file)
# LogConfig.logger(LOG_FILE_PATH)
# logging.info("This message will be written to the log file.")
"""
@staticmethod
def logger(file_path) -> None:
"""
Configures the root logger to write messages to the specified file.
Args:
file_path (str): The absolute path to the log file.
"""
file_handler = logging.FileHandler(
filename=f"{file_path}",
mode="a",
encoding="utf-8",
)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
formatter = logging.Formatter(
"%(asctime)s - %(levelname)s - %(message)s")
file_handler.setFormatter(formatter)
file_handler.setLevel(logging.DEBUG)
logger = logging.getLogger()
logger.setLevel(logging.DEBUG) # Set the root logger level
logger.addHandler(file_handler)
class IconManager:
"""
A class for central management and loading of application icons.
This class loads Tkinter PhotoImage objects from a specified base path,
organizing them by logical names and providing a convenient way to retrieve them.
It handles potential errors during image loading by creating a blank image placeholder.
Attributes:
base_path (str): The base directory where icon subfolders (e.g., '16', '32', '48', '64') are located.
icons (Dict[str, tk.PhotoImage]): A dictionary storing loaded PhotoImage objects,
keyed by their logical names (e.g., 'computer_small', 'folder_large').
Methods:
get_icon(name: str) -> Optional[tk.PhotoImage]:
Retrieves a loaded icon by its logical name.
Usage Example:
# Initialize the IconManager with the path to your icon directory
# icon_manager = IconManager(base_path="/usr/share/icons/lx-icons/")
# Retrieve an icon
# computer_icon = icon_manager.get_icon("computer_small")
# if computer_icon:
# my_label = tk.Label(root, image=computer_icon)
# my_label.pack()
"""
def __init__(self, base_path='/usr/share/icons/lx-icons/'):
self.base_path = base_path
self.icons = {}
self._define_icon_paths()
self._load_all()
def _define_icon_paths(self):
self.icon_paths = {
# 16x16
'settings_16': '16/settings.png',
# 32x32
'back': '32/arrow-left.png',
'forward': '32/arrow-right.png',
'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',
'device_small': '32/device.png',
'file_small': '32/document.png',
'download_error_small': '32/download_error.png',
'download_small': '32/download.png',
'error_small': '32/error.png',
'python_small': '32/file-python.png',
'documents_small': '32/folder-water-documents.png',
'downloads_small': '32/folder-water-download.png',
'music_small': '32/folder-water-music.png',
'pictures_small': '32/folder-water-pictures.png',
'folder_small': '32/folder-water.png',
'video_small': '32/folder-water-video.png',
'hide': '32/hide.png',
'home': '32/home.png',
'about': '32/about.png',
'info_small': '32/info.png',
'light_small': '32/light.png',
'dark_small': '32/dark.png',
'update_small': '32/update.png',
'no_update_small': '32/no_update.png',
'tooltip_small': '32/tip.png',
'no_tooltip_small': '32/no_tip.png',
'list_view': '32/list.png',
'log_small': '32/log.png',
'log_blue_small': '32/log_blue.png',
'lunix_tools_small': '32/Lunix_Tools.png',
'key_small': '32/lxtools_key.png',
'iso_small': '32/media-optical.png',
'new_document_small': '32/new-document.png',
'new_folder_small': '32/new-folder.png',
'pdf_small': '32/pdf.png',
'picture_small': '32/picture.png',
'question_mark_small': '32/question_mark.png',
'recursive_small': '32/recursive.png',
'search_small': '32/search.png',
'settings_small': '32/settings.png',
'settings-2_small': '32/settings-2.png',
'archive_small': '32/tar.png',
'unhide': '32/unhide.png',
'usb_small': '32/usb.png',
'video_small_file': '32/video.png',
'warning_small': '32/warning.png',
'export_small': '32/wg_export.png',
'import_small': '32/wg_import.png',
'message_small': '32/wg_msg.png',
'trash_small': '32/wg_trash.png',
'trash_small2': '32/trash.png',
'vpn_small': '32/wg_vpn.png',
'vpn_start_small': '32/wg_vpn-start.png',
'vpn_stop_small': '32/wg_vpn-stop.png',
# 48x48
'back_large': '48/arrow-left.png',
'forward_large': '48/arrow-right.png',
'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',
'download_error_large': '48/download_error.png',
'download_large': '48/download.png',
'error_large': '48/error.png',
'documents_large': '48/folder-water-documents.png',
'downloads_large': '48/folder-water-download.png',
'music_large': '48/folder-water-music.png',
'pictures_large': '48/folder-water-pictures.png',
'folder_large_48': '48/folder-water.png',
'video_large_folder': '48/folder-water-video.png',
'hide_large': '48/hide.png',
'home_large': '48/home.png',
'info_large': '48/info.png',
'light_large': '48/light.png',
'dark_large': '48/dark.png',
'update_large': '48/update.png',
'no_update_large': '48/no_update.png',
'tooltip_large': '48/tip.png',
'no_tooltip_large': '48/no_tip.png',
'about_large': '48/about.png',
'list_view_large': '48/list.png',
'log_large': '48/log.png',
'log_blue_large': '48/log_blue.png',
'lunix_tools_large': '48/Lunix_Tools.png',
'new_document_large': '48/new-document.png',
'new_folder_large': '48/new-folder.png',
'question_mark_large': '48/question_mark.png',
'search_large_48': '48/search.png',
'settings_large': '48/settings.png',
'unhide_large': '48/unhide.png',
'usb_large': '48/usb.png',
'warning_large_48': '48/warning.png',
'export_large': '48/wg_export.png',
'import_large': '48/wg_import.png',
'message_large': '48/wg_msg.png',
'trash_large': '48/wg_trash.png',
'trash_large2': '48/trash.png',
'vpn_large': '48/wg_vpn.png',
'vpn_start_large': '48/wg_vpn-start.png',
'vpn_stop_large': '48/wg_vpn-stop.png',
# 64x64
'back_extralarge': '64/arrow-left.png',
'forward_extralarge': '64/arrow-right.png',
'up_extralarge': '64/arrow-up.png',
'copy_extralarge': '64/copy.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',
'device_extralarge': '64/device.png',
'file_large': '64/document.png',
'download_error_extralarge': '64/download_error.png',
'download_extralarge': '64/download.png',
'error_extralarge': '64/error.png',
'python_large': '64/file-python.png',
'documents_extralarge': '64/folder-water-documents.png',
'downloads_extralarge': '64/folder-water-download.png',
'music_extralarge': '64/folder-water-music.png',
'pictures_extralarge': '64/folder-water-pictures.png',
'folder_large': '64/folder-water.png',
'video_extralarge_folder': '64/folder-water-video.png',
'hide_extralarge': '64/hide.png',
'home_extralarge': '64/home.png',
'info_extralarge': '64/info.png',
'light_extralarge': '64/light.png',
'dark_extralarge': '64/dark.png',
'update_extralarge': '64/update.png',
'no_update_extralarge': '64/no_update.png',
'tooltip_extralarge': '64/tip.png',
'no_tooltip_extralarge': '64/no_tip.png',
'about_extralarge': '64/about.png',
'list_view_extralarge': '64/list.png',
'log_extralarge': '64/log.png',
'log_blue_extralarge': '64/log_blue.png',
'lunix_tools_extralarge': '64/Lunix_Tools.png',
'iso_large': '64/media-optical.png',
'new_document_extralarge': '64/new-document.png',
'new_folder_extralarge': '64/new-folder.png',
'pdf_large': '64/pdf.png',
'picture_large': '64/picture.png',
'question_mark_extralarge': '64/question_mark.png',
'recursive_large': '64/recursive.png',
'search_large': '64/search.png',
'settings_extralarge': '64/settings.png',
'archive_large': '64/tar.png',
'unhide_extralarge': '64/unhide.png',
'usb_extralarge': '64/usb.png',
'video_large': '64/video.png',
'warning_large': '64/warning.png',
'export_extralarge': '64/wg_export.png',
'import_extralarge': '64/wg_import.png',
'message_extralarge': '64/wg_msg.png',
'trash_extralarge': '64/wg_trash.png',
'trash_extralarge2': '64/trash.png',
'vpn_extralarge': '64/wg_vpn.png',
'vpn_start_extralarge': '64/wg_vpn-start.png',
'vpn_stop_extralarge': '64/wg_vpn-stop.png',
}
def _load_all(self):
for key, rel_path in self.icon_paths.items():
full_path = os.path.join(self.base_path, rel_path)
try:
self.icons[key] = tk.PhotoImage(file=full_path)
except tk.TclError as e:
print(f"Error loading icon '{key}' from '{full_path}': {e}")
size = 32 # Default size
if '16' in rel_path:
size = 16
elif '48' in rel_path:
size = 48
elif '64' in rel_path:
size = 64
self.icons[key] = tk.PhotoImage(width=size, height=size)
def get_icon(self, name):
return self.icons.get(name)
class Translate:
@staticmethod
def setup_translations(app_name: str, locale_dir="/usr/share/locale/") -> gettext.gettext:
"""
Initialize translations and set the translation function
Special method for translating strings in this file
Returns:
The gettext translation function
"""
locale.bindtextdomain(app_name, locale_dir)
gettext.bindtextdomain(app_name, locale_dir)
gettext.textdomain(app_name)
return gettext.gettext
@contextmanager
def message_box_animation(animated_icon):
"""
A context manager to handle pausing and resuming an animated icon
around an operation like showing a message box.
Args:
animated_icon: The animated icon object with pause() and resume() methods.
"""
if animated_icon:
animated_icon.pause()
try:
yield
finally:
if animated_icon:
animated_icon.resume()

View File

@@ -0,0 +1,5 @@
# Gemini Project Configuration
## Language
Please respond in German.

View File

@@ -0,0 +1 @@
from .custom_file_dialog import CustomFileDialog

View File

@@ -0,0 +1,303 @@
#!/usr/bin/python3
"""App configuration for Custom File Dialog"""
import json
from pathlib import Path
from typing import Dict, Any, Optional, Type
from shared_libs.common_tools import Translate
# here is initializing the class for translation strings
_ = Translate.setup_translations("custom_file_dialog")
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
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
# Base paths
BASE_DIR: Path = Path.home()
CONFIG_DIR: Path = BASE_DIR / ".config/cfiledialog"
# UI configuration
UI_CONFIG: Dict[str, Any] = {
"window_size": (1050, 850),
"window_min_size": (650, 550),
"font_family": "Ubuntu",
"font_size": 11,
"resizable_window": (True, True),
}
_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'
"window_size_preset": "1050x850", # e.g., "1050x850"
"default_view_mode": "icons", # 'icons' or 'list'
"search_hidden_files": False, # True or False
"use_trash": False, # True or False
"confirm_delete": False, # True or False
"recursive_search": True,
"use_pillow_animation": True,
"keep_bookmarks_on_reset": True # Keep bookmarks when resetting settings
}
@classmethod
def _ensure_config_file(cls: Type['CfdConfigManager']) -> None:
"""Ensures the configuration file exists, creating it with default settings if necessary."""
if not cls._config_file.exists():
try:
cls._config_file.parent.mkdir(parents=True, exist_ok=True)
with open(cls._config_file, 'w', encoding='utf-8') as f:
json.dump(cls._default_settings, f, indent=4)
except IOError as e:
print(f"Error creating default settings file: {e}")
@classmethod
def load(cls: Type['CfdConfigManager']) -> Dict[str, Any]:
"""Loads settings from the JSON file. If the file doesn't exist or is invalid, it loads default settings."""
cls._ensure_config_file()
if cls._config is None:
try:
with open(cls._config_file, 'r', encoding='utf-8') as f:
loaded_config = json.load(f)
# Merge with defaults to ensure all keys are present
cls._config = cls._default_settings.copy()
cls._config.update(loaded_config)
except (IOError, json.JSONDecodeError):
cls._config = cls._default_settings.copy()
return cls._config
@classmethod
def save(cls: Type['CfdConfigManager'], settings: Dict[str, Any]) -> None:
"""Saves the given settings dictionary to the JSON file."""
try:
with open(cls._config_file, 'w', encoding='utf-8') as f:
json.dump(settings, f, indent=4)
cls._config = settings # Update cached config
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:
"""
Contains all translatable strings for the application, organized by module.
This class centralizes all user-facing strings to make translation and management easier.
The strings are grouped into nested dictionaries corresponding to the part of the application
where they are used (e.g., CFD for the main dialog, VIEW for view-related strings).
"""
# Strings from custom_file_dialog.py
CFD: Dict[str, str] = {
"title": _("Custom File Dialog"),
"select_file": _("Select a file"),
"open": _("Open"),
"cancel": _("Cancel"),
"file_label": _("File:"),
"no_file_selected": _("No file selected"),
"error_title": _("Error"),
"select_file_error": _("Please select a file."),
"all_files": _("All Files"),
"free_space": _("Free Space"),
"entries": _("entries"),
"directory_not_found": _("Directory not found"),
"unknown": _("Unknown"),
"showing": _("Showing"),
"of": _("of"),
"access_denied": _("Access denied."),
"path_not_found": _("Path not found"),
"directory": _("Directory"),
"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
VIEW: Dict[str, str] = {
"name": _("Name"),
"date_modified": _("Date Modified"),
"type": _("Type"),
"size": _("Size"),
"view_mode": _("View Mode"),
"icon_view": _("Icon View"),
"list_view": _("List View"),
"filename": _("Filename"),
"path": _("Path"),
}
# Strings from cfd_ui_setup.py
UI: Dict[str, str] = {
"search": _("Search"),
"go": _("Go"),
"up": _("Up"),
"back": _("Back"),
"forward": _("Forward"),
"home": _("Home"),
"new_folder": _("New Folder"),
"delete": _("Delete"),
"settings": _("Settings"),
"show_hidden_files": _("Show Hidden Files"),
"places": _("Places"),
"devices": _("Devices"),
"bookmarks": _("Bookmarks"),
"new_document": _("New Document"),
"hide_hidden_files": _("Hide Hidden Files"),
"start_search": _("Start Search"),
"cancel_search": _("Cancel Search"),
"delete_move": _("Delete/Move selected item"),
"copy_filename_to_clipboard": _("Copy Filename to Clipboard"),
"copy_path_to_clipboard": _("Copy Path to Clipboard"),
"open_file_location": _("Open File Location"),
"searching_for": _("Searching for"),
"search_cancelled_by_user": _("Search cancelled by user"),
"folders_and": _("folders and"),
"files_found": _("files found."),
"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
SET: Dict[str, str] = {
"title": _("Settings"),
"search_icon_pos_label": _("Search Icon Position"),
"left_radio": _("Left"),
"right_radio": _("Right"),
"button_box_pos_label": _("Button Box Position"),
"window_size_label": _("Window Size"),
"default_view_mode_label": _("Default View Mode"),
"icons_radio": _("Icons"),
"list_radio": _("List"),
"search_hidden_check": _("Search hidden files"),
"use_trash_check": _("Use trash for deletion"),
"confirm_delete_check": _("Confirm file deletion"),
"recursive_search_check": _("Recursive search"),
"use_pillow_check": _("Use Pillow animation"),
"save_button": _("Save"),
"cancel_button": _("Cancel"),
"search_settings": _("Search Settings"),
"deletion_settings": _("Deletion Settings"),
"recommended": _("recommended"),
"send2trash_not_found": _("send2trash library not found"),
"animation_settings": _("Animation Settings"),
"pillow": _("Pillow"),
"pillow_not_found": _("Pillow library not found"),
"animation_type": _("Animation Type"),
"counter_arc": _("Counter Arc"),
"double_arc": _("Double Arc"),
"line": _("Line"),
"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
FILE: Dict[str, str] = {
"new_folder_title": _("New Folder"),
"enter_folder_name_label": _("Enter folder name:"),
"untitled_folder": _("Untitled Folder"),
"error_title": _("Error"),
"folder_exists_error": _("Folder already exists."),
"create_folder_error": _("Could not create folder."),
"confirm_delete_title": _("Confirm Deletion"),
"confirm_delete_file_message": _("Are you sure you want to permanently delete this file?"),
"confirm_delete_files_message": _("Are you sure you want to permanently delete these files?"),
"delete_button": _("Delete"),
"cancel_button": _("Cancel"),
"file_not_found_error": _("File not found."),
"trash_error": _("Could not move file to trash."),
"delete_error": _("Could not delete file."),
"folder": _("Folder"),
"file": _("File"),
"move_to_trash": _("move to trash"),
"delete_permanently": _("delete permanently"),
"are_you_sure": _("Are you sure you want to"),
"was_successfully_removed": _("was successfully removed."),
"error_removing": _("Error removing"),
"new_document_txt": _("New Document.txt"),
"error_creating": _("Error creating"),
"copied_to_clipboard": _("copied to clipboard."),
"error_renaming": _("Error renaming"),
"not_accessible": _("not accessible"),
}
# Strings from cfd_navigation_manager.py
NAV: Dict[str, str] = {
"home": _("Home"),
"trash": _("Trash"),
"desktop": _("Desktop"),
"documents": _("Documents"),
"downloads": _("Downloads"),
"music": _("Music"),
"pictures": _("Pictures"),
"videos": _("Videos"),
"computer": _("Computer"),
}

View File

@@ -0,0 +1,409 @@
import os
import shutil
import tkinter as tk
from tkinter import ttk
from typing import Optional, Any, TYPE_CHECKING
try:
import send2trash
SEND2TRASH_AVAILABLE = True
except ImportError:
SEND2TRASH_AVAILABLE = False
from shared_libs.message import MessageDialog
from .cfd_app_config import LocaleStrings
if TYPE_CHECKING:
from custom_file_dialog import CustomFileDialog
class FileOperationsManager:
"""Manages file operations like delete, create, and rename."""
def __init__(self, dialog: 'CustomFileDialog') -> None:
"""
Initializes the FileOperationsManager.
Args:
dialog: The main CustomFileDialog instance.
"""
self.dialog = dialog
def delete_selected_item(self, event: Optional[tk.Event] = None) -> None:
"""
Deletes the selected item or moves it to the trash.
This method checks user settings to determine whether to move the item
to the system's trash (if available) or delete it permanently.
It also handles the confirmation dialog based on user preferences.
Args:
event: The event that triggered the deletion (optional).
"""
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(
"use_trash", False) and SEND2TRASH_AVAILABLE
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(selected_path)
if not confirm:
dialog = MessageDialog(
master=self.dialog,
title=LocaleStrings.FILE["confirm_delete_title"],
text=f"{LocaleStrings.FILE['are_you_sure']} '{item_name}' {action_text}?",
message_type="question"
)
if not dialog.show():
return
try:
if use_trash:
send2trash.send2trash(selected_path)
else:
if os.path.isdir(selected_path):
shutil.rmtree(selected_path)
else:
os.remove(selected_path)
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()
def create_new_folder(self) -> None:
"""Creates a new folder in the current directory."""
self._create_new_item(is_folder=True)
def create_new_file(self) -> None:
"""Creates a new empty file in the current directory."""
self._create_new_item(is_folder=False)
def _create_new_item(self, is_folder: bool) -> None:
"""
Internal helper to create a new file or folder.
It generates a unique name and creates the item, then refreshes the view.
Args:
is_folder (bool): True to create a folder, False to create a file.
"""
base_name = LocaleStrings.FILE["new_folder_title"] if is_folder else LocaleStrings.FILE["new_document_txt"]
new_name = self._get_unique_name(base_name)
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:
open(new_path, 'a').close()
self.dialog.view_manager.populate_files(item_to_rename=new_name)
except Exception as e:
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.FILE['error_creating']}: {e}")
def _get_unique_name(self, base_name: str) -> str:
"""
Generates a unique name for a file or folder.
If a file or folder with `base_name` already exists, it appends
a counter (e.g., "New Folder 2") until a unique name is found.
Args:
base_name (str): The initial name for the item.
Returns:
str: A unique name for the item in the current directory.
"""
name, ext = os.path.splitext(base_name)
counter = 1
new_name = base_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
def _copy_to_clipboard(self, data: str) -> None:
"""
Copies the given data to the system clipboard.
Args:
data (str): The text to be copied.
"""
self.dialog.clipboard_clear()
self.dialog.clipboard_append(data)
self.dialog.widget_manager.search_status_label.config(
text=f"'{self.dialog.shorten_text(data, 50)}' {LocaleStrings.FILE['copied_to_clipboard']}")
def _show_context_menu(self, event: tk.Event, item_path: str) -> str:
"""
Displays a context menu for the selected item.
Args:
event: The mouse event that triggered the menu.
item_path (str): The full path to the item.
Returns:
str: "break" to prevent further event propagation.
"""
if not item_path:
return "break"
if hasattr(self.dialog, 'context_menu') and self.dialog.context_menu.winfo_exists():
self.dialog.context_menu.destroy()
self.dialog.context_menu = tk.Menu(self.dialog, tearoff=0, background=self.dialog.style_manager.header, foreground=self.dialog.style_manager.color_foreground,
activebackground=self.dialog.style_manager.selection_color, activeforeground=self.dialog.style_manager.color_foreground, relief='flat', borderwidth=0)
self.dialog.context_menu.add_command(label=LocaleStrings.UI["copy_filename_to_clipboard"],
command=lambda: self._copy_to_clipboard(os.path.basename(item_path)),
image=self.dialog.icon_manager.get_icon('copy'), compound='left')
self.dialog.context_menu.add_command(
label=LocaleStrings.UI["copy_path_to_clipboard"], command=lambda: self._copy_to_clipboard(item_path),
image=self.dialog.icon_manager.get_icon('copy'), compound='left')
self.dialog.context_menu.add_separator()
self.dialog.context_menu.add_command(
label=LocaleStrings.UI["open_file_location"], command=lambda: self._open_file_location_from_context(item_path),
image=self.dialog.icon_manager.get_icon('stair'), compound='left')
self.dialog.context_menu.tk_popup(event.x_root, event.y_root)
return "break"
def _open_file_location_from_context(self, file_path: str) -> None:
"""
Navigates to the location of the given file path.
This is used by the context menu to jump to a file's directory,
which is especially useful when in search mode.
Args:
file_path (str): The full path to the file.
"""
directory = os.path.dirname(file_path)
filename = os.path.basename(file_path)
if self.dialog.search_mode:
self.dialog.search_manager.hide_search_bar()
self.dialog.navigation_manager.navigate_to(directory)
self.dialog.after(
100, lambda: self.dialog.view_manager._select_file_in_view(filename))
def on_rename_request(self, event: tk.Event, item_path: Optional[str] = None, item_frame: Optional[tk.Widget] = None) -> None:
"""
Handles the initial request to rename an item.
This method is triggered by an event (e.g., F2 key press) and
initiates the renaming process based on the current view mode.
Args:
event: The event that triggered the rename.
item_path (str, optional): The path of the item in icon view.
item_frame (tk.Widget, optional): The frame of the item in icon view.
"""
if self.dialog.view_mode.get() == "list":
if not self.dialog.tree.selection():
return
item_id = self.dialog.tree.selection()[0]
self.start_rename(item_id)
else: # icon view
if item_path and item_frame:
self.start_rename(item_frame, item_path)
def start_rename(self, item_widget: Any, item_path: Optional[str] = None) -> None:
"""
Starts the renaming UI for an item.
Dispatches to the appropriate method based on the current view mode.
Args:
item_widget: The widget representing the item (item_id for list view,
item_frame for icon view).
item_path (str, optional): The full path to the item being renamed.
Required for icon view.
"""
if self.dialog.view_mode.get() == "icons":
if item_path:
self._start_rename_icon_view(item_widget, item_path)
else: # list view
self._start_rename_list_view(item_widget) # item_widget is item_id
def _start_rename_icon_view(self, item_frame: ttk.Frame, item_path: str) -> None:
"""
Initiates the in-place rename UI for an item in icon view.
It replaces the item's label with an Entry widget.
Args:
item_frame (tk.Widget): The frame containing the item's icon and label.
item_path (str): The full path to the item.
"""
for child in item_frame.winfo_children():
child.destroy()
entry = ttk.Entry(item_frame)
entry.pack(fill="both", expand=True, padx=2, pady=20)
entry.insert(0, os.path.basename(item_path))
entry.select_range(0, tk.END)
entry.focus_set()
def finish_rename(event: tk.Event) -> None:
new_name = entry.get()
self._finish_rename_logic(item_path, new_name)
def cancel_rename(event: tk.Event) -> None:
self.dialog.view_manager.populate_files()
entry.bind("<Return>", finish_rename)
entry.bind("<FocusOut>", finish_rename)
entry.bind("<Escape>", cancel_rename)
def _start_rename_list_view(self, item_id: str) -> None:
"""
Initiates the in-place rename UI for an item in list view.
It places an Entry widget over the Treeview item's cell.
Args:
item_id: The ID of the treeview item to be renamed.
"""
self.dialog.tree.see(item_id)
self.dialog.tree.update_idletasks()
bbox = self.dialog.tree.bbox(item_id, column="#0")
if not bbox:
return
x, y, width, height = bbox
entry = ttk.Entry(self.dialog.tree)
entry_width = self.dialog.tree.column("#0", "width")
entry.place(x=x, y=y, width=entry_width, height=height)
item_text = self.dialog.tree.item(item_id, "text").strip()
entry.insert(0, item_text)
entry.select_range(0, tk.END)
entry.focus_set()
old_path = os.path.join(self.dialog.current_dir, item_text)
def finish_rename(event: tk.Event) -> None:
new_name = entry.get()
entry.destroy()
self._finish_rename_logic(old_path, new_name)
def cancel_rename(event: tk.Event) -> None:
entry.destroy()
entry.bind("<Return>", finish_rename)
entry.bind("<FocusOut>", finish_rename)
entry.bind("<Escape>", cancel_rename)
def _finish_rename_logic(self, old_path: str, new_name: str) -> None:
"""
Handles the core logic of renaming a file or folder after the user
submits the new name from an Entry widget.
Args:
old_path (str): The original full path of the item.
new_name (str): The new name for the item.
"""
new_path = os.path.join(self.dialog.current_dir, new_name)
old_name = os.path.basename(old_path)
if not new_name or new_path == old_path:
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']}")
self.dialog.view_manager.populate_files(item_to_select=old_name)
return
try:
os.rename(old_path, new_path)
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()

View File

@@ -0,0 +1,131 @@
import os
import tkinter as tk
from typing import Optional, TYPE_CHECKING
from .cfd_app_config import LocaleStrings
if TYPE_CHECKING:
from custom_file_dialog import CustomFileDialog
class NavigationManager:
"""Manages directory navigation, history, and path handling."""
def __init__(self, dialog: 'CustomFileDialog') -> None:
"""
Initializes the NavigationManager.
Args:
dialog: The main CustomFileDialog instance.
"""
self.dialog = dialog
def handle_path_entry_return(self, event: tk.Event) -> None:
"""
Handles the Return key press in the path entry field.
It attempts to navigate to the entered path. If the path is a file,
it navigates to the containing directory and selects the file.
Args:
event: The tkinter event that triggered this handler.
"""
path_text = self.dialog.widget_manager.path_entry.get().strip()
potential_path = os.path.realpath(os.path.expanduser(path_text))
if os.path.isdir(potential_path):
self.navigate_to(potential_path)
elif os.path.isfile(potential_path):
directory = os.path.dirname(potential_path)
filename = os.path.basename(potential_path)
self.navigate_to(directory, file_to_select=filename)
else:
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.CFD['path_not_found']}: {self.dialog.shorten_text(path_text, 50)}")
def navigate_to(self, path: str, file_to_select: Optional[str] = None) -> None:
"""
Navigates to a specified directory path.
This is the core navigation method. It validates the path, checks for
read permissions, updates the dialog's current directory, manages the
navigation history, and refreshes the file view.
Args:
path (str): The absolute path to navigate to.
file_to_select (str, optional): If provided, this filename will be
selected after navigation. Defaults to None.
"""
try:
real_path = os.path.realpath(
os.path.abspath(os.path.expanduser(path)))
if not os.path.isdir(real_path):
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.CFD['error_title']}: {LocaleStrings.CFD['directory']} '{os.path.basename(path)}' {LocaleStrings.CFD['not_found']}")
return
if not os.access(real_path, os.R_OK):
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.CFD['access_to']} '{os.path.basename(path)}' {LocaleStrings.CFD['denied']}")
return
self.dialog.current_dir = real_path
if self.dialog.history_pos < len(self.dialog.history) - 1:
self.dialog.history = self.dialog.history[:self.dialog.history_pos + 1]
if not self.dialog.history or self.dialog.history[-1] != self.dialog.current_dir:
self.dialog.history.append(self.dialog.current_dir)
self.dialog.history_pos = len(self.dialog.history) - 1
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_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(
text=f"{LocaleStrings.CFD['error_title']}: {e}")
def go_back(self) -> None:
"""Navigates to the previous directory in the history."""
if self.dialog.history_pos > 0:
self.dialog.history_pos -= 1
self.dialog.current_dir = self.dialog.history[self.dialog.history_pos]
self._update_ui_after_navigation()
def go_forward(self) -> None:
"""Navigates to the next directory in the history."""
if self.dialog.history_pos < len(self.dialog.history) - 1:
self.dialog.history_pos += 1
self.dialog.current_dir = self.dialog.history[self.dialog.history_pos]
self._update_ui_after_navigation()
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)
def _update_ui_after_navigation(self) -> None:
"""Updates all necessary UI components after a navigation action."""
self.dialog.view_manager.populate_files()
self.update_nav_buttons()
self.dialog.update_selection_info()
self.dialog.update_action_buttons_state()
def update_nav_buttons(self) -> None:
"""Updates the state of the back and forward navigation buttons."""
self.dialog.widget_manager.back_button.config(
state=tk.NORMAL if self.dialog.history_pos > 0 else tk.DISABLED)
self.dialog.widget_manager.forward_button.config(state=tk.NORMAL if self.dialog.history_pos < len(
self.dialog.history) - 1 else tk.DISABLED)

View File

@@ -0,0 +1,346 @@
import os
import threading
import subprocess
from datetime import datetime
import tkinter as tk
from tkinter import ttk
from typing import Optional, TYPE_CHECKING
from shared_libs.message import MessageDialog
from .cfd_ui_setup import get_xdg_user_dir
from .cfd_app_config import LocaleStrings
if TYPE_CHECKING:
from custom_file_dialog import CustomFileDialog
class SearchManager:
"""Manages the file search functionality, including UI and threading."""
def __init__(self, dialog: 'CustomFileDialog') -> None:
"""
Initializes the SearchManager.
Args:
dialog: The main CustomFileDialog instance.
"""
self.dialog = dialog
def show_search_ready(self, event: Optional[tk.Event] = None) -> None:
"""Shows the static 'full circle' to indicate search is ready."""
if not self.dialog.search_mode:
self.dialog.widget_manager.search_animation.show_full_circle()
def activate_search(self, event: Optional[tk.Event] = None) -> None:
"""
Activates the search entry or cancels an ongoing search.
If a search is running, it cancels it. Otherwise, it executes a new search
only if there is a search term present.
"""
if self.dialog.widget_manager.search_animation.running:
if self.dialog.search_thread and self.dialog.search_thread.is_alive():
self.dialog.search_thread.cancelled = True
self.dialog.widget_manager.search_animation.stop()
self.dialog.widget_manager.search_status_label.config(
text=LocaleStrings.UI["cancel_search"])
else:
# Only execute search if there is text in the entry
if self.dialog.widget_manager.filename_entry.get().strip():
self.execute_search()
def show_search_bar(self, event: tk.Event) -> None:
"""
Activates search mode and displays the search bar upon user typing.
Args:
event: The key press event that triggered the search.
"""
if isinstance(event.widget, (ttk.Entry, tk.Entry)) or not event.char.strip():
return
self.dialog.search_mode = True
self.dialog.widget_manager.filename_entry.focus_set()
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
self.dialog.widget_manager.filename_entry.insert(0, event.char)
self.dialog.widget_manager.search_animation.show_full_circle()
def hide_search_bar(self, event: Optional[tk.Event] = None) -> None:
"""
Deactivates search mode, clears the search bar, and restores the file view.
"""
self.dialog.search_mode = False
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
self.dialog.widget_manager.search_status_label.config(text="")
self.dialog.widget_manager.filename_entry.unbind("<Escape>")
self.dialog.view_manager.populate_files()
self.dialog.widget_manager.search_animation.hide()
def execute_search(self, event: Optional[tk.Event] = None) -> None:
"""
Initiates a file search in a background thread.
Prevents starting a new search if one is already running.
"""
if self.dialog.search_thread and self.dialog.search_thread.is_alive():
return
search_term = self.dialog.widget_manager.filename_entry.get().strip()
if not search_term:
self.hide_search_bar()
return
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.UI['searching_for']} '{search_term}'...")
self.dialog.widget_manager.search_animation.start(pulse=False)
self.dialog.update_idletasks()
self.dialog.search_thread = threading.Thread(
target=self._perform_search_in_thread, args=(search_term,))
self.dialog.search_thread.start()
def _perform_search_in_thread(self, search_term: str) -> None:
"""
Performs the actual file search in a background thread.
Searches the current directory and relevant XDG user directories.
Handles recursive/non-recursive and hidden/non-hidden file searches.
Updates the UI with results upon completion.
Args:
search_term (str): The term to search for.
"""
self.dialog.search_results.clear()
search_dirs = [self.dialog.current_dir]
home_dir = os.path.expanduser("~")
if os.path.abspath(self.dialog.current_dir) == os.path.abspath(home_dir):
xdg_dirs = [get_xdg_user_dir(d, f) for d, f in [("XDG_DOWNLOAD_DIR", "Downloads"), ("XDG_DOCUMENTS_DIR", "Documents"), (
"XDG_PICTURES_DIR", "Pictures"), ("XDG_MUSIC_DIR", "Music"), ("XDG_VIDEO_DIR", "Videos")]]
search_dirs.extend([d for d in xdg_dirs if os.path.exists(
d) and os.path.abspath(d) != home_dir and d not in search_dirs])
search_successful = False
try:
all_files = []
is_recursive = self.dialog.settings.get("recursive_search", True)
search_hidden = self.dialog.settings.get(
"search_hidden_files", False)
search_term_lower = search_term.lower()
for search_dir in search_dirs:
if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()):
break
if not os.path.exists(search_dir):
continue
is_home_search = os.path.abspath(search_dir) == home_dir
follow_links = is_recursive and is_home_search
if is_recursive:
for root, dirs, files in os.walk(search_dir, followlinks=follow_links):
if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()):
raise InterruptedError(
LocaleStrings.UI["search_cancelled_by_user"])
if not search_hidden:
dirs[:] = [
d for d in dirs if not d.startswith('.')]
files = [f for f in files if not f.startswith('.')]
for name in files:
if search_term_lower in name.lower() and self.dialog._matches_filetype(name):
all_files.append(os.path.join(root, name))
for name in dirs:
if search_term_lower in name.lower():
all_files.append(os.path.join(root, name))
else:
for name in os.listdir(search_dir):
if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()):
raise InterruptedError(
LocaleStrings.UI["search_cancelled_by_user"])
if not search_hidden and name.startswith('.'):
continue
path = os.path.join(search_dir, name)
is_dir = os.path.isdir(path)
if search_term_lower in name.lower():
if is_dir:
all_files.append(path)
elif self.dialog._matches_filetype(name):
all_files.append(path)
if is_recursive:
break
if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()):
raise InterruptedError(
LocaleStrings.UI["search_cancelled_by_user"])
seen = set()
self.dialog.search_results = [
x for x in all_files if not (x in seen or seen.add(x))]
def update_ui() -> None:
nonlocal search_successful
if self.dialog.search_results:
search_successful = True
self.show_search_results_treeview()
folder_count = sum(
1 for p in self.dialog.search_results if os.path.isdir(p))
file_count = len(self.dialog.search_results) - folder_count
self.dialog.widget_manager.search_status_label.config(
text=f"{folder_count} {LocaleStrings.UI['folders_and']} {file_count} {LocaleStrings.UI['files_found']}")
else:
search_successful = False
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.UI['no_results_for']} '{search_term}'.")
self.dialog.after(0, update_ui)
except (Exception, InterruptedError) as e:
if isinstance(e, (InterruptedError, subprocess.SubprocessError)):
self.dialog.after(0, lambda: self.dialog.widget_manager.search_status_label.config(
text=LocaleStrings.UI["cancel_search"]))
else:
self.dialog.after(0, lambda: MessageDialog(
message_type="error", text=f"{LocaleStrings.UI['error_during_search']}: {e}", title=LocaleStrings.UI["search_error"], master=self.dialog).show())
finally:
self.dialog.after(
0, lambda: self.dialog.widget_manager.search_animation.stop(status="DISABLE" if not search_successful else None))
self.dialog.search_process = None
def show_search_results_treeview(self) -> None:
"""Displays the search results in a dedicated Treeview."""
if self.dialog.widget_manager.file_list_frame.winfo_exists():
for widget in self.dialog.widget_manager.file_list_frame.winfo_children():
widget.destroy()
tree_frame = ttk.Frame(self.dialog.widget_manager.file_list_frame)
tree_frame.pack(fill='both', expand=True)
tree_frame.grid_rowconfigure(0, weight=1)
tree_frame.grid_columnconfigure(0, weight=1)
columns = ("path", "size", "modified")
search_tree = ttk.Treeview(
tree_frame, columns=columns, show="tree headings")
search_tree.heading(
"#0", text=LocaleStrings.VIEW["filename"], anchor="w")
search_tree.column("#0", anchor="w", width=200, stretch=True)
search_tree.heading(
"path", text=LocaleStrings.VIEW["path"], anchor="w")
search_tree.column("path", anchor="w", width=300, stretch=True)
search_tree.heading(
"size", text=LocaleStrings.VIEW["size"], anchor="e")
search_tree.column("size", anchor="e", width=100, stretch=False)
search_tree.heading(
"modified", text=LocaleStrings.VIEW["date_modified"], anchor="w")
search_tree.column("modified", anchor="w", width=160, stretch=False)
v_scrollbar = ttk.Scrollbar(
tree_frame, orient="vertical", command=search_tree.yview)
h_scrollbar = ttk.Scrollbar(
tree_frame, orient="horizontal", command=search_tree.xview)
search_tree.configure(yscrollcommand=v_scrollbar.set,
xscrollcommand=h_scrollbar.set)
search_tree.grid(row=0, column=0, sticky='nsew')
v_scrollbar.grid(row=0, column=1, sticky='ns')
h_scrollbar.grid(row=1, column=0, sticky='ew')
for file_path in self.dialog.search_results:
try:
filename = os.path.basename(file_path)
directory = os.path.dirname(file_path)
stat = os.stat(file_path)
size = self.dialog._format_size(stat.st_size)
modified_time = datetime.fromtimestamp(
stat.st_mtime).strftime('%d.%m.%Y %H:%M')
if os.path.isdir(file_path):
icon = self.dialog.icon_manager.get_icon('folder_small')
else:
icon = self.dialog.get_file_icon(filename, 'small')
search_tree.insert("", "end", text=f" {filename}", image=icon,
values=(directory, size, modified_time))
except (FileNotFoundError, PermissionError):
continue
def on_search_select(event: tk.Event) -> None:
selection = search_tree.selection()
if selection:
item = search_tree.item(selection[0])
filename = item['text'].strip()
directory = item['values'][0]
full_path = os.path.join(directory, filename)
try:
stat = os.stat(full_path)
size_str = self.dialog._format_size(stat.st_size)
self.dialog.widget_manager.search_status_label.config(
text=f"'{filename}' {LocaleStrings.VIEW['size']}: {size_str}")
except (FileNotFoundError, PermissionError):
self.dialog.widget_manager.search_status_label.config(
text=f"'{filename}' {LocaleStrings.FILE['not_accessible']}")
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
self.dialog.widget_manager.filename_entry.insert(0, filename)
search_tree.bind("<<TreeviewSelect>>", on_search_select)
def on_search_double_click(event: tk.Event) -> None:
selection = search_tree.selection()
if not selection:
return
item = search_tree.item(selection[0])
filename = item['text'].strip()
directory = item['values'][0]
full_path = os.path.join(directory, filename)
if not os.path.exists(full_path):
return # Item no longer exists
if os.path.isdir(full_path):
# For directories, navigate into them
self.hide_search_bar()
self.dialog.navigation_manager.navigate_to(full_path)
else:
# For files, select it and close the dialog
self.dialog.selected_file = full_path
self.dialog.destroy()
search_tree.bind("<Double-1>", on_search_double_click)
def show_context_menu(event: tk.Event) -> str:
iid = search_tree.identify_row(event.y)
if not iid:
return "break"
search_tree.selection_set(iid)
item = search_tree.item(iid)
filename = item['text'].strip()
directory = item['values'][0]
full_path = os.path.join(directory, filename)
self.dialog.file_op_manager._show_context_menu(event, full_path)
return "break"
search_tree.bind("<ButtonRelease-3>", show_context_menu)
def _open_file_location(self, search_tree: ttk.Treeview) -> None:
"""
Navigates to the directory of the selected item in the search results.
Args:
search_tree: The Treeview widget containing the search results.
"""
selection = search_tree.selection()
if not selection:
return
item = search_tree.item(selection[0])
filename = item['text'].strip()
directory = item['values'][0]
self.hide_search_bar()
self.dialog.navigation_manager.navigate_to(directory)
self.dialog.after(
100, lambda: self.dialog.view_manager._select_file_in_view(filename))

View File

@@ -0,0 +1,218 @@
import tkinter as tk
from tkinter import ttk
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
try:
import send2trash
SEND2TRASH_AVAILABLE = True
except ImportError:
SEND2TRASH_AVAILABLE = False
class SettingsDialog(tk.Toplevel):
"""A dialog window for configuring the application settings."""
def __init__(self, parent: 'CustomFileDialog', dialog_mode: str = "save") -> None:
"""
Initializes the SettingsDialog.
Args:
parent: The parent widget.
dialog_mode (str, optional): The mode of the main dialog ("open" or "save"),
which affects available settings. Defaults to "save".
"""
super().__init__(parent)
self.transient(parent)
self.grab_set()
self.title(LocaleStrings.SET["title"])
self.settings = CfdConfigManager.load()
self.dialog_mode = dialog_mode
# Variables
self.search_icon_pos = tk.StringVar(
value=self.settings.get("search_icon_pos", "right"))
self.button_box_pos = tk.StringVar(
value=self.settings.get("button_box_pos", "left"))
self.window_size_preset = tk.StringVar(
value=self.settings.get("window_size_preset", "1050x850"))
self.default_view_mode = tk.StringVar(
value=self.settings.get("default_view_mode", "icons"))
self.search_hidden_files = tk.BooleanVar(
value=self.settings.get("search_hidden_files", False))
self.recursive_search = tk.BooleanVar(
value=self.settings.get("recursive_search", True))
self.use_trash = tk.BooleanVar(
value=self.settings.get("use_trash", False))
self.confirm_delete = tk.BooleanVar(
value=self.settings.get("confirm_delete", False))
self.use_pillow_animation = tk.BooleanVar(
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)
main_frame.pack(fill="both", expand=True)
# Button Box Position
button_box_frame = ttk.LabelFrame(
main_frame, text=LocaleStrings.SET["button_box_pos_label"], padding=10)
button_box_frame.pack(fill="x", pady=5)
ttk.Radiobutton(button_box_frame, text=LocaleStrings.SET["left_radio"],
variable=self.button_box_pos, value="left").pack(side="left", padx=5)
ttk.Radiobutton(button_box_frame, text=LocaleStrings.SET["right_radio"],
variable=self.button_box_pos, value="right").pack(side="left", padx=5)
# Window Size
size_frame = ttk.LabelFrame(
main_frame, text=LocaleStrings.SET["window_size_label"], padding=10)
size_frame.pack(fill="x", pady=5)
sizes = ["1050x850", "850x650", "650x450"]
size_combo = ttk.Combobox(
size_frame, textvariable=self.window_size_preset, values=sizes, state="readonly")
size_combo.pack(fill="x")
# Default View Mode
view_mode_frame = ttk.LabelFrame(
main_frame, text=LocaleStrings.SET["default_view_mode_label"], padding=10)
view_mode_frame.pack(fill="x", pady=5)
ttk.Radiobutton(view_mode_frame, text=LocaleStrings.SET["icons_radio"],
variable=self.default_view_mode, value="icons").pack(side="left", padx=5)
ttk.Radiobutton(view_mode_frame, text=LocaleStrings.SET["list_radio"],
variable=self.default_view_mode, value="list").pack(side="left", padx=5)
# Search Hidden Files
search_hidden_frame = ttk.LabelFrame(
main_frame, text=LocaleStrings.SET["search_settings"], padding=10)
search_hidden_frame.pack(fill="x", pady=5)
ttk.Checkbutton(search_hidden_frame, text=LocaleStrings.SET["search_hidden_check"],
variable=self.search_hidden_files).pack(anchor="w")
ttk.Checkbutton(search_hidden_frame, text=LocaleStrings.SET["recursive_search_check"],
variable=self.recursive_search).pack(anchor="w")
# Deletion Settings
delete_frame = ttk.LabelFrame(
main_frame, text=LocaleStrings.SET["deletion_settings"], padding=10)
delete_frame.pack(fill="x", pady=5)
self.use_trash_checkbutton = ttk.Checkbutton(delete_frame, text=f"{LocaleStrings.SET['use_trash_check']} ({LocaleStrings.SET['recommended']})",
variable=self.use_trash)
self.use_trash_checkbutton.pack(anchor="w")
if not SEND2TRASH_AVAILABLE:
self.use_trash_checkbutton.config(state=tk.DISABLED)
ttk.Label(delete_frame, text=f"({LocaleStrings.SET['send2trash_not_found']})",
font=("TkDefaultFont", 9, "italic")).pack(anchor="w", padx=(20, 0))
self.confirm_delete_checkbutton = ttk.Checkbutton(delete_frame, text=LocaleStrings.SET["confirm_delete_check"],
variable=self.confirm_delete)
self.confirm_delete_checkbutton.pack(anchor="w")
# Pillow Animation
pillow_frame = ttk.LabelFrame(
main_frame, text=LocaleStrings.SET["animation_settings"], padding=10)
pillow_frame.pack(fill="x", pady=5)
self.use_pillow_animation_checkbutton = ttk.Checkbutton(pillow_frame, text=f"{LocaleStrings.SET['use_pillow_check']} ({LocaleStrings.SET['pillow']})",
variable=self.use_pillow_animation)
self.use_pillow_animation_checkbutton.pack(anchor="w")
if not PIL_AVAILABLE:
self.use_pillow_animation_checkbutton.config(state=tk.DISABLED)
ttk.Label(pillow_frame, text=f"({LocaleStrings.SET['pillow_not_found']})",
font=("TkDefaultFont", 9, "italic")).pack(anchor="w", padx=(20, 0))
# Animation Type
anim_type_frame = ttk.LabelFrame(
main_frame, text=LocaleStrings.SET["animation_type"], padding=10)
anim_type_frame.pack(fill="x", pady=5)
ttk.Radiobutton(anim_type_frame, text=LocaleStrings.SET["counter_arc"], variable=self.animation_type,
value="counter_arc").pack(side="left", padx=5)
ttk.Radiobutton(anim_type_frame, text=LocaleStrings.SET["double_arc"], variable=self.animation_type,
value="double_arc").pack(side="left", padx=5)
ttk.Radiobutton(anim_type_frame, text=LocaleStrings.SET["line"], variable=self.animation_type,
value="line").pack(side="left", padx=5)
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)
self.confirm_delete_checkbutton.config(state=tk.DISABLED)
info_label = ttk.Label(delete_frame, text=f"({LocaleStrings.SET['deletion_options_info']})",
font=("TkDefaultFont", 9, "italic"))
info_label.pack(anchor="w", padx=(20, 0))
# --- Action Buttons ---
button_frame = ttk.Frame(main_frame)
button_frame.pack(fill="x", pady=(10, 0))
ttk.Button(button_frame, text=LocaleStrings.SET["reset_to_default"],
command=self.reset_to_defaults).pack(side="left", padx=5)
ttk.Button(button_frame, text=LocaleStrings.SET["save_button"],
command=self.save_settings).pack(side="right", padx=5)
ttk.Button(button_frame, text=LocaleStrings.SET["cancel_button"],
command=self.destroy).pack(side="right")
def save_settings(self) -> None:
"""
Saves the current settings to the configuration file and closes the dialog.
Triggers a UI rebuild in the parent dialog to apply the changes.
"""
new_settings = {
"button_box_pos": self.button_box_pos.get(),
"window_size_preset": self.window_size_preset.get(),
"default_view_mode": self.default_view_mode.get(),
"search_hidden_files": self.search_hidden_files.get(),
"recursive_search": self.recursive_search.get(),
"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(),
"keep_bookmarks_on_reset": self.keep_bookmarks_on_reset.get()
}
CfdConfigManager.save(new_settings)
self.master.reload_config_and_rebuild_ui()
self.destroy()
def reset_to_defaults(self) -> None:
"""Resets all settings in the dialog to their default values."""
defaults = CfdConfigManager._default_settings
self.button_box_pos.set(defaults["button_box_pos"])
self.window_size_preset.set(defaults["window_size_preset"])
self.default_view_mode.set(defaults["default_view_mode"])
self.search_hidden_files.set(defaults["search_hidden_files"])
self.recursive_search.set(defaults["recursive_search"])
self.use_trash.set(defaults["use_trash"])
self.confirm_delete.set(defaults["confirm_delete"])
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))

View File

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

View File

@@ -0,0 +1,562 @@
import os
import shutil
import tkinter as tk
from tkinter import ttk
from typing import Dict, Any
# To avoid circular import with custom_file_dialog.py
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from custom_file_dialog import CustomFileDialog
from shared_libs.common_tools import Tooltip
from 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.
"""
home = os.path.expanduser("~")
fallback_path = os.path.join(home, fallback_name)
config_path = os.path.join(home, ".config", "user-dirs.dirs")
if not os.path.exists(config_path):
return fallback_path
try:
with open(config_path, 'r') as f:
for line in f:
line = line.strip()
if line.startswith(f"{dir_key}="):
path = line.split('=', 1)[1].strip().strip('"')
path = path.replace('$HOME', home)
if not os.path.isabs(path):
path = os.path.join(home, path)
return path
except Exception:
pass
return fallback_path
class StyleManager:
"""Manages the visual styling of the application using ttk styles."""
def __init__(self, dialog: 'CustomFileDialog') -> None:
self.dialog = dialog
self.setup_styles()
def setup_styles(self) -> None:
style = ttk.Style(self.dialog)
base_bg = self.dialog.cget('background')
self.is_dark = sum(self.dialog.winfo_rgb(base_bg)) / 3 < 32768
if self.is_dark:
self.selection_color = "#4a6984"
self.icon_bg_color = "#3c3c3c"
self.accent_color = "#2a2a2a"
self.header = "#2b2b2b"
self.hover_extrastyle = "#4a4a4a"
self.hover_extrastyle2 = "#494949"
self.sidebar_color = "#333333"
self.bottom_color = self.accent_color
self.color_foreground = "#ffffff"
self.freespace_background = self.sidebar_color
else:
self.selection_color = "#cce5ff"
self.icon_bg_color = base_bg
self.accent_color = "#e0e0e0"
self.header = "#d9d9d9"
self.hover_extrastyle = "#f5f5f5"
self.hover_extrastyle2 = "#494949"
self.sidebar_color = "#e7e7e7"
self.bottom_color = "#cecece"
self.freespace_background = self.sidebar_color
self.color_foreground = "#000000"
style.configure("Header.TButton.Borderless.Round",
background=self.header)
style.map("Header.TButton.Borderless.Round", background=[
('active', self.hover_extrastyle)])
style.configure("Header.TButton.Active.Round",
background=self.selection_color)
style.layout("Header.TButton.Active.Round",
style.layout("Header.TButton.Borderless.Round"))
style.map("Header.TButton.Active.Round", background=[
('active', self.selection_color)])
style.configure("Dark.TButton.Borderless", anchor="w", background=self.sidebar_color,
foreground=self.color_foreground, padding=(20, 5, 0, 5))
style.map("Dark.TButton.Borderless", background=[
('active', self.hover_extrastyle2)])
style.configure("Accent.TFrame", background=self.header)
style.configure("Accent.TLabel", background=self.header)
style.configure("AccentBottom.TFrame", background=self.bottom_color)
style.configure("AccentBottom.TLabel", background=self.bottom_color)
style.configure("Sidebar.TFrame", background=self.sidebar_color)
style.configure("Content.TFrame", background=self.icon_bg_color)
style.configure("Item.TFrame", background=self.icon_bg_color)
style.map('Item.TFrame', background=[
('selected', self.selection_color)])
style.configure("Item.TLabel", background=self.icon_bg_color)
style.map('Item.TLabel', background=[('selected', self.selection_color)], foreground=[
('selected', "black" if not self.is_dark else "white")])
style.configure("Icon.TLabel", background=self.icon_bg_color)
style.map('Icon.TLabel', background=[
('selected', self.selection_color)])
style.configure("Treeview.Heading", relief="flat",
borderwidth=0, font=('TkDefaultFont', 10, 'bold'))
style.configure("Treeview", rowheight=32, pady=2, background=self.icon_bg_color,
fieldbackground=self.icon_bg_color, borderwidth=0)
style.map("Treeview", background=[('selected', self.selection_color)], foreground=[
('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.layout("Bottom.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:
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:
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_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)
self.back_button = ttk.Button(left_nav_container, image=self.dialog.icon_manager.get_icon(
'back'), command=self.dialog.navigation_manager.go_back, state=tk.DISABLED, style="Header.TButton.Borderless.Round")
self.back_button.pack(side="left", padx=(10, 5))
Tooltip(self.back_button, LocaleStrings.UI["back"])
self.forward_button = ttk.Button(left_nav_container, image=self.dialog.icon_manager.get_icon(
'forward'), command=self.dialog.navigation_manager.go_forward, state=tk.DISABLED, style="Header.TButton.Borderless.Round")
self.forward_button.pack(side="left", padx=5)
Tooltip(self.forward_button, LocaleStrings.UI["forward"])
self.up_button = ttk.Button(left_nav_container, image=self.dialog.icon_manager.get_icon(
'up'), command=self.dialog.navigation_manager.go_up_level, style="Header.TButton.Borderless.Round")
self.up_button.pack(side="left", padx=5)
Tooltip(self.up_button, LocaleStrings.UI["up"])
self.home_button = ttk.Button(left_nav_container, image=self.dialog.icon_manager.get_icon(
'home'), command=lambda: self.dialog.navigation_manager.navigate_to(os.path.expanduser("~")), style="Header.TButton.Borderless.Round")
self.home_button.pack(side="left", padx=(5, 10))
Tooltip(self.home_button, LocaleStrings.UI["home"])
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()))
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_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')
self.responsive_buttons_container.pack(side="left")
self.new_folder_button = ttk.Button(self.responsive_buttons_container, image=self.dialog.icon_manager.get_icon(
'new_folder_small'), command=self.dialog.file_op_manager.create_new_folder, style="Header.TButton.Borderless.Round")
self.new_folder_button.pack(side="left", padx=5)
Tooltip(self.new_folder_button, LocaleStrings.UI["new_folder"])
self.new_file_button = ttk.Button(self.responsive_buttons_container, image=self.dialog.icon_manager.get_icon(
'new_document_small'), command=self.dialog.file_op_manager.create_new_file, style="Header.TButton.Borderless.Round")
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)
self.view_switch = ttk.Frame(
self.responsive_buttons_container, padding=(5, 0), style='Accent.TFrame')
self.view_switch.pack(side="left")
self.icon_view_button = ttk.Button(self.view_switch, image=self.dialog.icon_manager.get_icon(
'icon_view'), command=self.dialog.view_manager.set_icon_view, style="Header.TButton.Active.Round")
self.icon_view_button.pack(side="left", padx=5)
Tooltip(self.icon_view_button, LocaleStrings.VIEW["icon_view"])
self.list_view_button = ttk.Button(self.view_switch, image=self.dialog.icon_manager.get_icon(
'list_view'), command=self.dialog.view_manager.set_list_view, style="Header.TButton.Borderless.Round")
self.list_view_button.pack(side="left")
Tooltip(self.list_view_button, LocaleStrings.VIEW["list_view"])
self.hidden_files_button = ttk.Button(self.responsive_buttons_container, image=self.dialog.icon_manager.get_icon(
'hide'), command=self.dialog.view_manager.toggle_hidden_files, style="Header.TButton.Borderless.Round")
self.hidden_files_button.pack(side="left", padx=10)
Tooltip(self.hidden_files_button,
LocaleStrings.UI["show_hidden_files"])
self.more_button = ttk.Button(right_controls_container, text="...",
command=self.dialog.show_more_menu, style="Header.TButton.Borderless.Round", width=3)
def _setup_sidebar(self, parent_paned_window: ttk.PanedWindow) -> None:
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(4, weight=1)
self._setup_sidebar_bookmarks(sidebar_frame)
separator_color = "#a9a9a9" if self.style_manager.is_dark else "#7c7c7c"
tk.Frame(sidebar_frame, height=1, bg=separator_color).grid(
row=1, column=0, sticky="ew", padx=20, pady=15)
self._setup_sidebar_devices(sidebar_frame)
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:
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")
sidebar_buttons_config = [
{'name': LocaleStrings.NAV["computer"], 'icon': self.dialog.icon_manager.get_icon(
'computer_small'), 'path': '/'},
{'name': LocaleStrings.NAV["downloads"], 'icon': self.dialog.icon_manager.get_icon(
'downloads_small'), 'path': get_xdg_user_dir("XDG_DOWNLOAD_DIR", "Downloads")},
{'name': LocaleStrings.NAV["documents"], 'icon': self.dialog.icon_manager.get_icon(
'documents_small'), 'path': get_xdg_user_dir("XDG_DOCUMENTS_DIR", "Documents")},
{'name': LocaleStrings.NAV["pictures"], 'icon': self.dialog.icon_manager.get_icon(
'pictures_small'), 'path': get_xdg_user_dir("XDG_PICTURES_DIR", "Pictures")},
{'name': LocaleStrings.NAV["music"], 'icon': self.dialog.icon_manager.get_icon(
'music_small'), 'path': get_xdg_user_dir("XDG_MUSIC_DIR", "Music")},
{'name': LocaleStrings.NAV["videos"], 'icon': self.dialog.icon_manager.get_icon(
'video_small'), 'path': get_xdg_user_dir("XDG_VIDEO_DIR", "Videos")},
]
self.sidebar_buttons = []
for config in sidebar_buttons_config:
btn = ttk.Button(sidebar_buttons_frame, text=f" {config['name']}", image=config['icon'], compound="left",
command=lambda p=config['path']: self.dialog.navigation_manager.navigate_to(p), style="Dark.TButton.Borderless")
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:
mounted_devices_frame = ttk.Frame(
sidebar_frame, style="Sidebar.TFrame")
mounted_devices_frame.grid(row=2, column=0, sticky="nsew", padx=10)
mounted_devices_frame.grid_columnconfigure(0, weight=1)
ttk.Label(mounted_devices_frame, text=LocaleStrings.UI["devices"], 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.devices_canvas = tk.Canvas(
mounted_devices_frame, highlightthickness=0, bg=self.style_manager.sidebar_color, height=150, width=180)
self.devices_scrollbar = ttk.Scrollbar(
mounted_devices_frame, orient="vertical", command=self.devices_canvas.yview)
self.devices_canvas.configure(
yscrollcommand=self.devices_scrollbar.set)
self.devices_canvas.grid(row=1, column=0, sticky="nsew")
self.devices_scrollable_frame = ttk.Frame(
self.devices_canvas, style="Sidebar.TFrame")
self.devices_canvas_window = self.devices_canvas.create_window(
(0, 0), window=self.devices_scrollable_frame, anchor="nw")
def _configure_devices_canvas(event: tk.Event) -> None:
self.devices_canvas.configure(
scrollregion=self.devices_canvas.bbox("all"))
canvas_width = event.width
self.devices_canvas.itemconfig(
self.devices_canvas_window, width=canvas_width)
self.devices_scrollable_frame.bind("<Configure>", lambda e: self.devices_canvas.configure(
scrollregion=self.devices_canvas.bbox("all")))
self.devices_canvas.bind("<Configure>", _configure_devices_canvas)
def _on_devices_mouse_wheel(event: tk.Event) -> None:
if event.num == 4:
delta = -1
elif event.num == 5:
delta = 1
else:
delta = -1 * int(event.delta / 120)
self.devices_canvas.yview_scroll(delta, "units")
for widget in [self.devices_canvas, self.devices_scrollable_frame]:
widget.bind("<MouseWheel>", _on_devices_mouse_wheel)
widget.bind("<Button-4>", _on_devices_mouse_wheel)
widget.bind("<Button-5>", _on_devices_mouse_wheel)
self.device_buttons = []
for device_name, mount_point, removable in self.dialog._get_mounted_devices():
icon = self.dialog.icon_manager.get_icon(
'usb_small') if removable else self.dialog.icon_manager.get_icon('device_small')
button_text = f" {device_name}"
if len(device_name) > 15:
button_text = f" {device_name[:15]}\n{device_name[15:]}"
btn = ttk.Button(self.devices_scrollable_frame, text=button_text, image=icon, compound="left",
command=lambda p=mount_point: self.dialog.navigation_manager.navigate_to(p), style="Dark.TButton.Borderless")
btn.pack(fill="x", pady=1)
self.device_buttons.append((btn, button_text))
for w in [btn, self.devices_canvas, self.devices_scrollable_frame]:
w.bind("<MouseWheel>", _on_devices_mouse_wheel)
w.bind("<Button-4>", _on_devices_mouse_wheel)
w.bind("<Button-5>", _on_devices_mouse_wheel)
try:
total, used, _ = shutil.disk_usage(mount_point)
progress_bar = ttk.Progressbar(self.devices_scrollable_frame, orient="horizontal",
length=100, mode="determinate", style='Small.Horizontal.TProgressbar')
progress_bar.pack(fill="x", pady=(2, 8), padx=25)
progress_bar['value'] = (used / total) * 100
for w in [progress_bar]:
w.bind("<MouseWheel>", _on_devices_mouse_wheel)
w.bind("<Button-4>", _on_devices_mouse_wheel)
w.bind("<Button-5>", _on_devices_mouse_wheel)
except (FileNotFoundError, PermissionError):
pass
def _setup_sidebar_storage(self, sidebar_frame: ttk.Frame) -> None:
storage_frame = ttk.Frame(sidebar_frame, style="Sidebar.TFrame")
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)
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:
self.action_status_frame = ttk.Frame(
self.content_frame, style="AccentBottom.TFrame")
self.action_status_frame.grid(
row=1, column=0, sticky="ew", pady=(5, 5), padx=10)
self.status_container = ttk.Frame(
self.action_status_frame, style="AccentBottom.TFrame")
self.left_container = ttk.Frame(
self.action_status_frame, style="AccentBottom.TFrame")
self.center_container = ttk.Frame(
self.action_status_frame, style="AccentBottom.TFrame")
self.right_container = ttk.Frame(
self.action_status_frame, style="AccentBottom.TFrame")
self.action_status_frame.grid_columnconfigure(1, weight=1)
self.action_status_frame.grid_rowconfigure(0, weight=1)
self.left_container.grid(row=0, column=0, sticky='nsw', pady=(5, 0))
self.center_container.grid(
row=0, column=1, sticky='nsew', padx=5, pady=(5, 0))
self.right_container.grid(row=0, column=2, sticky='nse', pady=(5, 0))
self.separator_color = "#a9a9a9" if self.style_manager.is_dark else "#7c7c7c"
self.separator = tk.Frame(
self.action_status_frame, height=1, bg=self.separator_color)
self.separator.grid(row=1, column=0, columnspan=3,
sticky="ew", pady=(4, 0))
self.status_container.grid(row=2, column=0, columnspan=3, sticky='ew')
self.search_status_label = ttk.Label(
self.status_container, text="", style="AccentBottom.TLabel")
self.filename_entry = ttk.Entry(self.center_container)
self.settings_button = ttk.Button(self.action_status_frame, image=self.dialog.icon_manager.get_icon('settings-2_small'),
command=self.dialog.open_settings_dialog, style="Bottom.TButton.Borderless.Round")
self.search_animation = AnimatedIcon(self.status_container,
width=23, height=23, bg=self.style_manager.bottom_color,
animation_type=self.settings.get('animation_type', 'counter_arc'))
self.search_animation.grid(
row=0, column=0, sticky='w', padx=(0, 5), pady=(4, 0))
self.search_animation.bind(
"<Button-1>", lambda e: self.dialog.search_manager.activate_search())
self.search_status_label.grid(row=0, column=1, sticky="w")
button_box_pos = self.settings.get("button_box_pos", "left")
if self.dialog.dialog_mode == "save":
self.trash_button = ttk.Button(self.action_status_frame, image=self.dialog.icon_manager.get_icon('trash_small2'),
command=self.dialog.file_op_manager.delete_selected_item, style="Bottom.TButton.Borderless.Round")
Tooltip(self.trash_button, LocaleStrings.UI["delete_move"])
self.save_button = ttk.Button(
self.action_status_frame, text=LocaleStrings.SET["save_button"], command=self.dialog.on_save)
self.cancel_button = ttk.Button(
self.action_status_frame, text=LocaleStrings.SET["cancel_button"], command=self.dialog.on_cancel)
self.filter_combobox = ttk.Combobox(self.center_container, values=[
ft[0] for ft in self.dialog.filetypes], state="readonly")
self.filter_combobox.bind(
"<<ComboboxSelected>>", self.dialog.view_manager.on_filter_change)
self.filter_combobox.set(self.dialog.filetypes[0][0])
self.center_container.grid_rowconfigure(0, weight=1)
self.filename_entry.grid(
row=0, column=0, columnspan=2, sticky="ew")
self._layout_bottom_buttons(button_box_pos)
else: # Open mode
self.open_button = ttk.Button(
self.action_status_frame, text=LocaleStrings.CFD["open"], command=self.dialog.on_open)
self.cancel_button = ttk.Button(
self.action_status_frame, text=LocaleStrings.CFD["cancel"], command=self.dialog.on_cancel)
self.filter_combobox = ttk.Combobox(self.center_container, values=[
ft[0] for ft in self.dialog.filetypes], state="readonly")
self.filter_combobox.bind(
"<<ComboboxSelected>>", self.dialog.view_manager.on_filter_change)
self.filter_combobox.set(self.dialog.filetypes[0][0])
self.center_container.grid_rowconfigure(0, weight=1)
self.filename_entry.grid(
row=0, column=0, columnspan=2, sticky="ew")
self._layout_bottom_buttons(button_box_pos)
def _layout_bottom_buttons(self, button_box_pos: str) -> None:
self.left_container.grid_rowconfigure(0, weight=1)
self.right_container.grid_rowconfigure(0, weight=1)
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
action_button.grid(in_=action_container, row=0, column=0, pady=(0, 5))
self.cancel_button.grid(in_=action_container, row=1, column=0)
if button_box_pos == 'left':
self.settings_button.grid(
in_=other_container, row=0, column=0, sticky="ne")
if self.dialog.dialog_mode == "save":
self.trash_button.grid(
in_=other_container, row=1, column=0, sticky="se", padx=(5, 0))
else: # right
self.settings_button.grid(
in_=action_container, row=0, column=1, sticky="ne", padx=(5, 0))
if self.dialog.dialog_mode == "save":
self.trash_button.grid(
in_=other_container, row=0, column=0, sticky="sw")
if button_box_pos == 'left':
self.center_container.grid_columnconfigure(0, weight=1)
self.filter_combobox.grid(
in_=self.center_container, row=1, column=0, sticky="w", pady=(5, 0))
else: # right
self.center_container.grid_columnconfigure(1, weight=1)
self.filter_combobox.grid(
in_=self.center_container, row=1, column=1, sticky="e", pady=(5, 0))
def setup_widgets(self) -> None:
main_frame = ttk.Frame(self.dialog, style='Accent.TFrame')
main_frame.pack(fill="both", expand=True)
main_frame.grid_rowconfigure(2, weight=1)
main_frame.grid_columnconfigure(0, weight=1)
self._setup_top_bar(main_frame)
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")
paned_window = ttk.PanedWindow(
main_frame, orient=tk.HORIZONTAL, style="Sidebar.TFrame")
paned_window.grid(row=2, column=0, columnspan=2, sticky="nsew")
self._setup_sidebar(paned_window)
self.content_frame = ttk.Frame(paned_window, padding=(
0, 0, 0, 0), style="AccentBottom.TFrame")
paned_window.add(self.content_frame, weight=1)
self.content_frame.grid_rowconfigure(0, weight=1)
self.content_frame.grid_columnconfigure(0, weight=1)
self.file_list_frame = ttk.Frame(
self.content_frame, style="Content.TFrame")
self.file_list_frame.grid(row=0, column=0, sticky="nsew")
self.dialog.bind("<Configure>", self.dialog.on_window_resize)
self._setup_bottom_bar()

View File

@@ -0,0 +1,711 @@
import os
import tkinter as tk
from tkinter import ttk
from datetime import datetime
from typing import Optional, List, Tuple, Callable, Any, Dict
# To avoid circular import with custom_file_dialog.py
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from custom_file_dialog import CustomFileDialog
from shared_libs.common_tools import Tooltip
from shared_libs.message import MessageDialog
from .cfd_app_config import CfdConfigManager, LocaleStrings
class ViewManager:
"""Manages the display of files and folders in list and icon views."""
def __init__(self, dialog: 'CustomFileDialog'):
"""
Initializes the ViewManager.
Args:
dialog: The main CustomFileDialog instance.
"""
self.dialog = dialog
def _get_file_info_list(self) -> Tuple[List[Dict], Optional[str], Optional[str]]:
"""
Gets a sorted list of file information dictionaries from the current source.
"""
if self.dialog.current_fs_type == "sftp":
items, error = self.dialog.sftp_manager.list_directory(self.dialog.current_dir)
if error:
return [], error, None
file_info_list = []
import stat
for item in items:
if item.filename in ['.', '..']:
continue
is_dir = stat.S_ISDIR(item.st_mode)
# Manually construct SFTP path to ensure forward slashes
path = f"{self.dialog.current_dir}/{item.filename}".replace("//", "/")
file_info_list.append({
'name': item.filename,
'path': path,
'is_dir': is_dir,
'size': item.st_size,
'modified': item.st_mtime
})
return file_info_list, None, None
else:
try:
items = list(os.scandir(self.dialog.current_dir))
num_items = len(items)
warning_message = None
if num_items > CfdConfigManager.MAX_ITEMS_TO_DISPLAY:
warning_message = f"{LocaleStrings.CFD['showing']} {CfdConfigManager.MAX_ITEMS_TO_DISPLAY} {LocaleStrings.CFD['of']} {num_items} {LocaleStrings.CFD['entries']}."
items = items[:CfdConfigManager.MAX_ITEMS_TO_DISPLAY]
items.sort(key=lambda e: (not e.is_dir(), e.name.lower()))
file_info_list = []
for item in items:
try:
stat_result = item.stat()
file_info_list.append({
'name': item.name,
'path': item.path,
'is_dir': item.is_dir(),
'size': stat_result.st_size,
'modified': stat_result.st_mtime
})
except (FileNotFoundError, PermissionError):
continue
return file_info_list, None, warning_message
except PermissionError:
return ([], LocaleStrings.CFD["access_denied"], None)
except FileNotFoundError:
return ([], LocaleStrings.CFD["directory_not_found"], None)
def populate_files(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None:
"""
Populates the main file display area.
"""
self._unbind_mouse_wheel_events()
for widget in self.dialog.widget_manager.file_list_frame.winfo_children():
widget.destroy()
self.dialog.widget_manager.path_entry.delete(0, tk.END)
self.dialog.widget_manager.path_entry.insert(
0, self.dialog.current_dir)
self.dialog.result = None
self.dialog.selected_item_frames.clear()
self.dialog.update_selection_info()
if self.dialog.view_mode.get() == "list":
self.populate_list_view(item_to_rename, item_to_select)
else:
self.populate_icon_view(item_to_rename, item_to_select)
def _get_folder_content_count(self, folder_path: str) -> Optional[int]:
"""
Counts the number of items in a given folder.
"""
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
items = os.listdir(folder_path)
if not self.dialog.show_hidden_files.get():
items = [item for item in items if not item.startswith('.')]
return len(items)
except (PermissionError, FileNotFoundError):
return None
def _get_item_path_from_widget(self, widget: tk.Widget) -> Optional[str]:
"""
Traverses up the widget hierarchy to find the item_path attribute.
"""
while widget and not hasattr(widget, 'item_path'):
widget = widget.master
return getattr(widget, 'item_path', None)
def _is_dir(self, path: str) -> bool:
"""Checks if a given path is a directory, supporting both local and SFTP."""
if self.dialog.current_fs_type == 'sftp':
for item in self.dialog.all_items:
if item['path'] == path:
return item['is_dir']
return False
else:
return os.path.isdir(path)
def _handle_icon_click(self, event: tk.Event) -> None:
"""Handles a single click on an icon view item."""
item_path = self._get_item_path_from_widget(event.widget)
if item_path:
item_frame = event.widget
while not hasattr(item_frame, 'item_path'):
item_frame = item_frame.master
self.on_item_select(item_path, item_frame, event)
def _handle_icon_double_click(self, event: tk.Event) -> None:
"""Handles a double click on an icon view item."""
item_path = self._get_item_path_from_widget(event.widget)
if item_path:
self._handle_item_double_click(item_path)
def _handle_icon_context_menu(self, event: tk.Event) -> None:
"""Handles a context menu request on an icon view item."""
item_path = self._get_item_path_from_widget(event.widget)
if item_path:
self.dialog.file_op_manager._show_context_menu(event, item_path)
def _handle_icon_rename_request(self, event: tk.Event) -> None:
"""Handles a rename request on an icon view item."""
item_path = self._get_item_path_from_widget(event.widget)
if item_path:
item_frame = event.widget
while not hasattr(item_frame, 'item_path'):
item_frame = item_frame.master
self.dialog.file_op_manager.on_rename_request(
event, item_path, item_frame)
def populate_icon_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None:
"""
Populates the file display with items in an icon grid layout.
"""
self.dialog.all_items, error, warning = self._get_file_info_list()
self.dialog.currently_loaded_count = 0
self.dialog.icon_canvas = tk.Canvas(self.dialog.widget_manager.file_list_frame,
highlightthickness=0, bg=self.dialog.style_manager.icon_bg_color)
v_scrollbar = ttk.Scrollbar(
self.dialog.widget_manager.file_list_frame, orient="vertical", command=self.dialog.icon_canvas.yview)
self.dialog.icon_canvas.pack(side="left", fill="both", expand=True)
self.dialog.icon_canvas.focus_set()
v_scrollbar.pack(side="right", fill="y")
container_frame = ttk.Frame(
self.dialog.icon_canvas, style="Content.TFrame")
self.dialog.icon_canvas.create_window(
(0, 0), window=container_frame, anchor="nw")
container_frame.bind("<Configure>", lambda e: self.dialog.icon_canvas.configure(
scrollregion=self.dialog.icon_canvas.bbox("all")))
def _on_mouse_wheel(event: tk.Event) -> None:
if event.num == 4:
delta = -1
elif event.num == 5:
delta = 1
else:
delta = -1 * int(event.delta / 120)
self.dialog.icon_canvas.yview_scroll(delta, "units")
if self.dialog.currently_loaded_count < len(self.dialog.all_items) and self.dialog.icon_canvas.yview()[1] > 0.9:
self._load_more_items_icon_view(
container_frame, _on_mouse_wheel)
for widget in [self.dialog.icon_canvas, container_frame]:
widget.bind("<MouseWheel>", _on_mouse_wheel)
widget.bind("<Button-4>", _on_mouse_wheel)
widget.bind("<Button-5>", _on_mouse_wheel)
if warning:
self.dialog.widget_manager.search_status_label.config(text=warning)
if error:
ttk.Label(container_frame, text=error).pack(pady=20)
return
widget_to_focus = None
while self.dialog.currently_loaded_count < len(self.dialog.all_items):
widget_to_focus = self._load_more_items_icon_view(
container_frame, _on_mouse_wheel, item_to_rename, item_to_select)
if widget_to_focus:
break
if not (item_to_rename or item_to_select):
break
if widget_to_focus:
def scroll_to_widget() -> None:
self.dialog.update_idletasks()
if not widget_to_focus.winfo_exists():
return
y = widget_to_focus.winfo_y()
canvas_height = self.dialog.icon_canvas.winfo_height()
scroll_region = self.dialog.icon_canvas.bbox("all")
if not scroll_region:
return
scroll_height = scroll_region[3]
if scroll_height > canvas_height:
fraction = y / scroll_height
self.dialog.icon_canvas.yview_moveto(fraction)
self.dialog.after(100, scroll_to_widget)
def _load_more_items_icon_view(self, container: ttk.Frame, scroll_handler: Callable, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> Optional[ttk.Frame]:
"""
Loads a batch of items into the icon view.
"""
start_index = self.dialog.currently_loaded_count
end_index = min(len(self.dialog.all_items), start_index +
self.dialog.items_to_load_per_batch)
if start_index >= end_index:
return None
item_width, item_height = 125, 100
frame_width = self.dialog.widget_manager.file_list_frame.winfo_width()
col_count = max(1, frame_width // item_width - 1)
row = start_index // col_count if col_count > 0 else 0
col = start_index % col_count if col_count > 0 else 0
widget_to_focus = None
for i in range(start_index, end_index):
file_info = self.dialog.all_items[i]
name = file_info['name']
path = file_info['path']
is_dir = file_info['is_dir']
if not self.dialog.show_hidden_files.get() and name.startswith('.'):
continue
if not is_dir and not self.dialog._matches_filetype(name):
continue
item_frame = ttk.Frame(
container, width=item_width, height=item_height, style="Item.TFrame")
item_frame.grid(row=row, column=col, padx=5, ipadx=25, pady=5)
item_frame.grid_propagate(False)
item_frame.item_path = path
if name == item_to_rename:
self.dialog.file_op_manager.start_rename(item_frame, path)
widget_to_focus = item_frame
else:
icon = self.dialog.icon_manager.get_icon(
'folder_large') if is_dir else self.dialog.get_file_icon(name, 'large')
icon_label = ttk.Label(
item_frame, image=icon, style="Icon.TLabel")
icon_label.pack(pady=(10, 5))
name_label = ttk.Label(item_frame, text=self.dialog.shorten_text(
name, 14), anchor="center", style="Item.TLabel")
name_label.pack(fill="x", expand=True)
Tooltip(item_frame, name)
for widget in [item_frame, icon_label, name_label]:
widget.bind("<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, 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,
f=item_frame: self.dialog.file_op_manager.on_rename_request(e, p, f))
widget.bind("<MouseWheel>", scroll_handler)
widget.bind("<Button-4>", scroll_handler)
widget.bind("<Button-5>", scroll_handler)
if name == item_to_select:
self.on_item_select(path, item_frame)
widget_to_focus = item_frame
if col_count > 0:
col = (col + 1) % col_count
if col == 0:
row += 1
else:
row += 1
self.dialog.currently_loaded_count = end_index
return widget_to_focus
def populate_list_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None:
"""
Populates the file display with items in a list (Treeview) layout.
"""
self.dialog.all_items, error, warning = self._get_file_info_list()
self.dialog.currently_loaded_count = 0
tree_frame = ttk.Frame(self.dialog.widget_manager.file_list_frame)
tree_frame.pack(fill='both', expand=True)
tree_frame.grid_rowconfigure(0, weight=1)
tree_frame.grid_columnconfigure(0, weight=1)
columns = ("size", "type", "modified")
self.dialog.tree = ttk.Treeview(
tree_frame, columns=columns, show="tree headings")
if self.dialog.dialog_mode == 'multi':
self.dialog.tree.config(selectmode="extended")
self.dialog.tree.heading(
"#0", text=LocaleStrings.VIEW["name"], anchor="w")
self.dialog.tree.column("#0", anchor="w", width=250, stretch=True)
self.dialog.tree.heading(
"size", text=LocaleStrings.VIEW["size"], anchor="e")
self.dialog.tree.column("size", anchor="e", width=120, stretch=False)
self.dialog.tree.heading(
"type", text=LocaleStrings.VIEW["type"], anchor="w")
self.dialog.tree.column("type", anchor="w", width=120, stretch=False)
self.dialog.tree.heading(
"modified", text=LocaleStrings.VIEW["date_modified"], anchor="w")
self.dialog.tree.column("modified", anchor="w",
width=160, stretch=False)
v_scrollbar = ttk.Scrollbar(
tree_frame, orient="vertical", command=self.dialog.tree.yview)
h_scrollbar = ttk.Scrollbar(
tree_frame, orient="horizontal", command=self.dialog.tree.xview)
self.dialog.tree.configure(yscrollcommand=v_scrollbar.set,
xscrollcommand=h_scrollbar.set)
self.dialog.tree.grid(row=0, column=0, sticky='nsew')
self.dialog.tree.focus_set()
v_scrollbar.grid(row=0, column=1, sticky='ns')
h_scrollbar.grid(row=1, column=0, sticky='ew')
def _on_scroll(*args: Any) -> None:
if self.dialog.currently_loaded_count < len(self.dialog.all_items) and self.dialog.tree.yview()[1] > 0.9:
self._load_more_items_list_view()
v_scrollbar.set(*args)
self.dialog.tree.configure(yscrollcommand=_on_scroll)
self.dialog.tree.bind("<Double-1>", self.on_list_double_click)
self.dialog.tree.bind("<<TreeviewSelect>>", self.on_list_select)
self.dialog.tree.bind(
"<F2>", self.dialog.file_op_manager.on_rename_request)
self.dialog.tree.bind("<ButtonRelease-3>", self.on_list_context_menu)
if warning:
self.dialog.widget_manager.search_status_label.config(text=warning)
if error:
self.dialog.tree.insert("", "end", text=error, values=())
return
while self.dialog.currently_loaded_count < len(self.dialog.all_items):
item_found = self._load_more_items_list_view(
item_to_rename, item_to_select)
if item_found:
break
if not (item_to_rename or item_to_select):
break
def _load_more_items_list_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> bool:
"""
Loads a batch of items into the list view.
"""
start_index = self.dialog.currently_loaded_count
end_index = min(len(self.dialog.all_items), start_index +
self.dialog.items_to_load_per_batch)
if start_index >= end_index:
return False
item_found = False
for i in range(start_index, end_index):
file_info = self.dialog.all_items[i]
name = file_info['name']
path = file_info['path']
is_dir = file_info['is_dir']
if not self.dialog.show_hidden_files.get() and name.startswith('.'):
continue
if not is_dir and not self.dialog._matches_filetype(name):
continue
try:
modified_time = datetime.fromtimestamp(
file_info['modified']).strftime('%d.%m.%Y %H:%M')
if is_dir:
icon, file_type, size = self.dialog.icon_manager.get_icon(
'folder_small'), LocaleStrings.FILE["folder"], ""
else:
icon, file_type, size = self.dialog.get_file_icon(
name, 'small'), LocaleStrings.FILE["file"], self.dialog._format_size(file_info['size'])
item_id = self.dialog.tree.insert("", "end", text=f" {name}", image=icon, values=(
size, file_type, modified_time))
self.dialog.item_path_map[item_id] = path # Store path for later retrieval
if name == item_to_rename:
self.dialog.tree.selection_set(item_id)
self.dialog.tree.focus(item_id)
self.dialog.tree.see(item_id)
self.dialog.file_op_manager.start_rename(item_id, path)
item_found = True
elif name == item_to_select:
self.dialog.tree.selection_set(item_id)
self.dialog.tree.focus(item_id)
self.dialog.tree.see(item_id)
item_found = True
except (FileNotFoundError, PermissionError):
continue
self.dialog.currently_loaded_count = end_index
return item_found
def on_item_select(self, path: str, item_frame: ttk.Frame, event: Optional[tk.Event] = None) -> None:
"""
Handles the selection of an item in the icon view.
"""
if self.dialog.dialog_mode == 'dir' and not self._is_dir(path):
return
if self.dialog.dialog_mode == 'multi':
ctrl_pressed = (event.state & 0x4) != 0 if event else False
if ctrl_pressed:
if item_frame in self.dialog.selected_item_frames:
item_frame.state(['!selected'])
for child in item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['!selected'])
self.dialog.selected_item_frames.remove(item_frame)
else:
item_frame.state(['selected'])
for child in item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['selected'])
self.dialog.selected_item_frames.append(item_frame)
else:
for f in self.dialog.selected_item_frames:
f.state(['!selected'])
for child in f.winfo_children():
if isinstance(child, ttk.Label):
child.state(['!selected'])
self.dialog.selected_item_frames.clear()
item_frame.state(['selected'])
for child in item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['selected'])
self.dialog.selected_item_frames.append(item_frame)
selected_paths = [frame.item_path for frame in self.dialog.selected_item_frames]
self.dialog.result = selected_paths
self.dialog.update_selection_info()
else: # Single selection mode
if hasattr(self.dialog, 'selected_item_frame') and self.dialog.selected_item_frame.winfo_exists():
self.dialog.selected_item_frame.state(['!selected'])
for child in self.dialog.selected_item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['!selected'])
item_frame.state(['selected'])
for child in item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['selected'])
self.dialog.selected_item_frame = item_frame
self.dialog.update_selection_info(path)
self.dialog.search_manager.show_search_ready()
def on_list_select(self, event: tk.Event) -> None:
"""
Handles the selection of an item in the list view.
"""
selections = self.dialog.tree.selection()
if not selections:
self.dialog.result = [] if self.dialog.dialog_mode == 'multi' else None
self.dialog.update_selection_info()
return
if self.dialog.dialog_mode == 'multi':
selected_paths = []
for item_id in selections:
path = self.dialog.item_path_map.get(item_id)
if path:
selected_paths.append(path)
self.dialog.result = selected_paths
self.dialog.update_selection_info()
else:
item_id = selections[0]
path = self.dialog.item_path_map.get(item_id)
if not path:
return
if self.dialog.dialog_mode == 'dir' and not self._is_dir(path):
self.dialog.result = None
self.dialog.tree.selection_remove(item_id)
self.dialog.update_selection_info()
return
self.dialog.update_selection_info(path)
def on_list_context_menu(self, event: tk.Event) -> str:
"""
Shows the context menu for a list view item.
"""
iid = self.dialog.tree.identify_row(event.y)
if not iid:
return "break"
self.dialog.tree.selection_set(iid)
path = self.dialog.item_path_map.get(iid)
if path:
self.dialog.file_op_manager._show_context_menu(event, path)
return "break"
def _handle_item_double_click(self, path: str) -> None:
"""
Handles the logic for a double-click on any item, regardless of view.
"""
if self._is_dir(path):
if self.dialog.dialog_mode == 'dir':
has_subdirs = False
try:
if self.dialog.current_fs_type == "sftp":
import stat
items, _ = self.dialog.sftp_manager.list_directory(path)
for item in items:
if item.filename not in ['.', '..'] and stat.S_ISDIR(item.st_mode):
has_subdirs = True
break
else:
for item in os.listdir(path):
if os.path.isdir(os.path.join(path, item)) and not item.startswith('.'):
has_subdirs = True
break
except OSError:
self.dialog.navigation_manager.navigate_to(path)
return
if has_subdirs:
self.dialog.navigation_manager.navigate_to(path)
else:
dialog = MessageDialog(
master=self.dialog,
message_type="ask",
title=LocaleStrings.CFD["select_or_enter_title"],
text=LocaleStrings.CFD["select_or_enter_prompt"].format(folder_name=os.path.basename(path)),
buttons=[
LocaleStrings.CFD["select_button"],
LocaleStrings.CFD["enter_button"],
LocaleStrings.CFD["cancel_button"],
]
)
choice = dialog.show()
if choice is True:
self.dialog.result = path
self.dialog.on_open()
elif choice is False:
self.dialog.navigation_manager.navigate_to(path)
else:
self.dialog.navigation_manager.navigate_to(path)
elif self.dialog.dialog_mode in ["open", "multi"]:
self.dialog.result = path
self.dialog.on_open()
elif self.dialog.dialog_mode == "save":
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
self.dialog.widget_manager.filename_entry.insert(
0, os.path.basename(path))
self.dialog.on_save()
def on_list_double_click(self, event: tk.Event) -> None:
"""
Handles a double-click on a list view item.
"""
selection = self.dialog.tree.selection()
if not selection:
return
item_id = selection[0]
path = self.dialog.item_path_map.get(item_id)
if path:
self._handle_item_double_click(path)
def _select_file_in_view(self, filename: str) -> None:
"""
Programmatically selects a file in the current view.
"""
if self.dialog.view_mode.get() == "list":
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)
break
elif self.dialog.view_mode.get() == "icons":
if not hasattr(self.dialog, 'icon_canvas') or not self.dialog.icon_canvas.winfo_exists():
return
container_frame = self.dialog.icon_canvas.winfo_children()[0]
target_path = os.path.join(self.dialog.current_dir, filename)
for widget in container_frame.winfo_children():
if hasattr(widget, 'item_path') and widget.item_path == target_path:
self.on_item_select(widget.item_path, widget)
def scroll_to_widget() -> None:
self.dialog.update_idletasks()
if not widget.winfo_exists():
return
y = widget.winfo_y()
canvas_height = self.dialog.icon_canvas.winfo_height()
scroll_region = self.dialog.icon_canvas.bbox("all")
if not scroll_region:
return
scroll_height = scroll_region[3]
if scroll_height > canvas_height:
fraction = y / scroll_height
self.dialog.icon_canvas.yview_moveto(fraction)
self.dialog.after(100, scroll_to_widget)
break
def _update_view_mode_buttons(self) -> None:
"""Updates the visual state of the view mode toggle buttons."""
if self.dialog.view_mode.get() == "icons":
self.dialog.widget_manager.icon_view_button.configure(
style="Header.TButton.Active.Round")
self.dialog.widget_manager.list_view_button.configure(
style="Header.TButton.Borderless.Round")
else:
self.dialog.widget_manager.list_view_button.configure(
style="Header.TButton.Active.Round")
self.dialog.widget_manager.icon_view_button.configure(
style="Header.TButton.Borderless.Round")
def set_icon_view(self) -> None:
"""Switches to icon view and repopulates the files."""
self.dialog.view_mode.set("icons")
self._update_view_mode_buttons()
self.populate_files()
def set_list_view(self) -> None:
"""Switches to list view and repopulates the files."""
self.dialog.view_mode.set("list")
self._update_view_mode_buttons()
self.populate_files()
def toggle_hidden_files(self) -> None:
"""Toggles the visibility of hidden files and refreshes the view."""
self.dialog.show_hidden_files.set(
not self.dialog.show_hidden_files.get())
if self.dialog.show_hidden_files.get():
self.dialog.widget_manager.hidden_files_button.config(
image=self.dialog.icon_manager.get_icon('unhide'))
Tooltip(self.dialog.widget_manager.hidden_files_button,
LocaleStrings.UI["hide_hidden_files"])
else:
self.dialog.widget_manager.hidden_files_button.config(
image=self.dialog.icon_manager.get_icon('hide'))
Tooltip(self.dialog.widget_manager.hidden_files_button,
LocaleStrings.UI["show_hidden_files"])
self.populate_files()
def on_filter_change(self, event: tk.Event) -> None:
"""Handles a change in the file type filter combobox."""
selected_desc = self.dialog.widget_manager.filter_combobox.get()
for desc, pattern in self.dialog.filetypes:
if desc == selected_desc:
self.dialog.current_filter_pattern = pattern
break
self.populate_files()
def _unbind_mouse_wheel_events(self) -> None:
"""Unbinds all mouse wheel events from the dialog."""
self.dialog.unbind_all("<MouseWheel>")
self.dialog.unbind_all("<Button-4>")
self.dialog.unbind_all("<Button-5>")

View File

@@ -0,0 +1,718 @@
import os
import shutil
import tkinter as tk
import subprocess
import json
import threading
import webbrowser
from typing import Optional, List, Tuple, Dict, Union
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
from .cfd_settings_dialog import SettingsDialog
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):
"""
A custom file dialog window that provides functionalities for file selection,
directory navigation, search, and file operations.
"""
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.
"""
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()
self.geometry(self.settings["window_size_preset"])
min_width, min_height = self.get_min_size_from_preset(
self.settings["window_size_preset"])
self.minsize(min_width, min_height)
self.title(title)
self.image: IconManager = IconManager()
width, height = map(
int, self.settings["window_size_preset"].split('x'))
LxTools.center_window_cross_platform(self, width, height)
self.parent: tk.Widget = parent
self.transient(parent)
self.grab_set()
self.result: Optional[Union[str, List[str]]] = None
self.current_dir: str = os.path.abspath(
initial_dir) if initial_dir else os.path.expanduser("~")
self.filetypes: List[Tuple[str, str]] = filetypes if filetypes else [
(LocaleStrings.CFD["all_files"], "*.* ")]
self.current_filter_pattern: str = self.filetypes[0][1]
self.history: List[str] = []
self.history_pos: int = -1
self.view_mode: tk.StringVar = tk.StringVar(
value=self.settings.get("default_view_mode", "icons"))
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 = ""
self.items_to_load_per_batch: int = 250
self.item_path_map: Dict[int, str] = {}
self.responsive_buttons_hidden: Optional[bool] = None
self.search_job: Optional[str] = None
self.search_thread: Optional[threading.Thread] = None
self.search_process: Optional[subprocess.Popen] = None
self.icon_manager: IconManager = IconManager()
self._initialize_managers()
self.widget_manager.filename_entry.bind(
"<Return>", self.search_manager.execute_search)
self.update_animation_settings()
self.view_manager._update_view_mode_buttons()
def initial_load() -> None:
"""Performs the initial loading and UI setup."""
self.update_idletasks()
self.last_width = self.widget_manager.file_list_frame.winfo_width()
self._handle_responsive_buttons(self.winfo_width())
self.navigation_manager.navigate_to(self.current_dir)
self.after(10, initial_load)
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()
size_preset = self.settings.get("window_size_preset", "1050x850")
self.settings["window_size_preset"] = size_preset
if hasattr(self, 'view_mode'):
self.view_mode.set(self.settings.get("default_view_mode", "icons"))
def get_min_size_from_preset(self, preset: str) -> Tuple[int, int]:
"""
Calculates the minimum window size based on a preset string.
"""
w, h = map(int, preset.split('x'))
return max(650, w - 400), max(450, h - 400)
def reload_config_and_rebuild_ui(self) -> None:
"""Reloads the configuration and rebuilds the entire UI."""
self.load_settings()
self.geometry(self.settings["window_size_preset"])
min_width, min_height = self.get_min_size_from_preset(
self.settings["window_size_preset"])
self.minsize(min_width, min_height)
width, height = map(
int, self.settings["window_size_preset"].split('x'))
LxTools.center_window_cross_platform(self, width, height)
for widget in self.winfo_children():
widget.destroy()
self._initialize_managers()
self.widget_manager.filename_entry.bind(
"<Return>", self.search_manager.execute_search)
self.view_manager._update_view_mode_buttons()
self.responsive_buttons_hidden = None
self.update_idletasks()
self._handle_responsive_buttons(self.winfo_width())
self.update_animation_settings()
if self.search_mode:
self.search_manager.show_search_results_treeview()
else:
self.navigation_manager.navigate_to(self.current_dir)
def open_settings_dialog(self) -> None:
"""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)
anim_type = self.settings.get('animation_type', 'double')
is_running = self.widget_manager.search_animation.running
if is_running:
self.widget_manager.search_animation.stop()
self.widget_manager.search_animation.destroy()
self.widget_manager.search_animation = AnimatedIcon(
self.widget_manager.status_container,
width=23,
height=23,
use_pillow=use_pillow,
animation_type=anim_type,
color="#2a6fde",
highlight_color="#5195ff",
bg=self.style_manager.bottom_color
)
self.widget_manager.search_animation.grid(
row=0, column=0, sticky='w', padx=(0, 5), pady=(4, 0))
self.widget_manager.search_animation.bind(
"<Button-1>", lambda e: self.search_manager.activate_search())
self.my_tool_tip = Tooltip(
self.widget_manager.search_animation,
text=lambda: LocaleStrings.UI["cancel_search"] if self.widget_manager.search_animation.running else LocaleStrings.UI["start_search"]
)
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.
"""
ext = os.path.splitext(filename)[1].lower()
if ext == '.py':
return self.icon_manager.get_icon(f'python_{size}')
if ext == '.pdf':
return self.icon_manager.get_icon(f'pdf_{size}')
if ext in ['.tar', '.zip', '.rar', '.7z', '.gz']:
return self.icon_manager.get_icon(f'archive_{size}')
if ext in ['.mp3', '.wav', '.ogg', '.flac']:
return self.icon_manager.get_icon(f'audio_{size}')
if ext in ['.mp4', '.mkv', '.avi', '.mov']:
return self.icon_manager.get_icon(f'video_{size}') if size == 'large' else self.icon_manager.get_icon(
'video_small_file')
if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.svg']:
return self.icon_manager.get_icon(f'picture_{size}')
if ext == '.iso':
return self.icon_manager.get_icon(f'iso_{size}')
return self.icon_manager.get_icon(f'file_{size}')
def on_window_resize(self, event: tk.Event) -> None:
"""
Handles the window resize event.
"""
if event.widget is self:
if self.view_mode.get() == "icons" and not self.search_mode:
new_width = self.widget_manager.file_list_frame.winfo_width()
if abs(new_width - self.last_width) > 50:
if self.resize_job:
self.after_cancel(self.resize_job)
def repopulate_icons() -> None:
"""Repopulates the file list icons."""
self.update_idletasks()
self.view_manager.populate_files()
self.resize_job = self.after(150, repopulate_icons)
self.last_width = new_width
self._handle_responsive_buttons(event.width)
def _handle_responsive_buttons(self, window_width: int) -> None:
"""
Shows or hides buttons based on the window width.
"""
threshold = 850
container = self.widget_manager.responsive_buttons_container
more_button = self.widget_manager.more_button
should_be_hidden = window_width < threshold
if should_be_hidden != self.responsive_buttons_hidden:
if should_be_hidden:
container.pack_forget()
more_button.pack(side="left", padx=5)
else:
more_button.pack_forget()
container.pack(side="left")
self.responsive_buttons_hidden = should_be_hidden
def show_more_menu(self) -> None:
"""Displays a 'more options' menu."""
more_menu = tk.Menu(self, tearoff=0, background=self.style_manager.header, foreground=self.style_manager.color_foreground,
activebackground=self.style_manager.selection_color, activeforeground=self.style_manager.color_foreground, relief='flat', borderwidth=0)
is_writable = os.access(self.current_dir, os.W_OK)
creation_state = tk.NORMAL if is_writable and self.dialog_mode != "open" else tk.DISABLED
more_menu.add_command(label=LocaleStrings.UI["new_folder"], command=self.file_op_manager.create_new_folder,
image=self.icon_manager.get_icon('new_folder_small'), compound='left', state=creation_state)
more_menu.add_command(label=LocaleStrings.UI["new_document"], command=self.file_op_manager.create_new_file,
image=self.icon_manager.get_icon('new_document_small'), compound='left', state=creation_state)
more_menu.add_separator()
more_menu.add_command(label=LocaleStrings.VIEW["icon_view"], command=self.view_manager.set_icon_view,
image=self.icon_manager.get_icon('icon_view'), compound='left')
more_menu.add_command(label=LocaleStrings.VIEW["list_view"], command=self.view_manager.set_list_view,
image=self.icon_manager.get_icon('list_view'), compound='left')
more_menu.add_separator()
hidden_files_label = LocaleStrings.UI["hide_hidden_files"] if self.show_hidden_files.get(
) else LocaleStrings.UI["show_hidden_files"]
hidden_files_icon = self.icon_manager.get_icon(
'unhide') if self.show_hidden_files.get() else self.icon_manager.get_icon('hide')
more_menu.add_command(label=hidden_files_label, command=self.view_manager.toggle_hidden_files,
image=hidden_files_icon, compound='left')
more_button = self.widget_manager.more_button
x = more_button.winfo_rootx()
y = more_button.winfo_rooty() + more_button.winfo_height()
more_menu.tk_popup(x, y)
def on_sidebar_resize(self, event: tk.Event) -> None:
"""
Handles the sidebar resize event, adjusting button text visibility.
"""
current_width = event.width
threshold_width = 100
if current_width < threshold_width:
for btn, original_text in self.widget_manager.sidebar_buttons:
btn.config(text="", compound="top")
for btn, original_text in self.widget_manager.device_buttons:
btn.config(text="", compound="top")
else:
for btn, original_text in self.widget_manager.sidebar_buttons:
btn.config(text=original_text, compound="left")
for btn, original_text in self.widget_manager.device_buttons:
btn.config(text=original_text, compound="left")
def _on_devices_enter(self, event: tk.Event) -> None:
"""
Shows the scrollbar when the mouse enters the devices area.
"""
self.widget_manager.devices_scrollbar.grid(
row=1, column=1, sticky="ns")
def _on_devices_leave(self, event: tk.Event) -> None:
"""
Hides the scrollbar when the mouse leaves the devices area.
"""
x, y = event.x_root, event.y_root
widget_x = self.widget_manager.devices_canvas.winfo_rootx()
widget_y = self.widget_manager.devices_canvas.winfo_rooty()
widget_width = self.widget_manager.devices_canvas.winfo_width()
widget_height = self.widget_manager.devices_canvas.winfo_height()
buffer = 5
if not (widget_x - buffer <= x <= widget_x + widget_width + buffer and
widget_y - buffer <= y <= widget_y + widget_height + buffer):
self.widget_manager.devices_scrollbar.grid_remove()
def toggle_recursive_search(self) -> None:
"""
Toggles the recursive search option on or off."""
self.widget_manager.recursive_search.set(
not self.widget_manager.recursive_search.get())
if self.widget_manager.recursive_search.get():
self.widget_manager.recursive_button.configure(
style="Header.TButton.Active.Round")
else:
self.widget_manager.recursive_button.configure(
style="Header.TButton.Borderless.Round")
def update_selection_info(self, status_info: Optional[str] = None) -> None:
"""
Updates status bar, filename entry, and result based on current selection.
"""
self._update_disk_usage()
status_text = ""
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
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' 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:
"""Handles the 'Save' action, setting the selected file and closing the dialog."""
file_name = self.widget_manager.filename_entry.get()
if file_name:
self.result = os.path.join(self.current_dir, file_name)
self.destroy()
def on_cancel(self) -> None:
"""Handles the 'Cancel' action, clearing the selection and closing the dialog."""
self.result = None
self.destroy()
def get_result(self) -> Optional[Union[str, List[str]]]:
"""Returns the result of the dialog."""
return self.result
def update_action_buttons_state(self) -> None:
"""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)
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.
"""
if self.current_filter_pattern == "*.*":
return True
patterns = self.current_filter_pattern.lower().split()
fn_lower = filename.lower()
for p in patterns:
if p.startswith('*.'):
if fn_lower.endswith(p[1:]):
return True
elif p.startswith('.'):
if fn_lower.endswith(p):
return True
else:
if fn_lower == p:
return True
return False
def _format_size(self, size_bytes: Optional[int]) -> str:
"""
Formats a size in bytes into a human-readable string (KB, MB, GB).
"""
if size_bytes is None:
return ""
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1024**2:
return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1024**3:
return f"{size_bytes/1024**2:.1f} MB"
return f"{size_bytes/1024**3:.1f} GB"
def shorten_text(self, text: str, max_len: int) -> str:
"""
Shortens a string to a maximum length, adding '...' if truncated.
"""
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.
"""
devices: List[Tuple[str, str, bool]] = []
root_disk_name: Optional[str] = None
try:
result = subprocess.run(['lsblk', '-J', '-o', 'NAME,MOUNTPOINT,FSTYPE,SIZE,RO,RM,TYPE,LABEL,PKNAME'],
capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
for block_device in data.get('blockdevices', []):
if 'children' in block_device:
for child_device in block_device['children']:
if child_device.get('mountpoint') == '/':
root_disk_name = block_device.get('name')
break
if root_disk_name:
break
for block_device in data.get('blockdevices', []):
if (
block_device.get('mountpoint') and
block_device.get('type') not in ['loop', 'rom'] and
block_device.get('mountpoint') != '/'):
if block_device.get('name').startswith(root_disk_name) and not block_device.get('rm', False):
pass
else:
name = block_device.get('name')
mountpoint = block_device.get('mountpoint')
label = block_device.get('label')
removable = block_device.get('rm', False)
display_name = label if label else name
devices.append((display_name, mountpoint, removable))
if 'children' in block_device:
for child_device in block_device['children']:
if (
child_device.get('mountpoint') and
child_device.get('type') not in ['loop', 'rom'] and
child_device.get('mountpoint') != '/'):
if block_device.get('name') == root_disk_name and not child_device.get('rm', False):
pass
else:
name = child_device.get('name')
mountpoint = child_device.get('mountpoint')
label = child_device.get('label')
removable = child_device.get('rm', False)
display_name = label if label else name
devices.append(
(display_name, mountpoint, removable))
except Exception as e:
print(f"Error getting mounted devices: {e}")
return devices

View File

@@ -1,22 +0,0 @@
#!/usr/bin/python3
"""Utility functions for setting up the application."""
from logview_app_config import AppConfig
from pathlib import Path
# Logging
LOG_DIR = Path.home() / ".local/share/lxlogs"
Path(LOG_DIR).mkdir(parents=True, exist_ok=True)
LOG_FILE_PATH = LOG_DIR / "logviewer.log"
def prepare_app_environment() -> None:
"""Ensures that all required files and directories exist."""
AppConfig.ensure_directories()
AppConfig.create_default_settings()
AppConfig.ensure_log()
if __name__ == "__main__":
prepare_app_environment()

197
gitea.py
View File

@@ -1,132 +1,131 @@
#!/usr/bin/python3
import gettext
import locale
"""
A streamlined module to check for updates from a Gitea repository API.
"""
import re
import requests
from pathlib import Path
import subprocess
import shutil
from shared_libs.message import MessageDialog
from typing import Optional, Tuple
class GiteaUpdate:
class GiteaUpdaterError(Exception):
"""Base exception for GiteaUpdater."""
pass
class GiteaApiUrlError(GiteaUpdaterError):
"""Raised when the Gitea API URL is invalid."""
pass
class GiteaVersionParseError(GiteaUpdaterError, ValueError):
"""Raised when a version string cannot be parsed."""
pass
class GiteaApiResponseError(GiteaUpdaterError):
"""Raised for invalid or unexpected API responses."""
pass
class GiteaUpdater:
"""
Calling download requests the download URL of the running script,
the taskbar image for the “Download OK” window, the taskbar image for the
“Download error” window, and the variable res
Provides a clean interface to check for software updates via a Gitea API.
"""
@staticmethod
def api_down(update_api_url: str, version: str, update_setting: str = None) -> str:
def _parse_version(version_string: str) -> Optional[Tuple[int, ...]]:
"""
Checks for updates via API
Parses a version string into a tuple of integers for comparison.
Handles prefixes like 'v. ' or 'v'.
It prioritizes parsing as a date-based version string with the format
<major>.<month>.<day><year_short> (e.g., "v. 2.08.1025").
If the string does not match this format, it falls back to a general
semantic versioning parsing (e.g., "v2.1.0").
Args:
update_api_url: Update API URL
version: Current version
update_setting: Update setting from ConfigManager (on/off)
version_string: The version string (e.g., "v. 1.08.1325", "v2.1.0").
Returns:
New version or status message
A tuple of integers for comparison. For date-based versions, the
format is (major, year, month, day) to ensure correct comparison.
Returns None if parsing fails.
"""
# If updates are disabled, return immediately
if update_setting != "on":
return "False"
try:
response: requests.Response = requests.get(update_api_url, timeout=10)
response.raise_for_status() # Raise exception for HTTP errors
# Remove common prefixes like 'v', 'v. ', etc.
cleaned_string = re.sub(r'^[vV\.\s]+', '', version_string)
parts = cleaned_string.split('.')
response_data = response.json()
if not response_data:
return "No Updates"
# Try to parse as date-based version first
if len(parts) == 3:
day_year_str = parts[2]
if len(day_year_str) >= 3 and day_year_str.isdigit():
day_year_int = int(day_year_str)
major = int(parts[0])
month = int(parts[1])
latest_version = response_data[0].get("tag_name")
if not latest_version:
return "Invalid API Response"
day = day_year_int // 100
year = day_year_int % 100 + 2000
# Compare versions (strip 'v. ' prefix if present)
current_version = version[3:] if version.startswith("v. ") else version
# Basic validation for date components
if 1 <= month <= 12 and 1 <= day <= 31:
return (major, year, month, day)
if current_version != latest_version:
return latest_version
else:
return "No Updates"
# Fallback to standard version parsing for other formats (e.g., 2.1.0)
return tuple(map(int, parts))
except requests.exceptions.RequestException:
return "No Internet Connection!"
except (ValueError, KeyError, IndexError):
return "Invalid API Response"
except (ValueError, TypeError):
return None
@staticmethod
def download(urld: str, res: str) -> None:
def check_for_update(api_url: str, current_version: str) -> Optional[str]:
"""
Downloads new version of application
Checks for a newer version of the application on Gitea.
:param urld: Download URL
:param res: Result filename
"""
try:
to_down: str = f"wget -qP {Path.home()} {" "} {urld}"
result: int = subprocess.call(to_down, shell=True)
if result == 0:
shutil.chown(f"{Path.home()}/{res}.zip", 1000, 1000)
MessageDialog("info", text=Msg.STR["ok_message"])
else:
MessageDialog(
"error", text=Msg.STR["error_message"], title=Msg.STR["error_title"]
)
except subprocess.CalledProcessError:
MessageDialog(
"error", text=Msg.STR["error_no_internet"], title=Msg.STR["error_title"]
)
class AppConfig:
# Localization
APP_NAME: str = "gitea"
LOCALE_DIR: Path = Path("/usr/share/locale/")
@staticmethod
def setup_translations() -> gettext.gettext:
"""
Initialize translations and set the translation function
Special method for translating strings in this file
Args:
api_url: The Gitea API URL for releases.
current_version: The current version string of the application.
Returns:
The gettext translation function
The new version string if an update is available, otherwise None.
Raises:
GiteaApiUrlError: If the API URL is not provided.
GiteaVersionParseError: If the local or remote version string cannot be parsed.
GiteaApiResponseError: If the API response is invalid.
requests.exceptions.RequestException: For network or HTTP errors.
"""
locale.bindtextdomain(AppConfig.APP_NAME, AppConfig.LOCALE_DIR)
gettext.bindtextdomain(AppConfig.APP_NAME, AppConfig.LOCALE_DIR)
gettext.textdomain(AppConfig.APP_NAME)
return gettext.gettext
if not api_url:
raise GiteaApiUrlError("Gitea API URL is not provided.")
# Images and icons paths
IMAGE_PATHS: dict[str, Path] = {
"icon_info": "/usr/share/icons/lx-icons/64/info.png",
"icon_error": "/usr/share/icons/lx-icons/64/error.png",
"icon_download": "/usr/share/icons/lx-icons/48/download.png",
"icon_download_error": "/usr/share/icons/lx-icons/48/download_error.png",
}
local_version_tuple = GiteaUpdater._parse_version(current_version)
if not local_version_tuple:
raise GiteaVersionParseError(
f"Could not parse local version string: {current_version}")
response = requests.get(api_url, timeout=10)
response.raise_for_status() # Raises HTTPError for 4xx/5xx
# here is initializing the class for translation strings
_ = AppConfig.setup_translations()
try:
data = response.json()
if not data:
return None # No releases found is not an error
latest_tag_name = data[0].get("tag_name")
if not latest_tag_name:
raise GiteaApiResponseError(
"Invalid API response: 'tag_name' not found in the first release.")
class Msg:
except (ValueError, IndexError, KeyError) as e:
raise GiteaApiResponseError(
f"Could not process the response from Gitea: {e}") from e
STR: dict[str, str] = {
# Strings for messages
"title": _("Download Successful"),
"ok_message": _("Your zip file is in home directory"),
"error_title": _("Download error"),
"error_message": _("Download failed! Please try again"),
"error_no_internet": _("Download failed! No internet connection!"),
}
remote_version_tuple = GiteaUpdater._parse_version(latest_tag_name)
if not remote_version_tuple:
raise GiteaVersionParseError(
f"Could not parse remote version string: {latest_tag_name}")
if remote_version_tuple > local_version_tuple:
return latest_tag_name
return None

101
log_window.py Normal file
View File

@@ -0,0 +1,101 @@
import tkinter as tk
from tkinter import ttk
from datetime import datetime
import typing
from shared_libs.common_tools import IconManager
if typing.TYPE_CHECKING:
from tkinter import Event
class LogWindow(ttk.Frame):
"""A Tkinter frame that provides a scrollable text widget for logging messages."""
def __init__(self, parent: tk.Misc, copy: str = "Copy") -> None:
"""
Initializes the LogWindow widget.
Args:
parent: The parent widget.
copy: The text label for the 'Copy' context menu item.
"""
super().__init__(parent)
self.icon_manager = IconManager()
log_container = tk.Frame(self)
# Let the main app control padding via the grid options
log_container.pack(fill="both", expand=True, padx=0, pady=0)
self.log_text: tk.Text = tk.Text(
log_container,
wrap=tk.WORD,
font=("Consolas", 9),
bg="#1e1e1e",
fg="#d4d4d4",
insertbackground="white",
selectbackground="#264f78",
height=10 # Give it a default height
)
log_scrollbar = ttk.Scrollbar(
log_container, orient="vertical", command=self.log_text.yview
)
self.log_text.configure(yscrollcommand=log_scrollbar.set)
self.log_text.pack(side="left", fill="both", expand=True)
log_scrollbar.pack(side="right", fill="y")
self.context_menu: tk.Menu = tk.Menu(self, tearoff=0)
self.context_menu.add_command(
label=copy, command=self.copy_text, image=self.icon_manager.get_icon('copy'), compound='left')
self.log_text.bind("<Button-3>", self.show_context_menu)
def log_message(self, message: str) -> None:
"""
Adds a timestamped message to the log view.
Args:
message: The message string to add.
"""
timestamp = datetime.now().strftime("%H:%M:%S")
log_entry = f"[{timestamp}] {message}\n"
self.log_text.insert(tk.END, log_entry)
self.log_text.see(tk.END)
self.log_text.update()
def show_text_menu(self, event: 'Event') -> None:
"""
Displays the context menu at the event's coordinates.
(Note: This seems to be a remnant, show_context_menu is used).
Args:
event: The tkinter event that triggered the menu.
"""
try:
self.context_menu.tk_popup(event.x_root, event.y_root)
finally:
self.context_menu.grab_release()
def copy_text(self) -> None:
"""Copies the currently selected text in the log widget to the clipboard."""
try:
selected_text = self.log_text.selection_get()
self.clipboard_clear()
self.clipboard_append(selected_text)
except tk.TclError:
# No Text selected
pass
def show_context_menu(self, event: 'Event') -> None:
"""
Shows the right-click context menu.
Args:
event: The tkinter Button-3 event.
"""
try:
self.context_menu.tk_popup(event.x_root, event.y_root)
finally:
self.context_menu.grab_release()

33
logger.py Normal file
View File

@@ -0,0 +1,33 @@
import typing
from typing import Callable
class Logger:
"""A simple logger class that can be initialized with a logging function."""
def __init__(self) -> None:
"""Initializes the Logger, defaulting to the print function."""
self._log_func: Callable[[str], None] = print
def init_logger(self, log_func: Callable[[str], None]) -> None:
"""
Initializes the logger with a specific logging function.
Args:
log_func: The function to use for logging. It should accept a single
string argument.
"""
self._log_func = log_func
def log(self, message: str) -> None:
"""
Logs a message using the configured logging function.
Args:
message: The message to log.
"""
self._log_func(message)
# Global instance
app_logger: Logger = Logger()

View File

@@ -1,146 +0,0 @@
"""Configuration for the LogViewer application."""
import gettext
import locale
from pathlib import Path
from typing import Dict, Any
class AppConfig:
"""Central configuration and system setup manager for the LogViewer application.
This class serves as a singleton-like container for all global configuration data,
including paths, UI settings, localization, versioning, and system-specific resources.
It ensures that required directories, files, and services are created and configured
before the application starts. Additionally, it provides tools for managing translations,
default settings, and autostart functionality to maintain a consistent user experience.
Key Responsibilities:
- Centralizes all configuration values (paths, UI preferences, localization).
- Ensures required directories and files exist on startup.
- Handles translation setup via `gettext` for multilingual support.
- Manages default settings file generation.
- Configures autostart services using systemd for user-specific launch behavior.
This class is used globally across the application to access configuration data
consistently and perform system-level setup tasks.
"""
# Logging
LOG_DIR = Path.home() / ".local/share/lxlogs"
Path(LOG_DIR).mkdir(parents=True, exist_ok=True)
LOG_FILE_PATH = LOG_DIR / "logviewer.log"
# Localization
APP_NAME: str = "logviewer"
LOCALE_DIR: Path = Path("/usr/share/locale/")
# Base paths
BASE_DIR: Path = Path.home()
CONFIG_DIR: Path = BASE_DIR / ".config/logviewer"
# Configuration files
SETTINGS_FILE: Path = CONFIG_DIR / "settings"
DEFAULT_SETTINGS: Dict[str, str] = {
"# Configuration": "on",
"# Theme": "light",
"# Tooltips": True,
"# Autostart": "off",
"# Logfile": LOG_FILE_PATH,
}
# Updates
# 1 = 1. Year, 09 = Month of the Year, 2924 = Day and Year of the Year
VERSION: str = "v. 1.06.0325"
UPDATE_URL: str = "https://git.ilunix.de/api/v1/repos/punix/shared_libs/releases"
DOWNLOAD_URL: str = "https://git.ilunix.de/punix/shared_libs/archive"
# UI configuration
UI_CONFIG: Dict[str, Any] = {
"window_title2": "LogViewer",
"window_size": (600, 383),
"font_family": "Ubuntu",
"font_size": 11,
"resizable_window": (True, True),
}
# Images and icons paths
IMAGE_PATHS: Dict[str, Path] = {
"icon_info": "/usr/share/icons/lx-icons/64/info.png",
"icon_error": "/usr/share/icons/lx-icons/64/error.png",
"icon_log": "/usr/share/icons/lx-icons/48/log.png",
}
# System-dependent paths
SYSTEM_PATHS: Dict[str, Path] = {
"tcl_path": "/usr/share/TK-Themes",
}
@staticmethod
def setup_translations() -> gettext.gettext:
"""
Initialize translations and set the translation function
Special method for translating strings in this file
Returns:
The gettext translation function
"""
locale.bindtextdomain(AppConfig.APP_NAME, AppConfig.LOCALE_DIR)
gettext.bindtextdomain(AppConfig.APP_NAME, AppConfig.LOCALE_DIR)
gettext.textdomain(AppConfig.APP_NAME)
return gettext.gettext
@classmethod
def create_default_settings(cls) -> None:
"""Creates default settings if they don't exist"""
if not cls.SETTINGS_FILE.exists():
content = "\n".join(
f"[{k.upper()}]\n{v}" for k, v in cls.DEFAULT_SETTINGS.items()
)
cls.SETTINGS_FILE.write_text(content)
@classmethod
def ensure_directories(cls) -> None:
"""Ensures that all required directories exist"""
if not cls.CONFIG_DIR.exists():
cls.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
@classmethod
def ensure_log(cls) -> None:
"""Ensures that the log file exists"""
if not cls.LOG_FILE_PATH.exists():
cls.LOG_FILE_PATH.touch()
# here is initializing the class for translation strings
_ = AppConfig.setup_translations()
class Msg:
"""
A utility class that provides centralized access to translated message strings.
This class contains a dictionary of message strings used throughout the Wire-Py application.
All strings are prepared for translation using gettext. The short key names make the code
more concise while maintaining readability.
Attributes:
STR (dict): A dictionary mapping short keys to translated message strings.
Keys are abbreviated for brevity but remain descriptive.
Usage:
Import this class and access messages using the dictionary:
`Msg.STR["sel_tl"]` returns the translated "Select tunnel" message.
Note:
Ensure that gettext translation is properly initialized before
accessing these strings to ensure correct localization.
"""
STR: Dict[str, str] = {
# Strings for messages
}
TTIP: Dict[str, str] = {
# Strings for Tooltips
"settings": _("Click for Settings"),
}

View File

@@ -1,517 +0,0 @@
#!/usr/bin/python3
import argparse
import logging
import tkinter as tk
from tkinter import TclError, filedialog, ttk
from pathlib import Path
import webbrowser
from functools import partial
from shared_libs.gitea import GiteaUpdate
from shared_libs.message import MessageDialog
from shared_libs.common_tools import (
LogConfig,
ConfigManager,
ThemeManager,
LxTools,
Tooltip,
)
import sys
from file_and_dir_ensure import prepare_app_environment
class LogViewer(tk.Tk):
def __init__(self, modul_name):
super().__init__()
self.my_tool_tip = None
self.modul_name = modul_name # Save the module name
# from here the calls must be made with the module name
_ = modul_name.AppConfig.setup_translations()
self.x_width = modul_name.AppConfig.UI_CONFIG["window_size"][0]
self.y_height = modul_name.AppConfig.UI_CONFIG["window_size"][1]
# Set the window size
self.geometry(f"{self.x_width}x{self.y_height}")
self.minsize(
modul_name.AppConfig.UI_CONFIG["window_size"][0],
modul_name.AppConfig.UI_CONFIG["window_size"][1],
)
self.title(modul_name.AppConfig.UI_CONFIG["window_title2"])
self.tk.call(
"source", f"{modul_name.AppConfig.SYSTEM_PATHS['tcl_path']}/water.tcl"
)
ConfigManager.init(modul_name.AppConfig.SETTINGS_FILE)
theme = ConfigManager.get("theme")
ThemeManager.change_theme(self, theme)
LxTools.center_window_cross_platform(self, self.x_width, self.y_height)
self.createWidgets(_)
self.load_file(_, modul_name=modul_name)
self.log_icon = tk.PhotoImage(file=modul_name.AppConfig.IMAGE_PATHS["icon_log"])
self.iconphoto(True, self.log_icon)
self.grid_rowconfigure(0, weight=1)
self.grid_rowconfigure(1, weight=1)
self.grid_columnconfigure(0, weight=1)
# StringVar-Variables initialization
self.tooltip_state = tk.BooleanVar()
# Get value from configuration
state = ConfigManager.get("tooltips")
# NOTE: ConfigManager.get("tooltips") can return either a boolean value or a string,
# depending on whether the value was loaded from the file (bool) or the default value is used (string).
# The expression 'lines[5].strip() == "True"' in ConfigManager.load() converts the string to a boolean.
# Convert to boolean and set
if isinstance(state, bool):
# If it's already a boolean, use directly
self.tooltip_state.set(state)
else:
# If it's a string or something else
self.tooltip_state.set(str(state) == "True")
self.tooltip_label = (
tk.StringVar()
) # StringVar-Variable for tooltip label for view Disabled/Enabled
self.tooltip_update_label(modul_name, _)
self.update_label = tk.StringVar() # StringVar-Variable for update label
self.update_tooltip = (
tk.StringVar()
) # StringVar-Variable for update tooltip please not remove!
self.update_foreground = tk.StringVar(value="red")
# Frame for Menu
self.menu_frame = ttk.Frame(self)
self.menu_frame.configure(relief="flat")
if "'logview_app_config'" in f"{modul_name}".split():
self.menu_frame.grid(column=0, row=0, columnspan=4, sticky=tk.NSEW)
# App Menu
self.version_lb = ttk.Label(self.menu_frame, text=modul_name.AppConfig.VERSION)
self.version_lb.config(font=("Ubuntu", 11), foreground="#00c4ff")
self.version_lb.grid(column=0, row=0, rowspan=4, padx=10, pady=10)
Tooltip(
self.version_lb,
f"Version: {modul_name.AppConfig.VERSION[2:]}",
self.tooltip_state,
)
self.load_button = ttk.Button(
self.menu_frame,
text=_("Load Log"),
style="Toolbutton",
command=lambda: self.directory_load(modul_name, _),
)
self.load_button.grid(column=1, row=0)
self.options_btn = ttk.Menubutton(self.menu_frame, text=_("Options"))
self.options_btn.grid(column=2, row=0)
Tooltip(self.options_btn, modul_name.Msg.TTIP["settings"], self.tooltip_state)
self.set_update = tk.IntVar()
self.settings = tk.Menu(self, relief="flat")
self.options_btn.configure(menu=self.settings, style="Toolbutton")
self.settings.add_checkbutton(
label=_("Disable Updates"),
command=lambda: self.update_setting(self.set_update.get(), modul_name, _),
variable=self.set_update,
)
self.updates_lb = ttk.Label(self.menu_frame, textvariable=self.update_label)
self.updates_lb.grid(column=5, row=0, padx=10)
self.updates_lb.grid_remove()
self.update_label.trace_add("write", self.update_label_display)
self.update_foreground.trace_add("write", self.update_label_display)
res = GiteaUpdate.api_down(
modul_name.AppConfig.UPDATE_URL,
modul_name.AppConfig.VERSION,
ConfigManager.get("updates"),
)
self.update_ui_for_update(res, modul_name, _)
# Tooltip Menu
self.settings.add_command(
label=self.tooltip_label.get(),
command=lambda: self.tooltips_toggle(modul_name, _),
)
# Label show dark or light
self.theme_label = tk.StringVar()
self.update_theme_label(modul_name, _)
self.settings.add_command(
label=self.theme_label.get(),
command=lambda: self.on_theme_toggle(modul_name, _),
)
# About BTN Menu / Label
self.about_btn = ttk.Button(
self.menu_frame,
text=_("About"),
style="Toolbutton",
command=lambda: self.about(modul_name, _),
)
self.about_btn.grid(column=3, row=0)
self.readme = tk.Menu(self)
# self.grid_rowconfigure(0, weight=)
self.grid_rowconfigure(1, weight=25)
self.grid_columnconfigure(0, weight=1)
# Method that is called when the variable changes
def update_label_display(self, *args):
# Set the foreground color
self.updates_lb.configure(foreground=self.update_foreground.get())
# Show or hide the label based on whether it contains text
if self.update_label.get():
# Make sure the label is in the correct position every time it's shown
self.updates_lb.grid(column=5, row=0, padx=10)
else:
self.updates_lb.grid_remove()
# Update the labels based on the result
def update_ui_for_update(self, res, modul_name, _):
"""Update UI elements based on an update check result"""
# First, remove the update button if it exists to avoid conflicts
if hasattr(self, "update_btn"):
self.update_btn.grid_forget()
delattr(self, "update_btn")
if res == "False":
self.set_update.set(value=1)
self.update_label.set(_("Update search off"))
self.update_tooltip.set(_("Updates you have disabled"))
# Clear the foreground color as requested
self.update_foreground.set("")
# Set the tooltip for the label
Tooltip(self.updates_lb, self.update_tooltip.get(), self.tooltip_state)
elif res == "No Internet Connection!":
self.update_label.set(_("No Server Connection!"))
self.update_foreground.set("red")
# Set the tooltip for "No Server Connection"
Tooltip(
self.updates_lb,
_("Could not connect to update server"),
self.tooltip_state,
)
elif res == "No Updates":
self.update_label.set(_("No Updates"))
self.update_tooltip.set(_("Congratulations! Wire-Py is up to date"))
self.update_foreground.set("")
# Set the tooltip for the label
Tooltip(self.updates_lb, self.update_tooltip.get(), self.tooltip_state)
else:
self.set_update.set(value=0)
update_text = f"Update {res} {_('available!')}"
# Clear the label text since we'll show the button instead
self.update_label.set("")
# Create the update button
self.update_btn = ttk.Menubutton(self.menu_frame, text=update_text)
self.update_btn.grid(column=5, row=0, padx=0)
Tooltip(
self.update_btn, _("Click to download new version"), self.tooltip_state
)
self.download = tk.Menu(self, relief="flat")
self.update_btn.configure(menu=self.download, style="Toolbutton")
self.download.add_command(
label=_("Download"),
command=lambda: GiteaUpdate.download(
f"{modul_name.AppConfig.DOWNLOAD_URL}/{res}.zip", res
),
)
@staticmethod
def about(modul_name, _) -> None:
"""
a tk.Toplevel window
"""
msg_t = _(
"Logviewer a simple Gui for View Logfiles.\n\n"
"Logviewer is open source software written in Python.\n\n"
"Email: polunga40@unity-mail.de also likes for donation.\n\n"
"Use without warranty!\n"
)
MessageDialog(
"info",
text=msg_t,
buttons=["OK", "Go to Logviewer"],
commands=[
None, # Default on "OK"
partial(webbrowser.open, "https://git.ilunix.de/punix/shared_libs"),
],
icon=modul_name.AppConfig.IMAGE_PATHS["icon_log"],
title="Logviewer",
)
def update_setting(self, update_res, modul_name, _) -> None:
"""write off or on in file
Args:
update_res (int): argument that is passed contains 0 or 1
"""
if update_res == 1:
# Disable updates
ConfigManager.set("updates", "off")
# When updates are disabled, we know the result should be "False"
self.update_ui_for_update("False", modul_name, _)
else:
# Enable updates
ConfigManager.set("updates", "on")
# When enabling updates, we need to actually check for updates
try:
# Force a fresh check by passing "on" as the update setting
res = GiteaUpdate.api_down(
modul_name.AppConfig.UPDATE_URL, modul_name.AppConfig.VERSION, "on"
)
# Make sure the UI is updated regardless of the previous state
if hasattr(self, "update_btn"):
self.update_btn.grid_forget()
if hasattr(self, "updates_lb"):
self.updates_lb.grid_forget()
# Now update the UI with the fresh result
self.update_ui_for_update(res, modul_name, _)
except Exception as e:
logging.error(f"Error checking for updates: {e}")
# Fallback to a default message if there's an error
self.update_ui_for_update("No Internet Connection!", modul_name, _)
def tooltip_update_label(self, modul_name, _) -> None:
"""Updates the tooltip menu label based on the current tooltip status"""
# Set the menu text based on the current status
if self.tooltip_state.get():
# If tooltips are enabled, the menu option should be to disable them
self.tooltip_label.set(_("Disable Tooltips"))
else:
# If tooltips are disabled, the menu option should be to enable them
self.tooltip_label.set(_("Enable Tooltips"))
def tooltips_toggle(self, modul_name, _):
"""
Toggles the visibility of tooltips (on/off) and updates
the corresponding menu label. Inverts the current tooltip state
(`self.tooltip_state`), saves the new value in the configuration,
and applies the change immediately. Updates the menu entry's label to
reflect the new tooltip status (e.g., "Tooltips: On" or "Tooltips: Off").
"""
# Toggle the boolean state
new_bool_state = not self.tooltip_state.get()
# Save the converted value in the configuration
ConfigManager.set("tooltips", str(new_bool_state))
# Update the tooltip_state variable for immediate effect
self.tooltip_state.set(new_bool_state)
# Update the menu label
self.tooltip_update_label(modul_name, _)
# Update the menu entry - find the correct index
# This assumes it's the third item (index 2) in your menu
self.settings.entryconfigure(1, label=self.tooltip_label.get())
def update_theme_label(self, modul_name, _) -> None:
"""Update the theme label based on the current theme"""
current_theme = ConfigManager.get("theme")
if current_theme == "light":
self.theme_label.set(_("Dark"))
else:
self.theme_label.set(_("Light"))
def on_theme_toggle(self, modul_name, _) -> None:
"""Toggle between light and dark theme"""
current_theme = ConfigManager.get("theme")
new_theme = "dark" if current_theme == "light" else "light"
ThemeManager.change_theme(self, new_theme, new_theme)
self.update_theme_label(modul_name, _) # Update the theme label
# Update Menulfield
self.settings.entryconfigure(2, label=self.theme_label.get())
def createWidgets(self, _):
text_frame = ttk.Frame(self)
text_frame.grid(row=1, column=0, padx=5, pady=5, sticky=tk.NSEW)
text_frame.rowconfigure(0, weight=3)
text_frame.columnconfigure(0, weight=1)
next_frame = ttk.Frame(self)
next_frame.grid(row=2, column=0, sticky=tk.NSEW)
next_frame.rowconfigure(2, weight=1)
next_frame.columnconfigure(1, weight=1)
# Create a Text widget for displaying the log file
self.text_area = tk.Text(
text_frame, wrap=tk.WORD, padx=5, pady=5, relief="flat"
)
self.text_area.grid(row=0, column=0, sticky=tk.NSEW)
self.text_area.tag_configure(
"found-tag", foreground="yellow", background="green"
)
# Create a vertical scrollbar for the Text widget
v_scrollbar = ttk.Scrollbar(
text_frame, orient="vertical", command=self.text_area.yview
)
v_scrollbar.grid(row=0, column=1, sticky=tk.NS)
self.text_area.configure(yscrollcommand=v_scrollbar.set)
self._entry = ttk.Entry(next_frame)
self._entry.bind("<Return>", lambda e: self._onFind())
self._entry.grid(row=0, column=1, padx=5, sticky=tk.EW)
# Add a context menu to the Text widget
self.context_menu = tk.Menu(self, tearoff=0)
self.context_menu.add_command(label=_("Copy"), command=self.copy_text)
self.context_menu.add_command(label=_("Paste"), command=self.paste_into_entry)
self.text_area.bind("<Button-3>", self.show_context_menu)
self._entry.bind("<Button-3>", self.show_context_menu)
search_button = ttk.Button(next_frame, text="Search", command=self._onFind)
search_button.grid(row=0, column=0, padx=5, pady=5, sticky=tk.EW)
delete_button = ttk.Button(
next_frame, text="Delete_Log", command=self.delete_file
)
delete_button.grid(row=0, column=2, padx=5, pady=5, sticky=tk.EW)
def show_text_menu(self, event):
try:
self.configure.tk_popup(event.x_root, event.y_root)
finally:
self.context_menu.grab_release()
def copy_text(self):
try:
selected_text = self.text_area.selection_get()
self.clipboard_clear()
self.clipboard_append(selected_text)
except tk.TclError:
# No Text selected
pass
def show_context_menu(self, event):
try:
self.context_menu.tk_popup(event.x_root, event.y_root)
finally:
self.context_menu.grab_release()
def paste_into_entry(self):
try:
text = self.clipboard_get()
self._entry.delete(0, tk.END)
self._entry.insert(tk.END, text)
except tk.TclError:
# No Text on Clipboard
pass
def _onFind(self):
searchText = self._entry.get()
if len(searchText) == 0:
return
# Set the search start position to the last found position (initial value: "1.0")
start_pos = self.last_search_pos if hasattr(self, "last_search_pos") else "1.0"
var = tk.IntVar()
foundIndex = self.text_area.search(
searchText,
start_pos,
stopindex=tk.END,
nocase=tk.YES,
count=var,
regexp=tk.YES,
)
if not foundIndex:
# No further entry found, reset to the beginning
self.last_search_pos = "1.0"
return
count = var.get()
lastIndex = self.text_area.index(f"{foundIndex} + {count}c")
# Remove and reapply highlighting
self.text_area.tag_remove("found-tag", "1.0", tk.END)
self.text_area.tag_add("found-tag", foundIndex, lastIndex)
# Update the start position for the next search
self.last_search_pos = lastIndex
self.text_area.see(foundIndex)
def delete_file(self, modul_name):
Path.unlink(modul_name.AppConfig.LOG_FILE_PATH)
modul_name.AppConfig.ensure_log()
def load_file(self, _, modul_name):
try:
if not modul_name.AppConfig.LOG_FILE_PATH:
return
with open(
modul_name.AppConfig.LOG_FILE_PATH, "r", encoding="utf-8"
) as file:
self.text_area.delete(1.0, tk.END)
self.text_area.insert(tk.END, file.read())
except Exception as e:
logging.error(_(f"A mistake occurred: {str(e)}"))
MessageDialog("error", _(f"A mistake occurred:\n{str(e)}\n"))
def directory_load(self, modul_name, _):
filepath = filedialog.askopenfilename(
initialdir=f"{Path.home() / ".local/share/lxlogs/"}",
title="Select a Logfile File",
filetypes=[("Logfiles", "*.log")],
)
try:
with open(filepath, "r", encoding="utf-8") as file:
self.text_area.delete(1.0, tk.END)
self.text_area.insert(tk.END, file.read())
except (IsADirectoryError, TypeError, FileNotFoundError):
print("File load: abort by user...")
except Exception as e:
logging.error(_(f"A mistake occurred: {e}"))
MessageDialog("error", _(f"A mistake occurred:\n{e}\n"))
def main():
# Create an ArgumentParser object
parser = argparse.ArgumentParser(
description="LogViewer with optional module loading."
)
parser.add_argument(
"--modul",
type=str,
default="logview_app_config",
help="Give the name of the module to load.",
)
args = parser.parse_args()
import importlib
try:
modul = importlib.import_module(args.modul)
except ModuleNotFoundError:
print(f"Modul '{args.modul}' not found")
print("For help use logviewer -h")
sys.exit(1)
except Exception as e:
print(f"Error load Modul: {str(e)}")
sys.exit(1)
prepare_app_environment()
app = LogViewer(modul)
LogConfig.logger(ConfigManager.get("logfile"))
"""
the hidden files are hidden in Filedialog
"""
try:
app.tk.call("tk_getOpenFile", "-foobarbaz")
except TclError:
pass
app.tk.call("set", "::tk::dialog::file::showHiddenBtn", "1")
app.tk.call("set", "::tk::dialog::file::showHiddenVar", "0")
app.mainloop()
if __name__ == "__main__":
main()

301
menu_bar.py Normal file
View File

@@ -0,0 +1,301 @@
import os
import subprocess
import threading
import webbrowser
import requests
from functools import partial
from pathlib import Path
from tkinter import ttk
import typing
from typing import Any, Callable, Optional
if typing.TYPE_CHECKING:
from tkinter import BooleanVar
from .logger import app_logger
from .common_tools import ConfigManager, Tooltip, message_box_animation
from .gitea import (
GiteaUpdater,
GiteaApiUrlError,
GiteaVersionParseError,
GiteaApiResponseError,
)
from .animated_icon import AnimatedIcon, PIL_AVAILABLE
from .message import MessageDialog
class MenuBar(ttk.Frame):
"""A reusable menu bar widget for tkinter applications."""
def __init__(
self,
container: ttk.Frame,
image_manager: 'Any',
tooltip_state: 'BooleanVar',
on_theme_toggle: 'Callable[[], None]',
toggle_log_window: 'Callable[[], None]',
app_version: str, # Replaces app_config.VERSION
msg_config: 'Any', # Contains .STR and .TTIP
about_icon_path: str,
about_url: str,
gitea_api_url: str,
**kwargs: 'Any',
) -> None:
"""
Initializes the MenuBar.
Args:
container: The parent widget.
image_manager: An object with a `get_icon` method to retrieve icons.
tooltip_state: A tkinter BooleanVar to control tooltip visibility.
on_theme_toggle: Callback function to toggle the application's theme.
toggle_log_window: Callback function to show/hide the log window.
app_version: The current version string of the application.
msg_config: Project-specific messages and tooltips. Must have STR and TTIP dicts.
about_icon_path: Filesystem path to the icon for the 'About' dialog.
about_url: URL for the project's repository or website.
gitea_api_url: The Gitea API URL for update checks.
**kwargs: Additional keyword arguments for the ttk.Frame.
"""
super().__init__(container, **kwargs)
self.image_manager = image_manager
self.tooltip_state = tooltip_state
self.on_theme_toggle_callback = on_theme_toggle
self.app_version = app_version # Store the application version
self.msg_config = msg_config # Store the messages and tooltips object
self.about_icon_path = about_icon_path
self.about_url = about_url
self.gitea_api_url = gitea_api_url
self.update_status: str = ""
# --- Horizontal button frame for settings ---
actions_frame = ttk.Frame(self)
actions_frame.grid(column=0, row=0, padx=(5, 10), sticky="w")
# --- Theme Button ---
self.theme_btn = ttk.Button(
actions_frame, command=self.theme_toggle, style="TButton.Borderless.Round"
)
self.theme_btn.grid(column=0, row=0, padx=(0, 2))
self.update_theme_icon()
Tooltip(self.theme_btn, self.msg_config.TTIP["theme_toggle"],
state_var=self.tooltip_state)
# --- Tooltip Button ---
self.tooltip_btn = ttk.Button(
actions_frame, command=self.tooltips_toggle, style="TButton.Borderless.Round"
)
self.tooltip_btn.grid(column=1, row=0, padx=(0, 2))
self.update_tooltip_icon()
Tooltip(self.tooltip_btn, self.msg_config.TTIP["tooltips_toggle"],
state_var=self.tooltip_state)
# --- Update Button ---
self.update_btn = ttk.Button(
actions_frame,
command=self.toggle_update_setting,
style="TButton.Borderless.Round",
)
self.update_btn.grid(column=2, row=0)
self.update_update_icon()
Tooltip(self.update_btn, self.msg_config.TTIP["updates_toggle"],
state_var=self.tooltip_state)
# --- Animated Icon for Updates ---
self.animated_icon_frame = ttk.Frame(actions_frame)
self.animated_icon_frame.grid(column=3, row=0, padx=(5, 0))
current_theme = ConfigManager.get("theme")
bg_color = "#ffffff" if current_theme == "light" else "#333333"
self.animated_icon = AnimatedIcon(
self.animated_icon_frame,
animation_type="blink",
use_pillow=PIL_AVAILABLE,
bg=bg_color,
)
self.animated_icon.pack()
self.animated_icon_frame.bind("<Button-1>", lambda e: self.updater())
self.animated_icon.bind("<Button-1>", lambda e: self.updater())
self.animated_icon_frame.grid_remove() # Initially hidden
# Add a spacer column with weight to push subsequent buttons to the right
self.columnconfigure(1, weight=1)
# --- Log Button ---
self.log_btn = ttk.Button(
self,
image=self.image_manager.get_icon("log_blue_small"),
style="TButton.Borderless.Round",
command=toggle_log_window,
)
self.log_btn.grid(column=2, row=0, sticky="e")
Tooltip(self.log_btn,
self.msg_config.TTIP["show_log"], state_var=self.tooltip_state)
# --- About Button ---
self.about_btn = ttk.Button(
self,
image=self.image_manager.get_icon("about"),
style="TButton.Borderless.Round",
command=self.about,
)
self.about_btn.grid(column=3, row=0)
Tooltip(self.about_btn,
self.msg_config.TTIP["about_app"], state_var=self.tooltip_state)
# --- Start background update check ---
self.update_thread = threading.Thread(
target=self.check_for_updates, daemon=True)
self.update_thread.start()
def update_theme_icon(self) -> None:
"""Sets the theme button icon based on the current theme."""
current_theme = ConfigManager.get("theme")
icon_name = "dark_small" if current_theme == "light" else "light_small"
self.theme_btn.configure(image=self.image_manager.get_icon(icon_name))
def update_tooltip_icon(self) -> None:
"""Sets the tooltip button icon based on the tooltip state."""
icon_name = "tooltip_small" if self.tooltip_state.get() else "no_tooltip_small"
self.tooltip_btn.configure(
image=self.image_manager.get_icon(icon_name))
def update_update_icon(self) -> None:
"""Sets the update button icon based on the update setting."""
updates_on = ConfigManager.get("updates") == "on"
icon_name = "update_small" if updates_on else "no_update_small"
self.update_btn.configure(image=self.image_manager.get_icon(icon_name))
def theme_toggle(self) -> None:
"""Invokes the theme toggle callback."""
self.on_theme_toggle_callback()
def update_theme(self) -> None:
"""Updates theme-dependent widgets, like icon backgrounds."""
self.update_theme_icon()
current_theme = ConfigManager.get("theme")
bg_color = "#ffffff" if current_theme == "light" else "#333333"
self.animated_icon.configure(bg=bg_color)
def tooltips_toggle(self) -> None:
"""Toggles the tooltip state and updates the icon."""
new_bool_state = not self.tooltip_state.get()
ConfigManager.set("tooltips", str(new_bool_state))
self.tooltip_state.set(new_bool_state)
self.update_tooltip_icon()
def toggle_update_setting(self) -> None:
"""Toggles the automatic update setting and re-checks for updates."""
updates_on = ConfigManager.get("updates") == "on"
ConfigManager.set("updates", "off" if updates_on else "on")
self.update_update_icon()
# After changing the setting, re-run the check to update status
threading.Thread(target=self.check_for_updates, daemon=True).start()
def check_for_updates(self) -> None:
"""Checks for updates via the Gitea API in a background thread."""
if ConfigManager.get("updates") == "off":
self.after(0, self.update_ui_for_update, "DISABLED")
return
try:
new_version = GiteaUpdater.check_for_update(
self.gitea_api_url,
self.app_version,
)
self.after(0, self.update_ui_for_update, new_version)
except requests.exceptions.RequestException as e:
# Covers connection errors, timeouts, DNS errors, etc.
# Good indicator for "no internet" or "server unreachable"
app_logger.log(f"Network error during update check: {e}")
self.after(0, self.update_ui_for_update, "ERROR")
except (GiteaApiUrlError, GiteaVersionParseError, GiteaApiResponseError) as e:
# Covers bad configuration or unexpected API changes
app_logger.log(f"Gitea API or version error: {e}")
self.after(0, self.update_ui_for_update, "ERROR")
except Exception as e:
# Catch any other unexpected errors
app_logger.log(f"Unexpected error during update check: {e}", level="error")
self.after(0, self.update_ui_for_update, "ERROR")
def update_ui_for_update(self, new_version: Optional[str]) -> None:
"""
Updates the UI based on the result of the update check.
Args:
new_version: The new version string if an update is available,
"DISABLED" if updates are off, "ERROR" if an error occurred,
or None if no update is available.
This string also serves as the update status.
"""
self.update_status = new_version
self.animated_icon_frame.grid_remove()
self.animated_icon.hide()
self.animated_icon_frame.grid()
tooltip_msg = ""
animated_icon_frame_state = "normal" # Default to normal
if new_version == "DISABLED":
tooltip_msg = self.msg_config.TTIP["updates_disabled"]
self.animated_icon.stop()
animated_icon_frame_state = "disabled"
elif new_version == "ERROR":
tooltip_msg = self.msg_config.TTIP["no_server_conn_tt"]
self.animated_icon.stop(status="DISABLE")
animated_icon_frame_state = "disabled"
elif new_version is None:
tooltip_msg = self.msg_config.TTIP["up_to_date"]
self.animated_icon.stop()
animated_icon_frame_state = "disabled"
else: # A new version string is returned, meaning an update is available
tooltip_msg = self.msg_config.TTIP["install_new_version"].format(version=new_version)
self.animated_icon.start()
animated_icon_frame_state = "normal"
# The update_btn (toggle updates on/off) should always be active
self.update_btn.config(state="normal")
if animated_icon_frame_state == "disabled":
self.animated_icon_frame.unbind("<Button-1>")
self.animated_icon.unbind("<Button-1>")
self.animated_icon_frame.config(cursor="arrow")
else:
self.animated_icon_frame.bind("<Button-1>", lambda e: self.updater())
self.animated_icon.bind("<Button-1>", lambda e: self.updater())
self.animated_icon_frame.config(cursor="hand2")
Tooltip(self.animated_icon_frame, tooltip_msg,
state_var=self.tooltip_state)
def updater(self) -> None:
"""Runs the external installer script for updating the application."""
tmp_dir = Path("/tmp/lxtools")
Path.mkdir(tmp_dir, exist_ok=True)
os.chdir(tmp_dir)
with message_box_animation(self.animated_icon):
result = subprocess.run(
["/usr/local/bin/lxtools_installer"], check=False, capture_output=True, text=True
)
if result.returncode != 0:
MessageDialog("error", result.stderr or result.stdout).show()
def about(self) -> None:
"""Displays the application's About dialog."""
with message_box_animation(self.animated_icon):
MessageDialog(
"info",
self.msg_config.STR["about_msg"],
buttons=["OK", self.msg_config.STR["goto_git"]],
title=self.msg_config.STR["info"],
commands=[
None,
partial(webbrowser.open, self.about_url),
],
icon=self.about_icon,
wraplength=420,
).show()

View File

@@ -1,109 +1,18 @@
import os
from typing import List, Optional, Dict
from typing import List, Optional
import tkinter as tk
from tkinter import ttk
try:
from manager import LxTools
except (ModuleNotFoundError, NameError):
from shared_libs.common_tools import LxTools
from shared_libs.common_tools import LxTools, IconManager
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.
_load_icons(): Loads icons from system paths or fallback locations.
_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
"""
DEFAULT_ICON_PATH = "/usr/share/icons/lx-icons"
def __init__(
self,
message_type: str = "info",
@@ -116,39 +25,50 @@ 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_path = self._get_icon_path()
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", font=("Helvetica", 11), padding=5)
ttk.Style().configure("TButton")
self.buttons_widgets = []
self.current_button_index = 0
self._load_icons()
# Window title and icon
icon_manager = IconManager()
self.icons = {
"error": icon_manager.get_icon("error_extralarge"),
"info": icon_manager.get_icon("info_extralarge"),
"warning": icon_manager.get_icon("warning_large"),
"ask": icon_manager.get_icon("question_mark_extralarge"),
}
if self.icon:
if isinstance(self.icon, str) and os.path.exists(self.icon):
try:
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}")
elif isinstance(self.icon, tk.PhotoImage):
self.icons[self.message_type] = self.icon
self.window.title(self._get_title() if not self.title else self.title)
self.window.iconphoto(False, self.icons[self.message_type])
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[self.message_type])
icon_label = ttk.Label(frame, image=self.icons.get(self.message_type))
pady_value = 5 if self.icon is not None else 15
icon_label.grid(
row=0, column=0, padx=(20, 10), pady=(pady_value, 15), sticky="nsew"
@@ -163,170 +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
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]
def _load_icons(self):
# Try to load the icon from the provided path
self.icons = {}
icon_paths: Dict[str, str] = {
"error": os.path.join(self.icon_path, "64/error.png"),
"info": os.path.join(self.icon_path, "64/info.png"),
"warning": os.path.join(self.icon_path, "64/warning.png"),
"ask": os.path.join(self.icon_path, "64/question_mark.png"),
}
fallback_paths: Dict[str, str] = {
"error": "./lx-icons/64/error.png",
"info": "./lx-icons/64/info.png",
"warning": "./lx-icons/64/warning.png",
"ask": "./lx-icons/64/question_mark.png",
}
for key in icon_paths:
try:
# Check if an individual icon is provided
if (
self.message_type == key
and self.icon is not None
and os.path.exists(self.icon)
):
try:
self.icons[key] = tk.PhotoImage(file=self.icon)
except Exception as e:
print(
f"Erro on loading individual icon '{key}': {e}\n",
"Try to use the default icon",
)
else:
# Check for standard path
if os.path.exists(icon_paths[key]):
self.icons[key] = tk.PhotoImage(file=icon_paths[key])
else:
if os.path.exists(fallback_paths[key]):
self.icons[key] = tk.PhotoImage(file=fallback_paths[key])
except Exception as e:
print(f"Error on load Icon '{[key]}': {e}")
self.icons[key] = tk.PhotoImage()
print(f"⚠️ No Icon found for '{key}'. Use standard Tkinter icon.")
return self.icons
def _get_icon_path(self) -> str:
"""Get the path to the default icon."""
if os.path.exists(self.DEFAULT_ICON_PATH):
return self.DEFAULT_ICON_PATH
else:
# Fallback to the directory of the script
return os.path.dirname(os.path.abspath(__file__))
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