File: tksiril.py

package info (click to toggle)
siril 1.4.2-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 47,656 kB
  • sloc: ansic: 175,395; cpp: 28,654; python: 8,476; makefile: 974; xml: 879; sh: 280
file content (359 lines) | stat: -rw-r--r-- 13,666 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
# Copyright (C) 2005-2011 Francois Meyer (dulle at free.fr)
# Copyright (C) 2012-2026 team free-astro (see more in AUTHORS file)
# Reference site is https://siril.org
# SPDX-License-Identifier: GPL-3.0-or-later

"""
TKsiril submodule for Siril, providing utility methods to achieve consistent
script GUI appearance using the TKinter toolkit.
"""

import platform
from time import sleep
import tkinter as tk
from tkinter import ttk
from .connection import SirilInterface
from .exceptions import SirilError

def create_tooltip(widget, text, wrap_length=250):
    """
    Create a tooltip for a given Tkinter widget.

    Args:
        widget (tk.Widget): The widget to attach the tooltip to
        text (str): The tooltip text to display
        max_width (int, optional): Maximum width of the tooltip. Defaults to 300.
        wrap_length (int, optional): Length at which text wraps. Defaults to 250.

    Raises:
        TypeError: If text is not a string or the provided widget is not a
                   valid Tkinter widget
    """
    # Validate widget argument
    if not isinstance(widget, (tk.Widget, tk.Tk, tk.Toplevel)):
        raise TypeError(f"Invalid widget type. Expected a Tkinter widget, got {type(widget)}")

    # Validate text argument
    if not isinstance(text, str):
        raise TypeError(f"Tooltip text must be a string, got {type(text)}")

    def show_tooltip(event):
        try:
            # Ensure the widget still exists
            widget.winfo_exists()
        except tk.TclError:
            return  # Widget has been destroyed

        tooltip = tk.Toplevel(widget)
        tooltip.wm_overrideredirect(True)
        tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}")

        # Configure tooltip style
        tooltip.configure(bg='lightyellow')

        # Create label with text wrapping
        label = ttk.Label(tooltip, text=text,
                        justify=tk.LEFT,
                        relief=tk.SOLID,
                        borderwidth=1,
                        wraplength=wrap_length,
                        background='lightyellow')
        label.pack(ipadx=5, ipady=5)

        def hide_tooltip():
            try:
                tooltip.destroy()
            except tk.TclError:
                pass  # Ignore if tooltip is already destroyed

        widget.tooltip = tooltip
        widget.bind('<Leave>', lambda e: hide_tooltip())
        tooltip.bind('<Leave>', lambda e: hide_tooltip())

    widget.bind('<Enter>', show_tooltip)

def match_theme_to_siril(themed_tk, s, on_top=False):
    """
    Match the Tkinter theme to the Siril configuration and set the script dialog
    to have topmost status, meaning that it will remain in front of other
    non-topmost windows.

    Args:
        s (SirilInterface): sirilpy.SirilInterface class to provide the
                            Siril theme (light or dark) to match
        themed_tk (ThemedTk): ThemedTk instance to apply the theme to
        on_top: whether the script window should be always on top of other windows

    Raises:
        TypeError: If input arguments are of incorrect type
        ValueError: If the theme configuration is not 0 or 1
        AttributeError: If required methods are not available
        RuntimeError: If there are errors installing or setting the theme
    """
    # Strict type checking for s
    if not isinstance(s, SirilInterface):
        raise TypeError(f"First argument must be a SirilInterface. Got {type(s)}")

    # Check if s is an instance of expected SirilInterface class
    try:
        s.__class__.__name__  # Ensure the object is instantiated
    except Exception as e:
        raise TypeError(f"Invalid SirilInterface object: {e}") from e

    # Check if themed_tk has the required method
    if not (hasattr(themed_tk, 'set_theme') or
            (hasattr(themed_tk, 'configure') and hasattr(themed_tk, 'winfo_class'))):
        raise TypeError(f"Second argument must be a Tkinter-like object with theme-setting capabilities. Got {type(themed_tk)}")

    # Get theme configuration from Siril
    try:
        theme_value = s.get_siril_config("gui", "theme")
    except Exception as e:
        raise AttributeError(f"Unable to retrieve theme configuration: {e}") from e

    # Map theme values to theme names
    theme_map = {
        0: "equilux",
        1: "arc"
    }

    if on_top is True:
        # Settings to keep the script window above others
        themed_tk.focus_force()
        themed_tk.attributes('-topmost', True)

    # Check if theme value is valid
    if theme_value not in theme_map:
        raise ValueError(f"Invalid theme value: {theme_value}. Expected 0 or 1.")

    # Attempt to set the theme
    try:
        # Try set_theme method first (for ttkbootstrap/ttkthemes)
        if hasattr(themed_tk, 'set_theme'):
            themed_tk.set_theme(theme_map[theme_value])
        # Fallback for other theming methods
        elif hasattr(themed_tk, 'configure'):
            themed_tk.configure(theme=theme_map[theme_value])
        else:
            raise RuntimeError("No valid theme-setting method found")
    except Exception as e:
        raise RuntimeError(f"Failed to set theme: {e}") from e

