137 Commits

Author SHA1 Message Date
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
c1580f6ace import LxTools with try except and add images in readme 2025-06-15 15:37:27 +02:00
c3f1d114f2 fix false class in shared libs module MessageDialog 2025-06-14 23:20:06 +02:00
c2552696e4 add new modul MessageDialog and replace old message dialog 2025-06-14 22:55:47 +02:00
ffb9a5ba5f in common_tools CryptUtils.decrypt() method
remove check file .dat is exist in path
2025-06-08 00:50:21 +02:00
32e375b2e4 version update 2025-06-04 21:50:04 +02:00
23 changed files with 4860 additions and 981 deletions

View File

@@ -2,9 +2,64 @@ Changelog for shared_libs
## [Unreleased]
- add Info Window for user in delete logfile
bevore delete logfile.
-
### 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
- Update MessageDialog Class description
- import LxTools with try exception.
### Added
14-06-2025
- Added new MessageDialog module
and replace LxTools.msg_window() with MessageDialog.
### Added
03-06-2025
- in common_tools CryptUtils.decrypt() method
remove check file .dat is exist in path.
### Added
03-06-2025

View File

@@ -1,3 +1,13 @@
# shared_libs
Module Project for apps by git.ilunix.de
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?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

534
animated_icon.py Normal file
View File

