feat(ui): add hover tooltips to all controls
Add ToolTip class (modules/ui_tooltip.py) and wire descriptive hover tooltips onto every button, switch, slider, and dropdown in the main window. Tooltips appear after a 500ms hover delay and are clamped to screen bounds. This requires no new dependencies — ToolTip uses only customtkinter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ from modules.utilities import (
|
|||||||
)
|
)
|
||||||
from modules.video_capture import VideoCapturer
|
from modules.video_capture import VideoCapturer
|
||||||
from modules.gettext import LanguageManager
|
from modules.gettext import LanguageManager
|
||||||
|
from modules.ui_tooltip import ToolTip
|
||||||
from modules import globals
|
from modules import globals
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
@@ -191,11 +192,13 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
root, text=_("Select a face"), cursor="hand2", command=lambda: select_source_path()
|
root, text=_("Select a face"), cursor="hand2", command=lambda: select_source_path()
|
||||||
)
|
)
|
||||||
select_face_button.place(relx=0.1, rely=0.30, relwidth=0.3, relheight=0.1)
|
select_face_button.place(relx=0.1, rely=0.30, relwidth=0.3, relheight=0.1)
|
||||||
|
ToolTip(select_face_button, _("Choose the source face image to swap onto the target"))
|
||||||
|
|
||||||
swap_faces_button = ctk.CTkButton(
|
swap_faces_button = ctk.CTkButton(
|
||||||
root, text="↔", cursor="hand2", command=lambda: swap_faces_paths()
|
root, text="↔", cursor="hand2", command=lambda: swap_faces_paths()
|
||||||
)
|
)
|
||||||
swap_faces_button.place(relx=0.45, rely=0.30, relwidth=0.1, relheight=0.1)
|
swap_faces_button.place(relx=0.45, rely=0.30, relwidth=0.1, relheight=0.1)
|
||||||
|
ToolTip(swap_faces_button, _("Swap source and target images"))
|
||||||
|
|
||||||
select_target_button = ctk.CTkButton(
|
select_target_button = ctk.CTkButton(
|
||||||
root,
|
root,
|
||||||
@@ -204,6 +207,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
command=lambda: select_target_path(),
|
command=lambda: select_target_path(),
|
||||||
)
|
)
|
||||||
select_target_button.place(relx=0.6, rely=0.30, relwidth=0.3, relheight=0.1)
|
select_target_button.place(relx=0.6, rely=0.30, relwidth=0.3, relheight=0.1)
|
||||||
|
ToolTip(select_target_button, _("Choose the target image or video to apply face swap to"))
|
||||||
|
|
||||||
keep_fps_value = ctk.BooleanVar(value=modules.globals.keep_fps)
|
keep_fps_value = ctk.BooleanVar(value=modules.globals.keep_fps)
|
||||||
keep_fps_checkbox = ctk.CTkSwitch(
|
keep_fps_checkbox = ctk.CTkSwitch(
|
||||||
@@ -217,6 +221,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
keep_fps_checkbox.place(relx=0.1, rely=0.5)
|
keep_fps_checkbox.place(relx=0.1, rely=0.5)
|
||||||
|
ToolTip(keep_fps_checkbox, _("Output video keeps the original frame rate"))
|
||||||
|
|
||||||
keep_frames_value = ctk.BooleanVar(value=modules.globals.keep_frames)
|
keep_frames_value = ctk.BooleanVar(value=modules.globals.keep_frames)
|
||||||
keep_frames_switch = ctk.CTkSwitch(
|
keep_frames_switch = ctk.CTkSwitch(
|
||||||
@@ -230,6 +235,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
keep_frames_switch.place(relx=0.1, rely=0.55)
|
keep_frames_switch.place(relx=0.1, rely=0.55)
|
||||||
|
ToolTip(keep_frames_switch, _("Keep extracted frames on disk after processing"))
|
||||||
|
|
||||||
enhancer_value = ctk.BooleanVar(value=modules.globals.fp_ui["face_enhancer"])
|
enhancer_value = ctk.BooleanVar(value=modules.globals.fp_ui["face_enhancer"])
|
||||||
enhancer_switch = ctk.CTkSwitch(
|
enhancer_switch = ctk.CTkSwitch(
|
||||||
@@ -243,6 +249,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
enhancer_switch.place(relx=0.1, rely=0.6)
|
enhancer_switch.place(relx=0.1, rely=0.6)
|
||||||
|
ToolTip(enhancer_switch, _("Improve face quality using the GFPGAN restoration model"))
|
||||||
|
|
||||||
gpen256_value = ctk.BooleanVar(value=modules.globals.fp_ui.get("face_enhancer_gpen256", False))
|
gpen256_value = ctk.BooleanVar(value=modules.globals.fp_ui.get("face_enhancer_gpen256", False))
|
||||||
gpen256_switch = ctk.CTkSwitch(
|
gpen256_switch = ctk.CTkSwitch(
|
||||||
@@ -256,6 +263,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
gpen256_switch.place(relx=0.1, rely=0.65)
|
gpen256_switch.place(relx=0.1, rely=0.65)
|
||||||
|
ToolTip(gpen256_switch, _("Use GPEN face enhancement model at 256px resolution (faster)"))
|
||||||
|
|
||||||
gpen512_value = ctk.BooleanVar(value=modules.globals.fp_ui.get("face_enhancer_gpen512", False))
|
gpen512_value = ctk.BooleanVar(value=modules.globals.fp_ui.get("face_enhancer_gpen512", False))
|
||||||
gpen512_switch = ctk.CTkSwitch(
|
gpen512_switch = ctk.CTkSwitch(
|
||||||
@@ -269,6 +277,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
gpen512_switch.place(relx=0.1, rely=0.7)
|
gpen512_switch.place(relx=0.1, rely=0.7)
|
||||||
|
ToolTip(gpen512_switch, _("Use GPEN face enhancement model at 512px resolution (higher quality)"))
|
||||||
|
|
||||||
keep_audio_value = ctk.BooleanVar(value=modules.globals.keep_audio)
|
keep_audio_value = ctk.BooleanVar(value=modules.globals.keep_audio)
|
||||||
keep_audio_switch = ctk.CTkSwitch(
|
keep_audio_switch = ctk.CTkSwitch(
|
||||||
@@ -282,6 +291,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
keep_audio_switch.place(relx=0.6, rely=0.5)
|
keep_audio_switch.place(relx=0.6, rely=0.5)
|
||||||
|
ToolTip(keep_audio_switch, _("Copy audio track from the source video to output"))
|
||||||
|
|
||||||
many_faces_value = ctk.BooleanVar(value=modules.globals.many_faces)
|
many_faces_value = ctk.BooleanVar(value=modules.globals.many_faces)
|
||||||
many_faces_switch = ctk.CTkSwitch(
|
many_faces_switch = ctk.CTkSwitch(
|
||||||
@@ -295,6 +305,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
many_faces_switch.place(relx=0.6, rely=0.55)
|
many_faces_switch.place(relx=0.6, rely=0.55)
|
||||||
|
ToolTip(many_faces_switch, _("Swap every detected face, not just the primary one"))
|
||||||
|
|
||||||
color_correction_value = ctk.BooleanVar(value=modules.globals.color_correction)
|
color_correction_value = ctk.BooleanVar(value=modules.globals.color_correction)
|
||||||
color_correction_switch = ctk.CTkSwitch(
|
color_correction_switch = ctk.CTkSwitch(
|
||||||
@@ -308,6 +319,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
color_correction_switch.place(relx=0.6, rely=0.6)
|
color_correction_switch.place(relx=0.6, rely=0.6)
|
||||||
|
ToolTip(color_correction_switch, _("Fix blue/green color cast from some webcams"))
|
||||||
|
|
||||||
# nsfw_value = ctk.BooleanVar(value=modules.globals.nsfw_filter)
|
# nsfw_value = ctk.BooleanVar(value=modules.globals.nsfw_filter)
|
||||||
# nsfw_switch = ctk.CTkSwitch(root, text='NSFW filter', variable=nsfw_value, cursor='hand2', command=lambda: setattr(modules.globals, 'nsfw_filter', nsfw_value.get()))
|
# nsfw_switch = ctk.CTkSwitch(root, text='NSFW filter', variable=nsfw_value, cursor='hand2', command=lambda: setattr(modules.globals, 'nsfw_filter', nsfw_value.get()))
|
||||||
@@ -326,6 +338,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
map_faces_switch.place(relx=0.1, rely=0.75)
|
map_faces_switch.place(relx=0.1, rely=0.75)
|
||||||
|
ToolTip(map_faces_switch, _("Manually assign which source face maps to which target face"))
|
||||||
|
|
||||||
poisson_blend_value = ctk.BooleanVar(value=modules.globals.poisson_blend)
|
poisson_blend_value = ctk.BooleanVar(value=modules.globals.poisson_blend)
|
||||||
poisson_blend_switch = ctk.CTkSwitch(
|
poisson_blend_switch = ctk.CTkSwitch(
|
||||||
@@ -339,6 +352,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
poisson_blend_switch.place(relx=0.1, rely=0.8)
|
poisson_blend_switch.place(relx=0.1, rely=0.8)
|
||||||
|
ToolTip(poisson_blend_switch, _("Blend face edges smoothly using Poisson blending"))
|
||||||
|
|
||||||
show_fps_value = ctk.BooleanVar(value=modules.globals.show_fps)
|
show_fps_value = ctk.BooleanVar(value=modules.globals.show_fps)
|
||||||
show_fps_switch = ctk.CTkSwitch(
|
show_fps_switch = ctk.CTkSwitch(
|
||||||
@@ -352,6 +366,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
show_fps_switch.place(relx=0.6, rely=0.65)
|
show_fps_switch.place(relx=0.6, rely=0.65)
|
||||||
|
ToolTip(show_fps_switch, _("Display frames-per-second counter on the live preview"))
|
||||||
|
|
||||||
mouth_mask_var = ctk.BooleanVar(value=modules.globals.mouth_mask)
|
mouth_mask_var = ctk.BooleanVar(value=modules.globals.mouth_mask)
|
||||||
mouth_mask_switch = ctk.CTkSwitch(
|
mouth_mask_switch = ctk.CTkSwitch(
|
||||||
@@ -362,6 +377,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
command=lambda: setattr(modules.globals, "mouth_mask", mouth_mask_var.get()),
|
command=lambda: setattr(modules.globals, "mouth_mask", mouth_mask_var.get()),
|
||||||
)
|
)
|
||||||
mouth_mask_switch.place(relx=0.1, rely=0.45)
|
mouth_mask_switch.place(relx=0.1, rely=0.45)
|
||||||
|
ToolTip(mouth_mask_switch, _("Preserve original mouth movement in the swapped face"))
|
||||||
|
|
||||||
show_mouth_mask_box_var = ctk.BooleanVar(value=modules.globals.show_mouth_mask_box)
|
show_mouth_mask_box_var = ctk.BooleanVar(value=modules.globals.show_mouth_mask_box)
|
||||||
show_mouth_mask_box_switch = ctk.CTkSwitch(
|
show_mouth_mask_box_switch = ctk.CTkSwitch(
|
||||||
@@ -374,21 +390,25 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
show_mouth_mask_box_switch.place(relx=0.6, rely=0.45)
|
show_mouth_mask_box_switch.place(relx=0.6, rely=0.45)
|
||||||
|
ToolTip(show_mouth_mask_box_switch, _("Display the mouth mask boundary for debugging"))
|
||||||
|
|
||||||
start_button = ctk.CTkButton(
|
start_button = ctk.CTkButton(
|
||||||
root, text=_("Start"), cursor="hand2", command=lambda: analyze_target(start, root)
|
root, text=_("Start"), cursor="hand2", command=lambda: analyze_target(start, root)
|
||||||
)
|
)
|
||||||
start_button.place(relx=0.15, rely=0.86, relwidth=0.2, relheight=0.05)
|
start_button.place(relx=0.15, rely=0.86, relwidth=0.2, relheight=0.05)
|
||||||
|
ToolTip(start_button, _("Begin processing the target image/video with selected face"))
|
||||||
|
|
||||||
stop_button = ctk.CTkButton(
|
stop_button = ctk.CTkButton(
|
||||||
root, text=_("Destroy"), cursor="hand2", command=lambda: destroy()
|
root, text=_("Destroy"), cursor="hand2", command=lambda: destroy()
|
||||||
)
|
)
|
||||||
stop_button.place(relx=0.4, rely=0.86, relwidth=0.2, relheight=0.05)
|
stop_button.place(relx=0.4, rely=0.86, relwidth=0.2, relheight=0.05)
|
||||||
|
ToolTip(stop_button, _("Stop processing and close the application"))
|
||||||
|
|
||||||
preview_button = ctk.CTkButton(
|
preview_button = ctk.CTkButton(
|
||||||
root, text=_("Preview"), cursor="hand2", command=lambda: toggle_preview()
|
root, text=_("Preview"), cursor="hand2", command=lambda: toggle_preview()
|
||||||
)
|
)
|
||||||
preview_button.place(relx=0.65, rely=0.86, relwidth=0.2, relheight=0.05)
|
preview_button.place(relx=0.65, rely=0.86, relwidth=0.2, relheight=0.05)
|
||||||
|
ToolTip(preview_button, _("Show/hide a preview of the processed output"))
|
||||||
|
|
||||||
# --- Camera Selection ---
|
# --- Camera Selection ---
|
||||||
camera_label = ctk.CTkLabel(root, text=_("Select Camera:"))
|
camera_label = ctk.CTkLabel(root, text=_("Select Camera:"))
|
||||||
@@ -412,6 +432,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
)
|
)
|
||||||
|
|
||||||
camera_optionmenu.place(relx=0.35, rely=0.92, relwidth=0.25, relheight=0.05)
|
camera_optionmenu.place(relx=0.35, rely=0.92, relwidth=0.25, relheight=0.05)
|
||||||
|
ToolTip(camera_optionmenu, _("Select which camera to use for live mode"))
|
||||||
|
|
||||||
live_button = ctk.CTkButton(
|
live_button = ctk.CTkButton(
|
||||||
root,
|
root,
|
||||||
@@ -432,6 +453,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
live_button.place(relx=0.65, rely=0.92, relwidth=0.2, relheight=0.05)
|
live_button.place(relx=0.65, rely=0.92, relwidth=0.2, relheight=0.05)
|
||||||
|
ToolTip(live_button, _("Start real-time face swap using webcam"))
|
||||||
# --- End Camera Selection ---
|
# --- End Camera Selection ---
|
||||||
|
|
||||||
# 1) Define a DoubleVar for transparency (0 = fully transparent, 1 = fully opaque)
|
# 1) Define a DoubleVar for transparency (0 = fully transparent, 1 = fully opaque)
|
||||||
@@ -472,6 +494,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
corner_radius=3,
|
corner_radius=3,
|
||||||
)
|
)
|
||||||
transparency_slider.place(relx=0.35, rely=0.77, relwidth=0.5, relheight=0.02)
|
transparency_slider.place(relx=0.35, rely=0.77, relwidth=0.5, relheight=0.02)
|
||||||
|
ToolTip(transparency_slider, _("Blend between original and swapped face (0% = original, 100% = fully swapped)"))
|
||||||
|
|
||||||
# 3) Sharpness label & slider
|
# 3) Sharpness label & slider
|
||||||
sharpness_var = ctk.DoubleVar(value=0.0) # start at 0.0
|
sharpness_var = ctk.DoubleVar(value=0.0) # start at 0.0
|
||||||
@@ -497,6 +520,7 @@ def create_root(start: Callable[[], None], destroy: Callable[[], None]) -> ctk.C
|
|||||||
corner_radius=3,
|
corner_radius=3,
|
||||||
)
|
)
|
||||||
sharpness_slider.place(relx=0.35, rely=0.82, relwidth=0.5, relheight=0.02)
|
sharpness_slider.place(relx=0.35, rely=0.82, relwidth=0.5, relheight=0.02)
|
||||||
|
ToolTip(sharpness_slider, _("Sharpen the enhanced face output"))
|
||||||
|
|
||||||
# Status and link at the bottom
|
# Status and link at the bottom
|
||||||
global status_label
|
global status_label
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Lightweight hover tooltip for CustomTkinter widgets."""
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTip:
|
||||||
|
"""Show a floating tooltip popup when the user hovers over a widget.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ToolTip(my_button, "Helpful description text")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, widget: ctk.CTkBaseClass, text: str, delay: int = 500):
|
||||||
|
self._widget = widget
|
||||||
|
self._text = text
|
||||||
|
self._delay = delay
|
||||||
|
self._tooltip_window = None
|
||||||
|
self._after_id = None
|
||||||
|
|
||||||
|
widget.bind("<Enter>", self._schedule_show, add="+")
|
||||||
|
widget.bind("<Leave>", self._hide, add="+")
|
||||||
|
|
||||||
|
def _schedule_show(self, event=None):
|
||||||
|
self._cancel()
|
||||||
|
self._after_id = self._widget.after(self._delay, self._show)
|
||||||
|
|
||||||
|
def _show(self):
|
||||||
|
if self._tooltip_window is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
x = self._widget.winfo_rootx() + 20
|
||||||
|
y = self._widget.winfo_rooty() + self._widget.winfo_height() + 5
|
||||||
|
|
||||||
|
self._tooltip_window = tw = ctk.CTkToplevel(self._widget)
|
||||||
|
tw.withdraw()
|
||||||
|
tw.overrideredirect(True)
|
||||||
|
|
||||||
|
label = ctk.CTkLabel(
|
||||||
|
tw,
|
||||||
|
text=self._text,
|
||||||
|
fg_color="#333333",
|
||||||
|
text_color="#EEEEEE",
|
||||||
|
corner_radius=6,
|
||||||
|
padx=8,
|
||||||
|
pady=4,
|
||||||
|
)
|
||||||
|
label.pack()
|
||||||
|
|
||||||
|
tw.update_idletasks()
|
||||||
|
|
||||||
|
# Clamp to screen bounds
|
||||||
|
screen_w = tw.winfo_screenwidth()
|
||||||
|
screen_h = tw.winfo_screenheight()
|
||||||
|
tip_w = tw.winfo_reqwidth()
|
||||||
|
tip_h = tw.winfo_reqheight()
|
||||||
|
|
||||||
|
if x + tip_w > screen_w:
|
||||||
|
x = screen_w - tip_w - 5
|
||||||
|
if y + tip_h > screen_h:
|
||||||
|
y = self._widget.winfo_rooty() - tip_h - 5
|
||||||
|
|
||||||
|
tw.geometry(f"+{x}+{y}")
|
||||||
|
tw.deiconify()
|
||||||
|
|
||||||
|
def _hide(self, event=None):
|
||||||
|
self._cancel()
|
||||||
|
if self._tooltip_window is not None:
|
||||||
|
self._tooltip_window.destroy()
|
||||||
|
self._tooltip_window = None
|
||||||
|
|
||||||
|
def _cancel(self):
|
||||||
|
if self._after_id is not None:
|
||||||
|
self._widget.after_cancel(self._after_id)
|
||||||
|
self._after_id = None
|
||||||
Reference in New Issue
Block a user