Files
shared_libs/message.py

385 lines
13 KiB
Python

import os
from typing import List, Optional, Dict
import tkinter as tk
from tkinter import ttk
from shared_libs.common_tools import LxTools
"""
####################################################
Attention! MessageDialog returns different values.
From 3 buttons with Cancel, Cancel and the Close (x)
None returns. otherwise always False.
####################################################
Usage Examples
1. Basic Info Dialog
from tkinter import Tk
root = Tk()
dialog = MessageDialog(
message_type="info",
text="This is an information message.",
buttons=["OK"],
master=root,
)
result = dialog.show()
print("User clicked OK:", result)
-----------------------------------------------------
My Favorite Example,
for simply information message:
MessageDialog(text="This is an information message.")
result = MessageDialog(text="This is an information message.").show()
-----------------------------------------------------
Explanation: if you need the return value e.g. in the vaiable result,
you need to add .show(). otherwise only if no root.mainloop z.b is used to test the window.
#####################################################
2. Error Dialog with Custom Icon and Command
def on_cancel():
print("User canceled the operation.")
root = Tk()
result = MessageDialog(
message_type="error",
text="An error occurred during processing.",
buttons=["Retry", "Cancel"],
commands=[None, on_cancel],
icon="/path/to/custom/error_icon.png",
title="Critical Error"
).show()
print("User clicked Retry:", result)
-----------------------------------------------------
My Favorite Example,
for simply Error message:
MessageDialog(
"error",
text="An error occurred during processing.",
).show()
#####################################################
3. Confirmation Dialog with Yes/No Buttons
def on_confirm():
print("User confirmed the action.")
root = Tk()
dialog = MessageDialog(
message_type="ask",
text="Are you sure you want to proceed?",
buttons=["Yes", "No"],
commands=[on_confirm, None], # Either use comando or work with the values True and False
)
result = dialog.show()
print("User confirmed:", result)
-----------------------------------------------------
My Favorite Example,
for simply Question message:
dialog = MessageDialog(
"ask",
text="Are you sure you want to proceed?",
buttons=["Yes", "No"]
).show()
#####################################################
4. Warning Dialog with Custom Title
root = Tk()
dialog = MessageDialog(
message_type="warning",
text="This action cannot be undone.",
buttons=["Proceed", "Cancel"],
title="Warning: Irreversible Action"
)
result = dialog.show()
print("User proceeded:", result)
-----------------------------------------------------
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
dialog = MessageDialog(
"ask",
text="Are you sure you want to proceed?",
buttons=["Yes", "Go to Exapmle"],
commands=[
None, # Default on "OK"
partial(webbrowser.open, "https://exapmle.com"),
],
icon="/pathh/to/custom/icon.png",
title="Example",
).show()
In all dialogues, a font can also be specified as a tuple. With font=("ubuntu", 11)
and wraplength=300, the text is automatically wrapped.
"""
class MessageDialog:
"""
A customizable message dialog window using tkinter.
This class creates modal dialogs for displaying information, warnings, errors,
or questions to the user. It supports multiple button configurations and custom
icons. The dialog is centered on the screen and handles user interactions.
Attributes:
message_type (str): Type of message ("info", "error", "warning", "ask").
text (str): Main message content.
buttons (List[str]): List of button labels (e.g., ["OK", "Cancel"]).
result (bool): True if the user clicked a positive button (like "Yes" or "OK"), else False.
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).
Methods:
_get_title(): Returns the default window title based on message type.
_load_icons(): Loads icons from system paths or fallback locations.
_on_button_click(button_text): Sets result and closes the dialog.
show(): Displays the dialog and waits for user response (returns self.result).
"""
DEFAULT_ICON_PATH = "/usr/share/icons/lx-icons"
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_path = self._get_icon_path()
self.icon = icon
self.title = title
# Window creation
self.window = tk.Toplevel(master)
self.window.grab_set()
self.window.resizable(False, False)
ttk.Style().configure("TButton", font=("Helvetica", 11), padding=5)
self.buttons_widgets = []
self.current_button_index = 0
self._load_icons()
# Window title and icon
self.window.title(self._get_title() if not self.title else self.title)
self.window.iconphoto(False, self.icons[self.message_type])
# Layout
frame = ttk.Frame(self.window)
frame.pack(expand=True, fill="both")
# Grid-Configuration
frame.grid_rowconfigure(0, weight=1)
frame.grid_columnconfigure(0, weight=1)
frame.grid_columnconfigure(1, weight=3)
# Icon and Text
icon_label = ttk.Label(frame, image=self.icons[self.message_type])
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),
pady=(8, 20),
sticky="nsew",
)
# Create button frame
self.button_frame = ttk.Frame(frame)
self.button_frame.grid(row=1, columnspan=2, pady=(15, 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=15)
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 _load_icons(self):
# Try to load the icon from the provided path
self.icons = {}
icon_paths: Dict[str, str] = {
"error": os.path.join(self.icon_path, "64/error.png"),
"info": os.path.join(self.icon_path, "64/info.png"),
"warning": os.path.join(self.icon_path, "64/warning.png"),
"ask": os.path.join(self.icon_path, "64/question_mark.png"),
}
fallback_paths: Dict[str, str] = {
"error": "./lx-icons/64/error.png",
"info": "./lx-icons/64/info.png",
"warning": "./lx-icons/64/warning.png",
"ask": "./lx-icons/64/question_mark.png",
}
for key in icon_paths:
try:
# Check if an individual icon is provided
if (
self.message_type == key
and self.icon is not None
and os.path.exists(self.icon)
):
try:
self.icons[key] = tk.PhotoImage(file=self.icon)
except Exception as e:
print(
f"Erro on loading individual icon '{key}': {e}\n",
"Try to use the default icon",
)
else:
# Check for standard path
if os.path.exists(icon_paths[key]):
self.icons[key] = tk.PhotoImage(file=icon_paths[key])
else:
self.icons[key] = tk.PhotoImage(file=fallback_paths[key])
except Exception as e:
print(f"Error on load Icon '{[key]}': {e}")
self.icons[key] = tk.PhotoImage()
print(f"⚠️ No Icon found for '{key}'. Use standard Tkinter icon.")
return self.icons
def _get_icon_path(self) -> str:
"""Get the path to the default icon."""
if os.path.exists(self.DEFAULT_ICON_PATH):
return self.DEFAULT_ICON_PATH
else:
# Fallback to the directory of the script
return os.path.dirname(os.path.abspath(__file__))
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