@@ -0,0 +1,534 @@
"""
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.
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
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 ttk, Toplevel
class CryptoUtil:
@@ -21,31 +26,29 @@ class CryptoUtil:
"""
@staticmethod
def decrypt(user, path) -> None:
def decrypt(user) -> None:
"""
Starts SSL dencrypt
"""
if len([file.stem for file in path.glob("*.dat")]) == 0:
pass
process: CompletedProcess[str] = run(
["pkexec", "/usr/local/bin/ssl_decrypt.py", "--user", user],
capture_output=True,
text=True,
check=False,
)
# Output from Openssl Error
if process.stderr:
app_logger.log(process.stderr)
if process.returncode == 0:
app_logger.log("Files successfully decrypted...")
else:
process: CompletedProcess[str] = run(
["pkexec", "/usr/local/bin/ssl_decrypt.py", "--user", user],
capture_output=True,
text=True,
check=False,
app_logger.log(
f"Error process decrypt: Code {process.returncode}"
)
# Output from Openssl Error
if process.stderr:
logging.error(process.stderr, exc_info=True)
if process.returncode == 0:
logging.info("Files successfully decrypted...", exc_info=True)
else:
logging.error(
f"Error process decrypt: Code {process.returncode}", exc_info=True
)
@staticmethod
def encrypt(user) -> None:
"""
@@ -60,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
@@ -85,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
@@ -112,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
@@ -241,72 +243,6 @@ class LxTools:
except FileNotFoundError:
pass
@staticmethod
def msg_window(
image_path: Path,
image_path2: Path,
w_title: str,
w_txt: str,
txt2: Optional[str] = None,
com: Optional[str] = None,
) -> None:
"""
Creates message windows
:param image_path2:
:param image_path:
AppConfig.IMAGE_PATHS["icon_info"] = Image for TK window which is displayed to the left of the text
AppConfig.IMAGE_PATHS["icon_vpn"] = Image for Task Icon
:argument w_title = Windows Title
:argument w_txt = Text for Tk Window
:argument txt2 = Text for Button two
:argument com = function for Button command
"""
msg: tk.Toplevel = tk.Toplevel()
msg.resizable(width=False, height=False)
msg.title(w_title)
msg.configure(pady=15, padx=15)
# load first image for a window
try:
msg.img = tk.PhotoImage(file=image_path)
msg.i_window = tk.Label(msg, image=msg.img)
except Exception as e:
logging.error(f"Error on load Window Image: {e}", exc_info=True)
msg.i_window = tk.Label(msg, text="Image not found")
label: tk.Label = tk.Label(msg, text=w_txt)
label.grid(column=1, row=0)
if txt2 is not None and com is not None:
label.config(font=("Ubuntu", 11), padx=15, justify="left")
msg.i_window.grid(column=0, row=0, sticky="nw")
button2: ttk.Button = ttk.Button(
msg, text=f"{txt2}", command=com, padding=4
)
button2.grid(column=0, row=1, sticky="e", columnspan=2)
button: ttk.Button = ttk.Button(
msg, text="OK", command=msg.destroy, padding=4
)
button.grid(column=0, row=1, sticky="w", columnspan=2)
else:
label.config(font=("Ubuntu", 11), padx=15)
msg.i_window.grid(column=0, row=0)
button: ttk.Button = ttk.Button(
msg, text="OK", command=msg.destroy, padding=4
)
button.grid(column=0, columnspan=2, row=1)
try:
icon = tk.PhotoImage(file=image_path2)
msg.iconphoto(True, icon)
except Exception as e:
logging.error(f"Error loading the window icon: {e}", exc_info=True)
msg.columnconfigure(0, weight=1)
msg.rowconfigure(0, weight=1)
msg.winfo_toplevel()
@staticmethod
def sigi(file_path: Optional[Path] = None, file: Optional[Path] = None) -> None:
"""
@@ -341,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)
@@ -392,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
@@ -408,7 +343,6 @@ class ConfigManager:
"theme": "light",
"tooltips": "True", # Default Value as string!
"autostart": "off",
"logfile": LOG_FILE_PATH,
}
return cls._config
@@ -425,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")
@@ -464,110 +396,457 @@ 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',
'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',
'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',
'stairextralarge': '64/stair.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,240 @@
#!/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
VERSION: str = "v. 1.08.1325"
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"
_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
}
@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}")
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."),
}
# 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"),
}
# 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"),
}
# 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,342 @@
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.selected_file or not os.path.exists(self.dialog.selected_file):
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(self.dialog.selected_file)
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(self.dialog.selected_file)
else:
if os.path.isdir(self.dialog.selected_file):
shutil.rmtree(self.dialog.selected_file)
else:
os.remove(self.dialog.selected_file)
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 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
while os.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 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,120 @@
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()
self.dialog.view_manager.populate_files(
item_to_select=file_to_select)
self.update_nav_buttons()
self.dialog.update_status_bar()
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."""
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_status_bar()
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,195 @@
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
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"))
# --- 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)
# 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()
}
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"))

View File

@@ -0,0 +1,553 @@
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
from .cfd_app_config import LocaleStrings
def get_xdg_user_dir(dir_key: str, fallback_name: str) -> str:
"""
Retrieves a user directory path from the XDG user-dirs.dirs config file.
Args:
dir_key (str): The key for the directory (e.g., "XDG_DOWNLOAD_DIR").
fallback_name (str): The name of the directory to use as a fallback
if the key is not found (e.g., "Downloads").
Returns:
str: The absolute path to the user directory.
"""
home = os.path.expanduser("~")
fallback_path = os.path.join(home, fallback_name)
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:
"""
Initializes the StyleManager.
Args:
dialog: The main CustomFileDialog instance.
"""
self.dialog = dialog
self.setup_styles()
def setup_styles(self) -> None:
"""
Configures all the ttk styles for the dialog based on a light or dark theme.
"""
style = ttk.Style(self.dialog)
base_bg = self.dialog.cget('background')
self.is_dark = sum(self.dialog.winfo_rgb(base_bg)) / 3 < 32768
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:
"""
Initializes the WidgetManager.
Args:
dialog: The main CustomFileDialog instance.
settings (dict): The application settings.
"""
self.dialog = dialog
self.style_manager = dialog.style_manager
self.settings = settings
self.setup_widgets()
def _setup_top_bar(self, parent_frame: ttk.Frame) -> None:
"""Sets up the top bar with navigation and control buttons."""
top_bar = ttk.Frame(
parent_frame, style='Accent.TFrame', padding=(0, 5, 0, 5))
top_bar.grid(row=0, column=0, columnspan=2, sticky="ew")
top_bar.grid_columnconfigure(1, weight=1)
# Left navigation
left_nav_container = ttk.Frame(top_bar, style='Accent.TFrame')
left_nav_container.grid(row=0, column=0, sticky="w")
left_nav_container.grid_propagate(False)
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 and search
path_search_container = ttk.Frame(top_bar, style='Accent.TFrame')
path_search_container.grid(row=0, column=1, sticky="ew")
self.path_entry = ttk.Entry(path_search_container)
self.path_entry.bind(
"<Return>", lambda e: self.dialog.navigation_manager.navigate_to(self.path_entry.get()))
search_icon_pos = self.settings.get("search_icon_pos", "left")
if search_icon_pos == 'left':
path_search_container.grid_columnconfigure(1, weight=1)
self.path_entry.grid(row=0, column=1, sticky="ew")
else: # right
path_search_container.grid_columnconfigure(0, weight=1)
self.path_entry.grid(row=0, column=0, sticky="ew")
# Right controls
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"])
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:
"""Sets up the sidebar with bookmarks and devices."""
sidebar_frame = ttk.Frame(
parent_paned_window, style="Sidebar.TFrame", padding=(0, 0, 0, 0), width=200)
sidebar_frame.grid_propagate(False)
sidebar_frame.bind("<Configure>", self.dialog.on_sidebar_resize)
parent_paned_window.add(sidebar_frame, weight=0)
sidebar_frame.grid_rowconfigure(2, weight=1)
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_storage(sidebar_frame)
def _setup_sidebar_bookmarks(self, sidebar_frame: ttk.Frame) -> None:
"""Sets up the bookmark buttons in the sidebar."""
sidebar_buttons_frame = ttk.Frame(
sidebar_frame, style="Sidebar.TFrame", padding=(0, 15, 0, 0))
sidebar_buttons_frame.grid(row=0, column=0, sticky="nsew")
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_devices(self, sidebar_frame: ttk.Frame) -> None:
"""Sets up the mounted devices section in the sidebar."""
mounted_devices_frame = ttk.Frame(
sidebar_frame, style="Sidebar.TFrame")
mounted_devices_frame.grid(row=2, column=0, sticky="nsew", padx=10)
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")
self.devices_canvas.bind("<Enter>", self.dialog._on_devices_enter)
self.devices_canvas.bind("<Leave>", self.dialog._on_devices_leave)
self.devices_scrollable_frame.bind(
"<Enter>", self.dialog._on_devices_enter)
self.devices_scrollable_frame.bind(
"<Leave>", self.dialog._on_devices_leave)
def _configure_devices_canvas(event: tk.Event) -> None:
self.devices_canvas.configure(
scrollregion=self.devices_canvas.bbox("all"))
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)
w.bind("<Enter>", self.dialog._on_devices_enter)
w.bind("<Leave>", self.dialog._on_devices_leave)
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)
w.bind("<Enter>", self.dialog._on_devices_enter)
w.bind("<Leave>", self.dialog._on_devices_leave)
except (FileNotFoundError, PermissionError):
pass
def _setup_sidebar_storage(self, sidebar_frame: ttk.Frame) -> None:
"""Sets up the storage indicator in the sidebar."""
storage_frame = ttk.Frame(sidebar_frame, style="Sidebar.TFrame")
storage_frame.grid(row=5, column=0, sticky="sew", padx=10, pady=10)
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:
"""Sets up the bottom bar including filename entry, action buttons, and status info."""
self.action_status_frame = ttk.Frame(
self.content_frame, style="AccentBottom.TFrame")
self.action_status_frame.grid(
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')
# --- Define Widgets ---
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:
"""Lays out the bottom action buttons based on user settings."""
# Configure container weights
self.left_container.grid_rowconfigure(0, weight=1)
self.right_container.grid_rowconfigure(0, weight=1)
# Determine action button and its container
action_button = self.save_button if self.dialog.dialog_mode == "save" else self.open_button
action_container = self.left_container if button_box_pos == 'left' else self.right_container
other_container = self.right_container if button_box_pos == 'left' else self.left_container
# Place main action buttons
action_button.grid(in_=action_container, row=0, column=0, pady=(0, 5))
self.cancel_button.grid(in_=action_container, row=1, column=0)
# Place settings and trash buttons
if button_box_pos == 'left':
self.settings_button.grid(
in_=other_container, row=0, column=0, sticky="ne")
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")
# Layout for the center container (filename, filter, status)
if button_box_pos == 'left':
self.center_container.grid_columnconfigure(0, weight=1)
self.filter_combobox.grid(
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:
"""Creates and arranges all widgets in the main dialog window."""
# Main container
main_frame = ttk.Frame(self.dialog, style='Accent.TFrame')
main_frame.pack(fill="both", expand=True)
main_frame.grid_rowconfigure(2, weight=1)
main_frame.grid_columnconfigure(0, weight=1)
self._setup_top_bar(main_frame)
# Horizontal separator
separator_color = "#000000" if self.style_manager.is_dark else "#9c9c9c"
tk.Frame(main_frame, height=1, bg=separator_color).grid(
row=1, column=0, columnspan=2, sticky="ew")
# PanedWindow for resizable sidebar and content
paned_window = ttk.PanedWindow(
main_frame, orient=tk.HORIZONTAL, style="Sidebar.TFrame")
paned_window.grid(row=2, column=0, columnspan=2, sticky="nsew")
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)
# --- Bottom Bar ---
self._setup_bottom_bar()

View File

@@ -0,0 +1,613 @@
import os
import tkinter as tk
from tkinter import ttk
from datetime import datetime
from typing import Optional, List, Tuple, Callable, Any
# To avoid circular import with custom_file_dialog.py
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from custom_file_dialog import CustomFileDialog
from shared_libs.common_tools import Tooltip
from .cfd_app_config import 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 populate_files(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None:
"""
Populates the main file display area.
This method clears the current view and then calls the appropriate
method to populate either the list or icon view.
Args:
item_to_rename (str, optional): The name of an item to immediately
put into rename mode. Defaults to None.
item_to_select (str, optional): The name of an item to select
after populating. Defaults to None.
"""
self._unbind_mouse_wheel_events()
for widget in self.dialog.widget_manager.file_list_frame.winfo_children():
widget.destroy()
self.dialog.widget_manager.path_entry.delete(0, tk.END)
self.dialog.widget_manager.path_entry.insert(
0, self.dialog.current_dir)
self.dialog.result = None
self.dialog.update_status_bar()
if self.dialog.view_mode.get() == "list":
self.populate_list_view(item_to_rename, item_to_select)
else:
self.populate_icon_view(item_to_rename, item_to_select)
def _get_sorted_items(self) -> Tuple[List[str], Optional[str], Optional[str]]:
"""
Gets a sorted list of items from the current directory.
Returns:
tuple: A tuple containing (list of items, error message, warning message).
"""
try:
items = os.listdir(self.dialog.current_dir)
num_items = len(items)
warning_message = None
if num_items > CfdConfigManager.MAX_ITEMS_TO_DISPLAY:
warning_message = f"{LocaleStrings.CFD['showing']} {CfdConfigManager.MAX_ITEMS_TO_DISPLAY} {LocaleStrings.CFD['of']} {num_items} {LocaleStrings.CFD['entries']}."
items = items[:CfdConfigManager.MAX_ITEMS_TO_DISPLAY]
dirs = sorted([d for d in items if os.path.isdir(
os.path.join(self.dialog.current_dir, d))], key=str.lower)
files = sorted([f for f in items if not os.path.isdir(
os.path.join(self.dialog.current_dir, f))], key=str.lower)
return (dirs + files, None, warning_message)
except PermissionError:
return ([], LocaleStrings.CFD["access_denied"], None)
except FileNotFoundError:
return ([], LocaleStrings.CFD["directory_not_found"], None)
def _get_folder_content_count(self, folder_path: str) -> Optional[int]:
"""
Counts the number of items in a given folder.
Args:
folder_path (str): The path to the folder.
Returns:
int or None: The number of items, or None if an error occurs.
"""
try:
if not os.path.isdir(folder_path) or not os.access(folder_path, os.R_OK):
return None
items = os.listdir(folder_path)
if not self.dialog.show_hidden_files.get():
items = [item for item in items if not item.startswith('.')]
return len(items)
except (PermissionError, FileNotFoundError):
return None
def _get_item_path_from_widget(self, widget: tk.Widget) -> Optional[str]:
"""
Traverses up the widget hierarchy to find the item_path attribute.
Args:
widget: The widget to start from.
Returns:
str or None: The associated file path, or None if not found.
"""
while widget and not hasattr(widget, 'item_path'):
widget = widget.master
return getattr(widget, 'item_path', None)
def _handle_icon_click(self, event: tk.Event) -> None:
"""Handles a single click on an icon view item."""
item_path = self._get_item_path_from_widget(event.widget)
if item_path:
item_frame = event.widget
while not hasattr(item_frame, 'item_path'):
item_frame = item_frame.master
self.on_item_select(item_path, item_frame)
def _handle_icon_double_click(self, event: tk.Event) -> None:
"""Handles a double click on an icon view item."""
item_path = self._get_item_path_from_widget(event.widget)
if item_path:
self._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.
Args:
item_to_rename (str, optional): Item to enter rename mode.
item_to_select (str, optional): Item to select.
"""
self.dialog.all_items, error, warning = self._get_sorted_items()
self.dialog.currently_loaded_count = 0
self.dialog.icon_canvas = tk.Canvas(self.dialog.widget_manager.file_list_frame,
highlightthickness=0, bg=self.dialog.style_manager.icon_bg_color)
v_scrollbar = ttk.Scrollbar(
self.dialog.widget_manager.file_list_frame, orient="vertical", command=self.dialog.icon_canvas.yview)
self.dialog.icon_canvas.pack(side="left", fill="both", expand=True)
self.dialog.icon_canvas.focus_set()
v_scrollbar.pack(side="right", fill="y")
container_frame = ttk.Frame(
self.dialog.icon_canvas, style="Content.TFrame")
self.dialog.icon_canvas.create_window(
(0, 0), window=container_frame, anchor="nw")
container_frame.bind("<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.
Args:
container: The parent widget for the items.
scroll_handler: The function to handle mouse wheel events.
item_to_rename (str, optional): Item to enter rename mode.
item_to_select (str, optional): Item to select.
Returns:
The widget that was focused (renamed or selected), or None.
"""
start_index = self.dialog.currently_loaded_count
end_index = min(len(self.dialog.all_items), start_index +
self.dialog.items_to_load_per_batch)
if start_index >= end_index:
return None
item_width, item_height = 125, 100
frame_width = self.dialog.widget_manager.file_list_frame.winfo_width()
col_count = max(1, frame_width // item_width - 1)
row = start_index // col_count if col_count > 0 else 0
col = start_index % col_count if col_count > 0 else 0
widget_to_focus = None
for i in range(start_index, end_index):
name = self.dialog.all_items[i]
if not self.dialog.show_hidden_files.get() and name.startswith('.'):
continue
path = os.path.join(self.dialog.current_dir, name)
is_dir = os.path.isdir(path)
if not is_dir and not self.dialog._matches_filetype(name):
continue
item_frame = ttk.Frame(
container, width=item_width, height=item_height, style="Item.TFrame")
item_frame.grid(row=row, column=col, padx=5, ipadx=25, pady=5)
item_frame.grid_propagate(False)
item_frame.item_path = path
if name == item_to_rename:
self.dialog.file_op_manager.start_rename(item_frame, path)
widget_to_focus = item_frame
else:
icon = self.dialog.icon_manager.get_icon(
'folder_large') if is_dir else self.dialog.get_file_icon(name, 'large')
icon_label = ttk.Label(
item_frame, image=icon, style="Icon.TLabel")
icon_label.pack(pady=(10, 5))
name_label = ttk.Label(item_frame, text=self.dialog.shorten_text(
name, 14), anchor="center", style="Item.TLabel")
name_label.pack(fill="x", expand=True)
Tooltip(item_frame, name)
for widget in [item_frame, icon_label, name_label]:
widget.bind("<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))
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.
Args:
item_to_rename (str, optional): Item to enter rename mode.
item_to_select (str, optional): Item to select.
"""
self.dialog.all_items, error, warning = self._get_sorted_items()
self.dialog.currently_loaded_count = 0
tree_frame = ttk.Frame(self.dialog.widget_manager.file_list_frame)
tree_frame.pack(fill='both', expand=True)
tree_frame.grid_rowconfigure(0, weight=1)
tree_frame.grid_columnconfigure(0, weight=1)
columns = ("size", "type", "modified")
self.dialog.tree = ttk.Treeview(
tree_frame, columns=columns, show="tree headings")
self.dialog.tree.heading(
"#0", text=LocaleStrings.VIEW["name"], anchor="w")
self.dialog.tree.column("#0", anchor="w", width=250, stretch=True)
self.dialog.tree.heading(
"size", text=LocaleStrings.VIEW["size"], anchor="e")
self.dialog.tree.column("size", anchor="e", width=120, stretch=False)
self.dialog.tree.heading(
"type", text=LocaleStrings.VIEW["type"], anchor="w")
self.dialog.tree.column("type", anchor="w", width=120, stretch=False)
self.dialog.tree.heading(
"modified", text=LocaleStrings.VIEW["date_modified"], anchor="w")
self.dialog.tree.column("modified", anchor="w",
width=160, stretch=False)
v_scrollbar = ttk.Scrollbar(
tree_frame, orient="vertical", command=self.dialog.tree.yview)
h_scrollbar = ttk.Scrollbar(
tree_frame, orient="horizontal", command=self.dialog.tree.xview)
self.dialog.tree.configure(yscrollcommand=v_scrollbar.set,
xscrollcommand=h_scrollbar.set)
self.dialog.tree.grid(row=0, column=0, sticky='nsew')
self.dialog.tree.focus_set()
v_scrollbar.grid(row=0, column=1, sticky='ns')
h_scrollbar.grid(row=1, column=0, sticky='ew')
def _on_scroll(*args: Any) -> None:
if self.dialog.currently_loaded_count < len(self.dialog.all_items) and self.dialog.tree.yview()[1] > 0.9:
self._load_more_items_list_view()
v_scrollbar.set(*args)
self.dialog.tree.configure(yscrollcommand=_on_scroll)
self.dialog.tree.bind("<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.
Args:
item_to_rename (str, optional): Item to enter rename mode.
item_to_select (str, optional): Item to select.
Returns:
bool: True if the item to rename/select was found and processed.
"""
start_index = self.dialog.currently_loaded_count
end_index = min(len(self.dialog.all_items), start_index +
self.dialog.items_to_load_per_batch)
if start_index >= end_index:
return False
item_found = False
for i in range(start_index, end_index):
name = self.dialog.all_items[i]
if not self.dialog.show_hidden_files.get() and name.startswith('.'):
continue
path = os.path.join(self.dialog.current_dir, name)
is_dir = os.path.isdir(path)
if not is_dir and not self.dialog._matches_filetype(name):
continue
try:
stat = os.stat(path)
modified_time = datetime.fromtimestamp(
stat.st_mtime).strftime('%d.%m.%Y %H:%M')
if is_dir:
icon, file_type, size = self.dialog.icon_manager.get_icon(
'folder_small'), LocaleStrings.FILE["folder"], ""
else:
icon, file_type, size = self.dialog.get_file_icon(
name, 'small'), LocaleStrings.FILE["file"], self.dialog._format_size(stat.st_size)
item_id = self.dialog.tree.insert("", "end", text=f" {name}", image=icon, values=(
size, file_type, modified_time))
if name == item_to_rename:
self.dialog.tree.selection_set(item_id)
self.dialog.tree.focus(item_id)
self.dialog.tree.see(item_id)
self.dialog.file_op_manager.start_rename(item_id, path)
item_found = True
elif name == item_to_select:
self.dialog.tree.selection_set(item_id)
self.dialog.tree.focus(item_id)
self.dialog.tree.see(item_id)
item_found = True
except (FileNotFoundError, PermissionError):
continue
self.dialog.currently_loaded_count = end_index
return item_found
def on_item_select(self, path: str, item_frame: ttk.Frame) -> None:
"""
Handles the selection of an item in the icon view.
Args:
path (str): The path of the selected item.
item_frame: The widget frame of the selected item.
"""
if hasattr(self.dialog, 'selected_item_frame') and self.dialog.selected_item_frame.winfo_exists():
self.dialog.selected_item_frame.state(['!selected'])
for child in self.dialog.selected_item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['!selected'])
item_frame.state(['selected'])
for child in item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['selected'])
self.dialog.selected_item_frame = item_frame
self.dialog.selected_file = path
self.dialog.update_status_bar(path)
self.dialog.search_manager.show_search_ready()
if not os.path.isdir(path):
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
self.dialog.widget_manager.filename_entry.insert(
0, os.path.basename(path))
def on_list_select(self, event: tk.Event) -> None:
"""Handles the selection of an item in the list view."""
if not self.dialog.tree.selection():
return
item_id = self.dialog.tree.selection()[0]
item_text = self.dialog.tree.item(item_id, 'text').strip()
path = os.path.join(self.dialog.current_dir, item_text)
self.dialog.selected_file = path
self.dialog.update_status_bar(path)
self.dialog.search_manager.show_search_ready()
if not os.path.isdir(self.dialog.selected_file):
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
self.dialog.widget_manager.filename_entry.insert(0, item_text)
def on_list_context_menu(self, event: tk.Event) -> str:
"""Shows the context menu for a list view item."""
iid = self.dialog.tree.identify_row(event.y)
if not iid:
return "break"
self.dialog.tree.selection_set(iid)
item_text = self.dialog.tree.item(iid, "text").strip()
item_path = os.path.join(self.dialog.current_dir, item_text)
self.dialog.file_op_manager._show_context_menu(event, item_path)
return "break"
def _handle_item_double_click(self, path: str) -> None:
"""
Handles the logic for a double-click on any item, regardless of view.
Navigates into directories or selects files.
Args:
path (str): The full path of the double-clicked item.
"""
if os.path.isdir(path):
self.dialog.navigation_manager.navigate_to(path)
elif self.dialog.dialog_mode == "open":
self.dialog.selected_file = path
self.dialog.destroy()
elif self.dialog.dialog_mode == "save":
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
self.dialog.widget_manager.filename_entry.insert(
0, os.path.basename(path))
self.dialog.on_save()
def on_list_double_click(self, event: tk.Event) -> None:
"""Handles a double-click on a list view item."""
if not self.dialog.tree.selection():
return
item_id = self.dialog.tree.selection()[0]
item_text = self.dialog.tree.item(item_id, 'text').strip()
path = os.path.join(self.dialog.current_dir, item_text)
self._handle_item_double_click(path)
def _select_file_in_view(self, filename: str) -> None:
"""
Programmatically selects a file in the current view.
Args:
filename (str): The name of the file to select.
"""
if self.dialog.view_mode.get() == "list":
for item_id in self.dialog.tree.get_children():
if self.dialog.tree.item(item_id, "text").strip() == filename:
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,557 @@
import os
import shutil
import tkinter as tk
import subprocess
import json
import threading
from typing import Optional, List, Tuple, Dict, Union
from shared_libs.common_tools import IconManager, Tooltip, LxTools
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
class CustomFileDialog(tk.Toplevel):
"""
A custom file dialog window that provides functionalities for file selection,
directory navigation, search, and file operations.
"""
def _initialize_managers(self) -> None:
"""Initializes or re-initializes all the manager classes."""
self.style_manager: StyleManager = StyleManager(self)
self.file_op_manager: FileOperationsManager = FileOperationsManager(self)
self.search_manager: SearchManager = SearchManager(self)
self.navigation_manager: NavigationManager = NavigationManager(self)
self.view_manager: ViewManager = ViewManager(self)
self.widget_manager: WidgetManager = WidgetManager(self, self.settings)
def __init__(self, parent: tk.Widget, initial_dir: Optional[str] = None,
filetypes: Optional[List[Tuple[str, str]]] = None,
mode: str = "open", title: str = LocaleStrings.CFD["title"]):
"""
Initializes the CustomFileDialog.
Args:
parent: The parent widget.
initial_dir: The initial directory to display.
filetypes: A list of filetype tuples, e.g., [('Text files', '*.txt')].
mode: The dialog mode. Can be "open" (select single file),
"save" (select single file for saving),
"multi" (select multiple files/directories),
or "dir" (select a single directory).
title: The title of the dialog window.
"""
super().__init__(parent)
self.my_tool_tip: Optional[Tooltip] = None
self.dialog_mode: str = mode
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.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.bind("<Key>", self.search_manager.show_search_bar)
if self.dialog_mode == "save":
self.bind("<Delete>", self.file_op_manager.delete_selected_item)
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.
Args:
preset: The size preset string (e.g., "1050x850").
Returns:
A tuple containing the minimum width and height.
"""
w, h = map(int, preset.split('x'))
return max(650, w - 400), max(450, h - 400)
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 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 get_file_icon(self, filename: str, size: str = 'large') -> tk.PhotoImage:
"""
Gets the appropriate icon for a given filename.
Args:
filename: The name of the file.
size: The desired icon size ('large' or 'small').
Returns:
A PhotoImage object for the corresponding file type.
"""
ext = os.path.splitext(filename)[1].lower()
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.
Args:
event: The event object.
"""
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.
Args:
window_width: The current width of the window.
"""
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.
Args:
event: The event object.
"""
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.
Args:
event: The event object.
"""
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.
Args:
event: The event object.
"""
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_status_bar(self, status_info: Optional[str] = None) -> None:
"""
Updates the status bar with disk usage and selected item information.
Args:
status_info: The path of the currently selected item or a custom string.
"""
try:
total, used, free = shutil.disk_usage(self.current_dir)
free_str = self._format_size(free)
self.widget_manager.storage_label.config(
text=f"{LocaleStrings.CFD['free_space']}: {free_str}")
self.widget_manager.storage_bar['value'] = (used / total) * 100
status_text = ""
if status_info and os.path.exists(status_info):
selected_path = status_info
if os.path.isdir(selected_path):
content_count = self.view_manager._get_folder_content_count(
selected_path)
if content_count is not None:
status_text = f
def on_open(self) -> None:
"""Handles the 'Open' action, closing the dialog if a file is selected."""
if self.result and isinstance(self.result, str) and os.path.isfile(self.result):
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.
Returns:
- A string containing a single path for modes 'open', 'save', 'dir'.
- A list of strings for mode 'multi'.
- None if the dialog was cancelled.
"""
return self.result
def update_action_buttons_state(self) -> None:
"""Updates the state of action buttons (e.g., 'New Folder') based on directory permissions."""
is_writable = os.access(self.current_dir, os.W_OK)
state = tk.NORMAL if is_writable and self.dialog_mode != "open" else tk.DISABLED
self.widget_manager.new_folder_button.config(state=state)
self.widget_manager.new_file_button.config(state=state)
def _matches_filetype(self, filename: str) -> bool:
"""
Checks if a filename matches the current filetype filter.
Args:
filename: The name of the file to check.
Returns:
True if the file matches the filter, False otherwise.
"""
if self.current_filter_pattern == "*.*":
return True
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).
Args:
size_bytes: The size in bytes.
Returns:
A formatted string representing the size.
"""
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.
Args:
text: The text to shorten.
max_len: The maximum allowed length.
Returns:
The shortened text.
"""
return text if len(text) <= max_len else text[:max_len-3] + "..."
def _get_mounted_devices(self) -> List[Tuple[str, str, bool]]:
"""
Retrieves a list of mounted devices on the system.
Returns:
A list of tuples, where each tuple contains the display name,
mount point, and a boolean indicating if it's removable.
"""
devices: List[Tuple[str, str, bool]] = []
root_disk_name: Optional[str] = None
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()

208
gitea.py
View File

@@ -1,143 +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.common_tools import LxTools
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)
LxTools.msg_window(
AppConfig.IMAGE_PATHS["icon_info"],
AppConfig.IMAGE_PATHS["icon_download"],
Msg.STR["title"],
Msg.STR["ok_message"],
)
else:
LxTools.msg_window(
AppConfig.IMAGE_PATHS["icon_error"],
AppConfig.IMAGE_PATHS["icon_download_error"],
Msg.STR["error_title"],
Msg.STR["error_massage"],
)
except subprocess.CalledProcessError:
LxTools.msg_window(
AppConfig.IMAGE_PATHS["icon_error"],
AppConfig.IMAGE_PATHS["icon_msg"],
Msg.STR["error_title"],
Msg.STR["error_no_internet"],
)
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.3125"
UPDATE_URL: str = "https://git.ilunix.de/api/v1/repos/punix/Wire-Py/releases"
DOWNLOAD_URL: str = "https://git.ilunix.de/punix/Wire-Py/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,526 +0,0 @@
#!/usr/bin/python3
import argparse
import logging
import tkinter as tk
from tkinter import TclError, filedialog, ttk
from pathlib import Path
from shared_libs.gitea import GiteaUpdate
from shared_libs.common_tools import (
LogConfig,
ConfigManager,
ThemeManager,
LxTools,
Tooltip,
)
import sys
from file_and_dir_ensure import prepare_app_environment
import webbrowser
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
"""
def link_btn() -> None:
webbrowser.open("https://git.ilunix.de/punix/shared_libs")
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"
)
LxTools.msg_window(
modul_name.AppConfig.IMAGE_PATHS["icon_log"],
modul_name.AppConfig.IMAGE_PATHS["icon_log"],
_("Info"),
msg_t,
_("Go to shared_libs git"),
link_btn,
)
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)}"))
LxTools.msg_window(
modul_name.AppConfig.IMAGE_PATHS["icon_error"],
modul_name.AppConfig.IMAGE_PATHS["icon_log"],
"LogViewer",
_(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}"))
LxTools.msg_window(
modul_name.AppConfig.IMAGE_PATHS["icon_error"],
modul_name.AppConfig.IMAGE_PATHS["icon_log"],
"LogViewer",
_(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()

300
message.py Normal file
View File

@@ -0,0 +1,300 @@
import os
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, 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.
_on_button_click(button_text): Sets result and closes the dialog.
show(): Displays the dialog and waits for user response.
Example Usage:
1. Basic Info Dialog:
>>> MessageDialog(
... text="This is an information message.")
>>> result = dialog.show()
>>> print("User clicked OK:", result)
Notes:
My Favorite Example,
for simply information message:
>>> MessageDialog(text="This is an information message.")
>>> result = MessageDialog(text="This is an information message.").show()
Example Usage:
2. Error Dialog with Custom Command:
>>> def on_retry():
... print("User selected Retry")
>>> dialog = MessageDialog(
... message_type="error",
... text="An error occurred during processing.",
... buttons=["Retry", "Cancel"],
... commands=[on_retry, None],
... title="Critical Error"
... )
>>> result = dialog.show()
>>> print("User selected Retry:", result)
Example Usage:
3. And a special example for a "open link" button:
Be careful not to forget to import it into the script in which
this dialog is used!!! import webbrowser from functools import partial
>>> MessageDialog(
... "info"
... text="This is an information message.",
... buttons=["Yes", "Go to Exapmle"],
... commands=[
... None, # Default on "OK"
... partial(webbrowser.open, "https://exapmle.com"),
... ],
... icon="/pathh/to/custom/icon.png",
... title="Example",
... )
Notes:
- Returns None if "Cancel" was clicked with ≥3 buttons
- Supports keyboard navigation (Left/Right arrows and Enter)
- Dialog automatically centers on screen
- Result is False for window close (X) with 2 buttons
- Font and wraplength parameters enable text styling
"""
def __init__(
self,
message_type: str = "info",
text: str = "",
buttons: List[str] = ["OK"],
master: Optional[tk.Tk] = None,
commands: List[Optional[callable]] = [None],
icon: str = None,
title: str = None,
font: tuple = None,
wraplength: int = None,
):
self.message_type = message_type or "info" # Default is "info"
self.text = text
self.buttons = buttons
self.master = master
self.result: bool = False # Default is False
self.icon = icon
self.title = title
# Window creation
self.window = tk.Toplevel(master)
self.window.grab_set()
self.window.resizable(False, False)
ttk.Style().configure("TButton")
self.buttons_widgets = []
self.current_button_index = 0
# Load icons using IconManager
icon_manager = IconManager()
self.icons = {
"error": icon_manager.get_icon("error_extralarge"),
"info": icon_manager.get_icon("info_extralarge"),
"warning": icon_manager.get_icon("warning_large"),
"ask": icon_manager.get_icon("question_mark_extralarge"),
}
# Handle custom icon override
if self.icon:
if isinstance(self.icon, str) and os.path.exists(self.icon):
# If it's a path, load it
try:
self.icons[self.message_type] = tk.PhotoImage(
file=self.icon)
except tk.TclError as e:
print(
f"Error loading custom icon from path '{self.icon}': {e}")
elif isinstance(self.icon, tk.PhotoImage):
# If it's already a PhotoImage, use it directly
self.icons[self.message_type] = self.icon
# Window title and icon
self.window.title(self._get_title() if not self.title else self.title)
window_icon = self.icons.get(self.message_type)
if window_icon:
self.window.iconphoto(False, window_icon)
# Layout
frame = ttk.Frame(self.window)
frame.pack(expand=True, fill="both", padx=15, pady=8)
# Grid-Configuration
frame.grid_rowconfigure(0, weight=1)
frame.grid_columnconfigure(0, weight=1)
frame.grid_columnconfigure(1, weight=3)
# Icon and Text
icon_label = ttk.Label(frame, image=self.icons.get(self.message_type))
pady_value = 5 if self.icon is not None else 15
icon_label.grid(
row=0, column=0, padx=(20, 10), pady=(pady_value, 15), sticky="nsew"
)
text_label = tk.Label(
frame,
text=text,
wraplength=wraplength if wraplength else 300,
justify="left",
anchor="center",
font=font if font else ("Helvetica", 12),
pady=20,
)
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],
)
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),
)
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
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.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")
)
def _get_title(self) -> str:
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
)
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
)
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()
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",
]:
self.result = None
# Check: Button text is "Yes", "Ok", "Continue", "Next", or "Start"
elif button_text.lower() in ["yes", "ok", "continue", "next", "start"]:
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