Skip to content

Commit 51b24e3

Browse files
committed
update: switch timespinner to canvas-based rendering
1 parent d2b1f1e commit 51b24e3

File tree

5 files changed

+999
-423
lines changed

5 files changed

+999
-423
lines changed

examples/demo_timepicker.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def __init__(self):
3939
self.timepicker_type_var = tk.StringVar(value="TimeFrame")
4040
self.hour_format_var = tk.StringVar(value="24")
4141
self.show_seconds_var = tk.BooleanVar(value=True)
42+
self.theme_var = tk.StringVar(value="light")
4243
self.language_var = tk.StringVar(value="en")
4344
self.button_text_var = tk.StringVar(value="🕐")
4445
self.width_var = tk.StringVar(value="15")
@@ -139,6 +140,31 @@ def create_widgets(self):
139140
)
140141
self.show_seconds_check.pack(side="left", padx=(0, 20))
141142

143+
# Configuration controls - Row 1.7 (Theme selection)
144+
config_row1_7 = tk.Frame(config_frame)
145+
config_row1_7.pack(fill="x", pady=(0, 10))
146+
147+
# Theme selection
148+
tk.Label(config_row1_7, text="Theme:").pack(side="left")
149+
theme_frame = tk.Frame(config_row1_7)
150+
theme_frame.pack(side="left", padx=(5, 20))
151+
152+
tk.Radiobutton(
153+
theme_frame,
154+
text="Light",
155+
variable=self.theme_var,
156+
value="light",
157+
command=self.change_theme,
158+
).pack(side="left", padx=(0, 10))
159+
160+
tk.Radiobutton(
161+
theme_frame,
162+
text="Dark",
163+
variable=self.theme_var,
164+
value="dark",
165+
command=self.change_theme,
166+
).pack(side="left")
167+
142168
# Configuration controls - Row 2
143169
config_row2 = tk.Frame(config_frame)
144170
config_row2.pack(fill="x", pady=(0, 10))
@@ -262,6 +288,7 @@ def create_timepicker(self, parent):
262288
# Get current format
263289
hour_format = self.hour_format_var.get()
264290
show_seconds = self.show_seconds_var.get()
291+
theme = self.theme_var.get()
265292

266293
# Build time format string
267294
if hour_format == "24":
@@ -279,10 +306,11 @@ def create_timepicker(self, parent):
279306
width = int(self.width_var.get()) if self.width_var.get().isdigit() else 15
280307
language = self.language_var.get()
281308
self.logger.debug(
282-
"Creating timepicker type=%s hour_format=%s show_seconds=%s lang=%s width=%s",
309+
"Creating timepicker type=%s hour_format=%s show_seconds=%s theme=%s lang=%s width=%s",
283310
self.timepicker_type_var.get(),
284311
hour_format,
285312
show_seconds,
313+
theme,
286314
language,
287315
width,
288316
)
@@ -292,6 +320,7 @@ def create_timepicker(self, parent):
292320
time_format=time_format,
293321
hour_format=hour_format,
294322
show_seconds=show_seconds,
323+
theme=theme,
295324
language=language,
296325
time_callback=self.on_time_selected,
297326
button_text=self.button_text_var.get(),
@@ -303,6 +332,7 @@ def create_timepicker(self, parent):
303332
time_format=time_format,
304333
hour_format=hour_format,
305334
show_seconds=show_seconds,
335+
theme=theme,
306336
language=language,
307337
time_callback=self.on_time_selected,
308338
width=width,
@@ -323,6 +353,7 @@ def create_initial_timepicker(self, parent):
323353
time_format="%H:%M:%S",
324354
hour_format="24",
325355
show_seconds=True,
356+
theme=self.theme_var.get(),
326357
language=self.language_var.get(),
327358
time_callback=self.on_initial_time_selected,
328359
button_text="🕐",
@@ -453,6 +484,19 @@ def change_language(self):
453484
self.create_initial_timepicker(parent)
454485
self.generate_code()
455486

