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 @@

[![CI](https://github.com/AcademySoftwareFoundation/rawtoaces/actions/workflows/ci.yml/badge.svg)](https://github.com/AcademySoftwareFoundation/rawtoaces/actions/workflows/ci.yml) +[![GUI](https://github.com/AcademySoftwareFoundation/rawtoaces/actions/workflows/gui.yml/badge.svg)](https://github.com/AcademySoftwareFoundation/rawtoaces/actions/workflows/gui.yml) [![Code scanning – CodeQL](https://github.com/AcademySoftwareFoundation/rawtoaces/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/AcademySoftwareFoundation/rawtoaces/actions/workflows/github-code-scanning/codeql) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/11185/badge)](https://www.bestpractices.dev/projects/11185) [![codecov](https://codecov.io/gh/AcademySoftwareFoundation/rawtoaces/branch/main/graph/badge.svg)](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 )