def standard_style():
    """
    Provide a standardised ttk style to allow consistent visual appearance
    between different Siril python scripts.

    Args:
        none

    Raises:
        SirilError: If the style creation or configuration fails
    """
    try:
        style = ttk.Style()
        # Configure style
        style.configure("TFrame", padding=5)
        style.configure("TButton", padding=5)
        style.configure("TCheckbutton", padding=2)
        style.configure("TLabel", padding=2)
        style.configure("Header.TLabel", font=("Helvetica", 12, "bold"))
        style.configure("Value.TLabel", font=("Helvetica", 9))
        return style

    except Exception as e:
        raise SirilError(f"Failed to configure style: {e}") from e

def elevate(root):
    """
    Raises the Tk root window to the top of the window stack. Useful after
    calls to sirilpy methods that present child windows of the main Siril
    window such as info_messagebox().

    NOTE: For this to work on KDE desktops, focus-stealing prevention must
    be disabled.
    """
    root.lift()
    root.focus_force()

class ScrollableFrame(ttk.Frame):
    """
    A scrollable frame widget that can contain other widgets.

    This class creates a frame with vertical scrolling capability using a Canvas
    widget and Scrollbar. It supports both scrollbar and mouse wheel scrolling
    across all platforms (Windows, Mac, Linux).

    Usage:
        scrollable = ScrollableFrame(parent)
        scrollable.pack(fill="both", expand=True)

        # Add widgets to scrollable.scrollable_frame
        label = ttk.Label(scrollable.scrollable_frame, text="Hello")
        label.pack()

        # Optionally bind mouse wheel to child widgets
        scrollable.add_mousewheel_binding(label)
    """

    def __init__(self, container, *args, **kwargs):
        """
        Initialize the ScrollableFrame.

        Args:
            container: The parent widget
            *args: Additional arguments passed to ttk.Frame
            **kwargs: Additional keyword arguments passed to ttk.Frame
        """
        super().__init__(container, *args, **kwargs)

        # Create canvas and scrollbar
        self.canvas = tk.Canvas(self, highlightthickness=0)
        self.scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.canvas.yview)
        self.scrollable_frame = ttk.Frame(self.canvas)

        # Configure canvas to work with scrollbar
        self.canvas.configure(yscrollcommand=self.scrollbar.set)

        # Pack scrollbar and canvas
        self.scrollbar.pack(side="right", fill="y")
        self.canvas.pack(side="left", fill="both", expand=True)

        # Create window in canvas for the scrollable frame
        self.canvas_window = self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")

        # Bind events
        self.scrollable_frame.bind("<Configure>", self._on_frame_configure)
        self.canvas.bind("<Configure>", self._on_canvas_configure)

        # Mouse wheel binding - this approach works more reliably
        self._setup_mousewheel()

    def _on_frame_configure(self, event):
        """
        Handle frame configuration changes.

        This method is called when the scrollable frame's size changes.
        It updates the canvas scroll region to encompass all the content.

        Args:
            event: The tkinter event object
        """
        self.canvas.configure(scrollregion=self.canvas.bbox("all"))

    def _on_canvas_configure(self, event):
        """
        Handle canvas configuration changes.

        This method is called when the canvas size changes (e.g., window resize).
        It adjusts the width of the scrollable frame to match the canvas width,
        preventing unwanted horizontal scrolling.

        Args:
            event: The tkinter event object containing the new canvas dimensions
        """
        canvas_width = event.width
        self.canvas.itemconfig(self.canvas_window, width=canvas_width)

    def _setup_mousewheel(self):
        """
        Setup cross-platform mouse wheel scrolling.

        This method configures mouse wheel event bindings that work across
        Windows, Mac, and Unix-like platforms. It uses platform detection to
        apply the appropriate event bindings:
        - Windows/Mac: <MouseWheel> with event.delta
        - Linux/BSD/Unix: <Button-4> (scroll up) and <Button-5> (scroll down)

        Platform-specific binding prevents conflicts where Button-4/5 might
        represent different mouse buttons on Windows/Mac systems.

        The bindings are applied when the mouse enters the widget area
        and removed when it leaves to avoid conflicts with other scrollable widgets.
        """
        system = platform.system()

        def _on_mousewheel_windows_mac(event):
            """Handle mouse wheel events on Windows and Mac"""
            self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")

        def _on_mousewheel_linux(event):
            """Handle mouse wheel events on Linux, BSD, and other Unix-like systems"""
            if event.num == 4:
                self.canvas.yview_scroll(-1, "units")
            elif event.num == 5:
                self.canvas.yview_scroll(1, "units")

        def _bind_mousewheel(event):
            """Bind platform-appropriate mouse wheel events"""
            if system in ("Windows", "Darwin"):  # Darwin is macOS
                self.canvas.bind_all("<MouseWheel>", _on_mousewheel_windows_mac)
            else:  # Linux, BSD, and other Unix-like systems
                self.canvas.bind_all("<Button-4>", _on_mousewheel_linux)
                self.canvas.bind_all("<Button-5>", _on_mousewheel_linux)

        def _unbind_mousewheel(event):
            """Unbind platform-appropriate mouse wheel events"""
            if system in ("Windows", "Darwin"):
                self.canvas.unbind_all("<MouseWheel>")
            else:  # Linux, BSD, and other Unix-like systems
                self.canvas.unbind_all("<Button-4>")
                self.canvas.unbind_all("<Button-5>")

        # Bind to canvas enter/leave events
        self.canvas.bind('<Enter>', _bind_mousewheel)
        self.canvas.bind('<Leave>', _unbind_mousewheel)

        # Also bind to the main frame
        self.bind('<Enter>', _bind_mousewheel)
        self.bind('<Leave>', _unbind_mousewheel)

    def add_mousewheel_binding(self, widget=None):
        """
        Add mouse wheel scrolling support to a widget and its children.

        This method recursively binds mouse wheel events to the specified widget
        and all its child widgets. It uses platform detection to apply the
        appropriate event bindings for each operating system.

        Args:
            widget: The tkinter widget to bind mouse wheel events to.
                   The binding will be applied recursively to all children.
                   If no widget is specified it will default to the ScrollableFrame
                   itself.

        Example:
            # Add a complex widget to the scrollable frame
            frame = ttk.Frame(scrollable.scrollable_frame)
            label = ttk.Label(frame, text="Hello")
            button = ttk.Button(frame, text="Click me")

            # Bind mouse wheel to the entire widget hierarchy
            scrollable.add_mousewheel_binding(frame)
        """
        if widget is None:
            widget = self

        system = platform.system()

        def _on_mousewheel_windows_mac(event):
            """Handle mouse wheel events on Windows and Mac"""
            self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")

        def _on_mousewheel_linux(event):
            """Handle mouse wheel events on Linux, BSD, and other Unix-like systems"""
            if event.num == 4:
                self.canvas.yview_scroll(-1, "units")
            elif event.num == 5:
                self.canvas.yview_scroll(1, "units")

        # Bind platform-appropriate events
        if system in ("Windows", "Darwin"):  # Darwin is macOS
            widget.bind("<MouseWheel>", _on_mousewheel_windows_mac)
        else:  # Linux, BSD, and other Unix-like systems
            widget.bind("<Button-4>", _on_mousewheel_linux)
            widget.bind("<Button-5>", _on_mousewheel_linux)

        # Recursively bind to all children
        for child in widget.winfo_children():
            self.add_mousewheel_binding(child)