diff --git a/friture/analyzer.py b/friture/analyzer.py
index 42584b63..75cb9906 100755
--- a/friture/analyzer.py
+++ b/friture/analyzer.py
@@ -1,527 +1,542 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# Copyright (C) 2009 Timothée Lecomte
-
-# This file is part of Friture.
-#
-# Friture is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 3 as published by
-# the Free Software Foundation.
-#
-# Friture is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Friture. If not, see .
-
-import sys
-import os
-import os.path
-import argparse
-import errno
-import platform
-import logging
-import logging.handlers
-
-from PyQt5 import QtCore, QtWidgets
-# specifically import from PyQt5.QtGui and QWidgets for startup time improvement :
-from PyQt5.QtWidgets import QMainWindow, QHBoxLayout, QVBoxLayout, QApplication, QSplashScreen
-from PyQt5.QtGui import QPixmap
-from PyQt5.QtQml import QQmlEngine, qmlRegisterSingletonType, qmlRegisterType
-from PyQt5.QtQuickWidgets import QQuickWidget
-from PyQt5.QtCore import QObject
-
-import platformdirs
-
-# importing friture.exceptionhandler also installs a temporary exception hook
-from friture.exceptionhandler import errorBox, fileexcepthook
-import friture
-from friture.ui_friture import Ui_MainWindow
-from friture.about import About_Dialog # About dialog
-from friture.settings import Settings_Dialog # Setting dialog
-from friture.audiobuffer import AudioBuffer # audio ring buffer class
-from friture.audiobackend import AudioBackend # audio backend class
-from friture.dockmanager import DockManager
-from friture.tileLayout import TileLayout
-from friture.level_view_model import LevelViewModel
-from friture.level_data import LevelData
-from friture.levels import Levels_Widget
-from friture.store import GetStore, Store
-from friture.scope_data import Scope_Data
-from friture.axis import Axis
-from friture.colorBar import ColorBar
-from friture.curve import Curve
-from friture.playback.control import PlaybackControlWidget
-from friture.playback.player import Player
-from friture.plotCurve import PlotCurve
-from friture.plotting.coordinateTransform import CoordinateTransform
-from friture.plotting.scaleDivision import ScaleDivision, Tick
-from friture.spectrogram_item import SpectrogramItem
-from friture.spectrogram_item_data import SpectrogramImageData
-from friture.spectrum_data import Spectrum_Data
-from friture.plotFilledCurve import PlotFilledCurve
-from friture.filled_curve import FilledCurve
-from friture.qml_tools import qml_url, raise_if_error
-from friture.generators.sine import Sine_Generator_Settings_View_Model
-from friture.generators.white import White_Generator_Settings_View_Model
-from friture.generators.pink import Pink_Generator_Settings_View_Model
-from friture.generators.sweep import Sweep_Generator_Settings_View_Model
-from friture.generators.burst import Burst_Generator_Settings_View_Model
-
-# the display timer could be made faster when the processing
-# power allows it, firing down to every 10 ms
-SMOOTH_DISPLAY_TIMER_PERIOD_MS = 10
-
-# the slow timer is used for text refresh
-# Text has to be refreshed slowly in order to be readable.
-# (and text painting is costly)
-SLOW_TIMER_PERIOD_MS = 1000
-
-
-class Friture(QMainWindow, ):
-
- def __init__(self):
- QMainWindow.__init__(self)
-
- self.logger = logging.getLogger(__name__)
-
- # exception hook that logs to console, file, and display a message box
- self.errorDialogOpened = False
- sys.excepthook = self.excepthook
-
- store = GetStore()
-
- # set the store as the parent of the QML engine
- # so that the store outlives the engine
- # otherwise the store gets destroyed before the engine
- # which refreshes the QML bindings to undefined values
- # and QML errors are raised
- self.qml_engine = QQmlEngine(store)
-
- # Register the ScaleDivision type. Its URI is 'ScaleDivision', it's v1.0 and the type
- # will be called 'Person' in QML.
- qmlRegisterType(ScaleDivision, 'Friture', 1, 0, 'ScaleDivision')
- qmlRegisterType(CoordinateTransform, 'Friture', 1, 0, 'CoordinateTransform')
- qmlRegisterType(Scope_Data, 'Friture', 1, 0, 'ScopeData')
- qmlRegisterType(Spectrum_Data, 'Friture', 1, 0, 'SpectrumData')
- qmlRegisterType(LevelData, 'Friture', 1, 0, 'LevelData')
- qmlRegisterType(LevelViewModel, 'Friture', 1, 0, 'LevelViewModel')
- qmlRegisterType(Axis, 'Friture', 1, 0, 'Axis')
- qmlRegisterType(Curve, 'Friture', 1, 0, 'Curve')
- qmlRegisterType(FilledCurve, 'Friture', 1, 0, 'FilledCurve')
- qmlRegisterType(PlotCurve, 'Friture', 1, 0, 'PlotCurve')
- qmlRegisterType(PlotFilledCurve, 'Friture', 1, 0, 'PlotFilledCurve')
- qmlRegisterType(SpectrogramItem, 'Friture', 1, 0, 'SpectrogramItem')
- qmlRegisterType(SpectrogramImageData, 'Friture', 1, 0, 'SpectrogramImageData')
- qmlRegisterType(ColorBar, 'Friture', 1, 0, 'ColorBar')
- qmlRegisterType(Tick, 'Friture', 1, 0, 'Tick')
- qmlRegisterType(TileLayout, 'Friture', 1, 0, 'TileLayout')
- qmlRegisterType(Burst_Generator_Settings_View_Model, 'Friture', 1, 0, 'Burst_Generator_Settings_View_Model')
- qmlRegisterType(Pink_Generator_Settings_View_Model, 'Friture', 1, 0, 'Pink_Generator_Settings_View_Model')
- qmlRegisterType(White_Generator_Settings_View_Model, 'Friture', 1, 0, 'White_Generator_Settings_View_Model')
- qmlRegisterType(Sweep_Generator_Settings_View_Model, 'Friture', 1, 0, 'Sweep_Generator_Settings_View_Model')
- qmlRegisterType(Sine_Generator_Settings_View_Model, 'Friture', 1, 0, 'Sine_Generator_Settings_View_Model')
-
- qmlRegisterSingletonType(Store, 'Friture', 1, 0, 'Store', lambda engine, script_engine: GetStore())
-
- # Setup the user interface
- self.ui = Ui_MainWindow()
- self.ui.setupUi(self)
-
- # Initialize the audio data ring buffer
- self.audiobuffer = AudioBuffer()
-
- # Initialize the audio backend
- # signal containing new data from the audio callback thread, processed as numpy array
- AudioBackend().new_data_available.connect(self.audiobuffer.handle_new_data)
-
- self.player = Player(self)
- self.audiobuffer.new_data_available.connect(self.player.handle_new_data)
-
- # this timer is used to update widgets that just need to display as fast as they can
- self.display_timer = QtCore.QTimer()
- self.display_timer.setInterval(SMOOTH_DISPLAY_TIMER_PERIOD_MS) # constant timing
-
- # slow timer
- self.slow_timer = QtCore.QTimer()
- self.slow_timer.setInterval(SLOW_TIMER_PERIOD_MS) # constant timing
-
- self.about_dialog = About_Dialog(self, self.slow_timer)
- self.settings_dialog = Settings_Dialog(self)
-
- self.level_widget = Levels_Widget(self, self.qml_engine)
- self.level_widget.set_buffer(self.audiobuffer)
- self.audiobuffer.new_data_available.connect(self.level_widget.handle_new_data)
-
- self.hboxLayout = QHBoxLayout(self.ui.centralwidget)
- self.hboxLayout.setContentsMargins(0, 0, 0, 0)
- self.hboxLayout.addWidget(self.level_widget)
-
- self.vboxLayout = QVBoxLayout()
- self.hboxLayout.addLayout(self.vboxLayout)
-
- self.centralQuickWidget = QQuickWidget(self.qml_engine, self)
- self.centralQuickWidget.setObjectName("centralQuickWidget")
- self.centralQuickWidget.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
- self.centralQuickWidget.setResizeMode(QQuickWidget.SizeRootObjectToView)
- self.centralQuickWidget.setSource(qml_url("CentralWidget.qml"))
- self.vboxLayout.addWidget(self.centralQuickWidget)
-
- raise_if_error(self.centralQuickWidget)
-
- central_widget_root = self.centralQuickWidget.rootObject()
- self.main_grid_layout = central_widget_root.findChild(QObject, "main_tile_layout")
- assert self.main_grid_layout is not None, "Main grid layout not found in CentralWidget.qml"
-
- self.playback_widget = PlaybackControlWidget(
- self, self.qml_engine, self.player)
- self.playback_widget.setVisible(self.settings_dialog.show_playback)
- self.vboxLayout.addWidget(self.playback_widget)
-
- self.dockmanager = DockManager(self, self.main_grid_layout)
-
- # timer ticks
- self.display_timer.timeout.connect(self.dockmanager.canvasUpdate)
- self.display_timer.timeout.connect(self.level_widget.canvasUpdate)
- self.display_timer.timeout.connect(AudioBackend().fetchAudioData)
-
- # toolbar clicks
- self.ui.actionStart.triggered.connect(self.timer_toggle)
- self.ui.actionSettings.triggered.connect(self.settings_called)
- self.ui.actionAbout.triggered.connect(self.about_called)
- self.ui.actionNew_dock.triggered.connect(self.dockmanager.new_dock)
- self.playback_widget.recording_toggled.connect(self.timer_toggle)
-
- # settings changes
- self.settings_dialog.show_playback_changed.connect(self.show_playback_changed)
- self.settings_dialog.history_length_changed.connect(self.player.set_history_seconds)
-
- # restore the settings and widgets geometries
- self.restoreAppState()
-
- # make sure the toolbar is shown
- # in case it was closed by mistake (before it was made impossible)
- self.ui.toolBar.setVisible(True)
-
- # prevent from hiding or moving the toolbar
- self.ui.toolBar.toggleViewAction().setVisible(False)
- self.ui.toolBar.setMovable(False)
- self.ui.toolBar.setFloatable(False)
-
- # start timers
- self.timer_toggle()
- self.slow_timer.start()
-
- self.logger.info("Init finished, entering the main loop")
-
- # exception hook that logs to console, file, and display a message box
- def excepthook(self, exception_type, exception_value, traceback_object):
- # a keyboard interrupt is an intentional exit, so close the application
- if exception_type is KeyboardInterrupt:
- self.close()
- exit(0)
-
- gui_message = fileexcepthook(exception_type, exception_value, traceback_object)
-
- # we do not want to flood the user with message boxes when the error happens repeatedly on each timer event
- if not self.errorDialogOpened:
- self.errorDialogOpened = True
- errorBox(gui_message)
- self.errorDialogOpened = False
-
- # slot
- def settings_called(self):
- self.settings_dialog.show()
-
- def show_playback_changed(self, show: bool) -> None:
- self.playback_widget.setVisible(show)
-
- # slot
- def about_called(self):
- self.about_dialog.show()
-
- # event handler
- def closeEvent(self, event):
- AudioBackend().close()
- self.saveAppState()
- event.accept()
-
- # method
- def saveAppState(self):
- settings = QtCore.QSettings("Friture", "Friture")
-
- settings.beginGroup("Docks")
- self.dockmanager.saveState(settings)
- settings.endGroup()
-
- settings.beginGroup("MainWindow")
- windowGeometry = self.saveGeometry()
- settings.setValue("windowGeometry", windowGeometry)
- windowState = self.saveState()
- settings.setValue("windowState", windowState)
- settings.endGroup()
-
- settings.beginGroup("AudioBackend")
- self.settings_dialog.saveState(settings)
- settings.endGroup()
-
- # method
- def migrateSettings(self):
- settings = QtCore.QSettings("Friture", "Friture")
-
- # 1. move the central widget to a normal dock
- if settings.contains("CentralWidget/type"):
- settings.beginGroup("CentralWidget")
- centralWidgetKeys = settings.allKeys()
- children = {key: settings.value(key, type=QtCore.QVariant) for key in centralWidgetKeys}
- settings.endGroup()
-
- if not settings.contains("Docks/central/type"):
- # write them to a new dock instead
- for key, value in children.items():
- settings.setValue("Docks/central/" + key, value)
-
- # add the new dock name to dockNames
- docknames = settings.value("Docks/dockNames", [])
- docknames = ["central"] + docknames
- settings.setValue("Docks/dockNames", docknames)
-
- settings.remove("CentralWidget")
-
- # 2. remove any level widget
- if settings.contains("Docks/dockNames"):
- docknames = settings.value("Docks/dockNames", [])
- if docknames == None:
- docknames = []
- newDockNames = []
- for dockname in docknames:
- widgetType = settings.value("Docks/" + dockname + "/type", 0, type=int)
- if widgetType == 0:
- settings.remove("Docks/" + dockname)
- else:
- newDockNames.append(dockname)
- settings.setValue("Docks/dockNames", newDockNames)
-
- # method
- def restoreAppState(self):
- self.migrateSettings()
-
- settings = QtCore.QSettings("Friture", "Friture")
-
- settings.beginGroup("Docks")
- self.dockmanager.restoreState(settings)
- settings.endGroup()
-
- settings.beginGroup("MainWindow")
- self.restoreGeometry(settings.value("windowGeometry", type=QtCore.QByteArray))
- self.restoreState(settings.value("windowState", type=QtCore.QByteArray))
- settings.endGroup()
-
- settings.beginGroup("AudioBackend")
- self.settings_dialog.restoreState(settings)
- settings.endGroup()
-
- # slot
- def timer_toggle(self):
- if self.display_timer.isActive():
- self.logger.info("Timer stop")
- self.display_timer.stop()
- self.ui.actionStart.setText("Start")
- self.playback_widget.stop_recording()
- AudioBackend().pause()
- self.dockmanager.pause()
- else:
- self.logger.info("Timer start")
- self.display_timer.start()
- self.ui.actionStart.setText("Stop")
- self.playback_widget.start_recording()
- AudioBackend().restart()
- self.dockmanager.restart()
-
-
-def qt_message_handler(mode, context, message):
- logger = logging.getLogger(__name__)
- if mode == QtCore.QtInfoMsg:
- logger.info(message)
- elif mode == QtCore.QtWarningMsg:
- logger.warning(message)
- elif mode == QtCore.QtCriticalMsg:
- logger.error(message)
- elif mode == QtCore.QtFatalMsg:
- logger.critical(message)
- else:
- logger.debug(message)
-
-
-class StreamToLogger(object):
- """
- Fake file-like stream object that redirects writes to a logger instance.
- """
-
- def __init__(self, logger, log_level=logging.INFO):
- self.logger = logger
- self.log_level = log_level
- self.linebuf = ''
-
- def write(self, buf):
- for line in buf.rstrip().splitlines():
- self.logger.log(self.log_level, line.rstrip())
-
- def flush(self):
- pass
-
-
-def main():
- # parse command line arguments
- parser = argparse.ArgumentParser()
-
- parser.add_argument(
- "--python",
- action="store_true",
- help="Print data to friture.cprof")
-
- parser.add_argument(
- "--kcachegrind",
- action="store_true")
-
- parser.add_argument(
- "--no-splash",
- action="store_true",
- help="Disable the splash screen on startup")
-
- program_arguments, remaining_arguments = parser.parse_known_args()
- remaining_arguments.insert(0, sys.argv[0])
-
- # make the Python warnings go to Friture logger
- logging.captureWarnings(True)
-
- logFormat = "%(asctime)s %(levelname)s %(name)s: %(message)s"
- formatter = logging.Formatter(logFormat)
-
- logFileName = "friture.log.txt"
- logDir = platformdirs.user_log_dir("Friture", "")
- try:
- os.makedirs(logDir)
- except OSError as e:
- if e.errno != errno.EEXIST:
- raise
- logFilePath = os.path.join(logDir, logFileName)
-
- # log to file
- fileHandler = logging.handlers.RotatingFileHandler(logFilePath, maxBytes=100000, backupCount=5)
- fileHandler.setLevel(logging.DEBUG)
- fileHandler.setFormatter(formatter)
-
- rootLogger = logging.getLogger()
- rootLogger.setLevel(logging.DEBUG)
- rootLogger.addHandler(fileHandler)
-
- if hasattr(sys, "frozen"):
- # redirect stdout and stderr to the logger if this is a pyinstaller bundle
- sys.stdout = StreamToLogger(logging.getLogger('STDOUT'), logging.INFO)
- sys.stderr = StreamToLogger(logging.getLogger('STDERR'), logging.ERROR)
- else:
- # log to console if this is not a pyinstaller bundle
- console = logging.StreamHandler()
- console.setLevel(logging.DEBUG)
- console.setFormatter(formatter)
- rootLogger.addHandler(console)
-
- # make Qt logs go to Friture logger
- QtCore.qInstallMessageHandler(qt_message_handler)
-
- logger = logging.getLogger(__name__)
-
- logger.info("Friture %s starting on %s (%s)", friture.__version__, platform.system(), sys.platform)
-
- logger.info("QML path: %s", qml_url(""))
-
- if platform.system() == "Windows":
- logger.info("Applying Windows-specific setup")
-
- # enable automatic scaling for high-DPI screens
- os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
-
- # set the App ID for Windows 7 to properly display the icon in the
- # taskbar.
- import ctypes
- myappid = 'Friture.Friture.Friture.current' # arbitrary string
- try:
- ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
- except:
- logger.error("Could not set the app model ID. If the plaftorm is older than Windows 7, this is normal.")
-
- app = QApplication(remaining_arguments)
-
- if platform.system() == "Darwin":
- logger.info("Applying Mac OS-specific setup")
- # help the packaged application find the Qt plugins (imageformats and platforms)
- pluginsPath = os.path.normpath(os.path.join(QApplication.applicationDirPath(), os.path.pardir, 'PlugIns'))
- logger.info("Adding the following to the Library paths: %s", pluginsPath)
- QApplication.addLibraryPath(pluginsPath)
-
- if platform.system() == "Linux":
- if "PIPEWIRE_ALSA" not in os.environ:
- os.environ['PIPEWIRE_ALSA'] = '{ application.name = "Friture" }'
-
- # Set the style for Qt Quick Controls
- # We choose the Fusion style as it is a desktop-oriented style
- # It uses the standard system palettes to provide colors that match the desktop environment.
- os.environ["QT_QUICK_CONTROLS_STYLE"] = "Fusion"
-
- # Splash screen
- if not program_arguments.no_splash:
- pixmap = QPixmap(":/images/splash.png")
- splash = QSplashScreen(pixmap)
- splash.show()
- splash.showMessage("Initializing the audio subsystem")
- app.processEvents()
-
- window = Friture()
- window.show()
- if not program_arguments.no_splash:
- splash.finish(window)
-
- profile = "no" # "python" or "kcachegrind" or anything else to disable
-
- if program_arguments.python:
- profile = "python"
- elif program_arguments.kcachegrind:
- profile = "kcachegrind"
-
- return_code = 0
- if profile == "python":
- import cProfile
- import pstats
-
- # friture.cprof can be visualized with SnakeViz
- # http://jiffyclub.github.io/snakeviz/
- # snakeviz friture.cprof
- cProfile.runctx('app.exec_()', globals(), locals(), filename="friture.cprof")
-
- logger.info("Profile saved to '%s'", "friture.cprof")
-
- stats = pstats.Stats("friture.cprof")
- stats.strip_dirs().sort_stats('time').print_stats(20)
- stats.strip_dirs().sort_stats('cumulative').print_stats(20)
- elif profile == "kcachegrind":
- import cProfile
- import lsprofcalltree
-
- p = cProfile.Profile()
- p.run('app.exec_()')
-
- k = lsprofcalltree.KCacheGrind(p)
- with open('cachegrind.out.00000', 'wb') as data:
- k.output(data)
- else:
- return_code = app.exec_()
-
- # explicitly delete the main windows instead of waiting for the interpreter shutdown
- # tentative to prevent errors on exit on macos
- del window
-
- sys.exit(return_code)
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2009 Timothée Lecomte
+
+# This file is part of Friture.
+#
+# Friture is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as published by
+# the Free Software Foundation.
+#
+# Friture is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Friture. If not, see .
+
+import sys
+import os
+import os.path
+import argparse
+import errno
+import platform
+import logging
+import logging.handlers
+
+from PyQt5 import QtCore, QtWidgets
+# specifically import from PyQt5.QtGui and QWidgets for startup time improvement :
+from PyQt5.QtWidgets import QMainWindow, QHBoxLayout, QVBoxLayout, QApplication, QSplashScreen
+from PyQt5.QtGui import QPixmap
+from PyQt5.QtQml import QQmlEngine, qmlRegisterSingletonType, qmlRegisterType
+from PyQt5.QtQuickWidgets import QQuickWidget
+from PyQt5.QtCore import QObject
+
+import platformdirs
+
+# importing friture.exceptionhandler also installs a temporary exception hook
+from friture.exceptionhandler import errorBox, fileexcepthook
+import friture
+from friture.ui_friture import Ui_MainWindow
+from friture.about import About_Dialog # About dialog
+from friture.settings import Settings_Dialog # Setting dialog
+from friture.audiobuffer import AudioBuffer # audio ring buffer class
+from friture.audiobackend import AudioBackend # audio backend class
+from friture.dockmanager import DockManager
+from friture.tilelayout import TileLayout
+from friture.level_view_model import LevelViewModel
+from friture.level_data import LevelData
+from friture.levels import Levels_Widget
+from friture.store import GetStore, Store
+from friture.scope_data import Scope_Data
+from friture.axis import Axis
+from friture.colorBar import ColorBar
+from friture.curve import Curve
+from friture.playback.control import PlaybackControlWidget
+from friture.playback.player import Player
+from friture.plotCurve import PlotCurve
+from friture.plotting.coordinateTransform import CoordinateTransform
+from friture.plotting.scaleDivision import ScaleDivision, Tick
+from friture.spectrogram_item import SpectrogramItem
+from friture.spectrogram_item_data import SpectrogramImageData
+from friture.spectrum_data import Spectrum_Data
+from friture.plotFilledCurve import PlotFilledCurve
+from friture.filled_curve import FilledCurve
+from friture.qml_tools import qml_url, raise_if_error
+from friture.theme import apply_theme
+from friture.generators.sine import Sine_Generator_Settings_View_Model
+from friture.generators.white import White_Generator_Settings_View_Model
+from friture.generators.pink import Pink_Generator_Settings_View_Model
+from friture.generators.sweep import Sweep_Generator_Settings_View_Model
+from friture.generators.burst import Burst_Generator_Settings_View_Model
+
+# the display timer could be made faster when the processing
+# power allows it, firing down to every 10 ms
+SMOOTH_DISPLAY_TIMER_PERIOD_MS = 10
+
+# the slow timer is used for text refresh
+# Text has to be refreshed slowly in order to be readable.
+# (and text painting is costly)
+SLOW_TIMER_PERIOD_MS = 1000
+
+
+class Friture(QMainWindow, ):
+
+ def __init__(self):
+ QMainWindow.__init__(self)
+
+ self.logger = logging.getLogger(__name__)
+
+ # exception hook that logs to console, file, and display a message box
+ self.errorDialogOpened = False
+ sys.excepthook = self.excepthook
+
+ store = GetStore()
+
+ # set the store as the parent of the QML engine
+ # so that the store outlives the engine
+ # otherwise the store gets destroyed before the engine
+ # which refreshes the QML bindings to undefined values
+ # and QML errors are raised
+ self.qml_engine = QQmlEngine(store)
+
+ # Register the ScaleDivision type. Its URI is 'ScaleDivision', it's v1.0 and the type
+ # will be called 'Person' in QML.
+ qmlRegisterType(ScaleDivision, 'Friture', 1, 0, 'ScaleDivision')
+ qmlRegisterType(CoordinateTransform, 'Friture', 1, 0, 'CoordinateTransform')
+ qmlRegisterType(Scope_Data, 'Friture', 1, 0, 'ScopeData')
+ qmlRegisterType(Spectrum_Data, 'Friture', 1, 0, 'SpectrumData')
+ qmlRegisterType(LevelData, 'Friture', 1, 0, 'LevelData')
+ qmlRegisterType(LevelViewModel, 'Friture', 1, 0, 'LevelViewModel')
+ qmlRegisterType(Axis, 'Friture', 1, 0, 'Axis')
+ qmlRegisterType(Curve, 'Friture', 1, 0, 'Curve')
+ qmlRegisterType(FilledCurve, 'Friture', 1, 0, 'FilledCurve')
+ qmlRegisterType(PlotCurve, 'Friture', 1, 0, 'PlotCurve')
+ qmlRegisterType(PlotFilledCurve, 'Friture', 1, 0, 'PlotFilledCurve')
+ qmlRegisterType(SpectrogramItem, 'Friture', 1, 0, 'SpectrogramItem')
+ qmlRegisterType(SpectrogramImageData, 'Friture', 1, 0, 'SpectrogramImageData')
+ qmlRegisterType(ColorBar, 'Friture', 1, 0, 'ColorBar')
+ qmlRegisterType(Tick, 'Friture', 1, 0, 'Tick')
+ qmlRegisterType(TileLayout, 'Friture', 1, 0, 'TileLayout')
+ qmlRegisterType(Burst_Generator_Settings_View_Model, 'Friture', 1, 0, 'Burst_Generator_Settings_View_Model')
+ qmlRegisterType(Pink_Generator_Settings_View_Model, 'Friture', 1, 0, 'Pink_Generator_Settings_View_Model')
+ qmlRegisterType(White_Generator_Settings_View_Model, 'Friture', 1, 0, 'White_Generator_Settings_View_Model')
+ qmlRegisterType(Sweep_Generator_Settings_View_Model, 'Friture', 1, 0, 'Sweep_Generator_Settings_View_Model')
+ qmlRegisterType(Sine_Generator_Settings_View_Model, 'Friture', 1, 0, 'Sine_Generator_Settings_View_Model')
+
+ qmlRegisterSingletonType(Store, 'Friture', 1, 0, 'Store', lambda engine, script_engine: GetStore())
+
+ # Setup the user interface
+ self.ui = Ui_MainWindow()
+ self.ui.setupUi(self)
+
+ # Initialize the audio data ring buffer
+ self.audiobuffer = AudioBuffer()
+
+ # Initialize the audio backend
+ # signal containing new data from the audio callback thread, processed as numpy array
+ AudioBackend().new_data_available.connect(self.audiobuffer.handle_new_data)
+
+ self.player = Player(self)
+ self.audiobuffer.new_data_available.connect(self.player.handle_new_data)
+
+ # this timer is used to update widgets that just need to display as fast as they can
+ self.display_timer = QtCore.QTimer()
+ self.display_timer.setInterval(SMOOTH_DISPLAY_TIMER_PERIOD_MS) # constant timing
+
+ # slow timer
+ self.slow_timer = QtCore.QTimer()
+ self.slow_timer.setInterval(SLOW_TIMER_PERIOD_MS) # constant timing
+
+ self.about_dialog = About_Dialog(self, self.slow_timer)
+ self.settings_dialog = Settings_Dialog(self)
+
+ self.level_widget = Levels_Widget(self, self.qml_engine)
+ self.level_widget.set_buffer(self.audiobuffer)
+ self.audiobuffer.new_data_available.connect(self.level_widget.handle_new_data)
+
+ self.hboxLayout = QHBoxLayout(self.ui.centralwidget)
+ self.hboxLayout.setContentsMargins(0, 0, 0, 0)
+ self.hboxLayout.addWidget(self.level_widget)
+
+ self.vboxLayout = QVBoxLayout()
+ self.hboxLayout.addLayout(self.vboxLayout)
+
+ self.centralQuickWidget = QQuickWidget(self.qml_engine, self)
+ self.centralQuickWidget.setObjectName("centralQuickWidget")
+ self.centralQuickWidget.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
+ self.centralQuickWidget.setResizeMode(QQuickWidget.SizeRootObjectToView)
+ self.centralQuickWidget.setSource(qml_url("CentralWidget.qml"))
+ self.vboxLayout.addWidget(self.centralQuickWidget)
+
+ raise_if_error(self.centralQuickWidget)
+
+ central_widget_root = self.centralQuickWidget.rootObject()
+ self.main_grid_layout = central_widget_root.findChild(QObject, "main_tile_layout")
+ assert self.main_grid_layout is not None, "Main grid layout not found in CentralWidget.qml"
+
+ self.playback_widget = PlaybackControlWidget(
+ self, self.qml_engine, self.player)
+ self.playback_widget.setVisible(self.settings_dialog.show_playback)
+ self.vboxLayout.addWidget(self.playback_widget)
+
+ self.dockmanager = DockManager(self, self.main_grid_layout)
+
+ # timer ticks
+ self.display_timer.timeout.connect(self.dockmanager.canvasUpdate)
+ self.display_timer.timeout.connect(self.level_widget.canvasUpdate)
+ self.display_timer.timeout.connect(AudioBackend().fetchAudioData)
+
+ # toolbar clicks
+ self.ui.actionStart.triggered.connect(self.timer_toggle)
+ self.ui.actionSettings.triggered.connect(self.settings_called)
+ self.ui.actionAbout.triggered.connect(self.about_called)
+ self.ui.actionNew_dock.triggered.connect(self.dockmanager.new_dock)
+ self.playback_widget.recording_toggled.connect(self.timer_toggle)
+
+ # settings changes
+ self.settings_dialog.show_playback_changed.connect(self.show_playback_changed)
+ self.settings_dialog.history_length_changed.connect(self.player.set_history_seconds)
+ self.settings_dialog.theme_changed.connect(self.theme_changed)
+ self.settings_dialog.transparency_changed.connect(self.transparency_changed)
+
+ # restore the settings and widgets geometries
+ self.restoreAppState()
+
+ # make sure the toolbar is shown
+ # in case it was closed by mistake (before it was made impossible)
+ self.ui.toolBar.setVisible(True)
+
+ # prevent from hiding or moving the toolbar
+ self.ui.toolBar.toggleViewAction().setVisible(False)
+ self.ui.toolBar.setMovable(False)
+ self.ui.toolBar.setFloatable(False)
+
+ # start timers
+ self.timer_toggle()
+ self.slow_timer.start()
+
+ self.logger.info("Init finished, entering the main loop")
+
+ # exception hook that logs to console, file, and display a message box
+ def excepthook(self, exception_type, exception_value, traceback_object):
+ # a keyboard interrupt is an intentional exit, so close the application
+ if exception_type is KeyboardInterrupt:
+ self.close()
+ exit(0)
+
+ gui_message = fileexcepthook(exception_type, exception_value, traceback_object)
+
+ # we do not want to flood the user with message boxes when the error happens repeatedly on each timer event
+ if not self.errorDialogOpened:
+ self.errorDialogOpened = True
+ errorBox(gui_message)
+ self.errorDialogOpened = False
+
+ # slot
+ def settings_called(self):
+ self.settings_dialog.show()
+
+ def show_playback_changed(self, show: bool) -> None:
+ self.playback_widget.setVisible(show)
+
+ # slot
+ def theme_changed(self, theme_name: str) -> None:
+ apply_theme(theme_name)
+
+ # slot
+ def transparency_changed(self, enabled: bool) -> None:
+ GetStore().transparency_enabled = enabled
+
+ # slot
+ def about_called(self):
+ self.about_dialog.show()
+
+ # event handler
+ def closeEvent(self, event):
+ AudioBackend().close()
+ self.saveAppState()
+ event.accept()
+
+ # method
+ def saveAppState(self):
+ settings = QtCore.QSettings("Friture", "Friture")
+
+ settings.beginGroup("Docks")
+ self.dockmanager.saveState(settings)
+ settings.endGroup()
+
+ settings.beginGroup("MainWindow")
+ windowGeometry = self.saveGeometry()
+ settings.setValue("windowGeometry", windowGeometry)
+ windowState = self.saveState()
+ settings.setValue("windowState", windowState)
+ settings.endGroup()
+
+ settings.beginGroup("AudioBackend")
+ self.settings_dialog.saveState(settings)
+ settings.endGroup()
+
+ # method
+ def migrateSettings(self):
+ settings = QtCore.QSettings("Friture", "Friture")
+
+ # 1. move the central widget to a normal dock
+ if settings.contains("CentralWidget/type"):
+ settings.beginGroup("CentralWidget")
+ centralWidgetKeys = settings.allKeys()
+ children = {key: settings.value(key, type=QtCore.QVariant) for key in centralWidgetKeys}
+ settings.endGroup()
+
+ if not settings.contains("Docks/central/type"):
+ # write them to a new dock instead
+ for key, value in children.items():
+ settings.setValue("Docks/central/" + key, value)
+
+ # add the new dock name to dockNames
+ docknames = settings.value("Docks/dockNames", [])
+ docknames = ["central"] + docknames
+ settings.setValue("Docks/dockNames", docknames)
+
+ settings.remove("CentralWidget")
+
+ # 2. remove any level widget
+ if settings.contains("Docks/dockNames"):
+ docknames = settings.value("Docks/dockNames", [])
+ if docknames == None:
+ docknames = []
+ newDockNames = []
+ for dockname in docknames:
+ widgetType = settings.value("Docks/" + dockname + "/type", 0, type=int)
+ if widgetType == 0:
+ settings.remove("Docks/" + dockname)
+ else:
+ newDockNames.append(dockname)
+ settings.setValue("Docks/dockNames", newDockNames)
+
+ # method
+ def restoreAppState(self):
+ self.migrateSettings()
+
+ settings = QtCore.QSettings("Friture", "Friture")
+
+ settings.beginGroup("Docks")
+ self.dockmanager.restoreState(settings)
+ settings.endGroup()
+
+ settings.beginGroup("MainWindow")
+ self.restoreGeometry(settings.value("windowGeometry", type=QtCore.QByteArray))
+ self.restoreState(settings.value("windowState", type=QtCore.QByteArray))
+ settings.endGroup()
+
+ settings.beginGroup("AudioBackend")
+ self.settings_dialog.restoreState(settings)
+ settings.endGroup()
+
+ # Restore transparency setting to store
+ transparency_enabled = settings.value("transparency", 2, type=int) # Default to checked
+ GetStore().transparency_enabled = bool(transparency_enabled)
+
+ # slot
+ def timer_toggle(self):
+ if self.display_timer.isActive():
+ self.logger.info("Timer stop")
+ self.display_timer.stop()
+ self.ui.actionStart.setText("Start")
+ self.playback_widget.stop_recording()
+ AudioBackend().pause()
+ self.dockmanager.pause()
+ else:
+ self.logger.info("Timer start")
+ self.display_timer.start()
+ self.ui.actionStart.setText("Stop")
+ self.playback_widget.start_recording()
+ AudioBackend().restart()
+ self.dockmanager.restart()
+
+
+def qt_message_handler(mode, context, message):
+ logger = logging.getLogger(__name__)
+ if mode == QtCore.QtInfoMsg:
+ logger.info(message)
+ elif mode == QtCore.QtWarningMsg:
+ logger.warning(message)
+ elif mode == QtCore.QtCriticalMsg:
+ logger.error(message)
+ elif mode == QtCore.QtFatalMsg:
+ logger.critical(message)
+ else:
+ logger.debug(message)
+
+
+class StreamToLogger(object):
+ """
+ Fake file-like stream object that redirects writes to a logger instance.
+ """
+
+ def __init__(self, logger, log_level=logging.INFO):
+ self.logger = logger
+ self.log_level = log_level
+ self.linebuf = ''
+
+ def write(self, buf):
+ for line in buf.rstrip().splitlines():
+ self.logger.log(self.log_level, line.rstrip())
+
+ def flush(self):
+ pass
+
+
+def main():
+ # parse command line arguments
+ parser = argparse.ArgumentParser()
+
+ parser.add_argument(
+ "--python",
+ action="store_true",
+ help="Print data to friture.cprof")
+
+ parser.add_argument(
+ "--kcachegrind",
+ action="store_true")
+
+ parser.add_argument(
+ "--no-splash",
+ action="store_true",
+ help="Disable the splash screen on startup")
+
+ program_arguments, remaining_arguments = parser.parse_known_args()
+ remaining_arguments.insert(0, sys.argv[0])
+
+ # make the Python warnings go to Friture logger
+ logging.captureWarnings(True)
+
+ logFormat = "%(asctime)s %(levelname)s %(name)s: %(message)s"
+ formatter = logging.Formatter(logFormat)
+
+ logFileName = "friture.log.txt"
+ logDir = platformdirs.user_log_dir("Friture", "")
+ try:
+ os.makedirs(logDir)
+ except OSError as e:
+ if e.errno != errno.EEXIST:
+ raise
+ logFilePath = os.path.join(logDir, logFileName)
+
+ # log to file
+ fileHandler = logging.handlers.RotatingFileHandler(logFilePath, maxBytes=100000, backupCount=5)
+ fileHandler.setLevel(logging.DEBUG)
+ fileHandler.setFormatter(formatter)
+
+ rootLogger = logging.getLogger()
+ rootLogger.setLevel(logging.DEBUG)
+ rootLogger.addHandler(fileHandler)
+
+ if hasattr(sys, "frozen"):
+ # redirect stdout and stderr to the logger if this is a pyinstaller bundle
+ sys.stdout = StreamToLogger(logging.getLogger('STDOUT'), logging.INFO)
+ sys.stderr = StreamToLogger(logging.getLogger('STDERR'), logging.ERROR)
+ else:
+ # log to console if this is not a pyinstaller bundle
+ console = logging.StreamHandler()
+ console.setLevel(logging.DEBUG)
+ console.setFormatter(formatter)
+ rootLogger.addHandler(console)
+
+ # make Qt logs go to Friture logger
+ QtCore.qInstallMessageHandler(qt_message_handler)
+
+ logger = logging.getLogger(__name__)
+
+ logger.info("Friture %s starting on %s (%s)", friture.__version__, platform.system(), sys.platform)
+
+ logger.info("QML path: %s", qml_url(""))
+
+ if platform.system() == "Windows":
+ logger.info("Applying Windows-specific setup")
+
+ # enable automatic scaling for high-DPI screens
+ os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
+
+ # set the App ID for Windows 7 to properly display the icon in the
+ # taskbar.
+ import ctypes
+ myappid = 'Friture.Friture.Friture.current' # arbitrary string
+ try:
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
+ except:
+ logger.error("Could not set the app model ID. If the plaftorm is older than Windows 7, this is normal.")
+
+ app = QApplication(remaining_arguments)
+
+ if platform.system() == "Darwin":
+ logger.info("Applying Mac OS-specific setup")
+ # help the packaged application find the Qt plugins (imageformats and platforms)
+ pluginsPath = os.path.normpath(os.path.join(QApplication.applicationDirPath(), os.path.pardir, 'PlugIns'))
+ logger.info("Adding the following to the Library paths: %s", pluginsPath)
+ QApplication.addLibraryPath(pluginsPath)
+
+ if platform.system() == "Linux":
+ if "PIPEWIRE_ALSA" not in os.environ:
+ os.environ['PIPEWIRE_ALSA'] = '{ application.name = "Friture" }'
+
+ # Set the style for Qt Quick Controls
+ # We choose the Fusion style as it is a desktop-oriented style
+ # It uses the standard system palettes to provide colors that match the desktop environment.
+ os.environ["QT_QUICK_CONTROLS_STYLE"] = "Fusion"
+
+ # Splash screen
+ if not program_arguments.no_splash:
+ pixmap = QPixmap(":/images/splash.png")
+ splash = QSplashScreen(pixmap)
+ splash.show()
+ splash.showMessage("Initializing the audio subsystem")
+ app.processEvents()
+
+ window = Friture()
+ window.show()
+ if not program_arguments.no_splash:
+ splash.finish(window)
+
+ profile = "no" # "python" or "kcachegrind" or anything else to disable
+
+ if program_arguments.python:
+ profile = "python"
+ elif program_arguments.kcachegrind:
+ profile = "kcachegrind"
+
+ return_code = 0
+ if profile == "python":
+ import cProfile
+ import pstats
+
+ # friture.cprof can be visualized with SnakeViz
+ # http://jiffyclub.github.io/snakeviz/
+ # snakeviz friture.cprof
+ cProfile.runctx('app.exec_()', globals(), locals(), filename="friture.cprof")
+
+ logger.info("Profile saved to '%s'", "friture.cprof")
+
+ stats = pstats.Stats("friture.cprof")
+ stats.strip_dirs().sort_stats('time').print_stats(20)
+ stats.strip_dirs().sort_stats('cumulative').print_stats(20)
+ elif profile == "kcachegrind":
+ import cProfile
+ import lsprofcalltree
+
+ p = cProfile.Profile()
+ p.run('app.exec_()')
+
+ k = lsprofcalltree.KCacheGrind(p)
+ with open('cachegrind.out.00000', 'wb') as data:
+ k.output(data)
+ else:
+ return_code = app.exec_()
+
+ # explicitly delete the main windows instead of waiting for the interpreter shutdown
+ # tentative to prevent errors on exit on macos
+ del window
+
+ sys.exit(return_code)
diff --git a/friture/dockmanager.py b/friture/dockmanager.py
index 1c8dc27e..7b0b314e 100644
--- a/friture/dockmanager.py
+++ b/friture/dockmanager.py
@@ -1,160 +1,160 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# Copyright (C) 2013 Timothée Lecomte
-
-# This file is part of Friture.
-#
-# Friture is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 3 as published by
-# the Free Software Foundation.
-#
-# Friture is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Friture. If not, see .
-
-import logging
-
-from PyQt5 import QtCore
-from PyQt5.QtWidgets import QMainWindow
-from friture.defaults import DEFAULT_DOCKS
-from friture.dock import Dock
-from friture.tileLayout import TileLayout
-
-from typing import Dict, List, Optional, TYPE_CHECKING
-if TYPE_CHECKING:
- from friture.analyzer import Friture
-
-
-class DockManager(QtCore.QObject):
-
- def __init__(self, parent: 'Friture', dock_layout: TileLayout) -> None:
- super().__init__(parent)
- self._parent = parent
-
- self.logger = logging.getLogger(__name__)
-
- # the parent must of the QMainWindow so that docks are created as children of it
- assert isinstance(parent, QMainWindow)
-
- self.docks: List[Dock] = []
- self.last_settings: Dict[int, QtCore.QSettings] = {}
- self.last_widget_stack: List[int] = []
-
- self.dock_layout = dock_layout
-
- # slot
- def new_dock(self) -> None:
- # the dock objectName is unique
- docknames = [dock.objectName() for dock in self.docks]
- dockindexes = [int(str(name).partition(' ')[-1]) for name in docknames]
- if len(dockindexes) == 0:
- index = 1
- else:
- index = max(dockindexes) + 1
- name = "Dock %d" % index
-
- widget_id: Optional[int] = None
- settings: Optional[QtCore.QSettings] = None
- if self.last_widget_stack:
- widget_id = self.last_widget_stack.pop()
- settings = self.last_settings.get(widget_id)
-
- new_dock = Dock(self._parent, name, self._parent.qml_engine, widget_id)
- if settings is not None:
- new_dock.restoreState(settings)
-
- self.docks += [new_dock]
-
- # slot
- def close_dock(self, dock: Dock) -> None:
- settings = QtCore.QSettings()
- dock.saveState(settings)
- assert dock.widgetId is not None # true but mypy can't prove it
- self.last_settings[dock.widgetId] = settings
- self.last_widget_stack.append(dock.widgetId)
-
- self.docks.remove(dock)
- dock.cleanup()
-
- def movePrevious(self, dock):
- i = self.docks.index(dock)
- if i > 0:
- self.dock_layout.movePrevious(i)
- self.docks.insert(i-1, self.docks.pop(i))
-
- def moveNext(self, dock):
- i = self.docks.index(dock)
- if i < len(self.docks) - 1:
- self.dock_layout.moveNext(i)
- self.docks.insert(i+1, self.docks.pop(i))
-
- def saveState(self, settings):
- docknames = [dock.objectName() for dock in self.docks]
- settings.setValue("dockNames", docknames)
- for dock in self.docks:
- settings.beginGroup(dock.objectName())
- dock.saveState(settings)
- settings.endGroup()
-
- settings.setValue("widgetIdStack", self.last_widget_stack)
- settings.beginGroup("lastSettings")
- for widgetId, widgetSettings in self.last_settings.items():
- settings.beginGroup(str(widgetId))
- for key in widgetSettings.allKeys():
- settings.setValue(key, widgetSettings.value(key))
- settings.endGroup()
- settings.endGroup()
-
- def restoreState(self, settings):
- if settings.contains("dockNames"):
- docknames = settings.value("dockNames", [])
- # list of docks
- self.docks = []
- for name in docknames:
- settings.beginGroup(name)
- widgetId = settings.value("type", 0, type=int)
- dock = Dock(self.parent(), name, self.parent().qml_engine, widgetId)
- dock.restoreState(settings)
- settings.endGroup()
- self.docks.append(dock)
- else:
- self.logger.info("First launch, display a default set of docks")
- self.docks = [Dock(self.parent(), "Dock %d" % (i), self.parent().qml_engine, widgetId=widget_type) for i, widget_type in enumerate(DEFAULT_DOCKS)]
-
- # Ugh it seems QSettings encodes an empty stack the same as None, and
- # that counts as being set so it doesn't get the default, and hence the
- # `or []` to correctly read back an empty stack.
- self.last_widget_stack = settings.value("widgetIdStack", []) or []
- settings.beginGroup("lastSettings")
- for strId in settings.childGroups():
- settings.beginGroup(strId)
- widgetSettings = QtCore.QSettings()
- for key in settings.allKeys():
- widgetSettings.setValue(key, settings.value(key))
- self.last_settings[int(strId)] = widgetSettings
- settings.endGroup()
- settings.endGroup()
-
- def canvasUpdate(self):
- if self._parent.isVisible():
- for dock in self.docks:
- dock.canvasUpdate()
-
- def pause(self):
- for dock in self.docks:
- try:
- dock.pause()
- except AttributeError:
- pass
-
- def restart(self):
- for dock in self.docks:
- try:
- dock.restart()
- except AttributeError:
- pass
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2013 Timothée Lecomte
+
+# This file is part of Friture.
+#
+# Friture is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as published by
+# the Free Software Foundation.
+#
+# Friture is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Friture. If not, see .
+
+import logging
+
+from PyQt5 import QtCore
+from PyQt5.QtWidgets import QMainWindow
+from friture.defaults import DEFAULT_DOCKS
+from friture.dock import Dock
+from friture.tilelayout import TileLayout
+
+from typing import Dict, List, Optional, TYPE_CHECKING
+if TYPE_CHECKING:
+ from friture.analyzer import Friture
+
+
+class DockManager(QtCore.QObject):
+
+ def __init__(self, parent: 'Friture', dock_layout: TileLayout) -> None:
+ super().__init__(parent)
+ self._parent = parent
+
+ self.logger = logging.getLogger(__name__)
+
+ # the parent must of the QMainWindow so that docks are created as children of it
+ assert isinstance(parent, QMainWindow)
+
+ self.docks: List[Dock] = []
+ self.last_settings: Dict[int, QtCore.QSettings] = {}
+ self.last_widget_stack: List[int] = []
+
+ self.dock_layout = dock_layout
+
+ # slot
+ def new_dock(self) -> None:
+ # the dock objectName is unique
+ docknames = [dock.objectName() for dock in self.docks]
+ dockindexes = [int(str(name).partition(' ')[-1]) for name in docknames]
+ if len(dockindexes) == 0:
+ index = 1
+ else:
+ index = max(dockindexes) + 1
+ name = "Dock %d" % index
+
+ widget_id: Optional[int] = None
+ settings: Optional[QtCore.QSettings] = None
+ if self.last_widget_stack:
+ widget_id = self.last_widget_stack.pop()
+ settings = self.last_settings.get(widget_id)
+
+ new_dock = Dock(self._parent, name, self._parent.qml_engine, widget_id)
+ if settings is not None:
+ new_dock.restoreState(settings)
+
+ self.docks += [new_dock]
+
+ # slot
+ def close_dock(self, dock: Dock) -> None:
+ settings = QtCore.QSettings()
+ dock.saveState(settings)
+ assert dock.widgetId is not None # true but mypy can't prove it
+ self.last_settings[dock.widgetId] = settings
+ self.last_widget_stack.append(dock.widgetId)
+
+ self.docks.remove(dock)
+ dock.cleanup()
+
+ def movePrevious(self, dock):
+ i = self.docks.index(dock)
+ if i > 0:
+ self.dock_layout.movePrevious(i)
+ self.docks.insert(i-1, self.docks.pop(i))
+
+ def moveNext(self, dock):
+ i = self.docks.index(dock)
+ if i < len(self.docks) - 1:
+ self.dock_layout.moveNext(i)
+ self.docks.insert(i+1, self.docks.pop(i))
+
+ def saveState(self, settings):
+ docknames = [dock.objectName() for dock in self.docks]
+ settings.setValue("dockNames", docknames)
+ for dock in self.docks:
+ settings.beginGroup(dock.objectName())
+ dock.saveState(settings)
+ settings.endGroup()
+
+ settings.setValue("widgetIdStack", self.last_widget_stack)
+ settings.beginGroup("lastSettings")
+ for widgetId, widgetSettings in self.last_settings.items():
+ settings.beginGroup(str(widgetId))
+ for key in widgetSettings.allKeys():
+ settings.setValue(key, widgetSettings.value(key))
+ settings.endGroup()
+ settings.endGroup()
+
+ def restoreState(self, settings):
+ if settings.contains("dockNames"):
+ docknames = settings.value("dockNames", [])
+ # list of docks
+ self.docks = []
+ for name in docknames:
+ settings.beginGroup(name)
+ widgetId = settings.value("type", 0, type=int)
+ dock = Dock(self.parent(), name, self.parent().qml_engine, widgetId)
+ dock.restoreState(settings)
+ settings.endGroup()
+ self.docks.append(dock)
+ else:
+ self.logger.info("First launch, display a default set of docks")
+ self.docks = [Dock(self.parent(), "Dock %d" % (i), self.parent().qml_engine, widgetId=widget_type) for i, widget_type in enumerate(DEFAULT_DOCKS)]
+
+ # Ugh it seems QSettings encodes an empty stack the same as None, and
+ # that counts as being set so it doesn't get the default, and hence the
+ # `or []` to correctly read back an empty stack.
+ self.last_widget_stack = settings.value("widgetIdStack", []) or []
+ settings.beginGroup("lastSettings")
+ for strId in settings.childGroups():
+ settings.beginGroup(strId)
+ widgetSettings = QtCore.QSettings()
+ for key in settings.allKeys():
+ widgetSettings.setValue(key, settings.value(key))
+ self.last_settings[int(strId)] = widgetSettings
+ settings.endGroup()
+ settings.endGroup()
+
+ def canvasUpdate(self):
+ if self._parent.isVisible():
+ for dock in self.docks:
+ dock.canvasUpdate()
+
+ def pause(self):
+ for dock in self.docks:
+ try:
+ dock.pause()
+ except AttributeError:
+ pass
+
+ def restart(self):
+ for dock in self.docks:
+ try:
+ dock.restart()
+ except AttributeError:
+ pass
diff --git a/friture/plotFilledCurve.py b/friture/plotFilledCurve.py
index a6cc384c..b2ec37d4 100644
--- a/friture/plotFilledCurve.py
+++ b/friture/plotFilledCurve.py
@@ -1,168 +1,173 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# Copyright (C) 2021 Timothée Lecomte
-
-# This file is part of Friture.
-#
-# Friture is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 3 as published by
-# the Free Software Foundation.
-#
-# Friture is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Friture. If not, see .
-
-import numpy as np
-
-from PyQt5.QtCore import pyqtSignal, pyqtProperty # type: ignore
-from PyQt5.QtQuick import QQuickItem, QSGGeometryNode, QSGGeometry, QSGNode, QSGVertexColorMaterial # type: ignore
-
-from friture.filled_curve import CurveType, FilledCurve
-
-class PlotFilledCurve(QQuickItem):
- curveChanged = pyqtSignal()
-
- def __init__(self, parent = None):
- super().__init__(parent)
-
- self.setFlag(QQuickItem.ItemHasContents, True)
-
- self._curve = FilledCurve(CurveType.SIGNAL)
-
- @pyqtProperty(FilledCurve, notify=curveChanged)
- def curve(self):
- return self._curve
-
- @curve.setter # type: ignore
- def curve(self, curve):
- if curve != self._curve:
- self._curve = curve
- if self._curve is not None:
- self._curve.data_changed.connect(self.update)
-
- self.update()
- self.curveChanged.emit()
-
- def updatePaintNode(self, paint_node, update_data):
-
- # clip to the plot area
- # find the first quad that enters the plot area
- enter_indices = np.argwhere((self.curve.x_left_array() < 0.) * (self.curve.x_right_array() > 0.))
- enter_index = enter_indices[0][0] if len(enter_indices) > 0 else 0
-
- # find the first quad that exits the plot area
- exit_indices = np.argwhere((self.curve.x_left_array() < 1.) * (self.curve.x_right_array() > 1.))
- exit_index = exit_indices[0][0] if len(exit_indices) > 0 else -1
-
- x_left = np.clip(self.curve.x_left_array()[enter_index:exit_index], 0., 1.)
- x_right = np.clip(self.curve.x_right_array()[enter_index:exit_index], 0., 1.)
- y = np.clip(self.curve.y_array()[enter_index:exit_index], 0., 1.)
- z = np.clip(self.curve.z_array()[enter_index:exit_index], 0., 1.)
-
- rectangle_count = y.size
- triangle_count = rectangle_count * 2
- vertex_count = triangle_count * 3
-
- if rectangle_count == 0:
- return
-
- if paint_node is None:
- paint_node = QSGGeometryNode()
-
- geometry = QSGGeometry(QSGGeometry.defaultAttributes_ColoredPoint2D(), vertex_count)
- geometry.setDrawingMode(QSGGeometry.DrawTriangles)
- paint_node.setGeometry(geometry)
- paint_node.setFlag(QSGNode.OwnsGeometry)
-
- material = QSGVertexColorMaterial()
- opaque_material = QSGVertexColorMaterial()
- paint_node.setMaterial(material)
- paint_node.setMaterial(opaque_material)
- paint_node.setFlag(QSGNode.OwnsMaterial)
- paint_node.setFlag(QSGNode.OwnsOpaqueMaterial)
- else:
- geometry = paint_node.geometry()
- geometry.allocate(vertex_count) # geometry will be marked as dirty below
-
- # ideally we would use geometry.vertexDataAsColoredPoint2D
- # but there is a bug with the returned sip.array
- # whose total size is not interpreted correctly
- # `memoryview(geometry.vertexDataAsPoint2D()).nbytes` does not take itemsize into account
- vertex_data = geometry.vertexData()
-
- # a custom structured data type that represents the vertex data is interpreted
- vertex_dtype = np.dtype([('x', np.float32), ('y', np.float32), ('r', np.ubyte), ('g', np.ubyte), ('b', np.ubyte), ('a', np.ubyte)])
- vertex_data.setsize(vertex_dtype.itemsize * vertex_count)
-
- vertices = np.frombuffer(vertex_data, dtype=vertex_dtype)
-
- baseline = self.curve.baseline() * self.height() + 0.*y
- h = (y - self.curve.baseline()) * self.height()
-
- if self.curve.curve_type == CurveType.SIGNAL:
- r = 0.*z
- g = (0.3 + 0.5*z) * 255
- b = 0.*z
- else:
- r = 255 + 0.*z
- g = 255 * (1. - z)
- b = 255 * (1. - z)
-
- a = 255 + 0.*y
-
- x_left_plot = np.clip(x_left, 0., 1.) * self.width()
- x_right_plot = np.clip(x_right, 0., 1.) * self.width()
-
- # first triangle
- vertices[0::6]['x'] = x_left_plot
- vertices[0::6]['y'] = baseline + h
- vertices[0::6]['r'] = r
- vertices[0::6]['g'] = g
- vertices[0::6]['b'] = b
- vertices[0::6]['a'] = a
-
- vertices[1::6]['x'] = x_right_plot
- vertices[1::6]['y'] = baseline + h
- vertices[1::6]['r'] = r
- vertices[1::6]['g'] = g
- vertices[1::6]['b'] = b
- vertices[1::6]['a'] = a
-
- vertices[2::6]['x'] = x_left_plot
- vertices[2::6]['y'] = baseline
- vertices[2::6]['r'] = r
- vertices[2::6]['g'] = g
- vertices[2::6]['b'] = b
- vertices[2::6]['a'] = a
-
- # second triangle
- vertices[3::6]['x'] = x_left_plot
- vertices[3::6]['y'] = baseline
- vertices[3::6]['r'] = r
- vertices[3::6]['g'] = g
- vertices[3::6]['b'] = b
- vertices[3::6]['a'] = a
-
- vertices[4::6]['x'] = x_right_plot
- vertices[4::6]['y'] = baseline
- vertices[4::6]['r'] = r
- vertices[4::6]['g'] = g
- vertices[4::6]['b'] = b
- vertices[4::6]['a'] = a
-
- vertices[5::6]['x'] = x_right_plot
- vertices[5::6]['y'] = baseline + h
- vertices[5::6]['r'] = r
- vertices[5::6]['g'] = g
- vertices[5::6]['b'] = b
- vertices[5::6]['a'] = a
-
- paint_node.markDirty(QSGNode.DirtyGeometry)
- paint_node.markDirty(QSGNode.DirtyMaterial)
-
- return paint_node
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2021 Timothée Lecomte
+
+# This file is part of Friture.
+#
+# Friture is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as published by
+# the Free Software Foundation.
+#
+# Friture is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Friture. If not, see .
+
+import numpy as np
+
+from PyQt5.QtCore import pyqtSignal, pyqtProperty # type: ignore
+from PyQt5.QtQuick import QQuickItem, QSGGeometryNode, QSGGeometry, QSGNode, QSGVertexColorMaterial # type: ignore
+
+from friture.filled_curve import CurveType, FilledCurve
+from friture.store import GetStore
+
+class PlotFilledCurve(QQuickItem):
+ curveChanged = pyqtSignal()
+
+ def __init__(self, parent = None):
+ super().__init__(parent)
+
+ self.setFlag(QQuickItem.ItemHasContents, True)
+
+ self._curve = FilledCurve(CurveType.SIGNAL)
+
+ @pyqtProperty(FilledCurve, notify=curveChanged)
+ def curve(self):
+ return self._curve
+
+ @curve.setter # type: ignore
+ def curve(self, curve):
+ if curve != self._curve:
+ self._curve = curve
+ if self._curve is not None:
+ self._curve.data_changed.connect(self.update)
+
+ self.update()
+ self.curveChanged.emit()
+
+ def updatePaintNode(self, paint_node, update_data):
+
+ # clip to the plot area
+ # find the first quad that enters the plot area
+ enter_indices = np.argwhere((self.curve.x_left_array() < 0.) * (self.curve.x_right_array() > 0.))
+ enter_index = enter_indices[0][0] if len(enter_indices) > 0 else 0
+
+ # find the first quad that exits the plot area
+ exit_indices = np.argwhere((self.curve.x_left_array() < 1.) * (self.curve.x_right_array() > 1.))
+ exit_index = exit_indices[0][0] if len(exit_indices) > 0 else -1
+
+ x_left = np.clip(self.curve.x_left_array()[enter_index:exit_index], 0., 1.)
+ x_right = np.clip(self.curve.x_right_array()[enter_index:exit_index], 0., 1.)
+ y = np.clip(self.curve.y_array()[enter_index:exit_index], 0., 1.)
+ z = np.clip(self.curve.z_array()[enter_index:exit_index], 0., 1.)
+
+ rectangle_count = y.size
+ triangle_count = rectangle_count * 2
+ vertex_count = triangle_count * 3
+
+ if rectangle_count == 0:
+ return
+
+ if paint_node is None:
+ paint_node = QSGGeometryNode()
+
+ geometry = QSGGeometry(QSGGeometry.defaultAttributes_ColoredPoint2D(), vertex_count)
+ geometry.setDrawingMode(QSGGeometry.DrawTriangles)
+ paint_node.setGeometry(geometry)
+ paint_node.setFlag(QSGNode.OwnsGeometry)
+
+ material = QSGVertexColorMaterial()
+ opaque_material = QSGVertexColorMaterial()
+ paint_node.setMaterial(material)
+ paint_node.setMaterial(opaque_material)
+ paint_node.setFlag(QSGNode.OwnsMaterial)
+ paint_node.setFlag(QSGNode.OwnsOpaqueMaterial)
+ else:
+ geometry = paint_node.geometry()
+ geometry.allocate(vertex_count) # geometry will be marked as dirty below
+
+ # ideally we would use geometry.vertexDataAsColoredPoint2D
+ # but there is a bug with the returned sip.array
+ # whose total size is not interpreted correctly
+ # `memoryview(geometry.vertexDataAsPoint2D()).nbytes` does not take itemsize into account
+ vertex_data = geometry.vertexData()
+
+ # a custom structured data type that represents the vertex data is interpreted
+ vertex_dtype = np.dtype([('x', np.float32), ('y', np.float32), ('r', np.ubyte), ('g', np.ubyte), ('b', np.ubyte), ('a', np.ubyte)])
+ vertex_data.setsize(vertex_dtype.itemsize * vertex_count)
+
+ vertices = np.frombuffer(vertex_data, dtype=vertex_dtype)
+
+ baseline = self.curve.baseline() * self.height() + 0.*y
+ h = (y - self.curve.baseline()) * self.height()
+
+ if self.curve.curve_type == CurveType.SIGNAL:
+ r = 0.*z
+ g = (0.3 + 0.5*z) * 255
+ b = 0.*z
+ else:
+ r = 255 + 0.*z
+ g = 255 * (1. - z)
+ b = 255 * (1. - z)
+
+ # Add transparency to the bars - reduce alpha from 255 to about 180 for nice transparency
+ # Check the global transparency setting from store
+ store = GetStore()
+ alpha_value = 180 if store.transparency_enabled else 255
+ a = alpha_value + 0.*y
+
+ x_left_plot = np.clip(x_left, 0., 1.) * self.width()
+ x_right_plot = np.clip(x_right, 0., 1.) * self.width()
+
+ # first triangle
+ vertices[0::6]['x'] = x_left_plot
+ vertices[0::6]['y'] = baseline + h
+ vertices[0::6]['r'] = r
+ vertices[0::6]['g'] = g
+ vertices[0::6]['b'] = b
+ vertices[0::6]['a'] = a
+
+ vertices[1::6]['x'] = x_right_plot
+ vertices[1::6]['y'] = baseline + h
+ vertices[1::6]['r'] = r
+ vertices[1::6]['g'] = g
+ vertices[1::6]['b'] = b
+ vertices[1::6]['a'] = a
+
+ vertices[2::6]['x'] = x_left_plot
+ vertices[2::6]['y'] = baseline
+ vertices[2::6]['r'] = r
+ vertices[2::6]['g'] = g
+ vertices[2::6]['b'] = b
+ vertices[2::6]['a'] = a
+
+ # second triangle
+ vertices[3::6]['x'] = x_left_plot
+ vertices[3::6]['y'] = baseline
+ vertices[3::6]['r'] = r
+ vertices[3::6]['g'] = g
+ vertices[3::6]['b'] = b
+ vertices[3::6]['a'] = a
+
+ vertices[4::6]['x'] = x_right_plot
+ vertices[4::6]['y'] = baseline
+ vertices[4::6]['r'] = r
+ vertices[4::6]['g'] = g
+ vertices[4::6]['b'] = b
+ vertices[4::6]['a'] = a
+
+ vertices[5::6]['x'] = x_right_plot
+ vertices[5::6]['y'] = baseline + h
+ vertices[5::6]['r'] = r
+ vertices[5::6]['g'] = g
+ vertices[5::6]['b'] = b
+ vertices[5::6]['a'] = a
+
+ paint_node.markDirty(QSGNode.DirtyGeometry)
+ paint_node.markDirty(QSGNode.DirtyMaterial)
+
+ return paint_node
diff --git a/friture/settings.py b/friture/settings.py
index ee8e4b27..0401e5a3 100644
--- a/friture/settings.py
+++ b/friture/settings.py
@@ -1,209 +1,232 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# Copyright (C) 2009 Timothée Lecomte
-
-# This file is part of Friture.
-#
-# Friture is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 3 as published by
-# the Free Software Foundation.
-#
-# Friture is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Friture. If not, see .
-
-import sys
-import logging
-
-from PyQt5 import QtCore, QtWidgets
-from PyQt5.QtCore import pyqtSignal, pyqtProperty
-from friture.audiobackend import AudioBackend
-from friture.ui_settings import Ui_Settings_Dialog
-
-no_input_device_title = "No audio input device found"
-
-no_input_device_message = """No audio input device has been found.
-
-Friture needs at least one input device. Please check your audio configuration.
-
-Friture will now exit.
-"""
-
-
-class Settings_Dialog(QtWidgets.QDialog, Ui_Settings_Dialog):
- show_playback_changed = pyqtSignal(bool)
- history_length_changed = pyqtSignal(int)
-
- def __init__(self, parent):
- QtWidgets.QDialog.__init__(self, parent)
- Ui_Settings_Dialog.__init__(self)
-
- self.logger = logging.getLogger(__name__)
-
- # Setup the user interface
- self.setupUi(self)
-
- devices = AudioBackend().get_readable_devices_list()
-
- if devices == []:
- # no audio input device: display a message and exit
- QtWidgets.QMessageBox.critical(self, no_input_device_title, no_input_device_message)
- QtCore.QTimer.singleShot(0, self.exitOnInit)
- sys.exit(1)
- return
-
- for device in devices:
- self.comboBox_inputDevice.addItem(device)
-
- channels = AudioBackend().get_readable_current_channels()
- for channel in channels:
- self.comboBox_firstChannel.addItem(channel)
- self.comboBox_secondChannel.addItem(channel)
-
- current_device = AudioBackend().get_readable_current_device()
- self.comboBox_inputDevice.setCurrentIndex(current_device)
-
- first_channel = AudioBackend().get_current_first_channel()
- self.comboBox_firstChannel.setCurrentIndex(first_channel)
- second_channel = AudioBackend().get_current_second_channel()
- self.comboBox_secondChannel.setCurrentIndex(second_channel)
-
- # signals
- self.comboBox_inputDevice.currentIndexChanged.connect(self.input_device_changed)
- self.comboBox_firstChannel.activated.connect(self.first_channel_changed)
- self.comboBox_secondChannel.activated.connect(self.second_channel_changed)
- self.radioButton_single.toggled.connect(self.single_input_type_selected)
- self.radioButton_duo.toggled.connect(self.duo_input_type_selected)
- self.checkbox_showPlayback.stateChanged.connect(self.show_playback_checkbox_changed)
- self.spinBox_historyLength.editingFinished.connect(self.history_length_edit_finished)
-
- @pyqtProperty(bool, notify=show_playback_changed) # type: ignore
- def show_playback(self) -> bool:
- return bool(self.checkbox_showPlayback.checkState())
-
- # slot
- # used when no audio input device has been found, to exit immediately
- def exitOnInit(self):
- QtWidgets.QApplication.instance().quit()
-
- # slot
- def input_device_changed(self, index):
- self.parent().ui.actionStart.setChecked(False)
-
- success, index = AudioBackend().select_input_device(index)
-
- self.comboBox_inputDevice.setCurrentIndex(index)
-
- if not success:
- # Note: the error message is a child of the settings dialog, so that
- # that dialog remains on top when the error message is closed
- error_message = QtWidgets.QErrorMessage(self)
- error_message.setWindowTitle("Input device error")
- error_message.showMessage("Impossible to use the selected input device, reverting to the previous one")
-
- # reset the channels
- channels = AudioBackend().get_readable_current_channels()
-
- self.comboBox_firstChannel.clear()
- self.comboBox_secondChannel.clear()
-
- for channel in channels:
- self.comboBox_firstChannel.addItem(channel)
- self.comboBox_secondChannel.addItem(channel)
-
- first_channel = AudioBackend().get_current_first_channel()
- self.comboBox_firstChannel.setCurrentIndex(first_channel)
- second_channel = AudioBackend().get_current_second_channel()
- self.comboBox_secondChannel.setCurrentIndex(second_channel)
-
- self.parent().ui.actionStart.setChecked(True)
-
- # slot
- def first_channel_changed(self, index):
- self.parent().ui.actionStart.setChecked(False)
-
- success, index = AudioBackend().select_first_channel(index)
-
- self.comboBox_firstChannel.setCurrentIndex(index)
-
- if not success:
- # Note: the error message is a child of the settings dialog, so that
- # that dialog remains on top when the error message is closed
- error_message = QtWidgets.QErrorMessage(self)
- error_message.setWindowTitle("Input device error")
- error_message.showMessage("Impossible to use the selected channel as the first channel, reverting to the previous one")
-
- self.parent().ui.actionStart.setChecked(True)
-
- # slot
- def second_channel_changed(self, index):
- self.parent().ui.actionStart.setChecked(False)
-
- success, index = AudioBackend().select_second_channel(index)
-
- self.comboBox_secondChannel.setCurrentIndex(index)
-
- if not success:
- # Note: the error message is a child of the settings dialog, so that
- # that dialog remains on top when the error message is closed
- error_message = QtWidgets.QErrorMessage(self)
- error_message.setWindowTitle("Input device error")
- error_message.showMessage("Impossible to use the selected channel as the second channel, reverting to the previous one")
-
- self.parent().ui.actionStart.setChecked(True)
-
- # slot
- def single_input_type_selected(self, checked):
- if checked:
- self.groupBox_second.setEnabled(False)
- AudioBackend().set_single_input()
- self.logger.info("Switching to single input")
-
- # slot
- def duo_input_type_selected(self, checked):
- if checked:
- self.groupBox_second.setEnabled(True)
- AudioBackend().set_duo_input()
- self.logger.info("Switching to difference between two inputs")
-
- # slot
- def show_playback_checkbox_changed(self, state: int) -> None:
- self.show_playback_changed.emit(bool(state))
-
- # slot
- def history_length_edit_finished(self) -> None:
- self.history_length_changed.emit(self.spinBox_historyLength.value())
-
- # method
- def saveState(self, settings):
- # for the input device, we search by name instead of index, since
- # we do not know if the device order stays the same between sessions
- settings.setValue("deviceName", self.comboBox_inputDevice.currentText())
- settings.setValue("firstChannel", self.comboBox_firstChannel.currentIndex())
- settings.setValue("secondChannel", self.comboBox_secondChannel.currentIndex())
- settings.setValue("duoInput", self.inputTypeButtonGroup.checkedId())
- settings.setValue("showPlayback", self.checkbox_showPlayback.checkState())
- settings.setValue("historyLength", self.spinBox_historyLength.value())
-
- # method
- def restoreState(self, settings):
- device_name = settings.value("deviceName", "")
- device_index = self.comboBox_inputDevice.findText(device_name)
- # change the device only if it exists in the device list
- if device_index >= 0:
- self.comboBox_inputDevice.setCurrentIndex(device_index)
- channel = settings.value("firstChannel", 0, type=int)
- self.comboBox_firstChannel.setCurrentIndex(channel)
- channel = settings.value("secondChannel", 0, type=int)
- self.comboBox_secondChannel.setCurrentIndex(channel)
- duo_input_id = settings.value("duoInput", 0, type=int)
- self.inputTypeButtonGroup.button(duo_input_id).setChecked(True)
- self.checkbox_showPlayback.setCheckState(settings.value("showPlayback", 0, type=int))
- self.spinBox_historyLength.setValue(settings.value("historyLength", 30, type=int))
- # need to emit this because setValue doesn't emit editFinished
- self.history_length_changed.emit(self.spinBox_historyLength.value())
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2009 Timothée Lecomte
+
+# This file is part of Friture.
+#
+# Friture is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as published by
+# the Free Software Foundation.
+#
+# Friture is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Friture. If not, see .
+
+import sys
+import logging
+
+from PyQt5 import QtCore, QtWidgets
+from PyQt5.QtCore import pyqtSignal, pyqtProperty
+from friture.audiobackend import AudioBackend
+from friture.ui_settings import Ui_Settings_Dialog
+
+no_input_device_title = "No audio input device found"
+
+no_input_device_message = """No audio input device has been found.
+
+Friture needs at least one input device. Please check your audio configuration.
+
+Friture will now exit.
+"""
+
+
+class Settings_Dialog(QtWidgets.QDialog, Ui_Settings_Dialog):
+ show_playback_changed = pyqtSignal(bool)
+ history_length_changed = pyqtSignal(int)
+ theme_changed = pyqtSignal(str)
+ transparency_changed = pyqtSignal(bool)
+
+ def __init__(self, parent):
+ QtWidgets.QDialog.__init__(self, parent)
+ Ui_Settings_Dialog.__init__(self)
+
+ self.logger = logging.getLogger(__name__)
+
+ # Setup the user interface
+ self.setupUi(self)
+
+ devices = AudioBackend().get_readable_devices_list()
+
+ if devices == []:
+ # no audio input device: display a message and exit
+ QtWidgets.QMessageBox.critical(self, no_input_device_title, no_input_device_message)
+ QtCore.QTimer.singleShot(0, self.exitOnInit)
+ sys.exit(1)
+ return
+
+ for device in devices:
+ self.comboBox_inputDevice.addItem(device)
+
+ channels = AudioBackend().get_readable_current_channels()
+ for channel in channels:
+ self.comboBox_firstChannel.addItem(channel)
+ self.comboBox_secondChannel.addItem(channel)
+
+ current_device = AudioBackend().get_readable_current_device()
+ self.comboBox_inputDevice.setCurrentIndex(current_device)
+
+ first_channel = AudioBackend().get_current_first_channel()
+ self.comboBox_firstChannel.setCurrentIndex(first_channel)
+ second_channel = AudioBackend().get_current_second_channel()
+ self.comboBox_secondChannel.setCurrentIndex(second_channel)
+
+ # signals
+ self.comboBox_inputDevice.currentIndexChanged.connect(self.input_device_changed)
+ self.comboBox_firstChannel.activated.connect(self.first_channel_changed)
+ self.comboBox_secondChannel.activated.connect(self.second_channel_changed)
+ self.radioButton_single.toggled.connect(self.single_input_type_selected)
+ self.radioButton_duo.toggled.connect(self.duo_input_type_selected)
+ self.checkbox_showPlayback.stateChanged.connect(self.show_playback_checkbox_changed)
+ self.spinBox_historyLength.editingFinished.connect(self.history_length_edit_finished)
+ self.comboBox_theme.currentTextChanged.connect(self.theme_changed)
+ self.checkbox_transparency.stateChanged.connect(self.transparency_checkbox_changed)
+
+ @pyqtProperty(bool, notify=show_playback_changed) # type: ignore
+ def show_playback(self) -> bool:
+ return bool(self.checkbox_showPlayback.checkState())
+
+ @pyqtProperty(bool) # type: ignore
+ def transparency_enabled(self) -> bool:
+ return bool(self.checkbox_transparency.checkState())
+
+ # slot
+ # used when no audio input device has been found, to exit immediately
+ def exitOnInit(self):
+ QtWidgets.QApplication.instance().quit()
+
+ # slot
+ def input_device_changed(self, index):
+ self.parent().ui.actionStart.setChecked(False)
+
+ success, index = AudioBackend().select_input_device(index)
+
+ self.comboBox_inputDevice.setCurrentIndex(index)
+
+ if not success:
+ # Note: the error message is a child of the settings dialog, so that
+ # that dialog remains on top when the error message is closed
+ error_message = QtWidgets.QErrorMessage(self)
+ error_message.setWindowTitle("Input device error")
+ error_message.showMessage("Impossible to use the selected input device, reverting to the previous one")
+
+ # reset the channels
+ channels = AudioBackend().get_readable_current_channels()
+
+ self.comboBox_firstChannel.clear()
+ self.comboBox_secondChannel.clear()
+
+ for channel in channels:
+ self.comboBox_firstChannel.addItem(channel)
+ self.comboBox_secondChannel.addItem(channel)
+
+ first_channel = AudioBackend().get_current_first_channel()
+ self.comboBox_firstChannel.setCurrentIndex(first_channel)
+ second_channel = AudioBackend().get_current_second_channel()
+ self.comboBox_secondChannel.setCurrentIndex(second_channel)
+
+ self.parent().ui.actionStart.setChecked(True)
+
+ # slot
+ def first_channel_changed(self, index):
+ self.parent().ui.actionStart.setChecked(False)
+
+ success, index = AudioBackend().select_first_channel(index)
+
+ self.comboBox_firstChannel.setCurrentIndex(index)
+
+ if not success:
+ # Note: the error message is a child of the settings dialog, so that
+ # that dialog remains on top when the error message is closed
+ error_message = QtWidgets.QErrorMessage(self)
+ error_message.setWindowTitle("Input device error")
+ error_message.showMessage("Impossible to use the selected channel as the first channel, reverting to the previous one")
+
+ self.parent().ui.actionStart.setChecked(True)
+
+ # slot
+ def second_channel_changed(self, index):
+ self.parent().ui.actionStart.setChecked(False)
+
+ success, index = AudioBackend().select_second_channel(index)
+
+ self.comboBox_secondChannel.setCurrentIndex(index)
+
+ if not success:
+ # Note: the error message is a child of the settings dialog, so that
+ # that dialog remains on top when the error message is closed
+ error_message = QtWidgets.QErrorMessage(self)
+ error_message.setWindowTitle("Input device error")
+ error_message.showMessage("Impossible to use the selected channel as the second channel, reverting to the previous one")
+
+ self.parent().ui.actionStart.setChecked(True)
+
+ # slot
+ def single_input_type_selected(self, checked):
+ if checked:
+ self.groupBox_second.setEnabled(False)
+ AudioBackend().set_single_input()
+ self.logger.info("Switching to single input")
+
+ # slot
+ def duo_input_type_selected(self, checked):
+ if checked:
+ self.groupBox_second.setEnabled(True)
+ AudioBackend().set_duo_input()
+ self.logger.info("Switching to difference between two inputs")
+
+ # slot
+ def show_playback_checkbox_changed(self, state: int) -> None:
+ self.show_playback_changed.emit(bool(state))
+
+ # slot
+ def history_length_edit_finished(self) -> None:
+ self.history_length_changed.emit(self.spinBox_historyLength.value())
+
+ # slot
+ def transparency_checkbox_changed(self, state: int) -> None:
+ self.transparency_changed.emit(bool(state))
+
+ # method
+ def saveState(self, settings):
+ # for the input device, we search by name instead of index, since
+ # we do not know if the device order stays the same between sessions
+ settings.setValue("deviceName", self.comboBox_inputDevice.currentText())
+ settings.setValue("firstChannel", self.comboBox_firstChannel.currentIndex())
+ settings.setValue("secondChannel", self.comboBox_secondChannel.currentIndex())
+ settings.setValue("duoInput", self.inputTypeButtonGroup.checkedId())
+ settings.setValue("showPlayback", self.checkbox_showPlayback.checkState())
+ settings.setValue("historyLength", self.spinBox_historyLength.value())
+ settings.setValue("transparency", self.checkbox_transparency.checkState())
+ settings.setValue("theme", self.comboBox_theme.currentText())
+
+ # method
+ def restoreState(self, settings):
+ device_name = settings.value("deviceName", "")
+ device_index = self.comboBox_inputDevice.findText(device_name)
+ # change the device only if it exists in the device list
+ if device_index >= 0:
+ self.comboBox_inputDevice.setCurrentIndex(device_index)
+ channel = settings.value("firstChannel", 0, type=int)
+ self.comboBox_firstChannel.setCurrentIndex(channel)
+ channel = settings.value("secondChannel", 0, type=int)
+ self.comboBox_secondChannel.setCurrentIndex(channel)
+ duo_input_id = settings.value("duoInput", 0, type=int)
+ self.inputTypeButtonGroup.button(duo_input_id).setChecked(True)
+ self.checkbox_showPlayback.setCheckState(settings.value("showPlayback", 0, type=int))
+ self.spinBox_historyLength.setValue(settings.value("historyLength", 30, type=int))
+ # need to emit this because setValue doesn't emit editFinished
+ self.history_length_changed.emit(self.spinBox_historyLength.value())
+ self.checkbox_transparency.setCheckState(settings.value("transparency", 2, type=int)) # Default to checked (transparent)
+
+ # restore theme setting
+ theme_text = settings.value("theme", "System Default", type=str)
+ theme_index = self.comboBox_theme.findText(theme_text)
+ if theme_index >= 0:
+ self.comboBox_theme.setCurrentIndex(theme_index)
+ # emit the theme changed signal to apply the theme
+ self.theme_changed.emit(self.comboBox_theme.currentText())
diff --git a/friture/store.py b/friture/store.py
index 3ce5058b..a803e745 100644
--- a/friture/store.py
+++ b/friture/store.py
@@ -1,22 +1,34 @@
-from PyQt5 import QtCore
-from PyQt5.QtCore import QObject, pyqtProperty
-from PyQt5.QtQml import QQmlListProperty # type: ignore
-
-__storeInstance = None
-
-def GetStore():
- global __storeInstance
- if __storeInstance is None:
- __storeInstance = Store()
- return __storeInstance
-
-class Store(QtCore.QObject):
- dock_states_changed = QtCore.pyqtSignal()
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self._dock_states = []
-
- @pyqtProperty(QQmlListProperty, notify=dock_states_changed) # type: ignore
- def dock_states(self):
- return QQmlListProperty(QObject, self, self._dock_states)
+from PyQt5 import QtCore
+from PyQt5.QtCore import QObject, pyqtProperty
+from PyQt5.QtQml import QQmlListProperty # type: ignore
+
+__storeInstance = None
+
+def GetStore():
+ global __storeInstance
+ if __storeInstance is None:
+ __storeInstance = Store()
+ return __storeInstance
+
+class Store(QtCore.QObject):
+ dock_states_changed = QtCore.pyqtSignal()
+ transparency_changed = QtCore.pyqtSignal(bool)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._dock_states = []
+ self._transparency_enabled = True # Default to enabled
+
+ @pyqtProperty(QQmlListProperty, notify=dock_states_changed) # type: ignore
+ def dock_states(self):
+ return QQmlListProperty(QObject, self, self._dock_states)
+
+ @pyqtProperty(bool, notify=transparency_changed) # type: ignore
+ def transparency_enabled(self):
+ return self._transparency_enabled
+
+ @transparency_enabled.setter # type: ignore
+ def transparency_enabled(self, enabled):
+ if self._transparency_enabled != enabled:
+ self._transparency_enabled = enabled
+ self.transparency_changed.emit(enabled)
diff --git a/friture/theme.py b/friture/theme.py
new file mode 100644
index 00000000..f30ae3d6
--- /dev/null
+++ b/friture/theme.py
@@ -0,0 +1,308 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2009 Timothée Lecomte
+
+# This file is part of Friture.
+#
+# Friture is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as published by
+# the Free Software Foundation.
+#
+# Friture is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Friture. If not, see .
+
+import logging
+from PyQt5 import QtWidgets, QtGui, QtCore
+
+
+class ThemeManager:
+ def __init__(self):
+ self.logger = logging.getLogger(__name__)
+ self.app = QtWidgets.QApplication.instance()
+
+ def apply_theme(self, theme_name):
+ """Apply the selected theme to the application"""
+ self.logger.info(f"Applying theme: {theme_name}")
+
+ if theme_name == "Dark":
+ self._apply_dark_theme()
+ elif theme_name == "Light":
+ self._apply_light_theme()
+ else: # System Default
+ self._apply_system_theme()
+
+ def _apply_dark_theme(self):
+ """Apply dark theme"""
+ dark_palette = QtGui.QPalette()
+
+ # Window colors
+ dark_palette.setColor(QtGui.QPalette.Window, QtGui.QColor(53, 53, 53))
+ dark_palette.setColor(QtGui.QPalette.WindowText, QtGui.QColor(255, 255, 255))
+
+ # Base colors (for text input fields)
+ dark_palette.setColor(QtGui.QPalette.Base, QtGui.QColor(25, 25, 25))
+ dark_palette.setColor(QtGui.QPalette.AlternateBase, QtGui.QColor(53, 53, 53))
+
+ # Text colors
+ dark_palette.setColor(QtGui.QPalette.Text, QtGui.QColor(255, 255, 255))
+
+ # Button colors
+ dark_palette.setColor(QtGui.QPalette.Button, QtGui.QColor(53, 53, 53))
+ dark_palette.setColor(QtGui.QPalette.ButtonText, QtGui.QColor(255, 255, 255))
+
+ # Highlight colors
+ dark_palette.setColor(QtGui.QPalette.Highlight, QtGui.QColor(42, 130, 218))
+ dark_palette.setColor(QtGui.QPalette.HighlightedText, QtGui.QColor(0, 0, 0))
+
+ # Disabled colors
+ dark_palette.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.WindowText, QtGui.QColor(127, 127, 127))
+ dark_palette.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Text, QtGui.QColor(127, 127, 127))
+ dark_palette.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.ButtonText, QtGui.QColor(127, 127, 127))
+
+ self.app.setPalette(dark_palette)
+
+ # Set style sheet for better dark theme appearance
+ dark_style = """
+ QToolTip {
+ color: #ffffff;
+ background-color: #2a2a2a;
+ border: 1px solid white;
+ }
+
+ QGroupBox {
+ font-weight: bold;
+ border: 2px solid #555555;
+ border-radius: 5px;
+ margin-top: 1ex;
+ padding-top: 10px;
+ color: #ffffff;
+ }
+
+ QGroupBox::title {
+ subcontrol-origin: margin;
+ left: 10px;
+ padding: 0 5px 0 5px;
+ color: #ffffff;
+ }
+
+ QLabel {
+ color: #ffffff;
+ }
+
+ /* Form controls styling for dark theme */
+ QComboBox {
+ background-color: #353535;
+ color: #ffffff;
+ border: 1px solid #555555;
+ border-radius: 4px;
+ padding: 4px;
+ }
+
+ QComboBox:editable {
+ background-color: #191919;
+ }
+
+ QComboBox:on {
+ padding-top: 3px;
+ padding-left: 4px;
+ }
+
+ QComboBox::drop-down {
+ subcontrol-origin: padding;
+ subcontrol-position: top right;
+ width: 15px;
+ border-left-width: 1px;
+ border-left-color: #555555;
+ border-left-style: solid;
+ border-top-right-radius: 3px;
+ border-bottom-right-radius: 3px;
+ background-color: #353535;
+ }
+
+ QComboBox::down-arrow {
+ width: 0;
+ height: 0;
+ border: 5px solid transparent;
+ border-top: 5px solid #ffffff;
+ margin-left: 2px;
+ }
+
+ QComboBox QAbstractItemView {
+ background-color: #353535;
+ color: #ffffff;
+ border: 1px solid #555555;
+ selection-background-color: #2a82da;
+ }
+
+ QSpinBox, QDoubleSpinBox {
+ background-color: #191919;
+ color: #ffffff;
+ border: 1px solid #555555;
+ border-radius: 4px;
+ padding: 4px;
+ }
+
+ QSpinBox::up-button, QDoubleSpinBox::up-button {
+ subcontrol-origin: border;
+ subcontrol-position: top right;
+ width: 16px;
+ background-color: #353535;
+ border-left: 1px solid #555555;
+ }
+
+ QSpinBox::down-button, QDoubleSpinBox::down-button {
+ subcontrol-origin: border;
+ subcontrol-position: bottom right;
+ width: 16px;
+ background-color: #353535;
+ border-left: 1px solid #555555;
+ }
+
+ QCheckBox {
+ color: #ffffff;
+ spacing: 5px;
+ }
+
+ QCheckBox::indicator {
+ width: 18px;
+ height: 18px;
+ background-color: #191919;
+ border: 1px solid #555555;
+ border-radius: 3px;
+ }
+
+ QCheckBox::indicator:checked {
+ background-color: #2a82da;
+ border-color: #2a82da;
+ }
+
+ QCheckBox::indicator:checked:hover {
+ background-color: #3a92ea;
+ }
+
+ QRadioButton {
+ color: #ffffff;
+ spacing: 5px;
+ }
+
+ QRadioButton::indicator {
+ width: 18px;
+ height: 18px;
+ background-color: #191919;
+ border: 1px solid #555555;
+ border-radius: 9px;
+ }
+
+ QRadioButton::indicator:checked {
+ background-color: #2a82da;
+ border-color: #2a82da;
+ }
+
+ /* Tab widget styling */
+ QTabWidget::pane {
+ border: 1px solid #555555;
+ background-color: #353535;
+ }
+
+ QTabBar::tab {
+ background-color: #2a2a2a;
+ color: #ffffff;
+ border: 1px solid #555555;
+ border-bottom-color: #555555;
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ min-width: 8ex;
+ padding: 8px;
+ }
+
+ QTabBar::tab:selected {
+ background-color: #353535;
+ border-bottom-color: #353535;
+ }
+
+ QTabBar::tab:hover {
+ background-color: #404040;
+ }
+
+ /* Dialog buttons styling */
+ QPushButton {
+ background-color: #353535;
+ color: #ffffff;
+ border: 1px solid #555555;
+ border-radius: 4px;
+ padding: 6px 12px;
+ min-width: 80px;
+ }
+
+ QPushButton:hover {
+ background-color: #404040;
+ }
+
+ QPushButton:pressed {
+ background-color: #2a2a2a;
+ }
+ """
+ self.app.setStyleSheet(dark_style)
+
+ def _apply_light_theme(self):
+ """Apply light theme"""
+ light_palette = QtGui.QPalette()
+
+ # Window colors
+ light_palette.setColor(QtGui.QPalette.Window, QtGui.QColor(240, 240, 240))
+ light_palette.setColor(QtGui.QPalette.WindowText, QtGui.QColor(0, 0, 0))
+
+ # Base colors (for text input fields)
+ light_palette.setColor(QtGui.QPalette.Base, QtGui.QColor(255, 255, 255))
+ light_palette.setColor(QtGui.QPalette.AlternateBase, QtGui.QColor(233, 231, 227))
+
+ # Text colors
+ light_palette.setColor(QtGui.QPalette.Text, QtGui.QColor(0, 0, 0))
+
+ # Button colors
+ light_palette.setColor(QtGui.QPalette.Button, QtGui.QColor(240, 240, 240))
+ light_palette.setColor(QtGui.QPalette.ButtonText, QtGui.QColor(0, 0, 0))
+
+ # Highlight colors
+ light_palette.setColor(QtGui.QPalette.Highlight, QtGui.QColor(76, 163, 224))
+ light_palette.setColor(QtGui.QPalette.HighlightedText, QtGui.QColor(255, 255, 255))
+
+ # Disabled colors
+ light_palette.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.WindowText, QtGui.QColor(120, 120, 120))
+ light_palette.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Text, QtGui.QColor(120, 120, 120))
+ light_palette.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.ButtonText, QtGui.QColor(120, 120, 120))
+
+ self.app.setPalette(light_palette)
+
+ # Clear any custom stylesheet
+ self.app.setStyleSheet("")
+
+ def _apply_system_theme(self):
+ """Apply system default theme"""
+ # Reset to system default palette
+ self.app.setPalette(self.app.style().standardPalette())
+ # Clear any custom stylesheet
+ self.app.setStyleSheet("")
+
+
+# Global theme manager instance
+_theme_manager = None
+
+
+def get_theme_manager():
+ """Get the global theme manager instance"""
+ global _theme_manager
+ if _theme_manager is None:
+ _theme_manager = ThemeManager()
+ return _theme_manager
+
+
+def apply_theme(theme_name):
+ """Convenience function to apply a theme"""
+ get_theme_manager().apply_theme(theme_name)
diff --git a/friture/ui_settings.py b/friture/ui_settings.py
index 73d17032..12a17b08 100644
--- a/friture/ui_settings.py
+++ b/friture/ui_settings.py
@@ -1,120 +1,147 @@
-# -*- coding: utf-8 -*-
-
-# Form implementation generated from reading ui file 'ui/settings.ui'
-#
-# Created by: PyQt5 UI code generator 5.15.10
-#
-# WARNING: Any manual changes made to this file will be lost when pyuic5 is
-# run again. Do not edit this file unless you know what you are doing.
-
-
-from PyQt5 import QtCore, QtGui, QtWidgets
-
-
-class Ui_Settings_Dialog(object):
- def setupUi(self, Settings_Dialog):
- Settings_Dialog.setObjectName("Settings_Dialog")
- Settings_Dialog.resize(459, 369)
- icon = QtGui.QIcon()
- icon.addPixmap(QtGui.QPixmap(":/images-src/tools.svg"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
- Settings_Dialog.setWindowIcon(icon)
- self.verticalLayout_5 = QtWidgets.QVBoxLayout(Settings_Dialog)
- self.verticalLayout_5.setObjectName("verticalLayout_5")
- self.inputGroup = QtWidgets.QGroupBox(Settings_Dialog)
- self.inputGroup.setObjectName("inputGroup")
- self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.inputGroup)
- self.verticalLayout_6.setObjectName("verticalLayout_6")
- self.label_inputType_2 = QtWidgets.QLabel(self.inputGroup)
- self.label_inputType_2.setObjectName("label_inputType_2")
- self.verticalLayout_6.addWidget(self.label_inputType_2)
- self.comboBox_inputDevice = QtWidgets.QComboBox(self.inputGroup)
- self.comboBox_inputDevice.setObjectName("comboBox_inputDevice")
- self.verticalLayout_6.addWidget(self.comboBox_inputDevice)
- self.horizontalLayout = QtWidgets.QHBoxLayout()
- self.horizontalLayout.setObjectName("horizontalLayout")
- self.verticalLayout_3 = QtWidgets.QVBoxLayout()
- self.verticalLayout_3.setObjectName("verticalLayout_3")
- spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
- self.verticalLayout_3.addItem(spacerItem)
- self.label_inputType = QtWidgets.QLabel(self.inputGroup)
- self.label_inputType.setObjectName("label_inputType")
- self.verticalLayout_3.addWidget(self.label_inputType)
- self.radioButton_single = QtWidgets.QRadioButton(self.inputGroup)
- self.radioButton_single.setChecked(True)
- self.radioButton_single.setObjectName("radioButton_single")
- self.inputTypeButtonGroup = QtWidgets.QButtonGroup(Settings_Dialog)
- self.inputTypeButtonGroup.setObjectName("inputTypeButtonGroup")
- self.inputTypeButtonGroup.addButton(self.radioButton_single)
- self.verticalLayout_3.addWidget(self.radioButton_single)
- self.radioButton_duo = QtWidgets.QRadioButton(self.inputGroup)
- self.radioButton_duo.setObjectName("radioButton_duo")
- self.inputTypeButtonGroup.addButton(self.radioButton_duo)
- self.verticalLayout_3.addWidget(self.radioButton_duo)
- spacerItem1 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
- self.verticalLayout_3.addItem(spacerItem1)
- self.horizontalLayout.addLayout(self.verticalLayout_3)
- self.verticalLayout_4 = QtWidgets.QVBoxLayout()
- self.verticalLayout_4.setObjectName("verticalLayout_4")
- self.groupBox_first = QtWidgets.QGroupBox(self.inputGroup)
- self.groupBox_first.setObjectName("groupBox_first")
- self.verticalLayout = QtWidgets.QVBoxLayout(self.groupBox_first)
- self.verticalLayout.setObjectName("verticalLayout")
- self.comboBox_firstChannel = QtWidgets.QComboBox(self.groupBox_first)
- self.comboBox_firstChannel.setObjectName("comboBox_firstChannel")
- self.verticalLayout.addWidget(self.comboBox_firstChannel)
- self.verticalLayout_4.addWidget(self.groupBox_first)
- self.groupBox_second = QtWidgets.QGroupBox(self.inputGroup)
- self.groupBox_second.setEnabled(False)
- self.groupBox_second.setObjectName("groupBox_second")
- self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.groupBox_second)
- self.verticalLayout_2.setObjectName("verticalLayout_2")
- self.comboBox_secondChannel = QtWidgets.QComboBox(self.groupBox_second)
- self.comboBox_secondChannel.setObjectName("comboBox_secondChannel")
- self.verticalLayout_2.addWidget(self.comboBox_secondChannel)
- self.verticalLayout_4.addWidget(self.groupBox_second)
- self.horizontalLayout.addLayout(self.verticalLayout_4)
- self.verticalLayout_6.addLayout(self.horizontalLayout)
- self.verticalLayout_5.addWidget(self.inputGroup)
- self.playbackGroup = QtWidgets.QGroupBox(Settings_Dialog)
- self.playbackGroup.setMinimumSize(QtCore.QSize(0, 0))
- self.playbackGroup.setObjectName("playbackGroup")
- self.formLayout_2 = QtWidgets.QFormLayout(self.playbackGroup)
- self.formLayout_2.setObjectName("formLayout_2")
- self.label_showPlayback = QtWidgets.QLabel(self.playbackGroup)
- self.label_showPlayback.setObjectName("label_showPlayback")
- self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label_showPlayback)
- self.checkbox_showPlayback = QtWidgets.QCheckBox(self.playbackGroup)
- self.checkbox_showPlayback.setText("")
- self.checkbox_showPlayback.setObjectName("checkbox_showPlayback")
- self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.checkbox_showPlayback)
- self.label_historyLength = QtWidgets.QLabel(self.playbackGroup)
- self.label_historyLength.setObjectName("label_historyLength")
- self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.label_historyLength)
- self.spinBox_historyLength = QtWidgets.QSpinBox(self.playbackGroup)
- self.spinBox_historyLength.setMinimum(1)
- self.spinBox_historyLength.setMaximum(600)
- self.spinBox_historyLength.setProperty("value", 30)
- self.spinBox_historyLength.setObjectName("spinBox_historyLength")
- self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.spinBox_historyLength)
- self.verticalLayout_5.addWidget(self.playbackGroup)
- spacerItem2 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
- self.verticalLayout_5.addItem(spacerItem2)
-
- self.retranslateUi(Settings_Dialog)
- QtCore.QMetaObject.connectSlotsByName(Settings_Dialog)
-
- def retranslateUi(self, Settings_Dialog):
- _translate = QtCore.QCoreApplication.translate
- Settings_Dialog.setWindowTitle(_translate("Settings_Dialog", "Settings"))
- self.inputGroup.setTitle(_translate("Settings_Dialog", "Input"))
- self.label_inputType_2.setText(_translate("Settings_Dialog", "Select the input device :"))
- self.label_inputType.setText(_translate("Settings_Dialog", "Select the type of input :"))
- self.radioButton_single.setText(_translate("Settings_Dialog", "Single channel"))
- self.radioButton_duo.setText(_translate("Settings_Dialog", "Two channels"))
- self.groupBox_first.setTitle(_translate("Settings_Dialog", "First channel"))
- self.groupBox_second.setTitle(_translate("Settings_Dialog", "Second channel"))
- self.playbackGroup.setTitle(_translate("Settings_Dialog", "Playback"))
- self.label_showPlayback.setText(_translate("Settings_Dialog", "Show Playback Controls"))
- self.label_historyLength.setText(_translate("Settings_Dialog", "History Length"))
- self.spinBox_historyLength.setSuffix(_translate("Settings_Dialog", " s"))
-from . import friture_rc
+# -*- coding: utf-8 -*-
+
+# Form implementation generated from reading ui file 'ui/settings.ui'
+#
+# Created by: PyQt5 UI code generator 5.15.10
+#
+# WARNING: Any manual changes made to this file will be lost when pyuic5 is
+# run again. Do not edit this file unless you know what you are doing.
+
+
+from PyQt5 import QtCore, QtGui, QtWidgets
+
+
+class Ui_Settings_Dialog(object):
+ def setupUi(self, Settings_Dialog):
+ Settings_Dialog.setObjectName("Settings_Dialog")
+ Settings_Dialog.resize(459, 369)
+ icon = QtGui.QIcon()
+ icon.addPixmap(QtGui.QPixmap(":/images-src/tools.svg"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
+ Settings_Dialog.setWindowIcon(icon)
+ self.verticalLayout_5 = QtWidgets.QVBoxLayout(Settings_Dialog)
+ self.verticalLayout_5.setObjectName("verticalLayout_5")
+ self.inputGroup = QtWidgets.QGroupBox(Settings_Dialog)
+ self.inputGroup.setObjectName("inputGroup")
+ self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.inputGroup)
+ self.verticalLayout_6.setObjectName("verticalLayout_6")
+ self.label_inputType_2 = QtWidgets.QLabel(self.inputGroup)
+ self.label_inputType_2.setObjectName("label_inputType_2")
+ self.verticalLayout_6.addWidget(self.label_inputType_2)
+ self.comboBox_inputDevice = QtWidgets.QComboBox(self.inputGroup)
+ self.comboBox_inputDevice.setObjectName("comboBox_inputDevice")
+ self.verticalLayout_6.addWidget(self.comboBox_inputDevice)
+ self.horizontalLayout = QtWidgets.QHBoxLayout()
+ self.horizontalLayout.setObjectName("horizontalLayout")
+ self.verticalLayout_3 = QtWidgets.QVBoxLayout()
+ self.verticalLayout_3.setObjectName("verticalLayout_3")
+ spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
+ self.verticalLayout_3.addItem(spacerItem)
+ self.label_inputType = QtWidgets.QLabel(self.inputGroup)
+ self.label_inputType.setObjectName("label_inputType")
+ self.verticalLayout_3.addWidget(self.label_inputType)
+ self.radioButton_single = QtWidgets.QRadioButton(self.inputGroup)
+ self.radioButton_single.setChecked(True)
+ self.radioButton_single.setObjectName("radioButton_single")
+ self.inputTypeButtonGroup = QtWidgets.QButtonGroup(Settings_Dialog)
+ self.inputTypeButtonGroup.setObjectName("inputTypeButtonGroup")
+ self.inputTypeButtonGroup.addButton(self.radioButton_single)
+ self.verticalLayout_3.addWidget(self.radioButton_single)
+ self.radioButton_duo = QtWidgets.QRadioButton(self.inputGroup)
+ self.radioButton_duo.setObjectName("radioButton_duo")
+ self.inputTypeButtonGroup.addButton(self.radioButton_duo)
+ self.verticalLayout_3.addWidget(self.radioButton_duo)
+ spacerItem1 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
+ self.verticalLayout_3.addItem(spacerItem1)
+ self.horizontalLayout.addLayout(self.verticalLayout_3)
+ self.verticalLayout_4 = QtWidgets.QVBoxLayout()
+ self.verticalLayout_4.setObjectName("verticalLayout_4")
+ self.groupBox_first = QtWidgets.QGroupBox(self.inputGroup)
+ self.groupBox_first.setObjectName("groupBox_first")
+ self.verticalLayout = QtWidgets.QVBoxLayout(self.groupBox_first)
+ self.verticalLayout.setObjectName("verticalLayout")
+ self.comboBox_firstChannel = QtWidgets.QComboBox(self.groupBox_first)
+ self.comboBox_firstChannel.setObjectName("comboBox_firstChannel")
+ self.verticalLayout.addWidget(self.comboBox_firstChannel)
+ self.verticalLayout_4.addWidget(self.groupBox_first)
+ self.groupBox_second = QtWidgets.QGroupBox(self.inputGroup)
+ self.groupBox_second.setEnabled(False)
+ self.groupBox_second.setObjectName("groupBox_second")
+ self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.groupBox_second)
+ self.verticalLayout_2.setObjectName("verticalLayout_2")
+ self.comboBox_secondChannel = QtWidgets.QComboBox(self.groupBox_second)
+ self.comboBox_secondChannel.setObjectName("comboBox_secondChannel")
+ self.verticalLayout_2.addWidget(self.comboBox_secondChannel)
+ self.verticalLayout_4.addWidget(self.groupBox_second)
+ self.horizontalLayout.addLayout(self.verticalLayout_4)
+ self.verticalLayout_6.addLayout(self.horizontalLayout)
+ self.verticalLayout_5.addWidget(self.inputGroup)
+ self.playbackGroup = QtWidgets.QGroupBox(Settings_Dialog)
+ self.playbackGroup.setMinimumSize(QtCore.QSize(0, 0))
+ self.playbackGroup.setObjectName("playbackGroup")
+ self.formLayout_2 = QtWidgets.QFormLayout(self.playbackGroup)
+ self.formLayout_2.setObjectName("formLayout_2")
+ self.label_showPlayback = QtWidgets.QLabel(self.playbackGroup)
+ self.label_showPlayback.setObjectName("label_showPlayback")
+ self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label_showPlayback)
+ self.checkbox_showPlayback = QtWidgets.QCheckBox(self.playbackGroup)
+ self.checkbox_showPlayback.setText("")
+ self.checkbox_showPlayback.setObjectName("checkbox_showPlayback")
+ self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.checkbox_showPlayback)
+ self.label_historyLength = QtWidgets.QLabel(self.playbackGroup)
+ self.label_historyLength.setObjectName("label_historyLength")
+ self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.label_historyLength)
+ self.spinBox_historyLength = QtWidgets.QSpinBox(self.playbackGroup)
+ self.spinBox_historyLength.setMinimum(1)
+ self.spinBox_historyLength.setMaximum(600)
+ self.spinBox_historyLength.setProperty("value", 30)
+ self.spinBox_historyLength.setObjectName("spinBox_historyLength")
+ self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.spinBox_historyLength)
+ self.verticalLayout_5.addWidget(self.playbackGroup)
+ self.themeGroup = QtWidgets.QGroupBox(Settings_Dialog)
+ self.themeGroup.setObjectName("themeGroup")
+ self.formLayout_3 = QtWidgets.QFormLayout(self.themeGroup)
+ self.formLayout_3.setObjectName("formLayout_3")
+ self.label_theme = QtWidgets.QLabel(self.themeGroup)
+ self.label_theme.setObjectName("label_theme")
+ self.formLayout_3.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label_theme)
+ self.comboBox_theme = QtWidgets.QComboBox(self.themeGroup)
+ self.comboBox_theme.addItem("")
+ self.comboBox_theme.addItem("")
+ self.comboBox_theme.addItem("")
+ self.comboBox_theme.setObjectName("comboBox_theme")
+ self.formLayout_3.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.comboBox_theme)
+ self.label_transparency = QtWidgets.QLabel(self.themeGroup)
+ self.label_transparency.setObjectName("label_transparency")
+ self.formLayout_3.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.label_transparency)
+ self.checkbox_transparency = QtWidgets.QCheckBox(self.themeGroup)
+ self.checkbox_transparency.setText("")
+ self.checkbox_transparency.setObjectName("checkbox_transparency")
+ self.formLayout_3.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.checkbox_transparency)
+ self.verticalLayout_5.addWidget(self.themeGroup)
+ spacerItem2 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
+ self.verticalLayout_5.addItem(spacerItem2)
+
+ self.retranslateUi(Settings_Dialog)
+ QtCore.QMetaObject.connectSlotsByName(Settings_Dialog)
+
+ def retranslateUi(self, Settings_Dialog):
+ _translate = QtCore.QCoreApplication.translate
+ Settings_Dialog.setWindowTitle(_translate("Settings_Dialog", "Settings"))
+ self.inputGroup.setTitle(_translate("Settings_Dialog", "Input"))
+ self.label_inputType_2.setText(_translate("Settings_Dialog", "Select the input device :"))
+ self.label_inputType.setText(_translate("Settings_Dialog", "Select the type of input :"))
+ self.radioButton_single.setText(_translate("Settings_Dialog", "Single channel"))
+ self.radioButton_duo.setText(_translate("Settings_Dialog", "Two channels"))
+ self.groupBox_first.setTitle(_translate("Settings_Dialog", "First channel"))
+ self.groupBox_second.setTitle(_translate("Settings_Dialog", "Second channel"))
+ self.playbackGroup.setTitle(_translate("Settings_Dialog", "Playback"))
+ self.label_showPlayback.setText(_translate("Settings_Dialog", "Show Playback Controls"))
+ self.label_historyLength.setText(_translate("Settings_Dialog", "History Length"))
+ self.spinBox_historyLength.setSuffix(_translate("Settings_Dialog", " s"))
+ self.themeGroup.setTitle(_translate("Settings_Dialog", "Appearance"))
+ self.label_theme.setText(_translate("Settings_Dialog", "Theme"))
+ self.comboBox_theme.setItemText(0, _translate("Settings_Dialog", "System Default"))
+ self.comboBox_theme.setItemText(1, _translate("Settings_Dialog", "Light"))
+ self.comboBox_theme.setItemText(2, _translate("Settings_Dialog", "Dark"))
+ self.label_transparency.setText(_translate("Settings_Dialog", "Transparency"))
+from . import friture_rc
diff --git a/ui/settings.ui b/ui/settings.ui
index a5be73d1..061d809a 100644
--- a/ui/settings.ui
+++ b/ui/settings.ui
@@ -1,212 +1,247 @@
-
-
- Settings_Dialog
-
-
-
- 0
- 0
- 459
- 369
-
-
-
- Settings
-
-
-
- :/images-src/tools.svg:/images-src/tools.svg
-
-
- -
-
-
- Input
-
-
-
-
-
-
- Select the input device :
-
-
-
- -
-
-
- -
-
-
-
-
-
-
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
- -
-
-
- Select the type of input :
-
-
-
- -
-
-
- Single channel
-
-
- true
-
-
- inputTypeButtonGroup
-
-
-
- -
-
-
- Two channels
-
-
- inputTypeButtonGroup
-
-
-
- -
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
-
-
- -
-
-
-
-
-
- First channel
-
-
-
-
-
-
-
-
-
- -
-
-
- false
-
-
- Second channel
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Playback
-
-
-
-
-
-
- Show Playback Controls
-
-
-
- -
-
-
-
-
-
-
- -
-
-
- History Length
-
-
-
- -
-
-
- s
-
-
- 1
-
-
- 600
-
-
- 30
-
-
-
-
-
-
- -
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
-
-
-
-
-
-
-
- onHistoryLengthChanged(int)
-
-
-
-
-
+
+
+ Settings_Dialog
+
+
+
+ 0
+ 0
+ 459
+ 369
+
+
+
+ Settings
+
+
+
+ :/images-src/tools.svg:/images-src/tools.svg
+
+
+ -
+
+
+ Input
+
+
+
-
+
+
+ Select the input device :
+
+
+
+ -
+
+
+ -
+
+
-
+
+
-
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+ -
+
+
+ Select the type of input :
+
+
+
+ -
+
+
+ Single channel
+
+
+ true
+
+
+ inputTypeButtonGroup
+
+
+
+ -
+
+
+ Two channels
+
+
+ inputTypeButtonGroup
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+ -
+
+
-
+
+
+ First channel
+
+
+
-
+
+
+
+
+
+ -
+
+
+ false
+
+
+ Second channel
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Playback
+
+
+
-
+
+
+ Show Playback Controls
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+ History Length
+
+
+
+ -
+
+
+ s
+
+
+ 1
+
+
+ 600
+
+
+ 30
+
+
+
+
+
+
+ -
+
+
+ Appearance
+
+
+
-
+
+
+ Theme
+
+
+
+ -
+
+
-
+
+ System Default
+
+
+ -
+
+ Light
+
+
+ -
+
+ Dark
+
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+
+
+
+ onHistoryLengthChanged(int)
+
+
+
+
+
diff --git a/validate_theme.py b/validate_theme.py
new file mode 100644
index 00000000..1d195534
--- /dev/null
+++ b/validate_theme.py
@@ -0,0 +1,174 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+Validation script for theme implementation without GUI initialization
+"""
+
+import sys
+import os
+
+# Add the friture directory to Python path
+sys.path.insert(0, '/workspaces/friture')
+
+def test_theme_module():
+ """Test if theme module can be imported and has correct structure"""
+ try:
+ import friture.theme as theme_module
+ print("✓ Theme module imported successfully")
+
+ # Check if required classes and functions exist
+ assert hasattr(theme_module, 'ThemeManager'), "ThemeManager class missing"
+ assert hasattr(theme_module, 'apply_theme'), "apply_theme function missing"
+ assert hasattr(theme_module, 'get_theme_manager'), "get_theme_manager function missing"
+ print("✓ Theme module has all required components")
+
+ # Check ThemeManager methods
+ manager_methods = ['apply_theme', '_apply_dark_theme', '_apply_light_theme', '_apply_system_theme']
+ for method in manager_methods:
+ assert hasattr(theme_module.ThemeManager, method), f"ThemeManager.{method} method missing"
+ print("✓ ThemeManager has all required methods")
+
+ return True
+ except Exception as e:
+ print(f"✗ Theme module test failed: {e}")
+ return False
+
+def test_settings_changes():
+ """Test if settings module has theme-related changes"""
+ try:
+ # Read the settings file to check for our additions
+ with open('/workspaces/friture/friture/settings.py', 'r') as f:
+ settings_content = f.read()
+
+ required_strings = [
+ 'theme_changed = pyqtSignal(str)',
+ 'self.comboBox_theme.currentTextChanged.connect(self.theme_changed)',
+ 'settings.setValue("theme"',
+ 'theme_text = settings.value("theme"'
+ ]
+
+ for required in required_strings:
+ if required not in settings_content:
+ print(f"✗ Missing in settings.py: {required}")
+ return False
+
+ print("✓ Settings module has all theme-related changes")
+ return True
+ except Exception as e:
+ print(f"✗ Settings module test failed: {e}")
+ return False
+
+def test_ui_settings_changes():
+ """Test if ui_settings module has theme UI elements"""
+ try:
+ with open('/workspaces/friture/friture/ui_settings.py', 'r') as f:
+ ui_content = f.read()
+
+ required_elements = [
+ 'self.themeGroup = QtWidgets.QGroupBox',
+ 'self.label_theme = QtWidgets.QLabel',
+ 'self.comboBox_theme = QtWidgets.QComboBox',
+ 'self.themeGroup.setTitle(_translate("Settings_Dialog", "Appearance"))',
+ '"System Default"', '"Light"', '"Dark"'
+ ]
+
+ for required in required_elements:
+ if required not in ui_content:
+ print(f"✗ Missing in ui_settings.py: {required}")
+ return False
+
+ print("✓ UI Settings module has all theme UI elements")
+ return True
+ except Exception as e:
+ print(f"✗ UI Settings module test failed: {e}")
+ return False
+
+def test_analyzer_changes():
+ """Test if analyzer module has theme integration"""
+ try:
+ with open('/workspaces/friture/friture/analyzer.py', 'r') as f:
+ analyzer_content = f.read()
+
+ required_changes = [
+ 'from friture.theme import apply_theme',
+ 'self.settings_dialog.theme_changed.connect(self.theme_changed)',
+ 'def theme_changed(self, theme_name: str) -> None:',
+ 'apply_theme(theme_name)'
+ ]
+
+ for required in required_changes:
+ if required not in analyzer_content:
+ print(f"✗ Missing in analyzer.py: {required}")
+ return False
+
+ print("✓ Analyzer module has all theme integration changes")
+ return True
+ except Exception as e:
+ print(f"✗ Analyzer module test failed: {e}")
+ return False
+
+def test_ui_file_changes():
+ """Test if settings.ui has theme elements"""
+ try:
+ with open('/workspaces/friture/ui/settings.ui', 'r') as f:
+ ui_content = f.read()
+
+ required_ui_elements = [
+ 'name="themeGroup"',
+ 'Appearance',
+ 'name="comboBox_theme"',
+ 'System Default',
+ 'Light',
+ 'Dark'
+ ]
+
+ for required in required_ui_elements:
+ if required not in ui_content:
+ print(f"✗ Missing in settings.ui: {required}")
+ return False
+
+ print("✓ Settings UI file has all theme elements")
+ return True
+ except Exception as e:
+ print(f"✗ Settings UI file test failed: {e}")
+ return False
+
+def main():
+ """Run all validation tests"""
+ print("=== Friture Theme Implementation Validation ===\n")
+
+ tests = [
+ ("Theme Module", test_theme_module),
+ ("Settings Module Changes", test_settings_changes),
+ ("UI Settings Module Changes", test_ui_settings_changes),
+ ("Analyzer Module Changes", test_analyzer_changes),
+ ("UI File Changes", test_ui_file_changes)
+ ]
+
+ passed = 0
+ total = len(tests)
+
+ for test_name, test_func in tests:
+ print(f"Running {test_name} test...")
+ if test_func():
+ passed += 1
+ print()
+ else:
+ print()
+
+ print(f"=== Results: {passed}/{total} tests passed ===")
+
+ if passed == total:
+ print("🎉 All tests passed! Theme implementation is complete and ready to use.")
+ print("\nTo use the theme feature:")
+ print("1. Run Friture normally")
+ print("2. Open Settings from the toolbar")
+ print("3. Look for the 'Appearance' section")
+ print("4. Select your preferred theme from the dropdown")
+ print("5. The theme will apply immediately!")
+ else:
+ print(f"⚠️ {total - passed} tests failed. Please check the implementation.")
+
+if __name__ == '__main__':
+ main()