diff --git a/CHANGELOG.md b/CHANGELOG.md
index 869c195..6954003 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
# v1.1.0 (pre-release)
- Added a feature that allows selecting multiple items in book handler (in right click menu) by @jborza in #31, that fixes #28
+- Added dark theme support, allowing users to switch between light and dark themes in the settings.
# v1.0.9
- Added chunking/segmenting system that fixes memory outage issues when processing large audio files.
diff --git a/abogen/assets/settings.png b/abogen/assets/settings.png
deleted file mode 100644
index bd6fb88..0000000
Binary files a/abogen/assets/settings.png and /dev/null differ
diff --git a/abogen/assets/settings.svg b/abogen/assets/settings.svg
new file mode 100644
index 0000000..f7ab139
--- /dev/null
+++ b/abogen/assets/settings.svg
@@ -0,0 +1,31 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/abogen/book_handler.py b/abogen/book_handler.py
index 55476b6..e5bf8ac 100644
--- a/abogen/book_handler.py
+++ b/abogen/book_handler.py
@@ -161,6 +161,7 @@ class HandlerDialog(QDialog):
self.treeWidget.expandAll()
if self.treeWidget.topLevelItemCount() > 0:
self.treeWidget.setCurrentItem(self.treeWidget.topLevelItem(0))
+ self.treeWidget.setFocus()
# Update checkbox states
self._update_checkbox_states()
diff --git a/abogen/constants.py b/abogen/constants.py
index a8fdfef..fca17a0 100644
--- a/abogen/constants.py
+++ b/abogen/constants.py
@@ -102,3 +102,19 @@ SAMPLE_VOICE_TEXTS = {
"p": "Este é um exemplo da voz selecionada.",
"z": "这是所选语音的示例。",
}
+
+COLORS = {
+ "BLUE": "#007dff",
+ "RED": "#c0392b",
+ "YELLOW_BACKGROUND": "rgba(255, 221, 51, 0.40)",
+ "GREY_BACKGROUND": "rgba(128, 128, 128, 0.15)",
+ "RED_BACKGROUND": "rgba(232, 78, 60, 0.15)",
+ # Theme palette colors
+ "DARK_BG": "#202326",
+ "DARK_BASE": "#141618",
+ "DARK_ALT": "#2c2f31",
+ "DARK_BUTTON": "#292c30",
+ "DARK_DISABLED": "#535353",
+ "LIGHT_BG": "#eff0f1",
+ "LIGHT_DISABLED": "#9a9999",
+}
\ No newline at end of file
diff --git a/abogen/gui.py b/abogen/gui.py
index 1e0f603..322d5cc 100644
--- a/abogen/gui.py
+++ b/abogen/gui.py
@@ -39,6 +39,7 @@ from PyQt5.QtCore import (
QIODevice,
QSize,
QTimer,
+ QEvent,
)
from PyQt5.QtGui import (
QTextCursor,
@@ -71,6 +72,7 @@ from constants import (
LANGUAGE_DESCRIPTIONS,
VOICES_INTERNAL,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
+ COLORS
)
from threading import Thread
from voice_formula_gui import VoiceFormulaDialog
@@ -79,7 +81,22 @@ from voice_profiles import load_profiles
# Import ctypes for Windows-specific taskbar icon
if platform.system() == "Windows":
import ctypes
+ import winreg
+class DarkTitleBarEventFilter(QObject):
+ def __init__(self, is_windows, get_dark_mode_func, set_title_bar_dark_mode_func):
+ super().__init__()
+ self.is_windows = is_windows
+ self.get_dark_mode = get_dark_mode_func
+ self.set_title_bar_dark_mode = set_title_bar_dark_mode_func
+
+ def eventFilter(self, obj, event):
+ if event.type() == QEvent.Show:
+ # Only apply to QWidget windows
+ if isinstance(obj, QWidget) and obj.isWindow():
+ if self.is_windows and self.get_dark_mode():
+ self.set_title_bar_dark_mode(obj, True)
+ return False
class ShowWarningSignalEmitter(QObject): # New class to handle signal emission
show_warning_signal = pyqtSignal(str, str)
@@ -521,6 +538,7 @@ class abogen(QWidget):
def __init__(self):
super().__init__()
self.config = load_config()
+ self.apply_theme(self.config.get("theme", "system"))
migrate_subtitle_format(self.config)
self.check_updates = self.config.get("check_updates", True)
self.save_option = self.config.get("save_option", "Save next to input file")
@@ -808,7 +826,7 @@ class abogen(QWidget):
gpu_layout.addLayout(gpu_checkbox_layout)
# Settings button with icon
- settings_icon_path = get_resource_path("abogen.assets", "settings.png")
+ settings_icon_path = get_resource_path("abogen.assets", "settings.svg")
self.settings_btn = QPushButton(self)
if settings_icon_path and os.path.exists(settings_icon_path):
self.settings_btn.setIcon(QIcon(settings_icon_path))
@@ -1931,10 +1949,160 @@ class abogen(QWidget):
# If dialog is rejected, cancel the conversion
self.cancel_conversion()
+ def apply_theme(self, theme):
+ from PyQt5.QtGui import QPalette, QColor
+ from PyQt5.QtWidgets import QStyleFactory
+
+ app = QApplication.instance()
+ is_windows = platform.system() == "Windows"
+ available_styles = [s.lower() for s in QStyleFactory.keys()]
+
+ def is_windows_dark_mode():
+ try:
+ import winreg
+ with winreg.OpenKey(
+ winreg.HKEY_CURRENT_USER,
+ r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"
+ ) as key:
+ value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
+ return value == 0
+ except Exception:
+ return False
+
+ # --- Theme selection logic ---
+ def set_dark_palette():
+ palette = QPalette()
+ dark_bg = QColor(COLORS["DARK_BG"])
+ base_bg = QColor(COLORS["DARK_BASE"])
+ alt_bg = QColor(COLORS["DARK_ALT"])
+ button_bg = QColor(COLORS["DARK_BUTTON"])
+ disabled_fg = QColor(COLORS["DARK_DISABLED"])
+ palette.setColor(QPalette.ColorRole.Window, dark_bg)
+ palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white)
+ palette.setColor(QPalette.ColorRole.Base, base_bg)
+ palette.setColor(QPalette.ColorRole.AlternateBase, alt_bg)
+ palette.setColor(QPalette.ColorRole.ToolTipBase, dark_bg)
+ palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white)
+ palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white)
+ palette.setColor(QPalette.ColorRole.Button, button_bg)
+ palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white)
+ # Disabled roles
+ palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_fg)
+ palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_fg)
+ palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_fg)
+ palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, dark_bg)
+ palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Button, dark_bg)
+ app.setPalette(palette)
+
+
+ def set_light_palette():
+ palette = QPalette()
+ disabled_fg = QColor(COLORS["LIGHT_DISABLED"])
+ palette.setColor(QPalette.ColorRole.Window, QColor(COLORS["LIGHT_BG"]))
+ palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.black)
+ palette.setColor(QPalette.ColorRole.Base, Qt.GlobalColor.white)
+ palette.setColor(QPalette.ColorRole.AlternateBase, Qt.GlobalColor.white)
+ palette.setColor(QPalette.ColorRole.ToolTipBase, Qt.GlobalColor.white)
+ palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.black)
+ palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.black)
+ palette.setColor(QPalette.ColorRole.Button, Qt.GlobalColor.white)
+ palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.black)
+ # Disabled roles
+ palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_fg)
+ palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_fg)
+ palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_fg)
+ palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, Qt.GlobalColor.white)
+ palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Button, Qt.GlobalColor.white)
+ app.setPalette(palette)
+
+ # --- Dark title bar support for Windows ---
+ def set_title_bar_dark_mode(window, enable):
+ if is_windows:
+ try:
+ window.update()
+ DWMWA_USE_IMMERSIVE_DARK_MODE = 20
+ set_window_attribute = ctypes.windll.dwmapi.DwmSetWindowAttribute
+ hwnd = int(window.winId())
+ value = ctypes.c_int(2 if enable else 0)
+ set_window_attribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(value), ctypes.sizeof(value))
+ except Exception:
+ pass
+
+ # Main logic
+ dark_mode = theme == "dark" or (theme == "system" and is_windows and is_windows_dark_mode())
+ if dark_mode:
+ app.setStyle("Fusion")
+ set_dark_palette()
+ elif (theme == "light" or theme == "system") and is_windows:
+ if "windowsvista" in available_styles:
+ app.setStyle("windowsvista")
+ else:
+ app.setStyle("Fusion")
+ app.setPalette(QPalette())
+ elif theme == "light":
+ app.setStyle("Fusion")
+ set_light_palette()
+ else:
+ app.setStyle("Fusion")
+ app.setPalette(QPalette())
+
+ # Always set the title bar mode according to the current theme for all top-level widgets
+ for widget in app.topLevelWidgets():
+ set_title_bar_dark_mode(widget, dark_mode)
+
+ # Refresh all top-level widgets
+ style_name = app.style().objectName()
+ app.setStyle(style_name)
+ for widget in app.topLevelWidgets():
+ app.style().polish(widget)
+ widget.update()
+
+ # Remove old event filter if present, then install a new one for dark title bar on new windows
+ if hasattr(app, "_dark_titlebar_event_filter"):
+ app.removeEventFilter(app._dark_titlebar_event_filter)
+ delattr(app, "_dark_titlebar_event_filter")
+
+ def get_dark_mode():
+ return theme == "dark" or (theme == "system" and is_windows and is_windows_dark_mode())
+ app._dark_titlebar_event_filter = DarkTitleBarEventFilter(
+ is_windows, get_dark_mode, set_title_bar_dark_mode
+ )
+ app.installEventFilter(app._dark_titlebar_event_filter)
+
+ # Save config if changed
+ if self.config.get("theme", "system") != theme:
+ self.config["theme"] = theme
+ save_config(self.config)
+
def show_settings_menu(self):
"""Show a dropdown menu for settings options."""
menu = QMenu(self)
+ theme_menu = QMenu("Theme", self)
+ theme_menu.setToolTip("Choose the application theme")
+
+ theme_group = QActionGroup(self)
+ theme_group.setExclusive(True)
+
+ # Theme options: (internal_value, display_text)
+ theme_options = [
+ ("system", "System"),
+ ("light", "Light"),
+ ("dark", "Dark"),
+ ]
+
+ # Get current theme from config, default to "system"
+ current_theme = self.config.get("theme", "system")
+ for value, text in theme_options:
+ theme_action = QAction(text, self)
+ theme_action.setCheckable(True)
+ theme_action.setChecked(current_theme == value)
+ theme_action.triggered.connect(lambda checked, v=value: self.apply_theme(v))
+ theme_group.addAction(theme_action)
+ theme_menu.addAction(theme_action)
+
+ menu.addMenu(theme_menu)
+
# Add replace single newlines option
newline_action = QAction("Replace single newlines with spaces", self)
newline_action.setCheckable(True)
diff --git a/abogen/main.py b/abogen/main.py
index ad59430..2a311a9 100644
--- a/abogen/main.py
+++ b/abogen/main.py
@@ -3,6 +3,7 @@ import sys
import platform
from PyQt5.QtWidgets import QApplication
from PyQt5.QtGui import QIcon
+from PyQt5.QtCore import qInstallMessageHandler, QtMsgType
# Add the directory to Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
@@ -24,6 +25,25 @@ if sys.stderr is None:
if platform.system() == "Darwin" and platform.processor() == "arm":
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
+# Custom message handler to filter out specific Qt warnings
+def qt_message_handler(mode, context, message):
+ if "Wayland does not support QWindow::requestActivate()" in message:
+ return # Suppress this specific message
+ if "setGrabPopup called with a parent, QtWaylandClient" in message:
+ return
+ if mode == QtMsgType.QtWarningMsg:
+ print(f"Qt Warning: {message}")
+ elif mode == QtMsgType.QtCriticalMsg:
+ print(f"Qt Critical: {message}")
+ elif mode == QtMsgType.QtFatalMsg:
+ print(f"Qt Fatal: {message}")
+ elif mode == QtMsgType.QtInfoMsg:
+ print(f"Qt Info: {message}")
+
+
+# Install the custom message handler
+qInstallMessageHandler(qt_message_handler)
+
# Set application ID for Windows taskbar icon
if platform.system() == "Windows":
import ctypes
diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py
index b182b09..a9d0d70 100644
--- a/abogen/voice_formula_gui.py
+++ b/abogen/voice_formula_gui.py
@@ -24,14 +24,18 @@ from PyQt5.QtWidgets import (
QMenu,
QAction,
QComboBox,
+ QApplication,
)
from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QSize
-from PyQt5.QtGui import QPixmap, QIcon, QColor
+from PyQt5.QtGui import QPixmap, QIcon
from constants import (
VOICES_INTERNAL,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
LANGUAGE_DESCRIPTIONS,
+ COLORS,
)
+import re
+import platform
from utils import get_resource_path
from voice_profiles import (
load_profiles,
@@ -221,6 +225,19 @@ class VoiceMixer(QWidget):
self.slider.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.slider.setFixedWidth(SLIDER_WIDTH)
+ # Fix slider in Windows
+ if platform.system() == "Windows":
+ appstyle = QApplication.instance().style().objectName().lower()
+ if appstyle != "windowsvista":
+ # Set custom groove color for disabled state using COLORS["GREY_BACKGROUND"]
+ self.slider.setStyleSheet(f"""
+ QSlider::groove:vertical:disabled {{
+ background: {COLORS.get("GREY_BACKGROUND")};
+ width: 4px;
+ border-radius: 4px;
+ }}
+ """)
+
# Connect controls
self.slider.valueChanged.connect(lambda val: self.spin_box.setValue(val / 100))
self.spin_box.valueChanged.connect(
@@ -271,9 +288,9 @@ class HoverLabel(QLabel):
self.delete_button = QPushButton("×", self)
self.delete_button.setFixedSize(16, 16)
self.delete_button.setStyleSheet(
- """
- QPushButton {
- background-color: #ff5555;
+ f"""
+ QPushButton {{
+ background-color: {COLORS.get("RED")};
color: white;
border-radius: 7px;
font-weight: bold;
@@ -281,11 +298,11 @@ class HoverLabel(QLabel):
border: none;
padding: 0px;
margin: 0px;
- }
- QPushButton:hover {
+ }}
+ QPushButton:hover {{
background-color: red;
- }
- """
+ }}
+ """
)
# Make sure the entire button is clickable, not just the text
self.delete_button.setFocusPolicy(Qt.NoFocus)
@@ -350,6 +367,9 @@ class VoiceFormulaDialog(QDialog):
profile_layout.addLayout(header_layout)
# Profile list
self.profile_list = QListWidget()
+ self.profile_list.setSelectionMode(QListWidget.SingleSelection)
+ self.profile_list.setSelectionBehavior(QListWidget.SelectRows)
+ self.profile_list.setStyleSheet("QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }")
icon = QIcon(get_resource_path("abogen.assets", "profile.png"))
if self._virtual_new_profile:
item = QListWidgetItem(icon, "New profile")
@@ -738,7 +758,7 @@ class VoiceFormulaDialog(QDialog):
percentage = weight / total * 100
# Make the voice name bold and include percentage
voice_label = HoverLabel(
- f'{name}: {percentage:.1f}%',
+ f'{name}: {percentage:.1f}%',
name,
)
voice_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
@@ -953,6 +973,18 @@ class VoiceFormulaDialog(QDialog):
return
super().closeEvent(event)
+ def _parse_rgba_to_qcolor(self, rgba_str):
+ from PyQt5.QtCore import Qt
+ from PyQt5.QtGui import QColor
+ """Helper to convert 'rgba(R,G,B,A_float)' string to QColor."""
+ match = re.match(r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)", rgba_str)
+ if match:
+ r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3))
+ a_float = float(match.group(4))
+ a_int = int(a_float * 255)
+ return QColor(r, g, b, a_int)
+ return Qt.GlobalColor.transparent
+
def mark_profile_modified(self):
item = self.profile_list.currentItem()
if item and not item.text().startswith("*"):
@@ -1339,23 +1371,20 @@ class VoiceFormulaDialog(QDialog):
self.profile_list.setItemWidget(item, widget)
def update_profile_list_colors(self):
+ from PyQt5.QtCore import Qt
profiles = load_profiles()
for i in range(self.profile_list.count()):
item = self.profile_list.item(i)
name = item.text().lstrip("*")
if self._virtual_new_profile and name == "New profile":
- color = QColor("#ffff00") # yellow
- color.setAlpha(64) # Set transparency
- item.setBackground(color)
+ color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND"))
+ item.setData(Qt.BackgroundRole, color)
elif item.text().startswith("*"):
- color = QColor("#ffff00") # yellow
- color.setAlpha(64) # Set transparency
- item.setBackground(color)
+ color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND"))
+ item.setData(Qt.BackgroundRole, color)
else:
- item.setBackground(self.profile_list.palette().base().color()) # Use default list item background
-
+ item.setData(Qt.BackgroundRole, self.profile_list.palette().base().color())
weights = profiles.get(name, {}).get("voices", [])
- # Defensive: only sum if weights is a list of (voice, weight) pairs
total = 0
if isinstance(weights, list):
for entry in weights:
@@ -1366,9 +1395,8 @@ class VoiceFormulaDialog(QDialog):
):
total += entry[1]
if total == 0:
- color = QColor("#ff0000") # red
- color.setAlpha(64) # Set transparency
- item.setBackground(color)
+ color = self._parse_rgba_to_qcolor(COLORS.get("RED_BACKGROUND"))
+ item.setData(Qt.BackgroundRole, color)
self.update_profile_save_buttons()
def preview_current_mix(self):