487+
def change_theme(self):
488+
"""Change the theme."""
489+
# Recreate TimePicker to reflect theme changes
490+
if self.timepicker:
491+
parent = self.timepicker.master
492+
self.create_timepicker(parent)
493+
# Recreate initial time picker to reflect theme changes
494+
if hasattr(self, "initial_timepicker") and self.initial_timepicker:
495+
parent = self.initial_timepicker.master
496+
self.create_initial_timepicker(parent)
497+
self.display_result("Theme Changed", self.theme_var.get())
498+
self.generate_code()
499+
456500
def generate_code(self):
457501
"""Generate Python code for the current TimePicker configuration."""
458502
# Build the code string
@@ -507,6 +551,10 @@ def generate_code(self):
507551
code_lines.append(f" hour_format='{hour_format}',")
508552
code_lines.append(f" show_seconds={show_seconds},")
509553

554+
# Add theme if not default
555+
if self.theme_var.get() != "light":
556+
code_lines.append(f" theme='{self.theme_var.get()}',")
557+
510558
# Add time_callback
511559
code_lines.append(
512560
" time_callback=on_time_selected, # Define your callback function"

tkface/dialog/timepicker.py

Lines changed: 172 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,109 @@
55
popup time selectors for time selection.
66
"""
77

8+
import configparser
89
import datetime
910
import logging
1011
import sys
1112
import tkinter as tk
1213
from dataclasses import dataclass
14+
from pathlib import Path
1315
from tkinter import ttk
14-
from typing import Optional
16+
from typing import Dict, Optional
1517

1618
from ..widget import get_scaling_factor
1719
from ..widget.timespinner import TimeSpinner
1820

1921

22+
def _load_theme_colors(theme_name: str = "light") -> Dict[str, str]:
23+
"""
24+
Load theme colors from .ini file.
25+
26+
Args:
27+
theme_name: Name of the theme ('light' or 'dark')
28+
29+
Returns:
30+
Dict with color values
31+
"""
32+
try:
33+
current_dir = Path(__file__).parent.parent / "themes"
34+
theme_file = current_dir / f"{theme_name}.ini"
35+
36+
if not theme_file.exists():
37+
# Return default light theme if file doesn't exist
38+
return {
39+
'time_background': 'white',
40+
'time_foreground': '#333333',
41+
'time_spinbox_bg': '#f0f0f0',
42+
'time_spinbox_button_bg': 'white',
43+
'time_spinbox_hover_bg': '#e0e0e0',
44+
'time_spinbox_active_bg': '#d0d0d0',
45+
'time_spinbox_outline': '#cccccc',
46+
'time_separator_color': '#333333',
47+
'time_label_color': '#333333',
48+
'time_toggle_bg': '#f0f0f0',
49+
'time_toggle_slider_bg': 'white',
50+
'time_toggle_outline': '#cccccc',
51+
'time_toggle_active_fg': '#333333',
52+
'time_toggle_inactive_fg': '#999999',
53+
'time_button_bg': 'white',
54+
'time_button_fg': '#000000',
55+
'time_button_active_bg': '#f5f5f5',
56+
'time_button_active_fg': '#000000'
57+
}
58+
59+
config = configparser.ConfigParser()
60+
config.read(theme_file)
61+
62+
if theme_name not in config:
63+
# Return default light theme if section doesn't exist
64+
return {
65+
'time_background': 'white',
66+
'time_foreground': '#333333',
67+
'time_spinbox_bg': '#f0f0f0',
68+
'time_spinbox_button_bg': 'white',
69+
'time_spinbox_hover_bg': '#e0e0e0',
70+
'time_spinbox_active_bg': '#d0d0d0',
71+
'time_spinbox_outline': '#cccccc',
72+
'time_separator_color': '#333333',
73+
'time_label_color': '#333333',
74+
'time_toggle_bg': '#f0f0f0',
75+
'time_toggle_slider_bg': 'white',
76+
'time_toggle_outline': '#cccccc',
77+
'time_toggle_active_fg': '#333333',
78+
'time_toggle_inactive_fg': '#999999',
79+
'time_button_bg': 'white',
80+
'time_button_fg': '#000000',
81+
'time_button_active_bg': '#f5f5f5',
82+
'time_button_active_fg': '#000000'
83+
}
84+
85+
theme_section = config[theme_name]
86+
return dict(theme_section)
87+
except Exception:
88+
# Return default light theme on any error
89+
return {
90+
'time_background': 'white',
91+
'time_foreground': '#333333',
92+
'time_spinbox_bg': '#f0f0f0',
93+
'time_spinbox_button_bg': 'white',
94+
'time_spinbox_hover_bg': '#e0e0e0',
95+
'time_spinbox_active_bg': '#d0d0d0',
96+
'time_spinbox_outline': '#cccccc',
97+
'time_separator_color': '#333333',
98+
'time_label_color': '#333333',
99+
'time_toggle_bg': '#f0f0f0',
100+
'time_toggle_slider_bg': 'white',
101+
'time_toggle_outline': '#cccccc',
102+
'time_toggle_active_fg': '#333333',
103+
'time_toggle_inactive_fg': '#999999',
104+
'time_button_bg': 'white',
105+
'time_button_fg': '#000000',
106+
'time_button_active_bg': '#f5f5f5',
107+
'time_button_active_fg': '#000000'
108+
}
109+
110+
20111
@dataclass
21112
class TimePickerConfig:
22113
"""Configuration for TimePicker widgets."""
@@ -70,6 +161,7 @@ def __init__( # pylint: disable=R0917
70161
self.time_format = config.time_format
71162
self.hour_format = config.hour_format
72163
self.show_seconds = config.show_seconds
164+
self.theme = config.theme if hasattr(config, 'theme') else theme
73165
self.selected_time = None
74166
self.popup = None
75167
self.time_callback = config.time_callback
@@ -313,52 +405,84 @@ def show_time_picker(self):
313405
self.logger.debug("show_time_picker called but popup already exists; ignoring")
314406
return
315407
self.logger.debug("Creating time picker popup")
316-
self.popup = tk.Toplevel(self)
317-
self.popup.withdraw()
318-
self.popup.overrideredirect(True)
319-
self.popup.resizable(False, False)
320-
321-
# Set theme colors
322-
if self.hour_format == "12":
323-
bg_color = "#f0f0f0"
324-
else:
325-
bg_color = "#ffffff"
326-
self.popup.configure(bg=bg_color)
327-
328-
# Get the toplevel window from parent
329-
toplevel = self.master.winfo_toplevel() if hasattr(self, "master") else None
330-
if toplevel:
331-
self.popup.transient(toplevel)
332-
self.popup.after(100, self._setup_focus)
333-
334-
# Create time picker widget
335-
self.time_picker = TimeSpinner(
336-
self.popup,
337-
hour_format=self.hour_format,
338-
show_seconds=self.show_seconds,
339-
time_callback=self._on_time_selected,
340-
theme=self.theme if hasattr(self, "theme") else "light",
341-
initial_time=self.selected_time,
342-
)
343-
self.logger.debug(
344-
"TimeSpinner created with hour_format=%s show_seconds=%s initial=%r",
345-
self.hour_format,
346-
self.show_seconds,
347-
self.selected_time,
348-
)
408+
try:
409+
# Prefer using self as parent if it's a real widget; otherwise fallback to master
410+
parent_for_popup = self
411+
try:
412+
_ = self.winfo_toplevel # attribute presence check; may raise in fallback init
413+
except Exception: # pylint: disable=broad-except
414+
parent_for_popup = self.master
415+
416+
self.popup = tk.Toplevel(parent_for_popup)
417+
self.popup.withdraw()
418+
self.popup.overrideredirect(True)
419+
self.popup.resizable(False, False)
420+
except tk.TclError as e:
421+
# In headless/tests or when the application has been destroyed, avoid raising
422+
self.logger.debug("Failed to create Toplevel popup: %s", e)
423+
return
349424

350-
self._bind_time_picker_events(self.time_picker)
351-
self.time_picker.pack(expand=True, fill="both", padx=2, pady=2)
425+
try:
426+
# Set theme colors from theme file
427+
theme_colors = _load_theme_colors(self.theme)
428+
bg_color = theme_colors.get('time_background', 'white')
429+
self.popup.configure(bg=bg_color)
352430

353-
# Position the popup
354-
self.popup.update_idletasks()
355-
self._position_popup()
431+
# Force update to ensure background color is applied
432+
self.popup.update_idletasks()
433+
434+
# Get the toplevel window from parent
435+
toplevel = self.master.winfo_toplevel() if hasattr(self, "master") else None
436+
if toplevel:
437+
self.popup.transient(toplevel)
438+
self.popup.after(100, self._setup_focus)
439+
440+
# Create time picker widget
441+
self.time_picker = TimeSpinner(
442+
self.popup,
443+
hour_format=self.hour_format,
444+
show_seconds=self.show_seconds,
445+
time_callback=self._on_time_selected,
446+
theme=self.theme if hasattr(self, "theme") else "light",
447+
initial_time=self.selected_time,
448+
)
449+
450+
# Ensure TimeSpinner background matches popup background
451+
self.time_picker.configure(bg=bg_color)
452+
self.time_picker.canvas.configure(bg=bg_color)
453+
self.time_picker.button_frame.configure(bg=bg_color)
454+
455+
self.logger.debug(
456+
"TimeSpinner created with hour_format=%s show_seconds=%s theme=%s initial=%r",
457+
self.hour_format,
458+
self.show_seconds,
459+
self.theme if hasattr(self, "theme") else "light",
460+
self.selected_time,
461+
)
462+
463+
self._bind_time_picker_events(self.time_picker)
464+
self.time_picker.pack(expand=True, fill="both", padx=2, pady=2)
465+
466+
# Position the popup
467+
self.popup.update_idletasks()
468+
self._position_popup()
356469

357-
self.popup.deiconify()
358-
self.popup.lift()
359-
self.popup.bind("<Escape>", lambda e: self.hide_time_picker())
360-
self._setup_click_outside_handling()
361-
self.time_picker.focus_set()
470+
self.popup.deiconify()
471+
self.popup.lift()
472+
self.popup.bind("<Escape>", lambda e: self.hide_time_picker())
473+
self._setup_click_outside_handling()
474+
self.time_picker.focus_set()
475+
except tk.TclError as e:
476+
# If any part of popup setup fails (e.g., app destroyed), cleanup and exit gracefully
477+
self.logger.debug("Popup setup failed: %s", e)
478+
try:
479+
if self.popup:
480+
self.popup.destroy()
481+
except Exception: # pylint: disable=broad-except
482+
pass
483+
self.popup = None
484+
self.time_picker = None
485+
return
362486

363487
def _position_popup(self):
364488
"""Position the popup relative to the parent widget."""
@@ -367,7 +491,10 @@ def _position_popup(self):
367491
popup_height = self.popup.winfo_reqheight()
368492

369493
# Get parent widget position
370-
parent_y = self.master.winfo_rooty()
494+
try:
495+
parent_y = self.master.winfo_rooty()
496+
except tk.TclError:
497+
parent_y = 0
371498

372499
# Position below the input area (left edge aligned)
373500
if hasattr(self, "button") and self.entry is not None:

tkface/themes/dark.ini

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,19 @@ navigation_font = TkDefaultFont, 8
2828
navigation_hover_bg = #404040
2929
navigation_hover_fg = white
3030
weekend_bg = darkgray
31-
weekend_fg = white
31+
weekend_fg = white
32+
33+
time_background = #2d2d2d
34+
time_foreground = #cccccc
35+
time_spinbox_bg = #404040
36+
time_spinbox_button_bg = #353535
37+
time_spinbox_hover_bg = #4a4a4a
38+
time_spinbox_active_bg = #555555
39+
time_spinbox_outline = #555555
40+
time_separator_color = #cccccc
41+
time_label_color = #cccccc
42+
time_toggle_bg = #404040
43+
time_toggle_slider_bg = #555555
44+
time_toggle_outline = #555555
45+
time_toggle_active_fg = #ffffff
46+
time_toggle_inactive_fg = #777777

0 commit comments

Comments
 (0)