diff --git a/.github/workflows/gui.yml b/.github/workflows/gui.yml
new file mode 100644
index 00000000..6f25760d
--- /dev/null
+++ b/.github/workflows/gui.yml
@@ -0,0 +1,70 @@
+name: GUI
+
+permissions:
+ contents: read
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ gui-ubuntu:
+ name: "GUI Ubuntu"
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+
+ - name: Dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get -q install -y \
+ libceres-dev \
+ nlohmann-json3-dev \
+ libopencv-dev \
+ openimageio-tools libopenimageio-dev \
+ exiftool \
+ liblensfun-dev \
+ liblensfun-data-v1 \
+ libglib2.0-dev \
+ qt6-base-dev \
+ qt6-base-dev-tools
+
+ - name: Configure
+ run: >
+ cmake -S . -B build
+ -D CMAKE_BUILD_TYPE=Release
+ -D RTA_BUILD_GUI=ON
+ -D RTA_BUILD_TESTS=ON
+ -D RTA_BUILD_PYTHON_BINDINGS=OFF
+
+ - name: Build
+ run: cmake --build build --config Release
+
+ - name: Binary smoke
+ run: test -x build/src/rawtoaces_gui/rawtoaces_gui
+
+ gui-macos:
+ name: "GUI macOS"
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+
+ - name: Dependencies
+ run: |
+ brew install cmake ceres-solver nlohmann-json openimageio nanobind robin-map qt@6 exiftool lensfun
+ python3 -m pip install --break-system-packages pytest || python3 -m pip install pytest
+
+ - name: Configure
+ run: |
+ QT_PREFIX="$(brew --prefix qt@6)"
+ cmake -S . -B build \
+ -D CMAKE_BUILD_TYPE=Release \
+ -D RTA_BUILD_GUI=ON \
+ -D RTA_BUILD_TESTS=ON \
+ -D CMAKE_PREFIX_PATH="$QT_PREFIX"
+
+ - name: Build
+ run: cmake --build build --config Release
+
+ - name: Binary smoke
+ run: test -x "build/src/rawtoaces_gui/Raw to ACES.app/Contents/MacOS/Raw to ACES"
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 30274675..4ce23a92 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,4 +1,4 @@
-cmake_minimum_required(VERSION 3.12)
+cmake_minimum_required(VERSION 3.16)
project( RAWTOACES )
if( NOT DEFINED CMAKE_CXX_STANDARD )
@@ -78,6 +78,7 @@ option( RTA_BUILD_TESTS "Build unit tests" ON )
option( RTA_INSTALL_DATABASE "Download and install the spectral measurements database" ON )
option( ENABLE_COVERAGE "Enable code coverage reporting" OFF )
option( RTA_ENABLE_LENSFUN "Use lensfun for lens correction" ON )
+option( RTA_BUILD_GUI "Build Qt 6 GUI application (rawtoaces_gui)" OFF )
set ( RTA_SANITISER_MODE "none" CACHE STRING "Dynamic analysis sanitiser mode ('none', 'address', 'memory', or 'thread')" )
if ( ENABLE_SHARED )
@@ -102,12 +103,19 @@ add_subdirectory("src/${RAWTOACES_CORE_LIB}")
add_subdirectory("src/${RAWTOACES_UTIL_LIB}")
add_subdirectory("src/rawtoaces")
+if ( RTA_BUILD_TESTS )
+ enable_testing()
+endif()
+
+if ( RTA_BUILD_GUI )
+ add_subdirectory("src/rawtoaces_gui")
+endif()
+
if ( RTA_BUILD_PYTHON_BINDINGS )
add_subdirectory(src/bindings)
endif ( RTA_BUILD_PYTHON_BINDINGS )
if ( RTA_BUILD_TESTS )
- enable_testing()
add_subdirectory(tests)
endif ( RTA_BUILD_TESTS )
diff --git a/README.md b/README.md
index b1d2838d..e525d814 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
[](https://github.com/AcademySoftwareFoundation/rawtoaces/actions/workflows/ci.yml)
+[](https://github.com/AcademySoftwareFoundation/rawtoaces/actions/workflows/gui.yml)
[](https://github.com/AcademySoftwareFoundation/rawtoaces/actions/workflows/github-code-scanning/codeql)
[](https://www.bestpractices.dev/projects/11185)
[](https://codecov.io/gh/AcademySoftwareFoundation/rawtoaces)
@@ -35,16 +36,18 @@ The source code contains the following:
* [`config/`](./config) - CMake configuration files
* [`docs/`](./docs) - Credits and changes information
* [`include/`](./include) - Public header files for the `rawtoaces` library
-* [`src/`](./src) - Source code for the core library, utility library, and the command line tool
+* [`src/`](./src) - Source code for the core library, utility library, command line tool, and optional Qt GUI (`rawtoaces_gui`)
* [`unittest/`](./unittest) - Unit tests for `rawtoaces`
+
## Prerequisites
To build `rawtoaces` you would need to satisfy these dependencies:
| Library | Min Version| Purpose | Link to installation instruction |
| ------- | -----------| -------- | -------------------------------- |
-| `cmake` | `3.12` | | [CMake download](https://cmake.org/download/)|
+| `cmake` | `3.16` | | [CMake download](https://cmake.org/download/)|
+| Qt 6 (optional) | `6.4` | Widgets module for `rawtoaces_gui` | [Qt documentation](https://doc.qt.io/qt-6/) |
| `ceres` | `1.12.0` | Ceres Solver is an open source library for solving Non-linear Least Squares problems with bounds constraints and unconstrained optimization problems. It processes non-linear regression for rawtoaces. | [Ceres Solver installation](http://ceres-solver.org/installation.html)|
| `OpenImageIO` | `3.0` | OpenImageIO is an open source library providing vast functionality for image processing. rawtoaces relies on OpenImageIO for reading raw files, saving AcesContainer files, and also all pixel operations. | [OpenImageIO installation](https://github.com/AcademySoftwareFoundation/OpenImageIO/blob/main/INSTALL.md) |
| `nlohmann-json` | `3.6` | nlohmann-json is a simple header-only library for parsing JSON files. | [nlohmann-json integration](https://github.com/nlohmann/json#integration) |
@@ -116,6 +119,17 @@ $ sudo cmake --install build # Optional if you want it to be accessible system w
The default process will install `librawtoaces_core_${rawtoaces_version}.dylib` and `librawtoaces_util_${rawtoaces_version}.dylib` to `/usr/local/lib`, a few header files to `/usr/local/include/rawtoaces` and a number of data files into `/usr/local/include/rawtoaces/data`.
+#### Qt GUI (optional)
+
+Build the desktop application `rawtoaces_gui` with `-DRTA_BUILD_GUI=ON` (requires Qt 6 Widgets). On macOS with Homebrew Qt, point CMake at the prefix, for example:
+
+```sh
+$ cmake -S . -B build -DRTA_BUILD_GUI=ON -DCMAKE_PREFIX_PATH="$(brew --prefix qt@6)"
+$ cmake --build build
+```
+
+After building, start the GUI from `build/src/rawtoaces_gui/`: use the **`rawtoaces_gui`** program on Windows and Linux, or **`Raw to ACES.app`** on macOS (CMake target remains **`rawtoaces_gui`**). The name shown in the window title, menu bar, and About dialog is **Raw to ACES**.
+
#### Docker
Assuming you have [Docker](https://www.docker.com/) installed, installing and
diff --git a/build_scripts/install_deps_ubuntu.bash b/build_scripts/install_deps_ubuntu.bash
index 3c44c414..fa5c4b6a 100644
--- a/build_scripts/install_deps_ubuntu.bash
+++ b/build_scripts/install_deps_ubuntu.bash
@@ -11,7 +11,8 @@ time sudo apt-get -q -f install -y \
openimageio-tools libopenimageio-dev \
exiftool \
liblensfun-dev \
- liblensfun-data-v1
+ liblensfun-data-v1 \
+ libglib2.0-dev
# Nanobind in apt is still v1.9, we need at least v2.2.
pip3 install pytest nanobind
diff --git a/src/bindings/CMakeLists.txt b/src/bindings/CMakeLists.txt
index 6aa8eaff..587bdda6 100644
--- a/src/bindings/CMakeLists.txt
+++ b/src/bindings/CMakeLists.txt
@@ -1,4 +1,4 @@
-cmake_minimum_required ( VERSION 3.12 )
+cmake_minimum_required ( VERSION 3.16 )
include_directories ( "${CMAKE_CURRENT_SOURCE_DIR}" )
nanobind_add_module( rawtoaces_bindings
diff --git a/src/rawtoaces/CMakeLists.txt b/src/rawtoaces/CMakeLists.txt
index ec7116c2..bb8ddb70 100644
--- a/src/rawtoaces/CMakeLists.txt
+++ b/src/rawtoaces/CMakeLists.txt
@@ -1,4 +1,4 @@
-cmake_minimum_required(VERSION 3.12)
+cmake_minimum_required(VERSION 3.16)
# Include coverage support if enabled
if( ENABLE_COVERAGE )
diff --git a/src/rawtoaces_core/CMakeLists.txt b/src/rawtoaces_core/CMakeLists.txt
index 3cdac713..8818e1ab 100644
--- a/src/rawtoaces_core/CMakeLists.txt
+++ b/src/rawtoaces_core/CMakeLists.txt
@@ -1,4 +1,4 @@
-cmake_minimum_required(VERSION 3.12)
+cmake_minimum_required(VERSION 3.16)
include_directories( "${CMAKE_CURRENT_SOURCE_DIR}" )
# Include coverage support if enabled
diff --git a/src/rawtoaces_gui/CMakeLists.txt b/src/rawtoaces_gui/CMakeLists.txt
new file mode 100644
index 00000000..8c8dd382
--- /dev/null
+++ b/src/rawtoaces_gui/CMakeLists.txt
@@ -0,0 +1,70 @@
+cmake_minimum_required(VERSION 3.16)
+
+find_package(Qt6 6.4 REQUIRED COMPONENTS Widgets)
+
+set(CMAKE_AUTOMOC ON)
+
+qt_add_library(rawtoaces_gui_lib STATIC
+ main_window.cpp
+ conversion_thread.cpp
+)
+
+target_link_libraries(rawtoaces_gui_lib PUBLIC
+ ${RAWTOACES_UTIL_LIB}
+ Qt6::Widgets
+)
+
+target_include_directories(rawtoaces_gui_lib PUBLIC
+ "${CMAKE_CURRENT_SOURCE_DIR}"
+)
+target_include_directories(rawtoaces_gui_lib PRIVATE
+ $<$:${CMAKE_SOURCE_DIR}/src/rawtoaces_util>
+)
+
+target_compile_definitions(rawtoaces_gui_lib PUBLIC
+ $<$:RTA_GUI_HAS_LENSFUN=1>
+)
+
+qt_add_executable(rawtoaces_gui
+ main.cpp
+)
+
+target_link_libraries(rawtoaces_gui PRIVATE rawtoaces_gui_lib)
+
+if(WIN32)
+ set_target_properties(rawtoaces_gui PROPERTIES WIN32_EXECUTABLE TRUE)
+endif()
+
+# macOS: build a real .app so Finder/Dock get bundle metadata; menu bar title
+# follows setApplicationDisplayName in main.cpp (e.g. "Raw to ACES").
+if(APPLE)
+ set(RTA_GUI_MACOS_ICON "${CMAKE_CURRENT_SOURCE_DIR}/macos/rawtoaces_gui.icns")
+ set_target_properties(rawtoaces_gui PROPERTIES
+ MACOSX_BUNDLE TRUE
+ # Bundle folder and CFBundleExecutable (Linux/Windows keep target name: rawtoaces_gui).
+ OUTPUT_NAME "Raw to ACES"
+ MACOSX_BUNDLE_BUNDLE_NAME "Raw to ACES"
+ MACOSX_BUNDLE_GUI_IDENTIFIER "org.aswf.rawtoaces.gui"
+ MACOSX_BUNDLE_INFO_SHORT_VERSION_STRING "${RAWTOACES_VERSION}"
+ MACOSX_BUNDLE_BUNDLE_VERSION "${RAWTOACES_VERSION}"
+ )
+ if(EXISTS "${RTA_GUI_MACOS_ICON}")
+ target_sources(rawtoaces_gui PRIVATE "${RTA_GUI_MACOS_ICON}")
+ set_source_files_properties("${RTA_GUI_MACOS_ICON}" PROPERTIES
+ MACOSX_PACKAGE_LOCATION Resources
+ )
+ # Base name (no extension) must match Resources/*.icns; independent of OUTPUT_NAME.
+ set_target_properties(rawtoaces_gui PROPERTIES
+ MACOSX_BUNDLE_ICON_FILE "rawtoaces_gui"
+ )
+ endif()
+endif()
+
+if(APPLE)
+ install(TARGETS rawtoaces_gui
+ BUNDLE DESTINATION ${INSTALL_BIN_DIR}
+ RUNTIME DESTINATION ${INSTALL_BIN_DIR}
+ )
+else()
+ install(TARGETS rawtoaces_gui DESTINATION ${INSTALL_BIN_DIR})
+endif()
diff --git a/src/rawtoaces_gui/conversion_thread.cpp b/src/rawtoaces_gui/conversion_thread.cpp
new file mode 100644
index 00000000..4dfcac38
--- /dev/null
+++ b/src/rawtoaces_gui/conversion_thread.cpp
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Contributors to the rawtoaces Project.
+
+#include "conversion_thread.h"
+
+ConversionThread::ConversionThread( QObject *parent ) : QThread( parent )
+{}
+
+void ConversionThread::setJob(
+ rta::util::ImageConverter::Settings settings, QStringList paths )
+{
+ m_settings = std::move( settings );
+ m_paths = std::move( paths );
+ m_cancel.store( false );
+}
+
+void ConversionThread::requestCancel()
+{
+ m_cancel.store( true );
+}
+
+void ConversionThread::run()
+{
+ const int total = static_cast( m_paths.size() );
+ if ( total <= 0 )
+ {
+ emit progress( 0, 0 );
+ emit batchFinished();
+ return;
+ }
+ for ( int i = 0; i < total; ++i )
+ {
+ if ( m_cancel.load() )
+ {
+ emit batchFinished();
+ return;
+ }
+
+ const QString path = m_paths.at( i );
+ emit fileStarted( i, path );
+
+ rta::util::ImageConverter converter;
+ converter.settings = m_settings;
+ const bool ok = converter.process_image( path.toStdString() );
+ emit fileFinished(
+ i, ok, QString::fromStdString( converter.last_error_message ) );
+ emit progress( i + 1, total );
+ }
+ emit batchFinished();
+}
diff --git a/src/rawtoaces_gui/conversion_thread.h b/src/rawtoaces_gui/conversion_thread.h
new file mode 100644
index 00000000..c89c54d5
--- /dev/null
+++ b/src/rawtoaces_gui/conversion_thread.h
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Contributors to the rawtoaces Project.
+
+#pragma once
+
+#include
+
+#include
+#include
+
+#include
+
+class ConversionThread final : public QThread
+{
+ Q_OBJECT
+
+public:
+ explicit ConversionThread( QObject *parent = nullptr );
+
+ void
+ setJob( rta::util::ImageConverter::Settings settings, QStringList paths );
+
+ void requestCancel();
+
+signals:
+ void fileStarted( int index, QString path );
+ void fileFinished( int index, bool ok, QString message );
+ void progress( int done, int total );
+ void batchFinished();
+
+protected:
+ void run() override;
+
+private:
+ rta::util::ImageConverter::Settings m_settings{};
+ QStringList m_paths;
+ std::atomic_bool m_cancel{ false };
+};
diff --git a/src/rawtoaces_gui/macos/rawtoaces-icon-1024.png b/src/rawtoaces_gui/macos/rawtoaces-icon-1024.png
new file mode 100644
index 00000000..2a5c3ad3
Binary files /dev/null and b/src/rawtoaces_gui/macos/rawtoaces-icon-1024.png differ
diff --git a/src/rawtoaces_gui/macos/rawtoaces-icon-source.png b/src/rawtoaces_gui/macos/rawtoaces-icon-source.png
new file mode 100644
index 00000000..8c5afb0a
Binary files /dev/null and b/src/rawtoaces_gui/macos/rawtoaces-icon-source.png differ
diff --git a/src/rawtoaces_gui/macos/rawtoaces_gui.icns b/src/rawtoaces_gui/macos/rawtoaces_gui.icns
new file mode 100644
index 00000000..a45099f1
Binary files /dev/null and b/src/rawtoaces_gui/macos/rawtoaces_gui.icns differ
diff --git a/src/rawtoaces_gui/main.cpp b/src/rawtoaces_gui/main.cpp
new file mode 100644
index 00000000..5a78be3d
--- /dev/null
+++ b/src/rawtoaces_gui/main.cpp
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Contributors to the rawtoaces Project.
+
+#include "main_window.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+int main( int argc, char *argv[] )
+{
+#if defined( _WIN32 )
+ _putenv( const_cast( "TZ=UTC" ) );
+#else
+ setenv( "TZ", "UTC", 1 );
+#endif
+
+ QApplication application( argc, argv );
+ QApplication::setApplicationName( QStringLiteral( "rawtoaces" ) );
+ // macOS menu bar uses the display name (not the .app / executable basename).
+ QApplication::setApplicationDisplayName( QStringLiteral( "Raw to ACES" ) );
+ QApplication::setOrganizationName( QStringLiteral( "rawtoaces" ) );
+
+#if defined( Q_OS_MACOS )
+ // Qt paints the Dock tile from this pixmap (no system squircle); the .icns carries
+ // transparent corners (squircle)
+ const QString icnsPath = QDir{ QCoreApplication::applicationDirPath() }.filePath(
+ QStringLiteral( "../Resources/rawtoaces_gui.icns" ) );
+ if ( QFile::exists( icnsPath ) )
+ {
+ QApplication::setWindowIcon( QIcon( icnsPath ) );
+ }
+#endif
+
+ MainWindow mainWindow;
+ mainWindow.show();
+ return application.exec();
+}
diff --git a/src/rawtoaces_gui/main_window.cpp b/src/rawtoaces_gui/main_window.cpp
new file mode 100644
index 00000000..c0e1b32c
--- /dev/null
+++ b/src/rawtoaces_gui/main_window.cpp
@@ -0,0 +1,2576 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Contributors to the rawtoaces Project.
+
+#include "main_window.h"
+#include "conversion_thread.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#ifdef RTA_GUI_HAS_LENSFUN
+# include "lens_correction.h"
+#endif
+
+#include
+#include
+
+namespace
+{
+using S = rta::util::ImageConverter::Settings;
+
+/// `QFormLayout` often leaves a bare `QCheckBox` top-aligned in a tall label row
+/// (style-dependent). Hosting it in an expanding row with `AlignVCenter`
+/// matches the label column’s vertical center without per-platform pixel tweaks.
+QWidget *wrapCheckBoxForFormRow( QCheckBox *checkBox )
+{
+ if ( checkBox == nullptr )
+ {
+ return nullptr;
+ }
+ auto *host = new QWidget;
+ auto *lay = new QHBoxLayout( host );
+ lay->setContentsMargins( 0, 0, 6, 0 );
+ lay->setSpacing( 0 );
+ lay->setAlignment( Qt::AlignVCenter );
+ lay->addWidget( checkBox, 0, Qt::AlignVCenter );
+ lay->addStretch( 1 );
+ host->setSizePolicy(
+ QSizePolicy::Expanding, QSizePolicy::MinimumExpanding );
+ return host;
+}
+
+/// Fixed width for numeric fields so similar controls align across the window.
+constexpr int kStdNumericFieldWidth = 112;
+
+/// Inset from the scroll viewport edges on settings tabs (Raw, Colour, Lens,
+/// Output). Vertical gaps between sections do **not** use `QVBoxLayout::spacing`
+/// because each `QGroupBox` carries style-dependent chrome; instead each
+/// **logical** block (including a vertical stack of group boxes) sits in
+/// `wrapSettingsSectionTail` with a fixed bottom margin (`kSettingsSectionTailGap`).
+constexpr int kSettingsTabPageMargin = 6;
+constexpr int kSettingsSectionTailGap = 6;
+
+/// Vertical gap between collapsible blocks on the settings tabs.
+constexpr int kCollapsibleTabSectionSpacing = 12;
+
+/// Space above each nested `QGroupBox` inside the LibRaw collapsible (between groups).
+constexpr int kNestedFieldGroupPadAboveTitle = 15;
+
+/// Inset around the main tab widget and the bottom Convert / progress row.
+constexpr int kCentralChromeMargin = 10;
+
+void applySettingsTabPageMarginsOnly( QVBoxLayout *outerColumn )
+{
+ if ( outerColumn == nullptr )
+ {
+ return;
+ }
+ outerColumn->setContentsMargins(
+ kSettingsTabPageMargin,
+ kSettingsTabPageMargin,
+ kSettingsTabPageMargin,
+ kSettingsTabPageMargin );
+}
+
+void applySettingsTabPageChrome( QVBoxLayout *outerColumn )
+{
+ applySettingsTabPageMarginsOnly( outerColumn );
+ outerColumn->setSpacing( 0 );
+}
+
+/// Uniform space **below** a settings “section” (one full-width group or a
+/// stacked block of groups). Style engine margins on `QGroupBox` differ;
+/// this wrapper is the single place that defines rhythm.
+/// `contentStretchInTail` > 0 lets the content fill extra height inside the
+/// wrapper (e.g. Input files on the Inputs tab).
+QWidget *wrapSettingsSectionTail( QWidget *content, int contentStretchInTail = 0 )
+{
+ if ( content == nullptr )
+ {
+ return nullptr;
+ }
+ auto *wrap = new QWidget;
+ auto *lay = new QVBoxLayout( wrap );
+ lay->setContentsMargins( 0, 0, 0, kSettingsSectionTailGap );
+ lay->setSpacing( 0 );
+ lay->addWidget( content, contentStretchInTail );
+ return wrap;
+}
+
+QWidget *wrapScroll( QWidget *inner )
+{
+ auto *scroll = new QScrollArea;
+ scroll->setWidgetResizable( true );
+ scroll->setFrameShape( QFrame::NoFrame );
+ scroll->setWidget( inner );
+ return scroll;
+}
+
+/// Qt Widgets has no collapsible `QGroupBox`; a checkable `QToolButton` header
+/// with a disclosure arrow is the usual pattern. `body` holds the form fields
+/// (no inner `QGroupBox` frame — the header is the section chrome).
+QWidget *wrapCollapsibleSection(
+ QWidget *body, const QString &title, bool expandedByDefault = true )
+{
+ if ( body == nullptr )
+ {
+ return nullptr;
+ }
+
+ auto *section = new QWidget;
+ auto *vlay = new QVBoxLayout( section );
+ vlay->setContentsMargins( 0, 0, 0, 0 );
+ vlay->setSpacing( 4 );
+
+ auto *toggle = new QToolButton;
+ toggle->setText( title );
+ toggle->setCheckable( true );
+ toggle->setChecked( expandedByDefault );
+ toggle->setToolButtonStyle( Qt::ToolButtonTextBesideIcon );
+ toggle->setArrowType(
+ expandedByDefault ? Qt::DownArrow : Qt::RightArrow );
+ body->setVisible( expandedByDefault );
+ toggle->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Fixed );
+ toggle->setAutoRaise( true );
+ toggle->setFocusPolicy( Qt::StrongFocus );
+ {
+ QFont headerFont = toggle->font();
+ headerFont.setBold( true );
+ toggle->setFont( headerFont );
+ }
+ toggle->setAccessibleName( title );
+
+ QObject::connect(
+ toggle,
+ &QToolButton::toggled,
+ section,
+ [toggle, body]( bool expanded ) {
+ body->setVisible( expanded );
+ toggle->setArrowType( expanded ? Qt::DownArrow : Qt::RightArrow );
+ } );
+
+ vlay->addWidget( toggle );
+ vlay->addWidget( body );
+ return section;
+}
+
+/// Titled `QGroupBox` nested inside another section (e.g. LibRaw). Only the
+/// **outer** top margin separates stacked groups; native title/body spacing is
+/// left to the platform style.
+QWidget *wrapFieldGroup( QWidget *body, const QString &title )
+{
+ if ( body == nullptr )
+ {
+ return nullptr;
+ }
+ auto *group = new QGroupBox( title );
+ auto *lay = new QVBoxLayout( group );
+ lay->setSpacing( 6 );
+ lay->addWidget( body );
+ group->setSizePolicy( QSizePolicy::Preferred, QSizePolicy::Preferred );
+
+ auto *wrap = new QWidget;
+ auto *outerLay = new QVBoxLayout( wrap );
+ outerLay->setContentsMargins( 0, kNestedFieldGroupPadAboveTitle, 0, 0 );
+ outerLay->setSpacing( 0 );
+ outerLay->addWidget( group );
+ return wrap;
+}
+
+/// Fixed pixel width for compact numeric fields. Uses setFixedWidth (not only
+/// setMaximumWidth) so e.g. QDoubleSpinBox widgets match despite different decimals.
+void setFieldMaxWidth( QWidget *widget, int widthPixels )
+{
+ if ( widget == nullptr || widthPixels <= 0 )
+ {
+ return;
+ }
+ widget->setFixedWidth( widthPixels );
+ widget->setSizePolicy( QSizePolicy::Fixed, QSizePolicy::Fixed );
+}
+
+/// macOS (`QMacStyle`) and Fusion differ on default `QFormLayout` label alignment.
+/// Use a single policy: leading form, **right-aligned** labels, **vertically
+/// centered** next to fields. Standalone checkboxes use `wrapCheckBoxForFormRow`.
+void polishFormLayout( QFormLayout *form )
+{
+ if ( form == nullptr )
+ {
+ return;
+ }
+ form->setFormAlignment( Qt::AlignLeading | Qt::AlignTop );
+ form->setLabelAlignment( Qt::AlignRight | Qt::AlignVCenter );
+}
+
+/// Sidebar button column for list + buttons rows (Inputs, Spectral data).
+QVBoxLayout *mountTightButtonStack( QWidget *host )
+{
+ auto *lay = new QVBoxLayout( host );
+ // Keep this 6 at the bottom to push out lower edge, otherwise on mac last button is cut off.
+ lay->setContentsMargins( 0, 0, 0, 6 );
+ lay->setSpacing( 6 );
+ return lay;
+}
+
+/// Rows whose field is a nested `QFormLayout` need the *outer* label top-aligned
+/// so it lines up with the first inner row (baseline / default center looks wrong).
+void alignFormLabelTopForField( QFormLayout *form, QWidget *field )
+{
+ if ( form == nullptr || field == nullptr )
+ {
+ return;
+ }
+ for ( int r = 0; r < form->rowCount(); ++r )
+ {
+ QLayoutItem *const fieldItem =
+ form->itemAt( r, QFormLayout::FieldRole );
+ if ( fieldItem == nullptr || fieldItem->widget() != field )
+ {
+ continue;
+ }
+ QLayoutItem *const labelItem =
+ form->itemAt( r, QFormLayout::LabelRole );
+ if ( labelItem != nullptr )
+ {
+ labelItem->setAlignment( Qt::AlignRight | Qt::AlignTop );
+ }
+ break;
+ }
+}
+
+/// Pair with `alignFormLabelTopForField` for tall / stacked fields so the field
+/// column stays top-aligned with the outer label.
+void alignFormFieldTopForField( QFormLayout *form, QWidget *field )
+{
+ if ( form == nullptr || field == nullptr )
+ {
+ return;
+ }
+ for ( int r = 0; r < form->rowCount(); ++r )
+ {
+ QLayoutItem *const fieldItem =
+ form->itemAt( r, QFormLayout::FieldRole );
+ if ( fieldItem == nullptr || fieldItem->widget() != field )
+ {
+ continue;
+ }
+ fieldItem->setAlignment( Qt::AlignLeft | Qt::AlignTop );
+ break;
+ }
+}
+
+/// macOS often stretches a `QFormLayout` that is the direct layout of a wide
+/// Form in a plain host with narrow field column (Maximum width), anchored
+/// top-leading — same layout pattern as former `QGroupBox` forms without frame.
+void mountFormInWidget( QWidget *host, QFormLayout **outForm )
+{
+ if ( host == nullptr || outForm == nullptr )
+ {
+ return;
+ }
+ auto *outer = new QVBoxLayout( host );
+ outer->setContentsMargins( 0, 0, 0, 0 );
+ auto *inner = new QWidget;
+ *outForm = new QFormLayout( inner );
+ polishFormLayout( *outForm );
+ inner->setSizePolicy( QSizePolicy::Maximum, QSizePolicy::Preferred );
+ outer->addWidget( inner, 0, Qt::AlignLeft | Qt::AlignTop );
+}
+
+/// Full-width form host (paths / wide rows); same layout as a full-width
+/// `QGroupBox` form without the group frame.
+void mountFormInWidgetFullWidth( QWidget *host, QFormLayout **outForm )
+{
+ if ( host == nullptr || outForm == nullptr )
+ {
+ return;
+ }
+ auto *outer = new QVBoxLayout( host );
+ outer->setContentsMargins( 0, 0, 0, 0 );
+ auto *inner = new QWidget;
+ *outForm = new QFormLayout( inner );
+ polishFormLayout( *outForm );
+ inner->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Preferred );
+ // Top-align so short forms do not stretch to fill the host vertically.
+ outer->addWidget( inner, 0, Qt::AlignTop );
+}
+
+void addLabeledSpinRows(
+ QFormLayout *form,
+ QSpinBox *boxes[4],
+ const QStringList &rowLabels,
+ int maxFieldWidth )
+{
+ for ( int i = 0; i < 4; ++i )
+ {
+ if ( maxFieldWidth > 0 )
+ {
+ setFieldMaxWidth( boxes[i], maxFieldWidth );
+ }
+ form->addRow( rowLabels.at( i ), boxes[i] );
+ }
+}
+
+QStringList
+flattenBatches( const std::vector> &batches )
+{
+ QStringList out;
+ for ( const auto &batch: batches )
+ {
+ for ( const auto &p: batch )
+ {
+ out.push_back( QString::fromStdString( p ) );
+ }
+ }
+ return out;
+}
+
+/// Split persisted spectral path override (`;` / `:`). Empty → no override.
+std::vector spectralDatabaseDirsFromLineEdit( const QString &text )
+{
+ const QString trimmed = text.trimmed();
+ if ( trimmed.isEmpty() )
+ {
+ return {};
+ }
+ std::vector out;
+ const QStringList parts = trimmed.split(
+ QRegularExpression( QStringLiteral( "[;:]" ) ), Qt::SkipEmptyParts );
+ for ( const QString &part: parts )
+ {
+ const QString one = part.trimmed();
+ if ( one.isEmpty() )
+ {
+ continue;
+ }
+ out.push_back( one.toStdString() );
+ }
+ return out;
+}
+
+std::vector spectralDatabaseDirsFromListWidget( const QListWidget *list )
+{
+ std::vector out;
+ if ( list == nullptr )
+ {
+ return out;
+ }
+ for ( int i = 0; i < list->count(); ++i )
+ {
+ const QString one = list->item( i )->text().trimmed();
+ if ( one.isEmpty() )
+ {
+ continue;
+ }
+ out.push_back( one.toStdString() );
+ }
+ return out;
+}
+
+QString spectralDataPathsJoinedForSettings( const QListWidget *list )
+{
+ QStringList parts;
+ if ( list == nullptr )
+ {
+ return {};
+ }
+ for ( int i = 0; i < list->count(); ++i )
+ {
+ const QString one = list->item( i )->text().trimmed();
+ if ( !one.isEmpty() )
+ {
+ parts << one;
+ }
+ }
+ return parts.join( QLatin1Char( ';' ) );
+}
+
+void populateSpectralDataListFromSettingsString(
+ QListWidget *list, const QString &saved )
+{
+ if ( list == nullptr )
+ {
+ return;
+ }
+ list->clear();
+ for ( const auto &dir: spectralDatabaseDirsFromLineEdit( saved ) )
+ {
+ list->addItem( QString::fromStdString( dir ) );
+ }
+}
+
+void addDirectoryToPathList(
+ QWidget *parent, QListWidget *list, const QString &dialogTitle )
+{
+ if ( list == nullptr )
+ {
+ return;
+ }
+ const QString d = QFileDialog::getExistingDirectory(
+ parent,
+ dialogTitle,
+ {},
+ QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks );
+ if ( d.isEmpty() )
+ {
+ return;
+ }
+ const QString clean = QDir::cleanPath( d );
+ for ( int i = 0; i < list->count(); ++i )
+ {
+ if ( QDir::cleanPath( list->item( i )->text() ) == clean )
+ {
+ return;
+ }
+ }
+ list->addItem( clean );
+}
+
+void removeSelectedFromPathList( QListWidget *list )
+{
+ if ( list != nullptr )
+ {
+ qDeleteAll( list->selectedItems() );
+ }
+}
+
+void movePathListItemUp( QListWidget *list )
+{
+ if ( list == nullptr )
+ {
+ return;
+ }
+ const int row = list->currentRow();
+ if ( row <= 0 )
+ {
+ return;
+ }
+ QListWidgetItem *const item = list->takeItem( row );
+ list->insertItem( row - 1, item );
+ list->setCurrentRow( row - 1 );
+}
+
+void movePathListItemDown( QListWidget *list )
+{
+ if ( list == nullptr )
+ {
+ return;
+ }
+ const int row = list->currentRow();
+ if ( row < 0 || row >= list->count() - 1 )
+ {
+ return;
+ }
+ QListWidgetItem *const item = list->takeItem( row );
+ list->insertItem( row + 1, item );
+ list->setCurrentRow( row + 1 );
+}
+
+} // namespace
+
+MainWindow::MainWindow( QWidget *parent ) : QMainWindow( parent )
+{
+ setObjectName( QStringLiteral( "rawtoacesMainWindow" ) );
+ setWindowTitle( QApplication::applicationDisplayName() );
+
+ auto *inputGroup = new QGroupBox( tr( "Input files" ) );
+ auto *inputLay = new QVBoxLayout( inputGroup );
+ inputLay->setSpacing( 6 );
+ inputGroup->setSizePolicy(
+ QSizePolicy::Preferred, QSizePolicy::MinimumExpanding );
+
+ auto *fileRow = new QHBoxLayout;
+ m_fileList = new QListWidget;
+ m_fileList->setObjectName( QStringLiteral( "guiFileList" ) );
+ m_fileList->setSelectionMode( QAbstractItemView::ExtendedSelection );
+ m_fileList->setMinimumHeight( 60 );
+ m_fileList->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Expanding );
+ auto *fileBtnHost = new QWidget;
+ QVBoxLayout *const fileBtnCol = mountTightButtonStack( fileBtnHost );
+ auto *addFiles = new QPushButton( tr( "Add files…" ) );
+ auto *addFolder = new QPushButton( tr( "Add folder…" ) );
+ auto *removeBtn = new QPushButton( tr( "Remove" ) );
+ auto *clearBtn = new QPushButton( tr( "Clear" ) );
+ fileBtnCol->addWidget( addFiles );
+ fileBtnCol->addWidget( addFolder );
+ fileBtnCol->addWidget( removeBtn );
+ fileBtnCol->addWidget( clearBtn );
+ fileBtnCol->addStretch();
+ fileRow->setAlignment( Qt::AlignTop );
+ fileRow->addWidget( m_fileList, 1 );
+ fileRow->addWidget( fileBtnHost, 0 );
+ inputLay->addLayout( fileRow, 1 );
+
+ connect( addFiles, &QPushButton::clicked, this, &MainWindow::onAddFiles );
+ connect( addFolder, &QPushButton::clicked, this, &MainWindow::onAddFolder );
+ connect(
+ removeBtn, &QPushButton::clicked, this, &MainWindow::onRemoveSelected );
+ connect( clearBtn, &QPushButton::clicked, this, &MainWindow::onClearFiles );
+
+ auto *pathGroup = new QWidget;
+ QFormLayout *pathForm = nullptr;
+ mountFormInWidgetFullWidth( pathGroup, &pathForm );
+ pathForm->setHorizontalSpacing( 12 );
+ pathForm->setVerticalSpacing( 8 );
+ pathForm->setFieldGrowthPolicy( QFormLayout::ExpandingFieldsGrow );
+
+ m_outputDir = new QLineEdit;
+ m_outputDir->setObjectName( QStringLiteral( "guiOutputDir" ) );
+ m_outputDir->setPlaceholderText(
+ tr( "Empty = same folder as each RAW; or set a subfolder / path" ) );
+ m_outputDir->setToolTip( tr(
+ "Leave empty to write .exr next to the source file. "
+ "If set, output paths are resolved under each input file’s directory." ) );
+ auto *outBrowse = new QPushButton( tr( "Browse…" ) );
+ auto *outWrap = new QWidget;
+ outWrap->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Preferred );
+ auto *outHBox = new QHBoxLayout( outWrap );
+ outHBox->setContentsMargins( 0, 0, 0, 0 );
+ outHBox->setSpacing( 8 );
+ outHBox->addWidget( m_outputDir, 1 );
+ outHBox->addWidget( outBrowse );
+ pathForm->addRow( tr( "Output directory:" ), outWrap );
+ connect(
+ outBrowse, &QPushButton::clicked, this, &MainWindow::onBrowseOutput );
+
+ m_overwrite = new QCheckBox;
+ m_createDirs = new QCheckBox;
+ m_overwrite->setToolTip(
+ tr( "Replace existing output files instead of skipping them." ) );
+ m_createDirs->setToolTip(
+ tr( "When an output directory is set, create missing parent folders "
+ "if they do not exist." ) );
+ pathForm->addRow(
+ tr( "Overwrite existing files:" ),
+ wrapCheckBoxForFormRow( m_overwrite ) );
+ pathForm->addRow(
+ tr( "Create missing directories:" ),
+ wrapCheckBoxForFormRow( m_createDirs ) );
+
+ m_log = new QTextEdit;
+ m_log->setObjectName( QStringLiteral( "guiLog" ) );
+ m_log->setReadOnly( true );
+ m_log->setPlaceholderText(
+ tr( "Conversion progress and messages will appear here." ) );
+ m_log->setMinimumHeight( 72 );
+
+ m_useTiming = new QCheckBox;
+ m_disableCache = new QCheckBox;
+ m_disableExiftool = new QCheckBox;
+ m_verbosity = new QComboBox;
+ m_verbosity->setObjectName( QStringLiteral( "guiVerbosity" ) );
+ m_verbosity->addItem( tr( "Quiet" ), 0 );
+ m_verbosity->addItem( tr( "Progress" ), 1 );
+ m_verbosity->addItem( tr( "Detailed" ), 2 );
+ m_verbosity->addItem( tr( "Solver report" ), 3 );
+ m_verbosity->addItem( tr( "Solver trace" ), 4 );
+ m_verbosity->setCurrentIndex( 0 );
+ m_verbosity->setMaximumWidth( kStdNumericFieldWidth * 2 );
+ m_verbosity->setToolTip( tr(
+ "How much is printed to the log and terminal. "
+ "Progress: per-step messages. Detailed: adds configuration summary. "
+ "Solver report: Ceres summary and IDT matrix. "
+ "Solver trace: also Ceres minimizer progress." ) );
+
+ auto *logsOptionsGroup = new QWidget;
+ QFormLayout *logsOptionsForm = nullptr;
+ mountFormInWidget( logsOptionsGroup, &logsOptionsForm );
+ logsOptionsForm->setHorizontalSpacing( 12 );
+ logsOptionsForm->setVerticalSpacing( 8 );
+ logsOptionsForm->setFieldGrowthPolicy( QFormLayout::ExpandingFieldsGrow );
+ logsOptionsForm->addRow( tr( "Verbosity:" ), m_verbosity );
+ logsOptionsForm->addRow(
+ tr( "Log timing:" ), wrapCheckBoxForFormRow( m_useTiming ) );
+
+ auto *logCacheGroup = new QWidget;
+ QFormLayout *cacheDiagLay = nullptr;
+ mountFormInWidget( logCacheGroup, &cacheDiagLay );
+ cacheDiagLay->setHorizontalSpacing( 12 );
+ cacheDiagLay->setVerticalSpacing( 8 );
+ cacheDiagLay->setFieldGrowthPolicy( QFormLayout::ExpandingFieldsGrow );
+ cacheDiagLay->addRow(
+ tr( "Disable cache:" ), wrapCheckBoxForFormRow( m_disableCache ) );
+ cacheDiagLay->addRow(
+ tr( "Disable exiftool:" ),
+ wrapCheckBoxForFormRow( m_disableExiftool ) );
+
+ m_settingsTabs = new QTabWidget;
+ m_settingsTabs->setObjectName( QStringLiteral( "guiSettingsTabs" ) );
+ m_settingsTabs->setDocumentMode( true );
+
+ // --- LibRaw-related decode / colour controls (under Settings → Other libraw) ---
+
+ auto *rawLevels = new QWidget;
+ QFormLayout *rawLay = nullptr;
+ mountFormInWidget( rawLevels, &rawLay );
+ rawLay->setHorizontalSpacing( 12 );
+ rawLay->setVerticalSpacing( 8 );
+ // `FieldsStayAtSizeHint` keeps checkbox hosts at a tiny preferred width and
+ // clips macOS indicators; expanding policy still respects fixed-width spins.
+ rawLay->setFieldGrowthPolicy( QFormLayout::ExpandingFieldsGrow );
+ m_autoBright = new QCheckBox;
+ m_autoBright->setToolTip( tr(
+ "LibRaw automatic brightening; other level options still apply." ) );
+ m_adjustMaximum = new QDoubleSpinBox;
+ m_adjustMaximum->setRange( 0.0, 1.0 );
+ m_adjustMaximum->setDecimals( 4 );
+ m_adjustMaximum->setValue( 0.75 );
+ setFieldMaxWidth( m_adjustMaximum, kStdNumericFieldWidth );
+
+ m_blackLevelFromMetadata = new QCheckBox( tr( "Take from metadata" ) );
+ m_blackLevelFromMetadata->setChecked( true );
+ m_blackLevelFromMetadata->setToolTip(
+ tr( "Use the black level from RAW metadata; when off, use the value "
+ "below." ) );
+ m_blackLevel = new QSpinBox;
+ m_blackLevel->setRange( 0, 2147483647 );
+ m_blackLevel->setValue( 0 );
+ m_blackLevel->setEnabled( false );
+ m_blackLevel->setToolTip(
+ tr( "Override sensor black level when metadata is not used." ) );
+ setFieldMaxWidth( m_blackLevel, kStdNumericFieldWidth );
+
+ m_saturationFromMetadata = new QCheckBox( tr( "Take from metadata" ) );
+ m_saturationFromMetadata->setChecked( true );
+ m_saturationFromMetadata->setToolTip( tr(
+ "Use the saturation (clip / white) level from RAW metadata; when off, "
+ "use the value below." ) );
+ m_saturationLevel = new QSpinBox;
+ m_saturationLevel->setRange( 1, 2147483647 );
+ m_saturationLevel->setValue( 16383 );
+ m_saturationLevel->setEnabled( false );
+ m_saturationLevel->setToolTip(
+ tr( "Raw value treated as saturated when not using metadata." ) );
+ setFieldMaxWidth( m_saturationLevel, kStdNumericFieldWidth );
+
+ auto *blackLevelBlock = new QWidget;
+ auto *blackLevelVBox = new QVBoxLayout( blackLevelBlock );
+ blackLevelVBox->setContentsMargins( 0, 0, 0, 0 );
+ blackLevelVBox->setSpacing( 8 );
+ blackLevelVBox->addWidget( m_blackLevelFromMetadata );
+ blackLevelVBox->addWidget( m_blackLevel );
+
+ auto *saturationBlock = new QWidget;
+ auto *saturationVBox = new QVBoxLayout( saturationBlock );
+ saturationVBox->setContentsMargins( 0, 0, 0, 0 );
+ saturationVBox->setSpacing( 8 );
+ saturationVBox->addWidget( m_saturationFromMetadata );
+ saturationVBox->addWidget( m_saturationLevel );
+
+ rawLay->addRow(
+ tr( "Auto bright:" ), wrapCheckBoxForFormRow( m_autoBright ) );
+ rawLay->addRow( tr( "Adjust maximum threshold:" ), m_adjustMaximum );
+ rawLay->addRow( tr( "Black level:" ), blackLevelBlock );
+ rawLay->addRow( tr( "Saturation level:" ), saturationBlock );
+ alignFormLabelTopForField( rawLay, blackLevelBlock );
+ alignFormLabelTopForField( rawLay, saturationBlock );
+ connect(
+ m_blackLevelFromMetadata,
+ &QCheckBox::toggled,
+ this,
+ &MainWindow::updateBlackSaturationUi );
+ connect(
+ m_saturationFromMetadata,
+ &QCheckBox::toggled,
+ this,
+ &MainWindow::updateBlackSaturationUi );
+ updateBlackSaturationUi();
+
+ auto *rawChroma = new QWidget;
+ QFormLayout *chForm = nullptr;
+ mountFormInWidget( rawChroma, &chForm );
+ chForm->setHorizontalSpacing( 12 );
+ chForm->setVerticalSpacing( 8 );
+ chForm->setFieldGrowthPolicy( QFormLayout::ExpandingFieldsGrow );
+ m_chromaR = new QDoubleSpinBox;
+ m_chromaB = new QDoubleSpinBox;
+ m_chromaR->setRange( 0.0, 1.0e6 );
+ m_chromaB->setRange( 0.0, 1.0e6 );
+ m_chromaR->setDecimals( 4 );
+ m_chromaB->setDecimals( 4 );
+ m_chromaR->setValue( 1.0 );
+ m_chromaB->setValue( 1.0 );
+ m_halfSize = new QCheckBox;
+ m_highlightMode = new QComboBox;
+ m_highlightMode->setObjectName( QStringLiteral( "guiHighlightMode" ) );
+ m_highlightMode->addItem( tr( "0 — Clip" ), 0 );
+ m_highlightMode->addItem( tr( "1 — Unclip" ), 1 );
+ m_highlightMode->addItem( tr( "2 — Blend" ), 2 );
+ for ( int h = 3; h <= 9; ++h )
+ {
+ m_highlightMode->addItem(
+ tr( "%1 — Rebuild (level %2)" ).arg( h ).arg( h ), h );
+ }
+ m_highlightMode->setMaximumWidth( kStdNumericFieldWidth * 3 );
+ m_highlightMode->setToolTip(
+ tr( "LibRaw highlight recovery: 0 clip, 1 unclip, 2 blend; 3–9 are "
+ "rebuild levels with increasing strength." ) );
+ setFieldMaxWidth( m_chromaR, kStdNumericFieldWidth );
+ setFieldMaxWidth( m_chromaB, kStdNumericFieldWidth );
+ chForm->addRow( tr( "Red channel multiplier:" ), m_chromaR );
+ chForm->addRow( tr( "Blue channel multiplier:" ), m_chromaB );
+ chForm->addRow(
+ tr( "Half-size decode:" ), wrapCheckBoxForFormRow( m_halfSize ) );
+ chForm->addRow( tr( "Highlight mode:" ), m_highlightMode );
+
+ auto *rawCrop = new QWidget;
+ QFormLayout *crForm = nullptr;
+ mountFormInWidget( rawCrop, &crForm );
+ crForm->setHorizontalSpacing( 12 );
+ crForm->setVerticalSpacing( 8 );
+ crForm->setFieldGrowthPolicy( QFormLayout::FieldsStayAtSizeHint );
+ for ( int i = 0; i < 4; ++i )
+ {
+ m_cropBox[i] = new QSpinBox;
+ m_cropBox[i]->setRange( -1000000000, 1000000000 );
+ }
+ auto *cropRegionInner = new QWidget;
+ auto *cropRegionForm = new QFormLayout( cropRegionInner );
+ polishFormLayout( cropRegionForm );
+ cropRegionForm->setLabelAlignment( Qt::AlignRight | Qt::AlignTop );
+ cropRegionForm->setContentsMargins( 0, 0, 0, 0 );
+ cropRegionForm->setHorizontalSpacing( 12 );
+ cropRegionForm->setFieldGrowthPolicy( QFormLayout::FieldsStayAtSizeHint );
+ addLabeledSpinRows(
+ cropRegionForm,
+ m_cropBox,
+ { tr( "X:" ), tr( "Y:" ), tr( "Width:" ), tr( "Height:" ) },
+ kStdNumericFieldWidth );
+ m_cropMode = new QComboBox;
+ m_cropMode->addItems( { tr( "off" ), tr( "soft" ), tr( "hard" ) } );
+ m_cropMode->setCurrentIndex( 1 );
+ m_cropMode->setMaximumWidth( kStdNumericFieldWidth * 2 );
+ m_flip = new QComboBox;
+ m_flip->setObjectName( QStringLiteral( "guiFlip" ) );
+ m_flip->addItem( tr( "0 — No override (use file metadata)" ), 0 );
+ m_flip->addItem( tr( "1 — Normal (0°)" ), 1 );
+ m_flip->addItem( tr( "2 — Mirror horizontal" ), 2 );
+ m_flip->addItem( tr( "3 — Rotate 180°" ), 3 );
+ m_flip->addItem( tr( "4 — Mirror vertical" ), 4 );
+ m_flip->addItem( tr( "5 — Mirror horizontal, rotate 270° CW" ), 5 );
+ m_flip->addItem( tr( "6 — Rotate 90° CCW" ), 6 );
+ m_flip->addItem( tr( "7 — Mirror horizontal, rotate 90° CW" ), 7 );
+ m_flip->addItem( tr( "8 — Rotate 90° CW" ), 8 );
+ m_flip->setMaximumWidth( kStdNumericFieldWidth * 4 );
+ m_flip->setToolTip(
+ tr( "Override orientation. 0 uses metadata; 1–8 are EXIF orientation "
+ "codes (e.g. 3 = 180°, 6 = 90° CCW, 8 = 90° CW)." ) );
+ m_denoise = new QDoubleSpinBox;
+ m_denoise->setRange( 0.0, 1.0e9 );
+ m_denoise->setDecimals( 4 );
+ m_denoise->setValue( 0.0 );
+ crForm->addRow( tr( "Crop region (pixels):" ), cropRegionInner );
+ alignFormLabelTopForField( crForm, cropRegionInner );
+ crForm->addRow( tr( "Crop mode:" ), m_cropMode );
+ crForm->addRow( tr( "Flip:" ), m_flip );
+ crForm->addRow( tr( "Denoise threshold:" ), m_denoise );
+ setFieldMaxWidth( m_denoise, kStdNumericFieldWidth );
+
+ auto *rawDemo = new QWidget;
+ QFormLayout *dmForm = nullptr;
+ mountFormInWidget( rawDemo, &dmForm );
+ dmForm->setHorizontalSpacing( 12 );
+ dmForm->setFieldGrowthPolicy( QFormLayout::FieldsStayAtSizeHint );
+ m_demosaic = new QComboBox;
+ const QStringList demosaicNames = {
+ QStringLiteral( "linear" ), QStringLiteral( "VNG" ),
+ QStringLiteral( "PPG" ), QStringLiteral( "AHD" ),
+ QStringLiteral( "DCB" ), QStringLiteral( "AHD-Mod" ),
+ QStringLiteral( "AFD" ), QStringLiteral( "VCD" ),
+ QStringLiteral( "Mixed" ), QStringLiteral( "LMMSE" ),
+ QStringLiteral( "AMaZE" ), QStringLiteral( "DHT" ),
+ QStringLiteral( "AAHD" ), QStringLiteral( "AHD" )
+ };
+ m_demosaic->addItems( demosaicNames );
+ m_demosaic->setMaximumWidth( kStdNumericFieldWidth * 2 );
+ dmForm->addRow( tr( "Algorithm:" ), m_demosaic );
+
+ // Spectral list + buttons (no inner row label — settings dialog group title).
+ auto *grpSpectral = new QWidget;
+ grpSpectral->setSizePolicy(
+ QSizePolicy::Expanding, QSizePolicy::Preferred );
+ auto *spectralBodyLay = new QVBoxLayout( grpSpectral );
+ spectralBodyLay->setContentsMargins( 0, 0, 0, 0 );
+ spectralBodyLay->setSpacing( 0 );
+
+ m_dataDirList = new QListWidget;
+ m_dataDirList->setObjectName( QStringLiteral( "guiDataDirList" ) );
+ m_dataDirList->setSelectionMode( QAbstractItemView::ExtendedSelection );
+ m_dataDirList->setSizePolicy(
+ QSizePolicy::Expanding, QSizePolicy::Preferred );
+ m_dataDirList->setToolTip( tr(
+ "Directories for camera / illuminant spectral data. "
+ "Earlier entries are searched first. Empty uses library defaults "
+ "and environment." ) );
+
+ auto *spectralAddFolder = new QPushButton( tr( "Add folder…" ) );
+ auto *spectralRemove = new QPushButton( tr( "Remove" ) );
+ auto *spectralMoveUp = new QPushButton( tr( "Move up" ) );
+ auto *spectralMoveDown = new QPushButton( tr( "Move down" ) );
+ auto *spectralBtnHost = new QWidget;
+ QVBoxLayout *const spectralBtnCol = mountTightButtonStack( spectralBtnHost );
+ spectralBtnCol->addWidget( spectralAddFolder );
+ spectralBtnCol->addWidget( spectralRemove );
+ spectralBtnCol->addWidget( spectralMoveUp );
+ spectralBtnCol->addWidget( spectralMoveDown );
+ spectralBtnCol->addStretch();
+
+ auto *dataWrap = new QWidget;
+ dataWrap->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Preferred );
+ dataWrap->setContentsMargins( 0, 0, 0, 0 );
+ auto *dataHBox = new QHBoxLayout( dataWrap );
+ dataHBox->setSpacing( 8 );
+ dataHBox->setAlignment( Qt::AlignTop );
+ dataHBox->addWidget( m_dataDirList, 1 );
+ dataHBox->addWidget( spectralBtnHost, 0 );
+ dataHBox->setContentsMargins( 0, 0, 0, 0 );
+
+ spectralBodyLay->addWidget( dataWrap );
+
+ connect(
+ spectralAddFolder,
+ &QPushButton::clicked,
+ this,
+ &MainWindow::onAddSpectralDataFolder );
+ connect(
+ spectralRemove,
+ &QPushButton::clicked,
+ this,
+ &MainWindow::onRemoveSpectralDataSelected );
+ connect(
+ spectralMoveUp,
+ &QPushButton::clicked,
+ this,
+ &MainWindow::onMoveSpectralDataUp );
+ connect(
+ spectralMoveDown,
+ &QPushButton::clicked,
+ this,
+ &MainWindow::onMoveSpectralDataDown );
+
+#ifdef RTA_GUI_HAS_LENSFUN
+ auto *grpLensfun = new QWidget;
+ grpLensfun->setSizePolicy(
+ QSizePolicy::Expanding, QSizePolicy::Preferred );
+ auto *lensfunBodyLay = new QVBoxLayout( grpLensfun );
+ lensfunBodyLay->setContentsMargins( 0, 0, 0, 0 );
+ lensfunBodyLay->setSpacing( 0 );
+
+ m_lensfunDirList = new QListWidget;
+ m_lensfunDirList->setObjectName( QStringLiteral( "guiLensfunDirList" ) );
+ m_lensfunDirList->setSelectionMode( QAbstractItemView::ExtendedSelection );
+ m_lensfunDirList->setSizePolicy(
+ QSizePolicy::Expanding, QSizePolicy::Preferred );
+ m_lensfunDirList->setToolTip( tr(
+ "Directories searched for Lensfun camera and lens profiles (XML). "
+ "Earlier entries are loaded first. On startup, paths from "
+ "RAWTOACES_LENSFUNDB_PATH are merged into this list and the variable "
+ "is updated to match. After editing the list, use Reload to apply "
+ "changes to the running process (otherwise the list is saved when you "
+ "quit and applied on the next launch)." ) );
+
+ auto *lfAddFolder = new QPushButton( tr( "Add folder…" ) );
+ auto *lfRemove = new QPushButton( tr( "Remove" ) );
+ auto *lfMoveUp = new QPushButton( tr( "Move up" ) );
+ auto *lfMoveDown = new QPushButton( tr( "Move down" ) );
+ auto *lfReloadDb = new QPushButton( tr( "Reload" ) );
+ lfReloadDb->setToolTip( tr(
+ "Write the list above into RAWTOACES_LENSFUNDB_PATH for this process "
+ "and drop the cached Lensfun database so the next conversion run "
+ "reloads XML from disk." ) );
+ auto *lfBtnHost = new QWidget;
+ QVBoxLayout *const lfBtnCol = mountTightButtonStack( lfBtnHost );
+ lfBtnCol->addWidget( lfAddFolder );
+ lfBtnCol->addWidget( lfRemove );
+ lfBtnCol->addWidget( lfMoveUp );
+ lfBtnCol->addWidget( lfMoveDown );
+ lfBtnCol->addWidget( lfReloadDb );
+ lfBtnCol->addStretch();
+
+ auto *lfDataWrap = new QWidget;
+ lfDataWrap->setSizePolicy(
+ QSizePolicy::Expanding, QSizePolicy::Preferred );
+ auto *lfHBox = new QHBoxLayout( lfDataWrap );
+ lfHBox->setSpacing( 8 );
+ lfHBox->setAlignment( Qt::AlignTop );
+ lfHBox->setContentsMargins( 0, 0, 0, 0 );
+ lfHBox->addWidget( m_lensfunDirList, 1 );
+ lfHBox->addWidget( lfBtnHost, 0 );
+
+ lensfunBodyLay->addWidget( lfDataWrap );
+
+ connect( lfAddFolder, &QPushButton::clicked, this, [this]() {
+ addDirectoryToPathList(
+ this,
+ m_lensfunDirList,
+ tr( "Add Lensfun database directory" ) );
+ } );
+ connect( lfRemove, &QPushButton::clicked, this, [this]() {
+ removeSelectedFromPathList( m_lensfunDirList );
+ } );
+ connect( lfMoveUp, &QPushButton::clicked, this, [this]() {
+ movePathListItemUp( m_lensfunDirList );
+ } );
+ connect( lfMoveDown, &QPushButton::clicked, this, [this]() {
+ movePathListItemDown( m_lensfunDirList );
+ } );
+ connect(
+ lfReloadDb, &QPushButton::clicked, this, &MainWindow::onReloadLensfunDatabase );
+#endif
+
+ // App-level options live only in this dialog so each section body has one
+ // parent. The Settings tab holds conversion UI (WB, matrix, lens flags,
+ // libraw, etc.) without re-embedding the same widgets.
+ auto *preferencesPageInner = new QWidget;
+ auto *preferencesOuterLay = new QVBoxLayout( preferencesPageInner );
+ applySettingsTabPageChrome( preferencesOuterLay );
+ preferencesOuterLay->setSpacing( kCollapsibleTabSectionSpacing );
+ preferencesOuterLay->addWidget( wrapFieldGroup(
+ grpSpectral, tr( "Spectral data — data directories" ) ) );
+#ifdef RTA_GUI_HAS_LENSFUN
+ preferencesOuterLay->addWidget( wrapFieldGroup(
+ grpLensfun,
+ tr( "Lensfun profile directories — database roots" ) ) );
+#endif
+ preferencesOuterLay->addWidget(
+ wrapFieldGroup( logCacheGroup, tr( "Cache" ) ) );
+ preferencesOuterLay->addWidget(
+ wrapFieldGroup( logsOptionsGroup, tr( "Log output" ) ) );
+ preferencesOuterLay->addStretch();
+
+ m_preferencesDialog = new QDialog( this );
+ m_preferencesDialog->setObjectName( QStringLiteral( "guiPreferencesDialog" ) );
+ m_preferencesDialog->setWindowTitle(
+ tr( "%1 — Settings" )
+ .arg( QGuiApplication::applicationDisplayName() ) );
+ auto *prefsRootLay = new QVBoxLayout( m_preferencesDialog );
+ prefsRootLay->setContentsMargins(
+ kCentralChromeMargin,
+ 0,
+ kCentralChromeMargin,
+ kCentralChromeMargin
+ );
+ prefsRootLay->setSpacing( kCentralChromeMargin );
+ prefsRootLay->addWidget( wrapScroll( preferencesPageInner ), 1 );
+ auto *prefsButtons = new QDialogButtonBox( QDialogButtonBox::Close );
+ QObject::connect(
+ prefsButtons,
+ &QDialogButtonBox::rejected,
+ m_preferencesDialog,
+ &QDialog::reject );
+ prefsRootLay->addWidget( prefsButtons );
+ m_preferencesDialog->resize( 720, 580 );
+
+ auto *grpWb = new QWidget;
+ QFormLayout *wbForm = nullptr;
+ mountFormInWidget( grpWb, &wbForm );
+ wbForm->setHorizontalSpacing( 12 );
+ wbForm->setVerticalSpacing( 8 );
+ wbForm->setFieldGrowthPolicy( QFormLayout::FieldsStayAtSizeHint );
+ m_wbMethod = new QComboBox;
+ m_wbMethod->setObjectName( QStringLiteral( "guiWbMethod" ) );
+ m_wbMethod->addItems(
+ { tr( "metadata" ), tr( "illuminant" ), tr( "box" ), tr( "custom" ) } );
+ m_wbMethod->setMaximumWidth( kStdNumericFieldWidth * 2 );
+ m_illuminant = new QLineEdit;
+ m_illuminant->setPlaceholderText( tr( "e.g. D55, 3200K" ) );
+ m_wbIlluminantWrap = new QWidget;
+ auto *illumHBox = new QHBoxLayout( m_wbIlluminantWrap );
+ illumHBox->setContentsMargins( 0, 0, 0, 0 );
+ illumHBox->addWidget( m_illuminant, 1 );
+ for ( int i = 0; i < 4; ++i )
+ {
+ m_wbBox[i] = new QSpinBox;
+ m_wbBox[i]->setRange( -1000000000, 1000000000 );
+ }
+ m_wbBoxRegionWrap = new QWidget;
+ auto *wbBoxForm = new QFormLayout( m_wbBoxRegionWrap );
+ polishFormLayout( wbBoxForm );
+ wbBoxForm->setLabelAlignment( Qt::AlignRight | Qt::AlignTop );
+ wbBoxForm->setContentsMargins( 0, 0, 0, 0 );
+ wbBoxForm->setHorizontalSpacing( 12 );
+ wbBoxForm->setFieldGrowthPolicy( QFormLayout::FieldsStayAtSizeHint );
+ addLabeledSpinRows(
+ wbBoxForm,
+ m_wbBox,
+ { tr( "X:" ), tr( "Y:" ), tr( "Width:" ), tr( "Height:" ) },
+ kStdNumericFieldWidth );
+
+ m_wbCustomGainsWrap = new QWidget;
+ auto *cwbForm = new QFormLayout( m_wbCustomGainsWrap );
+ polishFormLayout( cwbForm );
+ cwbForm->setLabelAlignment( Qt::AlignRight | Qt::AlignTop );
+ cwbForm->setContentsMargins( 0, 0, 0, 0 );
+ cwbForm->setHorizontalSpacing( 12 );
+ cwbForm->setFieldGrowthPolicy( QFormLayout::FieldsStayAtSizeHint );
+ for ( int i = 0; i < 4; ++i )
+ {
+ m_customWb[i] = new QDoubleSpinBox;
+ m_customWb[i]->setRange( 0.0, 1.0e6 );
+ m_customWb[i]->setDecimals( 6 );
+ m_customWb[i]->setValue( 1.0 );
+ setFieldMaxWidth( m_customWb[i], kStdNumericFieldWidth );
+ }
+ cwbForm->addRow( tr( "Red:" ), m_customWb[0] );
+ cwbForm->addRow( tr( "Green 1:" ), m_customWb[1] );
+ cwbForm->addRow( tr( "Green 2:" ), m_customWb[2] );
+ cwbForm->addRow( tr( "Blue:" ), m_customWb[3] );
+
+ wbForm->addRow( tr( "Method:" ), m_wbMethod );
+ wbForm->addRow( tr( "Illuminant:" ), m_wbIlluminantWrap );
+ wbForm->addRow( tr( "Region for 'box' mode:" ), m_wbBoxRegionWrap );
+ wbForm->addRow( tr( "Custom gains:" ), m_wbCustomGainsWrap );
+ alignFormLabelTopForField( wbForm, m_wbBoxRegionWrap );
+ alignFormLabelTopForField( wbForm, m_wbCustomGainsWrap );
+ m_wbIlluminantLabel =
+ qobject_cast( wbForm->labelForField( m_wbIlluminantWrap ) );
+ m_wbBoxRegionLabel =
+ qobject_cast( wbForm->labelForField( m_wbBoxRegionWrap ) );
+ m_wbCustomGainsLabel =
+ qobject_cast( wbForm->labelForField( m_wbCustomGainsWrap ) );
+ connect(
+ m_wbMethod,
+ &QComboBox::currentIndexChanged,
+ this,
+ &MainWindow::updateWbMethodDependentUi );
+ updateWbMethodDependentUi();
+
+ auto *grpMat = new QWidget;
+ QFormLayout *matForm = nullptr;
+ mountFormInWidget( grpMat, &matForm );
+ matForm->setHorizontalSpacing( 12 );
+ matForm->setVerticalSpacing( 8 );
+ matForm->setFieldGrowthPolicy( QFormLayout::FieldsStayAtSizeHint );
+ m_matrixMethod = new QComboBox;
+ m_matrixMethod->addItems( { tr( "auto" ),
+ tr( "spectral" ),
+ tr( "metadata" ),
+ tr( "Adobe" ),
+ tr( "custom" ) } );
+ m_matrixMethod->setMaximumWidth( kStdNumericFieldWidth * 2 );
+ matForm->addRow( tr( "Matrix method:" ), m_matrixMethod );
+
+ m_customMatrixWrap = new QWidget;
+ auto *matGrid = new QGridLayout( m_customMatrixWrap );
+ matGrid->setContentsMargins( 0, 0, 0, 0 );
+ for ( int r = 0; r < 3; ++r )
+ {
+ for ( int c = 0; c < 3; ++c )
+ {
+ m_customMat[r][c] = new QDoubleSpinBox;
+ m_customMat[r][c]->setRange( -1.0e6, 1.0e6 );
+ m_customMat[r][c]->setDecimals( 6 );
+ m_customMat[r][c]->setValue( r == c ? 1.0 : 0.0 );
+ setFieldMaxWidth( m_customMat[r][c], kStdNumericFieldWidth );
+ matGrid->addWidget( m_customMat[r][c], r, c );
+ }
+ }
+ matForm->addRow( tr( "Custom 3×3 matrix:" ), m_customMatrixWrap );
+ m_customMatrixLabel =
+ qobject_cast( matForm->labelForField( m_customMatrixWrap ) );
+ connect(
+ m_matrixMethod,
+ &QComboBox::currentIndexChanged,
+ this,
+ &MainWindow::updateMatrixMethodDependentUi );
+ updateMatrixMethodDependentUi();
+
+ m_customCameraMake = new QLineEdit;
+ m_customCameraModel = new QLineEdit;
+ auto *makeWrap = new QWidget;
+ auto *makeHBox = new QHBoxLayout( makeWrap );
+ makeHBox->setContentsMargins( 0, 0, 0, 0 );
+ makeHBox->addWidget( m_customCameraMake, 1 );
+ auto *modelWrap = new QWidget;
+ auto *modelHBox = new QHBoxLayout( modelWrap );
+ modelHBox->setContentsMargins( 0, 0, 0, 0 );
+ modelHBox->addWidget( m_customCameraModel, 1 );
+ matForm->addRow( tr( "Override camera make:" ), makeWrap );
+ matForm->addRow( tr( "Override camera model:" ), modelWrap );
+
+ auto *librawSettingsBody = new QWidget;
+ auto *librawOuter = new QVBoxLayout( librawSettingsBody );
+ librawOuter->setContentsMargins( 0, 0, 0, 0 );
+ // Spacing above each nested group title comes from `wrapFieldGroup`; keep
+ // zero here so it does not stack with that outer top margin.
+ librawOuter->setSpacing( 0 );
+ librawOuter->addWidget( wrapFieldGroup(
+ rawLevels,
+ tr( "Levels && exposure" )));
+ librawOuter->addWidget( wrapFieldGroup(
+ rawChroma,
+ tr( "Chromatic aberration && size" ) ) );
+ librawOuter->addWidget( wrapFieldGroup(
+ rawCrop,
+ tr( "Crop, orientation && denoise" )) );
+ librawOuter->addWidget( wrapFieldGroup(
+ rawDemo,
+ tr( "Demosaic" )) );
+
+ auto *grpTone = new QWidget;
+ QFormLayout *toneForm = nullptr;
+ mountFormInWidget( grpTone, &toneForm );
+ toneForm->setHorizontalSpacing( 12 );
+ toneForm->setFieldGrowthPolicy( QFormLayout::FieldsStayAtSizeHint );
+ m_headroom = new QDoubleSpinBox;
+ m_headroom->setRange( 0.0, 1.0e6 );
+ m_headroom->setDecimals( 3 );
+ m_headroom->setValue( 6.0 );
+ m_scale = new QDoubleSpinBox;
+ m_scale->setRange( 0.0, 1.0e6 );
+ m_scale->setDecimals( 6 );
+ m_scale->setValue( 1.0 );
+ toneForm->addRow( tr( "Headroom:" ), m_headroom );
+ toneForm->addRow( tr( "Scale:" ), m_scale );
+ setFieldMaxWidth( m_headroom, kStdNumericFieldWidth );
+ setFieldMaxWidth( m_scale, kStdNumericFieldWidth );
+
+ QWidget *lensFlagsBox = nullptr;
+ QWidget *lensOverride = nullptr;
+#ifdef RTA_GUI_HAS_LENSFUN
+ lensFlagsBox = new QWidget;
+ QFormLayout *lensLay = nullptr;
+ mountFormInWidget( lensFlagsBox, &lensLay );
+ lensLay->setHorizontalSpacing( 12 );
+ lensLay->setVerticalSpacing( 8 );
+ lensLay->setFieldGrowthPolicy( QFormLayout::ExpandingFieldsGrow );
+ m_lensCorrAberration = new QCheckBox( tr( "Chromatic aberration" ) );
+ m_lensCorrDistortion = new QCheckBox( tr( "Distortion" ) );
+ m_lensCorrVignetting = new QCheckBox( tr( "Vignetting" ) );
+ m_requireLens = new QCheckBox;
+ auto *lensCorrStack = new QWidget;
+ auto *lensCorrVBox = new QVBoxLayout( lensCorrStack );
+ lensCorrVBox->setContentsMargins( 0, 0, 0, 0 );
+ lensCorrVBox->setSpacing( 4 );
+ lensCorrVBox->addWidget( m_lensCorrAberration );
+ lensCorrVBox->addWidget( m_lensCorrDistortion );
+ lensCorrVBox->addWidget( m_lensCorrVignetting );
+ lensLay->addRow( tr( "Correction types:" ), lensCorrStack );
+ lensLay->addRow(
+ tr( "Fail if correction unavailable:" ),
+ wrapCheckBoxForFormRow( m_requireLens ) );
+ // Tall field: align label with the first checkbox, not vertical center of stack.
+ alignFormLabelTopForField( lensLay, lensCorrStack );
+ alignFormFieldTopForField( lensLay, lensCorrStack );
+
+ lensOverride = new QWidget;
+ QFormLayout *ovLay = nullptr;
+ mountFormInWidget( lensOverride, &ovLay );
+ ovLay->setHorizontalSpacing( 12 );
+ ovLay->setVerticalSpacing( 8 );
+ ovLay->setFieldGrowthPolicy( QFormLayout::ExpandingFieldsGrow );
+ m_lensMetadataOverride = new QCheckBox;
+ m_lensMetadataOverride->setObjectName(
+ QStringLiteral( "guiLensMetadataOverride" ) );
+ m_lensMetadataOverride->setToolTip(
+ tr( "When enabled, use the make, model, aperture, focal length, and "
+ "focus distance below for lens correction; when off, converter "
+ "defaults apply (same as leaving CLI overrides unset)." ) );
+ ovLay->addRow(
+ tr( "Override:" ), wrapCheckBoxForFormRow( m_lensMetadataOverride ) );
+ connect(
+ m_lensMetadataOverride,
+ &QCheckBox::toggled,
+ this,
+ &MainWindow::updateLensMetadataOverrideUi );
+ m_lensMake = new QLineEdit;
+ m_lensModel = new QLineEdit;
+ m_lensAperture = new QDoubleSpinBox;
+ m_lensAperture->setRange( 0.0, 1.0e6 );
+ m_lensAperture->setDecimals( 3 );
+ m_lensFocal = new QDoubleSpinBox;
+ m_lensFocal->setRange( 0.0, 1.0e6 );
+ m_lensFocal->setDecimals( 3 );
+ m_lensFocus = new QDoubleSpinBox;
+ m_lensFocus->setRange( 0.0, 1.0e9 );
+ m_lensFocus->setDecimals( 4 );
+ auto *lensMakeWrap = new QWidget;
+ auto *lensMakeHBox = new QHBoxLayout( lensMakeWrap );
+ lensMakeHBox->setContentsMargins( 0, 0, 0, 0 );
+ lensMakeHBox->addWidget( m_lensMake, 1 );
+ auto *lensModelWrap = new QWidget;
+ auto *lensModelHBox = new QHBoxLayout( lensModelWrap );
+ lensModelHBox->setContentsMargins( 0, 0, 0, 0 );
+ lensModelHBox->addWidget( m_lensModel, 1 );
+ ovLay->addRow( tr( "Make:" ), lensMakeWrap );
+ ovLay->addRow( tr( "Model:" ), lensModelWrap );
+ ovLay->addRow( tr( "Aperture (f-number):" ), m_lensAperture );
+ ovLay->addRow( tr( "Focal length (mm):" ), m_lensFocal );
+ ovLay->addRow( tr( "Focus distance:" ), m_lensFocus );
+ setFieldMaxWidth( m_lensAperture, kStdNumericFieldWidth );
+ setFieldMaxWidth( m_lensFocal, kStdNumericFieldWidth );
+ setFieldMaxWidth( m_lensFocus, kStdNumericFieldWidth );
+ updateLensMetadataOverrideUi();
+
+#else
+ m_lensCorrAberration = nullptr;
+ m_lensCorrDistortion = nullptr;
+ m_lensCorrVignetting = nullptr;
+ m_requireLens = nullptr;
+ m_lensMetadataOverride = nullptr;
+ m_lensMake = nullptr;
+ m_lensModel = nullptr;
+ m_lensAperture = nullptr;
+ m_lensFocal = nullptr;
+ m_lensFocus = nullptr;
+#endif
+
+ // --- Tab pages: Inputs, Settings, Logs ---
+ auto *inputsInner = new QWidget;
+ auto *inputsOuter = new QVBoxLayout( inputsInner );
+ applySettingsTabPageChrome( inputsOuter );
+ inputsOuter->setSpacing( kCollapsibleTabSectionSpacing );
+ inputsOuter->addWidget( wrapSettingsSectionTail( inputGroup, 1 ), 1 );
+
+ auto *settingsInner = new QWidget;
+ auto *settingsOuter = new QVBoxLayout( settingsInner );
+ applySettingsTabPageChrome( settingsOuter );
+ settingsOuter->setSpacing( kCollapsibleTabSectionSpacing );
+ settingsOuter->addWidget( wrapCollapsibleSection(
+ pathGroup, tr( "Output settings" ), false ), 0 );
+ settingsOuter->addWidget(
+ wrapCollapsibleSection( grpWb, tr( "White balance" ) ) );
+ settingsOuter->addWidget( wrapCollapsibleSection(
+ grpMat, tr( "Colour matrix && camera" ) ) );
+#ifdef RTA_GUI_HAS_LENSFUN
+ settingsOuter->addWidget( wrapCollapsibleSection(
+ lensFlagsBox, tr( "Lens corrections" ) ) );
+ settingsOuter->addWidget( wrapCollapsibleSection(
+ lensOverride, tr( "Lens metadata" ) ) );
+#endif
+ settingsOuter->addWidget(
+ wrapCollapsibleSection( grpTone, tr( "Tone && scale" ) ) );
+ settingsOuter->addWidget( wrapCollapsibleSection(
+ librawSettingsBody, tr( "Other libraw settings" ) ) );
+ settingsOuter->addStretch();
+
+ auto *logsInner = new QWidget;
+ auto *logsOuter = new QVBoxLayout( logsInner );
+ applySettingsTabPageChrome( logsOuter );
+ logsOuter->setSpacing( kCollapsibleTabSectionSpacing );
+ logsOuter->addWidget( m_log, 1 );
+
+ m_settingsTabs->addTab( wrapScroll( inputsInner ), tr( "Inputs" ) );
+ m_settingsTabs->addTab( wrapScroll( settingsInner ), tr( "Settings" ) );
+ m_settingsTabs->addTab( logsInner, tr( "Logs" ) );
+
+ auto *runRow = new QWidget;
+ auto *runLay = new QHBoxLayout( runRow );
+ runLay->setContentsMargins( 0, 0, 0, 0 );
+ m_convertButton = new QPushButton( tr( "Convert" ) );
+ m_convertButton->setObjectName( QStringLiteral( "guiConvertButton" ) );
+ m_convertButton->setDefault( true );
+ m_convertButton->setAutoDefault( true );
+ m_cancelButton = new QPushButton( tr( "Cancel" ) );
+ m_cancelButton->setObjectName( QStringLiteral( "guiCancelButton" ) );
+ m_cancelButton->setAutoDefault( false );
+ m_cancelButton->setEnabled( false );
+ m_progress = new QProgressBar;
+ m_progress->setObjectName( QStringLiteral( "guiProgressBar" ) );
+ m_progress->setRange( 0, 1 );
+ m_progress->setValue( 0 );
+ m_progress->setTextVisible( true );
+ m_progress->setMaximumHeight( 24 );
+ m_progress->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Fixed );
+ runLay->addWidget( m_convertButton );
+ runLay->addWidget( m_cancelButton );
+ runLay->addWidget( m_progress, 1 );
+ connect(
+ m_convertButton, &QPushButton::clicked, this, &MainWindow::onConvert );
+ connect(
+ m_cancelButton, &QPushButton::clicked, this, &MainWindow::onCancel );
+
+ auto *bottom = new QWidget;
+ auto *bottomLay = new QVBoxLayout( bottom );
+ bottomLay->setContentsMargins( 0, 0, 0, 0 );
+ bottomLay->setSpacing( 0 );
+ bottomLay->addWidget( runRow );
+ bottom->setSizePolicy( QSizePolicy::Preferred, QSizePolicy::Maximum );
+
+ auto *central = new QWidget;
+ auto *centralLay = new QVBoxLayout( central );
+ centralLay->setContentsMargins(
+ kCentralChromeMargin,
+ 0,
+ kCentralChromeMargin,
+ kCentralChromeMargin );
+ centralLay->setSpacing( kCentralChromeMargin );
+ centralLay->addWidget( m_settingsTabs, 1 );
+ centralLay->addWidget( bottom, 0 );
+ setCentralWidget( central );
+
+ alignFormFieldTopForField( rawLay, blackLevelBlock );
+ alignFormFieldTopForField( rawLay, saturationBlock );
+
+ auto *fileMenu = menuBar()->addMenu( tr( "File" ) );
+ fileMenu->addAction( tr( "Add files…" ), this, &MainWindow::onAddFiles );
+ fileMenu->addAction( tr( "Add folder…" ), this, &MainWindow::onAddFolder );
+ fileMenu->addSeparator();
+ fileMenu->addAction( tr( "Quit" ), this, &QWidget::close );
+
+ auto *helpMenu = menuBar()->addMenu( tr( "Help" ) );
+ auto *prefsAction = new QAction( tr( "Settings…" ), this );
+ prefsAction->setMenuRole( QAction::PreferencesRole );
+ connect(
+ prefsAction, &QAction::triggered, this, &MainWindow::showPreferences );
+ helpMenu->addAction( prefsAction );
+ helpMenu->addSeparator();
+ helpMenu->addAction( tr( "About" ), this, &MainWindow::onAbout );
+
+ resize( 1100, 820 );
+
+ loadPreferences();
+#ifdef RTA_GUI_HAS_LENSFUN
+ mergeProcessEnvironmentLensfunPathsIntoList();
+ writeLensfunListToProcessEnvironment();
+#endif
+}
+
+rta::util::ImageConverter::Settings MainWindow::buildSettingsFromUi() const
+{
+ S s;
+
+ switch ( m_wbMethod->currentIndex() )
+ {
+ case 0: s.WB_method = S::WBMethod::Metadata; break;
+ case 1: s.WB_method = S::WBMethod::Illuminant; break;
+ case 2: s.WB_method = S::WBMethod::Box; break;
+ default: s.WB_method = S::WBMethod::Custom; break;
+ }
+
+ switch ( m_matrixMethod->currentIndex() )
+ {
+ case 0: s.matrix_method = S::MatrixMethod::Auto; break;
+ case 1: s.matrix_method = S::MatrixMethod::Spectral; break;
+ case 2: s.matrix_method = S::MatrixMethod::Metadata; break;
+ case 3: s.matrix_method = S::MatrixMethod::Adobe; break;
+ default: s.matrix_method = S::MatrixMethod::Custom; break;
+ }
+
+ s.illuminant = m_illuminant->text().toStdString();
+ if ( s.WB_method == S::WBMethod::Illuminant && s.illuminant.empty() )
+ {
+ s.illuminant = "D55";
+ }
+
+ for ( int i = 0; i < 4; ++i )
+ {
+ s.WB_box[i] = m_wbBox[i]->value();
+ }
+ for ( int i = 0; i < 4; ++i )
+ {
+ s.custom_WB[i] = static_cast( m_customWb[i]->value() );
+ }
+ for ( int r = 0; r < 3; ++r )
+ {
+ for ( int c = 0; c < 3; ++c )
+ {
+ s.custom_matrix[r][c] =
+ static_cast( m_customMat[r][c]->value() );
+ }
+ }
+
+ s.custom_camera_make = m_customCameraMake->text().toStdString();
+ s.custom_camera_model = m_customCameraModel->text().toStdString();
+ s.headroom = static_cast( m_headroom->value() );
+ s.scale = static_cast( m_scale->value() );
+
+ s.auto_bright = m_autoBright->isChecked();
+ s.adjust_maximum_threshold = static_cast( m_adjustMaximum->value() );
+ s.black_level = ( m_blackLevelFromMetadata != nullptr &&
+ m_blackLevelFromMetadata->isChecked() )
+ ? -1
+ : m_blackLevel->value();
+ s.saturation_level = ( m_saturationFromMetadata != nullptr &&
+ m_saturationFromMetadata->isChecked() )
+ ? 0
+ : m_saturationLevel->value();
+ s.chromatic_aberration[0] = static_cast( m_chromaR->value() );
+ s.chromatic_aberration[1] = static_cast( m_chromaB->value() );
+ s.half_size = m_halfSize->isChecked();
+ s.highlight_mode = m_highlightMode->currentData().toInt();
+ for ( int i = 0; i < 4; ++i )
+ {
+ s.crop_box[i] = m_cropBox[i]->value();
+ }
+
+ switch ( m_cropMode->currentIndex() )
+ {
+ case 0: s.crop_mode = S::CropMode::Off; break;
+ case 1: s.crop_mode = S::CropMode::Soft; break;
+ default: s.crop_mode = S::CropMode::Hard; break;
+ }
+
+ s.flip = m_flip->currentData().toInt();
+ s.denoise_threshold = static_cast( m_denoise->value() );
+ s.demosaic_algorithm = m_demosaic->currentText().toStdString();
+
+ s.overwrite = m_overwrite->isChecked();
+ s.create_dirs = m_createDirs->isChecked();
+ s.output_dir = m_outputDir->text().toStdString();
+
+ s.database_directories =
+ spectralDatabaseDirsFromListWidget( m_dataDirList );
+
+#ifdef RTA_GUI_HAS_LENSFUN
+ if ( m_lensCorrAberration != nullptr )
+ {
+ s.lens_correction_types = S::LensCorrectionType::None;
+ if ( m_lensCorrAberration->isChecked() )
+ {
+ s.lens_correction_types |= S::LensCorrectionType::Aberration;
+ }
+ if ( m_lensCorrDistortion->isChecked() )
+ {
+ s.lens_correction_types |= S::LensCorrectionType::Distortion;
+ }
+ if ( m_lensCorrVignetting->isChecked() )
+ {
+ s.lens_correction_types |= S::LensCorrectionType::Vignetting;
+ }
+ s.require_lens_correction = m_requireLens->isChecked();
+ if ( m_lensMetadataOverride != nullptr &&
+ m_lensMetadataOverride->isChecked() )
+ {
+ s.custom_lens_make = m_lensMake->text().toStdString();
+ s.custom_lens_model = m_lensModel->text().toStdString();
+ s.custom_aperture = static_cast( m_lensAperture->value() );
+ s.custom_focal_length = static_cast( m_lensFocal->value() );
+ s.custom_focus_distance =
+ static_cast( m_lensFocus->value() );
+ }
+ else
+ {
+ s.custom_lens_make.clear();
+ s.custom_lens_model.clear();
+ s.custom_aperture = 0.0f;
+ s.custom_focal_length = 0.0f;
+ s.custom_focus_distance = 0.0f;
+ }
+ }
+#endif
+
+ s.use_timing = m_useTiming->isChecked();
+ s.disable_cache = m_disableCache->isChecked();
+ s.disable_exiftool = m_disableExiftool->isChecked();
+ {
+ bool ok = false;
+ const int v = m_verbosity->currentData().toInt( &ok );
+ s.verbosity = ok ? v : 0;
+ }
+
+ return s;
+}
+
+void MainWindow::appendLog( const QString &line )
+{
+ m_log->append( line );
+}
+
+void MainWindow::setUiBusy( bool busy )
+{
+ m_convertButton->setEnabled( !busy );
+ m_cancelButton->setEnabled( busy );
+}
+
+void MainWindow::updateMatrixMethodDependentUi()
+{
+ // Combo order matches `buildSettingsFromUi`: …, custom (index 4).
+ const bool useCustomMatrix =
+ m_matrixMethod != nullptr && m_matrixMethod->currentIndex() == 4;
+ if ( m_customMatrixWrap != nullptr )
+ {
+ m_customMatrixWrap->setVisible( useCustomMatrix );
+ }
+ if ( m_customMatrixLabel != nullptr )
+ {
+ m_customMatrixLabel->setVisible( useCustomMatrix );
+ }
+}
+
+void MainWindow::updateWbMethodDependentUi()
+{
+ if ( m_wbMethod == nullptr )
+ {
+ return;
+ }
+ const int idx = m_wbMethod->currentIndex();
+ const bool showIlluminant = ( idx == 1 );
+ const bool showBoxRegion = ( idx == 2 );
+ const bool showCustomGains = ( idx == 3 );
+
+ auto showPair = []( QWidget *field, QLabel *lab, bool on ) {
+ if ( field != nullptr )
+ {
+ field->setVisible( on );
+ }
+ if ( lab != nullptr )
+ {
+ lab->setVisible( on );
+ }
+ };
+
+ showPair( m_wbIlluminantWrap, m_wbIlluminantLabel, showIlluminant );
+ showPair( m_wbBoxRegionWrap, m_wbBoxRegionLabel, showBoxRegion );
+ showPair( m_wbCustomGainsWrap, m_wbCustomGainsLabel, showCustomGains );
+}
+
+void MainWindow::updateBlackSaturationUi()
+{
+ if ( m_blackLevel != nullptr && m_blackLevelFromMetadata != nullptr )
+ {
+ m_blackLevel->setEnabled( !m_blackLevelFromMetadata->isChecked() );
+ }
+ if ( m_saturationLevel != nullptr && m_saturationFromMetadata != nullptr )
+ {
+ m_saturationLevel->setEnabled( !m_saturationFromMetadata->isChecked() );
+ }
+}
+
+void MainWindow::updateLensMetadataOverrideUi()
+{
+#ifdef RTA_GUI_HAS_LENSFUN
+ if ( m_lensMetadataOverride == nullptr || m_lensMake == nullptr )
+ {
+ return;
+ }
+ const bool on = m_lensMetadataOverride->isChecked();
+ m_lensMake->setEnabled( on );
+ m_lensModel->setEnabled( on );
+ m_lensAperture->setEnabled( on );
+ m_lensFocal->setEnabled( on );
+ m_lensFocus->setEnabled( on );
+#endif
+}
+
+void MainWindow::onAddFiles()
+{
+ const QStringList files = QFileDialog::getOpenFileNames( this );
+ for ( const QString &f: files )
+ {
+ m_fileList->addItem( f );
+ }
+}
+
+void MainWindow::onAddFolder()
+{
+ const QString dir = QFileDialog::getExistingDirectory( this );
+ if ( dir.isEmpty() )
+ {
+ return;
+ }
+ const std::vector paths = { dir.toStdString() };
+ const auto batches = rta::util::collect_image_files( paths );
+ const QStringList flat = flattenBatches( batches );
+ for ( const QString &f: flat )
+ {
+ m_fileList->addItem( f );
+ }
+}
+
+void MainWindow::onRemoveSelected()
+{
+ for ( auto *item: m_fileList->selectedItems() )
+ {
+ delete m_fileList->takeItem( m_fileList->row( item ) );
+ }
+}
+
+void MainWindow::onClearFiles()
+{
+ m_fileList->clear();
+}
+
+void MainWindow::onBrowseOutput()
+{
+ const QString d = QFileDialog::getExistingDirectory( this );
+ if ( !d.isEmpty() )
+ {
+ m_outputDir->setText( d );
+ }
+}
+
+void MainWindow::onAddSpectralDataFolder()
+{
+ if ( m_dataDirList == nullptr )
+ {
+ return;
+ }
+ const QString d = QFileDialog::getExistingDirectory(
+ this,
+ tr( "Add spectral data directory" ),
+ {},
+ QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks );
+ if ( d.isEmpty() )
+ {
+ return;
+ }
+ const QString clean = QDir::cleanPath( d );
+ for ( int i = 0; i < m_dataDirList->count(); ++i )
+ {
+ if ( QDir::cleanPath( m_dataDirList->item( i )->text() ) == clean )
+ {
+ return;
+ }
+ }
+ m_dataDirList->addItem( clean );
+}
+
+void MainWindow::onRemoveSpectralDataSelected()
+{
+ if ( m_dataDirList == nullptr )
+ {
+ return;
+ }
+ qDeleteAll( m_dataDirList->selectedItems() );
+}
+
+void MainWindow::onMoveSpectralDataUp()
+{
+ if ( m_dataDirList == nullptr )
+ {
+ return;
+ }
+ const int row = m_dataDirList->currentRow();
+ if ( row <= 0 )
+ {
+ return;
+ }
+ QListWidgetItem *const item = m_dataDirList->takeItem( row );
+ m_dataDirList->insertItem( row - 1, item );
+ m_dataDirList->setCurrentRow( row - 1 );
+}
+
+void MainWindow::onMoveSpectralDataDown()
+{
+ if ( m_dataDirList == nullptr )
+ {
+ return;
+ }
+ const int row = m_dataDirList->currentRow();
+ if ( row < 0 || row >= m_dataDirList->count() - 1 )
+ {
+ return;
+ }
+ QListWidgetItem *const item = m_dataDirList->takeItem( row );
+ m_dataDirList->insertItem( row + 1, item );
+ m_dataDirList->setCurrentRow( row + 1 );
+}
+
+void MainWindow::onConvert()
+{
+ QStringList paths;
+ for ( int i = 0; i < m_fileList->count(); ++i )
+ {
+ paths << m_fileList->item( i )->text();
+ }
+ if ( paths.isEmpty() )
+ {
+ QMessageBox::warning(
+ this,
+ QApplication::applicationDisplayName(),
+ tr( "Add at least one input file." ) );
+ return;
+ }
+
+ const S settings = buildSettingsFromUi();
+
+ m_progress->setRange( 0, paths.size() );
+ m_progress->setValue( 0 );
+ appendLog( tr( "Starting batch (%1 files)…" ).arg( paths.size() ) );
+
+ auto *thread = new ConversionThread( this );
+ m_worker = thread;
+ connect(
+ thread,
+ &ConversionThread::fileStarted,
+ this,
+ &MainWindow::onConversionFileStarted );
+ connect(
+ thread,
+ &ConversionThread::fileFinished,
+ this,
+ &MainWindow::onConversionFileFinished );
+ connect(
+ thread,
+ &ConversionThread::progress,
+ this,
+ &MainWindow::onConversionProgress );
+ connect(
+ thread,
+ &ConversionThread::batchFinished,
+ this,
+ &MainWindow::onBatchFinished );
+ connect( thread, &QThread::finished, thread, &QObject::deleteLater );
+
+ thread->setJob( settings, paths );
+ setUiBusy( true );
+ thread->start();
+}
+
+void MainWindow::onCancel()
+{
+ if ( m_worker )
+ {
+ m_worker->requestCancel();
+ appendLog( tr( "Cancel requested after current file…" ) );
+ }
+}
+
+void MainWindow::onConversionFileStarted( int index, QString path )
+{
+ appendLog( tr( "[%1] %2" ).arg( index + 1 ).arg( path ) );
+}
+
+void MainWindow::onConversionFileFinished( int index, bool ok, QString message )
+{
+ if ( ok )
+ {
+ appendLog( tr( " OK" ) );
+ }
+ else
+ {
+ appendLog( tr( " FAILED: %1" ).arg( message ) );
+ }
+ Q_UNUSED( index );
+}
+
+void MainWindow::onConversionProgress( int done, int total )
+{
+ m_progress->setMaximum( total );
+ m_progress->setValue( done );
+}
+
+void MainWindow::onBatchFinished()
+{
+ appendLog( tr( "Batch finished." ) );
+ setUiBusy( false );
+ m_worker = nullptr;
+}
+
+void MainWindow::onAbout()
+{
+ QString body = tr(
+ "%1 — ACES container output from camera RAW.\n"
+ "Settings match the same options as the rawtoaces image converter." )
+ .arg( QApplication::applicationDisplayName() );
+#ifdef VERSION
+ body.prepend( tr( "Version %1\n\n" ).arg( QStringLiteral( VERSION ) ) );
+#endif
+ QMessageBox::about(
+ this,
+ tr( "About %1" ).arg( QApplication::applicationDisplayName() ),
+ body );
+}
+
+void MainWindow::showPreferences()
+{
+ if ( m_preferencesDialog == nullptr )
+ {
+ return;
+ }
+ m_preferencesDialog->exec();
+}
+
+#ifdef RTA_GUI_HAS_LENSFUN
+
+namespace
+{
+QString lensfunEnvPathListSeparator()
+{
+#if defined( Q_OS_WIN )
+ return QStringLiteral( ";" );
+#else
+ return QStringLiteral( ":" );
+#endif
+}
+} // namespace
+
+void MainWindow::writeLensfunListToProcessEnvironment()
+{
+ if ( m_lensfunDirList == nullptr )
+ {
+ return;
+ }
+ QStringList parts;
+ for ( int i = 0; i < m_lensfunDirList->count(); ++i )
+ {
+ const QString one = m_lensfunDirList->item( i )->text().trimmed();
+ if ( !one.isEmpty() )
+ {
+ parts << QDir::cleanPath( one );
+ }
+ }
+ if ( parts.isEmpty() )
+ {
+ qunsetenv( "RAWTOACES_LENSFUNDB_PATH" );
+ }
+ else
+ {
+ qputenv(
+ "RAWTOACES_LENSFUNDB_PATH",
+ parts.join( lensfunEnvPathListSeparator() ).toUtf8() );
+ }
+}
+
+void MainWindow::mergeProcessEnvironmentLensfunPathsIntoList()
+{
+ if ( m_lensfunDirList == nullptr )
+ {
+ return;
+ }
+ const QByteArray raw = qgetenv( "RAWTOACES_LENSFUNDB_PATH" );
+ if ( raw.isEmpty() )
+ {
+ return;
+ }
+ const QString envPaths = QString::fromLocal8Bit( raw );
+ const QStringList split =
+ envPaths.split( lensfunEnvPathListSeparator(), Qt::SkipEmptyParts );
+ for ( QString path: split )
+ {
+ path = path.trimmed();
+ if ( path.isEmpty() )
+ {
+ continue;
+ }
+ const QString clean = QDir::cleanPath( path );
+ bool dup = false;
+ for ( int i = 0; i < m_lensfunDirList->count(); ++i )
+ {
+ if ( QDir::cleanPath( m_lensfunDirList->item( i )->text() ) ==
+ clean )
+ {
+ dup = true;
+ break;
+ }
+ }
+ if ( !dup )
+ {
+ m_lensfunDirList->addItem( clean );
+ }
+ }
+}
+
+void MainWindow::onReloadLensfunDatabase()
+{
+ writeLensfunListToProcessEnvironment();
+ rta::util::reset_database();
+ appendLog( tr(
+ "Lensfun database paths were written to RAWTOACES_LENSFUNDB_PATH and "
+ "the cached profile database was cleared; the next conversion will "
+ "reload XML from disk." ) );
+}
+
+#endif // RTA_GUI_HAS_LENSFUN
+
+namespace
+{
+constexpr auto kPrefsRootQLS = "rawtoaces_gui";
+constexpr int kPrefsFormatVersion = 1;
+
+void setComboBoxIndexClamped( QComboBox *comboBox, int index )
+{
+ if ( comboBox == nullptr || comboBox->count() <= 0 )
+ {
+ return;
+ }
+ comboBox->setCurrentIndex( qBound( 0, index, comboBox->count() - 1 ) );
+}
+
+void setComboBoxCurrentByIntData( QComboBox *comboBox, int value )
+{
+ if ( comboBox == nullptr || comboBox->count() <= 0 )
+ {
+ return;
+ }
+ const int found = comboBox->findData( value );
+ comboBox->setCurrentIndex( found >= 0 ? found : 0 );
+}
+
+void setVerbosityComboFromLevel( QComboBox *comboBox, int level )
+{
+ if ( comboBox == nullptr )
+ {
+ return;
+ }
+ const int clamped = std::clamp( level, 0, 4 );
+ const int found = comboBox->findData( clamped );
+ comboBox->setCurrentIndex( found >= 0 ? found : 0 );
+}
+
+} // namespace
+
+void MainWindow::closeEvent( QCloseEvent *event )
+{
+ if ( m_worker != nullptr && m_worker->isRunning() )
+ {
+ const auto reply = QMessageBox::question(
+ this,
+ QApplication::applicationDisplayName(),
+ tr( "A conversion is still running. Cancel it and close?" ),
+ QMessageBox::Yes | QMessageBox::No,
+ QMessageBox::No );
+ if ( reply != QMessageBox::Yes )
+ {
+ event->ignore();
+ return;
+ }
+ m_worker->requestCancel();
+ constexpr int kStopWaitMs = 300000;
+ if ( !m_worker->wait( kStopWaitMs ) )
+ {
+ QMessageBox::warning(
+ this,
+ QApplication::applicationDisplayName(),
+ tr( "The conversion could not be stopped before the timeout. "
+ "Try again after the current file finishes." ) );
+ event->ignore();
+ return;
+ }
+ }
+ savePreferences();
+ QMainWindow::closeEvent( event );
+}
+
+void MainWindow::savePreferences() const
+{
+ QSettings settings;
+ settings.beginGroup( kPrefsRootQLS );
+ settings.setValue( QStringLiteral( "formatVersion" ), kPrefsFormatVersion );
+
+ settings.beginGroup( QStringLiteral( "window" ) );
+ settings.setValue( QStringLiteral( "geometry" ), saveGeometry() );
+ if ( m_settingsTabs != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "settingsTab" ), m_settingsTabs->currentIndex() );
+ }
+ settings.endGroup();
+
+ settings.beginGroup( QStringLiteral( "paths" ) );
+ if ( m_outputDir != nullptr )
+ {
+ settings.setValue( QStringLiteral( "outputDir" ), m_outputDir->text() );
+ }
+ if ( m_dataDirList != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "spectralDataOverride" ),
+ spectralDataPathsJoinedForSettings( m_dataDirList ) );
+ }
+ settings.endGroup();
+
+ settings.beginGroup( QStringLiteral( "colour" ) );
+ if ( m_wbMethod != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "wbMethodIndex" ), m_wbMethod->currentIndex() );
+ }
+ if ( m_illuminant != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "illuminant" ), m_illuminant->text() );
+ }
+ for ( int i = 0; i < 4 && m_wbBox[i] != nullptr; ++i )
+ {
+ settings.setValue(
+ QStringLiteral( "wbBox%1" ).arg( i ), m_wbBox[i]->value() );
+ }
+ for ( int i = 0; i < 4 && m_customWb[i] != nullptr; ++i )
+ {
+ settings.setValue(
+ QStringLiteral( "customWb%1" ).arg( i ), m_customWb[i]->value() );
+ }
+ if ( m_matrixMethod != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "matrixMethodIndex" ),
+ m_matrixMethod->currentIndex() );
+ }
+ for ( int r = 0; r < 3; ++r )
+ {
+ for ( int c = 0; c < 3; ++c )
+ {
+ if ( m_customMat[r][c] != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "customMatrix_%1_%2" ).arg( r ).arg( c ),
+ m_customMat[r][c]->value() );
+ }
+ }
+ }
+ if ( m_customCameraMake != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "customCameraMake" ), m_customCameraMake->text() );
+ }
+ if ( m_customCameraModel != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "customCameraModel" ),
+ m_customCameraModel->text() );
+ }
+ if ( m_headroom != nullptr )
+ {
+ settings.setValue( QStringLiteral( "headroom" ), m_headroom->value() );
+ }
+ if ( m_scale != nullptr )
+ {
+ settings.setValue( QStringLiteral( "scale" ), m_scale->value() );
+ }
+ settings.endGroup();
+
+ settings.beginGroup( QStringLiteral( "raw" ) );
+ if ( m_autoBright != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "autoBright" ), m_autoBright->isChecked() );
+ }
+ if ( m_adjustMaximum != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "adjustMaximum" ), m_adjustMaximum->value() );
+ }
+ if ( m_blackLevelFromMetadata != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "blackLevelFromMetadata" ),
+ m_blackLevelFromMetadata->isChecked() );
+ }
+ if ( m_blackLevel != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "blackLevel" ), m_blackLevel->value() );
+ }
+ if ( m_saturationFromMetadata != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "saturationFromMetadata" ),
+ m_saturationFromMetadata->isChecked() );
+ }
+ if ( m_saturationLevel != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "saturationLevel" ), m_saturationLevel->value() );
+ }
+ if ( m_chromaR != nullptr )
+ {
+ settings.setValue( QStringLiteral( "chromaR" ), m_chromaR->value() );
+ }
+ if ( m_chromaB != nullptr )
+ {
+ settings.setValue( QStringLiteral( "chromaB" ), m_chromaB->value() );
+ }
+ if ( m_halfSize != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "halfSize" ), m_halfSize->isChecked() );
+ }
+ if ( m_highlightMode != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "highlightMode" ),
+ m_highlightMode->currentData().toInt() );
+ }
+ for ( int i = 0; i < 4 && m_cropBox[i] != nullptr; ++i )
+ {
+ settings.setValue(
+ QStringLiteral( "cropBox%1" ).arg( i ), m_cropBox[i]->value() );
+ }
+ if ( m_cropMode != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "cropModeIndex" ), m_cropMode->currentIndex() );
+ }
+ if ( m_flip != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "flip" ), m_flip->currentData().toInt() );
+ }
+ if ( m_denoise != nullptr )
+ {
+ settings.setValue( QStringLiteral( "denoise" ), m_denoise->value() );
+ }
+ if ( m_demosaic != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "demosaicAlgorithm" ), m_demosaic->currentText() );
+ }
+ settings.endGroup();
+
+ settings.beginGroup( QStringLiteral( "output" ) );
+ if ( m_overwrite != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "overwrite" ), m_overwrite->isChecked() );
+ }
+ if ( m_createDirs != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "createDirs" ), m_createDirs->isChecked() );
+ }
+ settings.endGroup();
+
+ settings.beginGroup( QStringLiteral( "diagnostics" ) );
+ if ( m_useTiming != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "useTiming" ), m_useTiming->isChecked() );
+ }
+ if ( m_disableCache != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "disableCache" ), m_disableCache->isChecked() );
+ }
+ if ( m_disableExiftool != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "disableExiftool" ),
+ m_disableExiftool->isChecked() );
+ }
+ if ( m_verbosity != nullptr )
+ {
+ bool ok = false;
+ const int v = m_verbosity->currentData().toInt( &ok );
+ settings.setValue( QStringLiteral( "verbosity" ), ok ? v : 0 );
+ }
+ settings.endGroup();
+
+#ifdef RTA_GUI_HAS_LENSFUN
+ if ( m_lensCorrAberration != nullptr )
+ {
+ settings.beginGroup( QStringLiteral( "lens" ) );
+ settings.setValue(
+ QStringLiteral( "corrAberration" ),
+ m_lensCorrAberration->isChecked() );
+ settings.setValue(
+ QStringLiteral( "corrDistortion" ),
+ m_lensCorrDistortion->isChecked() );
+ settings.setValue(
+ QStringLiteral( "corrVignetting" ),
+ m_lensCorrVignetting->isChecked() );
+ settings.setValue(
+ QStringLiteral( "requireLens" ), m_requireLens->isChecked() );
+ settings.setValue(
+ QStringLiteral( "lensMetadataOverride" ),
+ m_lensMetadataOverride->isChecked() );
+ settings.setValue( QStringLiteral( "lensMake" ), m_lensMake->text() );
+ settings.setValue( QStringLiteral( "lensModel" ), m_lensModel->text() );
+ settings.setValue(
+ QStringLiteral( "lensAperture" ), m_lensAperture->value() );
+ settings.setValue(
+ QStringLiteral( "lensFocal" ), m_lensFocal->value() );
+ settings.setValue(
+ QStringLiteral( "lensFocus" ), m_lensFocus->value() );
+ if ( m_lensfunDirList != nullptr )
+ {
+ settings.setValue(
+ QStringLiteral( "lensfunDatabasePaths" ),
+ spectralDataPathsJoinedForSettings( m_lensfunDirList ) );
+ }
+ settings.endGroup();
+ }
+#endif
+
+ settings.endGroup();
+ settings.sync();
+}
+
+void MainWindow::loadPreferences()
+{
+ QSettings settings;
+ settings.beginGroup( kPrefsRootQLS );
+ if ( !settings.contains( QStringLiteral( "formatVersion" ) ) )
+ {
+ settings.endGroup();
+ return;
+ }
+
+ settings.beginGroup( QStringLiteral( "window" ) );
+ const QByteArray geometry =
+ settings.value( QStringLiteral( "geometry" ) ).toByteArray();
+ if ( !geometry.isEmpty() )
+ {
+ restoreGeometry( geometry );
+ }
+ int tabIndex =
+ settings.value( QStringLiteral( "settingsTab" ), 0 ).toInt();
+ // Was: 0 Inputs, 1 Basic, 2 Advanced, 3 Logs. Now: 0 Inputs, 1 Settings, 2 Logs.
+ if ( tabIndex >= 3 )
+ {
+ tabIndex -= 1;
+ }
+ else if ( tabIndex >= 1 )
+ {
+ tabIndex = 1;
+ }
+ settings.endGroup();
+
+ settings.beginGroup( QStringLiteral( "paths" ) );
+ if ( m_outputDir != nullptr )
+ {
+ m_outputDir->setText(
+ settings.value( QStringLiteral( "outputDir" ) ).toString() );
+ }
+ if ( m_dataDirList != nullptr )
+ {
+ populateSpectralDataListFromSettingsString(
+ m_dataDirList,
+ settings.value( QStringLiteral( "spectralDataOverride" ) )
+ .toString() );
+ }
+ settings.endGroup();
+
+ settings.beginGroup( QStringLiteral( "colour" ) );
+ setComboBoxIndexClamped(
+ m_wbMethod,
+ settings.value( QStringLiteral( "wbMethodIndex" ), 0 ).toInt() );
+ if ( m_illuminant != nullptr )
+ {
+ m_illuminant->setText(
+ settings.value( QStringLiteral( "illuminant" ) ).toString() );
+ }
+ for ( int i = 0; i < 4 && m_wbBox[i] != nullptr; ++i )
+ {
+ m_wbBox[i]->setValue(
+ settings
+ .value(
+ QStringLiteral( "wbBox%1" ).arg( i ), m_wbBox[i]->value() )
+ .toInt() );
+ }
+ for ( int i = 0; i < 4 && m_customWb[i] != nullptr; ++i )
+ {
+ m_customWb[i]->setValue(
+ settings
+ .value(
+ QStringLiteral( "customWb%1" ).arg( i ),
+ m_customWb[i]->value() )
+ .toDouble() );
+ }
+ setComboBoxIndexClamped(
+ m_matrixMethod,
+ settings.value( QStringLiteral( "matrixMethodIndex" ), 0 ).toInt() );
+ for ( int r = 0; r < 3; ++r )
+ {
+ for ( int c = 0; c < 3; ++c )
+ {
+ if ( m_customMat[r][c] != nullptr )
+ {
+ m_customMat[r][c]->setValue(
+ settings
+ .value(
+ QStringLiteral( "customMatrix_%1_%2" )
+ .arg( r )
+ .arg( c ),
+ m_customMat[r][c]->value() )
+ .toDouble() );
+ }
+ }
+ }
+ if ( m_customCameraMake != nullptr )
+ {
+ m_customCameraMake->setText(
+ settings.value( QStringLiteral( "customCameraMake" ) ).toString() );
+ }
+ if ( m_customCameraModel != nullptr )
+ {
+ m_customCameraModel->setText(
+ settings.value( QStringLiteral( "customCameraModel" ) )
+ .toString() );
+ }
+ if ( m_headroom != nullptr )
+ {
+ m_headroom->setValue(
+ settings.value( QStringLiteral( "headroom" ), m_headroom->value() )
+ .toDouble() );
+ }
+ if ( m_scale != nullptr )
+ {
+ m_scale->setValue(
+ settings.value( QStringLiteral( "scale" ), m_scale->value() )
+ .toDouble() );
+ }
+ settings.endGroup();
+
+ settings.beginGroup( QStringLiteral( "raw" ) );
+ if ( m_autoBright != nullptr )
+ {
+ m_autoBright->setChecked(
+ settings
+ .value(
+ QStringLiteral( "autoBright" ), m_autoBright->isChecked() )
+ .toBool() );
+ }
+ if ( m_adjustMaximum != nullptr )
+ {
+ m_adjustMaximum->setValue( settings
+ .value(
+ QStringLiteral( "adjustMaximum" ),
+ m_adjustMaximum->value() )
+ .toDouble() );
+ }
+ if ( m_blackLevelFromMetadata != nullptr )
+ {
+ m_blackLevelFromMetadata->setChecked(
+ settings
+ .value(
+ QStringLiteral( "blackLevelFromMetadata" ),
+ m_blackLevelFromMetadata->isChecked() )
+ .toBool() );
+ }
+ if ( m_blackLevel != nullptr )
+ {
+ m_blackLevel->setValue(
+ settings
+ .value( QStringLiteral( "blackLevel" ), m_blackLevel->value() )
+ .toInt() );
+ }
+ if ( m_saturationFromMetadata != nullptr )
+ {
+ m_saturationFromMetadata->setChecked(
+ settings
+ .value(
+ QStringLiteral( "saturationFromMetadata" ),
+ m_saturationFromMetadata->isChecked() )
+ .toBool() );
+ }
+ if ( m_saturationLevel != nullptr )
+ {
+ m_saturationLevel->setValue(
+ settings
+ .value(
+ QStringLiteral( "saturationLevel" ),
+ m_saturationLevel->value() )
+ .toInt() );
+ }
+ if ( m_chromaR != nullptr )
+ {
+ m_chromaR->setValue(
+ settings.value( QStringLiteral( "chromaR" ), m_chromaR->value() )
+ .toDouble() );
+ }
+ if ( m_chromaB != nullptr )
+ {
+ m_chromaB->setValue(
+ settings.value( QStringLiteral( "chromaB" ), m_chromaB->value() )
+ .toDouble() );
+ }
+ if ( m_halfSize != nullptr )
+ {
+ m_halfSize->setChecked(
+ settings
+ .value( QStringLiteral( "halfSize" ), m_halfSize->isChecked() )
+ .toBool() );
+ }
+ if ( m_highlightMode != nullptr )
+ {
+ const int v = settings
+ .value(
+ QStringLiteral( "highlightMode" ),
+ m_highlightMode->currentData().toInt() )
+ .toInt();
+ setComboBoxCurrentByIntData( m_highlightMode, std::clamp( v, 0, 9 ) );
+ }
+ for ( int i = 0; i < 4 && m_cropBox[i] != nullptr; ++i )
+ {
+ m_cropBox[i]->setValue( settings
+ .value(
+ QStringLiteral( "cropBox%1" ).arg( i ),
+ m_cropBox[i]->value() )
+ .toInt() );
+ }
+ setComboBoxIndexClamped(
+ m_cropMode,
+ settings.value( QStringLiteral( "cropModeIndex" ), 1 ).toInt() );
+ if ( m_flip != nullptr )
+ {
+ const int v =
+ settings
+ .value(
+ QStringLiteral( "flip" ), m_flip->currentData().toInt() )
+ .toInt();
+ setComboBoxCurrentByIntData( m_flip, std::clamp( v, 0, 8 ) );
+ }
+ if ( m_denoise != nullptr )
+ {
+ m_denoise->setValue(
+ settings.value( QStringLiteral( "denoise" ), m_denoise->value() )
+ .toDouble() );
+ }
+ if ( m_demosaic != nullptr )
+ {
+ const QString algo =
+ settings.value( QStringLiteral( "demosaicAlgorithm" ) ).toString();
+ if ( !algo.isEmpty() )
+ {
+ const int demosaicIx = m_demosaic->findText( algo );
+ if ( demosaicIx >= 0 )
+ {
+ m_demosaic->setCurrentIndex( demosaicIx );
+ }
+ }
+ }
+ settings.endGroup();
+
+ settings.beginGroup( QStringLiteral( "output" ) );
+ if ( m_overwrite != nullptr )
+ {
+ m_overwrite->setChecked(
+ settings
+ .value(
+ QStringLiteral( "overwrite" ), m_overwrite->isChecked() )
+ .toBool() );
+ }
+ if ( m_createDirs != nullptr )
+ {
+ m_createDirs->setChecked(
+ settings
+ .value(
+ QStringLiteral( "createDirs" ), m_createDirs->isChecked() )
+ .toBool() );
+ }
+ settings.endGroup();
+
+ settings.beginGroup( QStringLiteral( "diagnostics" ) );
+ if ( m_useTiming != nullptr )
+ {
+ m_useTiming->setChecked(
+ settings
+ .value(
+ QStringLiteral( "useTiming" ), m_useTiming->isChecked() )
+ .toBool() );
+ }
+ if ( m_disableCache != nullptr )
+ {
+ m_disableCache->setChecked( settings
+ .value(
+ QStringLiteral( "disableCache" ),
+ m_disableCache->isChecked() )
+ .toBool() );
+ }
+ if ( m_disableExiftool != nullptr )
+ {
+ m_disableExiftool->setChecked(
+ settings
+ .value(
+ QStringLiteral( "disableExiftool" ),
+ m_disableExiftool->isChecked() )
+ .toBool() );
+ }
+ if ( m_verbosity != nullptr )
+ {
+ setVerbosityComboFromLevel(
+ m_verbosity,
+ settings.value( QStringLiteral( "verbosity" ), 0 ).toInt() );
+ }
+ settings.endGroup();
+
+#ifdef RTA_GUI_HAS_LENSFUN
+ if ( m_lensCorrAberration != nullptr )
+ {
+ settings.beginGroup( QStringLiteral( "lens" ) );
+ m_lensCorrAberration->setChecked(
+ settings
+ .value(
+ QStringLiteral( "corrAberration" ),
+ m_lensCorrAberration->isChecked() )
+ .toBool() );
+ m_lensCorrDistortion->setChecked(
+ settings
+ .value(
+ QStringLiteral( "corrDistortion" ),
+ m_lensCorrDistortion->isChecked() )
+ .toBool() );
+ m_lensCorrVignetting->setChecked(
+ settings
+ .value(
+ QStringLiteral( "corrVignetting" ),
+ m_lensCorrVignetting->isChecked() )
+ .toBool() );
+ m_requireLens->setChecked( settings
+ .value(
+ QStringLiteral( "requireLens" ),
+ m_requireLens->isChecked() )
+ .toBool() );
+ m_lensMetadataOverride->setChecked(
+ settings.value( QStringLiteral( "lensMetadataOverride" ), false )
+ .toBool() );
+ m_lensMake->setText(
+ settings.value( QStringLiteral( "lensMake" ) ).toString() );
+ m_lensModel->setText(
+ settings.value( QStringLiteral( "lensModel" ) ).toString() );
+ m_lensAperture->setValue(
+ settings
+ .value(
+ QStringLiteral( "lensAperture" ), m_lensAperture->value() )
+ .toDouble() );
+ m_lensFocal->setValue(
+ settings
+ .value( QStringLiteral( "lensFocal" ), m_lensFocal->value() )
+ .toDouble() );
+ m_lensFocus->setValue(
+ settings
+ .value( QStringLiteral( "lensFocus" ), m_lensFocus->value() )
+ .toDouble() );
+ if ( m_lensfunDirList != nullptr )
+ {
+ populateSpectralDataListFromSettingsString(
+ m_lensfunDirList,
+ settings.value( QStringLiteral( "lensfunDatabasePaths" ) )
+ .toString() );
+ }
+ settings.endGroup();
+ updateLensMetadataOverrideUi();
+ }
+#endif
+
+ settings.endGroup();
+
+ updateWbMethodDependentUi();
+ updateMatrixMethodDependentUi();
+ updateBlackSaturationUi();
+
+ if ( m_settingsTabs != nullptr )
+ {
+ const int tabCount = m_settingsTabs->count();
+ if ( tabCount > 0 )
+ {
+ m_settingsTabs->setCurrentIndex(
+ qBound( 0, tabIndex, tabCount - 1 ) );
+ }
+ }
+}
diff --git a/src/rawtoaces_gui/main_window.h b/src/rawtoaces_gui/main_window.h
new file mode 100644
index 00000000..e28df9cc
--- /dev/null
+++ b/src/rawtoaces_gui/main_window.h
@@ -0,0 +1,147 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Contributors to the rawtoaces Project.
+
+#pragma once
+
+#include
+#include
+
+#include
+
+class QListWidget;
+class QLineEdit;
+class QComboBox;
+class QSpinBox;
+class QDoubleSpinBox;
+class QCheckBox;
+class QPushButton;
+class QProgressBar;
+class QTextEdit;
+class QTabWidget;
+class QCloseEvent;
+class QDialog;
+class QLabel;
+class ConversionThread;
+
+class MainWindow final : public QMainWindow
+{
+ Q_OBJECT
+
+public:
+ explicit MainWindow( QWidget *parent = nullptr );
+
+private slots:
+ void onAddFiles();
+ void onAddFolder();
+ void onRemoveSelected();
+ void onClearFiles();
+ void onBrowseOutput();
+ void onAddSpectralDataFolder();
+ void onRemoveSpectralDataSelected();
+ void onMoveSpectralDataUp();
+ void onMoveSpectralDataDown();
+ void onConvert();
+ void onCancel();
+ void onConversionFileStarted( int index, QString path );
+ void onConversionFileFinished( int index, bool ok, QString message );
+ void onConversionProgress( int done, int total );
+ void onBatchFinished();
+ void onAbout();
+ void showPreferences();
+#ifdef RTA_GUI_HAS_LENSFUN
+ void onReloadLensfunDatabase();
+#endif
+ void updateMatrixMethodDependentUi();
+ void updateWbMethodDependentUi();
+ void updateBlackSaturationUi();
+ void updateLensMetadataOverrideUi();
+
+protected:
+ void closeEvent( QCloseEvent *event ) override;
+
+private:
+ rta::util::ImageConverter::Settings buildSettingsFromUi() const;
+ void appendLog( const QString &line );
+ void setUiBusy( bool busy );
+ void loadPreferences();
+ void savePreferences() const;
+#ifdef RTA_GUI_HAS_LENSFUN
+ void mergeProcessEnvironmentLensfunPathsIntoList();
+ void writeLensfunListToProcessEnvironment();
+#endif
+
+ QListWidget *m_fileList = nullptr;
+ QLineEdit *m_outputDir = nullptr;
+ QListWidget *m_dataDirList = nullptr;
+ /// Lensfun XML roots (optional); only used when built with lensfun.
+ QListWidget *m_lensfunDirList = nullptr;
+ QPushButton *m_convertButton = nullptr;
+ QPushButton *m_cancelButton = nullptr;
+ QProgressBar *m_progress = nullptr;
+ QTextEdit *m_log = nullptr;
+
+ QTabWidget *m_settingsTabs = nullptr;
+
+ QComboBox *m_wbMethod = nullptr;
+ QComboBox *m_matrixMethod = nullptr;
+ QLineEdit *m_illuminant = nullptr;
+ QSpinBox *m_wbBox[4]{};
+ QDoubleSpinBox *m_customWb[4]{};
+ QDoubleSpinBox *m_customMat[3][3]{};
+ QLineEdit *m_customCameraMake = nullptr;
+ QLineEdit *m_customCameraModel = nullptr;
+ QDoubleSpinBox *m_headroom = nullptr;
+ QDoubleSpinBox *m_scale = nullptr;
+
+ QCheckBox *m_autoBright = nullptr;
+ QDoubleSpinBox *m_adjustMaximum = nullptr;
+ QCheckBox *m_blackLevelFromMetadata = nullptr;
+ QSpinBox *m_blackLevel = nullptr;
+ QCheckBox *m_saturationFromMetadata = nullptr;
+ QSpinBox *m_saturationLevel = nullptr;
+ QDoubleSpinBox *m_chromaR = nullptr;
+ QDoubleSpinBox *m_chromaB = nullptr;
+ QCheckBox *m_halfSize = nullptr;
+ QComboBox *m_highlightMode = nullptr;
+ QSpinBox *m_cropBox[4]{};
+ QComboBox *m_cropMode = nullptr;
+ QComboBox *m_flip = nullptr;
+ QDoubleSpinBox *m_denoise = nullptr;
+ QComboBox *m_demosaic = nullptr;
+
+ QCheckBox *m_overwrite = nullptr;
+ QCheckBox *m_createDirs = nullptr;
+
+ QCheckBox *m_lensCorrAberration = nullptr;
+ QCheckBox *m_lensCorrDistortion = nullptr;
+ QCheckBox *m_lensCorrVignetting = nullptr;
+ QCheckBox *m_requireLens = nullptr;
+ QCheckBox *m_lensMetadataOverride = nullptr;
+ QLineEdit *m_lensMake = nullptr;
+ QLineEdit *m_lensModel = nullptr;
+ QDoubleSpinBox *m_lensAperture = nullptr;
+ QDoubleSpinBox *m_lensFocal = nullptr;
+ QDoubleSpinBox *m_lensFocus = nullptr;
+
+ QCheckBox *m_useTiming = nullptr;
+ QCheckBox *m_disableCache = nullptr;
+ QCheckBox *m_disableExiftool = nullptr;
+ QComboBox *m_verbosity = nullptr;
+
+ QPointer m_worker;
+
+ /// Spectral paths, Lensfun DB roots, cache, log verbosity (single parent —
+ /// not duplicated on the Settings tab).
+ QDialog *m_preferencesDialog = nullptr;
+
+ /// Shown only when matrix method is Custom (matches `ImageConverter` usage).
+ QWidget *m_customMatrixWrap = nullptr;
+ QLabel *m_customMatrixLabel = nullptr;
+
+ QWidget *m_wbIlluminantWrap = nullptr;
+ QLabel *m_wbIlluminantLabel = nullptr;
+ QWidget *m_wbBoxRegionWrap = nullptr;
+ QLabel *m_wbBoxRegionLabel = nullptr;
+ QWidget *m_wbCustomGainsWrap = nullptr;
+ QLabel *m_wbCustomGainsLabel = nullptr;
+};
diff --git a/src/rawtoaces_util/CMakeLists.txt b/src/rawtoaces_util/CMakeLists.txt
index d76d9a42..0e39c8c7 100644
--- a/src/rawtoaces_util/CMakeLists.txt
+++ b/src/rawtoaces_util/CMakeLists.txt
@@ -1,4 +1,4 @@
-cmake_minimum_required(VERSION 3.12)
+cmake_minimum_required(VERSION 3.16)
include_directories( "${CMAKE_CURRENT_SOURCE_DIR}" )
# Include coverage support if enabled
diff --git a/src/rawtoaces_util/lens_correction.cpp b/src/rawtoaces_util/lens_correction.cpp
index 824d78f9..2cdab75b 100644
--- a/src/rawtoaces_util/lens_correction.cpp
+++ b/src/rawtoaces_util/lens_correction.cpp
@@ -8,6 +8,7 @@
#include
#include
#include
+#include
namespace rta
{
@@ -75,6 +76,10 @@ const lfModifier *modifier_from_spec(
if ( !loaded_succesfully )
{
+ // Drop failed init: avoid a leak and reset the singleton so the next
+ // call (e.g. after RAWTOACES_LENSFUNDB_PATH changes) can retry Load().
+ delete Database;
+ Database = nullptr;
error_message =
"Lensfun DB not found, please provide the path to "
"the database directory via the "
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 46fbb1c8..f7d60511 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -1,4 +1,4 @@
-cmake_minimum_required(VERSION 3.12)
+cmake_minimum_required(VERSION 3.16)
# Enable coverage for all test targets if coverage is enabled
if( ENABLE_COVERAGE AND COVERAGE_SUPPORTED )