diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98afdf1..60bc853 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,8 @@ jobs: run: cmake -B build -DCMAKE_BUILD_TYPE=Release - name: Build - run: cmake --build build --config Release --parallel + # cap parallelism: full -j OOM-kills cc1plus (heavy Qt PCH) on the runner + run: cmake --build build --config Release --parallel 2 - name: Test run: ctest --test-dir build --output-on-failure @@ -136,7 +137,9 @@ jobs: -DCMAKE_PREFIX_PATH=/opt/homebrew/opt/qt@6 - name: Build - run: cmake --build build --config Release --parallel + # cap parallelism: full -j exhausts the 7GB arm64 runner on Qt PCH + # (cc1plus), starving/killing the runner ("lost communication") + run: cmake --build build --config Release --parallel 2 - name: Test run: ctest --test-dir build --output-on-failure @@ -172,9 +175,10 @@ jobs: OpenMix-windows/* OpenMix-macos/* - # generate the Sparkle/WinSparkle appcasts that the app polls, and publish - # them to GitHub Pages. Requires a Sparkle EdDSA private key in the - # OPENMIX_SPARKLE_ED_PRIVATE_KEY secret (see docs/AUTOUPDATE.md). + # generate the Sparkle/WinSparkle appcasts + installer tree and rsync them to + # the openmix.dev VPS. Requires a Sparkle EdDSA private key in + # OPENMIX_SPARKLE_ED_PRIVATE_KEY and OPENMIX_DEPLOY_* SSH secrets + # (see docs/AUTOUPDATE.md). appcast: if: startsWith(github.ref, 'refs/tags/v') needs: [release] @@ -190,14 +194,15 @@ jobs: - name: Download artifacts uses: actions/download-artifact@v4 - - name: Build appcasts + - name: Build appcasts + installer tree env: SPARKLE_KEY: ${{ secrets.OPENMIX_SPARKLE_ED_PRIVATE_KEY }} TAG: ${{ github.ref_name }} run: | VERSION="${TAG#v}" - BASE="https://github.com/johnqherman/OpenMix/releases/download/${TAG}" - mkdir -p site + BASE="https://openmix.dev/download/${TAG}" + mkdir -p site/download/${TAG} + cp OpenMix-macos/* OpenMix-windows/* OpenMix-linux/* site/download/${TAG}/ 2>/dev/null || true # macOS: sign the DMG with Sparkle's EdDSA key and emit a full appcast SIGN=$(find /opt/homebrew /usr/local -name sign_update -type f 2>/dev/null | head -1) @@ -205,7 +210,6 @@ jobs: if [ -n "$SPARKLE_KEY" ] && [ -n "$SIGN" ] && [ -n "$DMG" ]; then echo "$SPARKLE_KEY" > /tmp/ed_key SIG=$("$SIGN" -f /tmp/ed_key "$DMG") # prints sparkle:edSignature=... length=... - LEN=$(stat -f%z "$DMG") cat > site/appcast-macos.xml < @@ -214,7 +218,7 @@ jobs: OpenMix ${VERSION} ${VERSION} ${VERSION} - + @@ -240,9 +244,43 @@ jobs: EOF fi - - name: Publish to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./site - keep_files: true + # Linux notify-fallback: tiny JSON polled by UpdateChecker + cat > site/latest.json <' + echo '' + echo "Download OpenMix ${VERSION}" + echo '' + echo "

Download OpenMix ${VERSION}

' + } > site/download/index.html + + - name: Deploy to VPS + env: + SSH_KEY: ${{ secrets.OPENMIX_DEPLOY_SSH_KEY }} + SSH_HOST: ${{ secrets.OPENMIX_DEPLOY_HOST }} + SSH_USER: ${{ secrets.OPENMIX_DEPLOY_USER }} + DEPLOY_PATH: ${{ secrets.OPENMIX_DEPLOY_PATH }} + run: | + if [ -z "$SSH_KEY" ] || [ -z "$SSH_HOST" ]; then + echo "::error::Missing OPENMIX_DEPLOY_* secrets; cannot publish to VPS" + exit 1 + fi + mkdir -p ~/.ssh + echo "$SSH_KEY" > ~/.ssh/deploy && chmod 600 ~/.ssh/deploy + ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null + # keep old release dirs (keep_files-style); appcasts + latest.json overwrite + rsync -avz -e "ssh -i ~/.ssh/deploy" site/ "${SSH_USER}@${SSH_HOST}:${DEPLOY_PATH}/" + # prune download/ to the newest 5 release dirs (version-sorted) + ssh -i ~/.ssh/deploy "${SSH_USER}@${SSH_HOST}" \ + "cd '${DEPLOY_PATH}/download' && ls -1d v*/ 2>/dev/null | sed 's:/*\$::' | sort -V | head -n -5 | xargs -r rm -rf --" diff --git a/CMakeLists.txt b/CMakeLists.txt index 6d7fd9a..3deaa3f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,13 +45,26 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(qlementine-icons) -# find liblo for OSC +# find liblo for OSC; expose one platform-agnostic target (openmix_liblo) so app +# and tests link the same name on every OS +add_library(openmix_liblo INTERFACE) if(WIN32) find_path(LIBLO_INCLUDE_DIRS lo/lo.h REQUIRED) find_library(LIBLO_LIBRARIES NAMES lo liblo REQUIRED) + target_include_directories(openmix_liblo INTERFACE ${LIBLO_INCLUDE_DIRS}) + target_link_libraries(openmix_liblo INTERFACE ${LIBLO_LIBRARIES}) + # the deploy script only bundles Qt; ship liblo's runtime DLL ourselves + get_filename_component(_liblo_libdir ${LIBLO_LIBRARIES} DIRECTORY) + find_file(LIBLO_DLL NAMES lo.dll liblo.dll + HINTS ${_liblo_libdir}/../bin ${LIBLO_INCLUDE_DIRS}/../bin) + if(NOT LIBLO_DLL) + message(FATAL_ERROR "liblo runtime DLL not found; the installer would ship without it") + endif() + install(FILES ${LIBLO_DLL} DESTINATION ${CMAKE_INSTALL_BINDIR}) else() find_package(PkgConfig REQUIRED) pkg_check_modules(LIBLO REQUIRED IMPORTED_TARGET liblo) + target_link_libraries(openmix_liblo INTERFACE PkgConfig::LIBLO) endif() # RtMidi @@ -351,12 +364,7 @@ target_link_libraries(${PROJECT_NAME} PRIVATE ) # liblo -if(WIN32) - target_include_directories(${PROJECT_NAME} PRIVATE ${LIBLO_INCLUDE_DIRS}) - target_link_libraries(${PROJECT_NAME} PRIVATE ${LIBLO_LIBRARIES}) -else() - target_link_libraries(${PROJECT_NAME} PRIVATE PkgConfig::LIBLO) -endif() +target_link_libraries(${PROJECT_NAME} PRIVATE openmix_liblo) target_link_libraries(${PROJECT_NAME} PRIVATE RtMidi) @@ -367,18 +375,42 @@ if(WIN32) FetchContent_Declare(winsparkle URL https://github.com/vslavik/winsparkle/releases/download/v0.8.1/WinSparkle-0.8.1.zip) FetchContent_MakeAvailable(winsparkle) - set(WINSPARKLE_ROOT ${winsparkle_SOURCE_DIR}/WinSparkle-0.8.1) - target_include_directories(${PROJECT_NAME} PRIVATE ${WINSPARKLE_ROOT}/include) - target_link_libraries(${PROJECT_NAME} PRIVATE ${WINSPARKLE_ROOT}/x64/WinSparkle.lib) + # the 0.8.1 zip layout (nesting, x64/Release/ subdir) is fragile; glob for the + # header, x64 lib, and x64 dll so the exact structure can't break the build + file(GLOB_RECURSE _ws_hdrs "${winsparkle_SOURCE_DIR}/*winsparkle.h") + file(GLOB_RECURSE _ws_libs "${winsparkle_SOURCE_DIR}/*WinSparkle.lib") + file(GLOB_RECURSE _ws_dlls "${winsparkle_SOURCE_DIR}/*WinSparkle.dll") + list(GET _ws_hdrs 0 _ws_hdr) + get_filename_component(WINSPARKLE_INCLUDE ${_ws_hdr} DIRECTORY) + foreach(f ${_ws_libs}) + if(f MATCHES "x64") + set(WINSPARKLE_LIB ${f}) + endif() + endforeach() + foreach(f ${_ws_dlls}) + if(f MATCHES "x64") + set(WINSPARKLE_DLL ${f}) + endif() + endforeach() + if(NOT WINSPARKLE_INCLUDE OR NOT WINSPARKLE_LIB OR NOT WINSPARKLE_DLL) + message(FATAL_ERROR "WinSparkle header/x64 lib/dll not found under ${winsparkle_SOURCE_DIR}") + endif() + target_include_directories(${PROJECT_NAME} PRIVATE ${WINSPARKLE_INCLUDE}) + target_link_libraries(${PROJECT_NAME} PRIVATE ${WINSPARKLE_LIB}) add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different - ${WINSPARKLE_ROOT}/x64/WinSparkle.dll $) - install(FILES ${WINSPARKLE_ROOT}/x64/WinSparkle.dll DESTINATION ${CMAKE_INSTALL_BINDIR}) + ${WINSPARKLE_DLL} $) + install(FILES ${WINSPARKLE_DLL} DESTINATION ${CMAKE_INSTALL_BINDIR}) elseif(APPLE) FetchContent_Declare(sparkle URL https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz) FetchContent_MakeAvailable(sparkle) target_sources(${PROJECT_NAME} PRIVATE src/app/AutoUpdaterSparkle.mm) + # the C++ PCH is built with Objective-C disabled; reusing it for this + # Objective-C++ source fails ("Objective-C was disabled in precompiled + # file ... but is currently enabled"), so skip the PCH for it + set_source_files_properties(src/app/AutoUpdaterSparkle.mm + PROPERTIES SKIP_PRECOMPILE_HEADERS ON) target_compile_options(${PROJECT_NAME} PRIVATE -F${sparkle_SOURCE_DIR}) target_link_libraries(${PROJECT_NAME} PRIVATE "-F${sparkle_SOURCE_DIR}" "-framework Sparkle") configure_file(${CMAKE_SOURCE_DIR}/packaging/Info.plist.in @@ -415,6 +447,10 @@ if(WIN32) set_target_properties(${PROJECT_NAME} PROPERTIES WIN32_EXECUTABLE TRUE ) + # embed the app icon into the executable (needs the RC language, which + # project(LANGUAGES CXX) leaves off) + enable_language(RC) + target_sources(${PROJECT_NAME} PRIVATE packaging/openmix.rc) elseif(APPLE) set_target_properties(${PROJECT_NAME} PROPERTIES MACOSX_BUNDLE TRUE @@ -422,6 +458,10 @@ elseif(APPLE) MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION} ) + # bundle icon: ship the icns in Resources (Info.plist.in names it) + target_sources(${PROJECT_NAME} PRIVATE packaging/openmix.icns) + set_source_files_properties(packaging/openmix.icns + PROPERTIES MACOSX_PACKAGE_LOCATION Resources) endif() # install rules @@ -464,6 +504,18 @@ if(WIN32) set(CPACK_NSIS_DISPLAY_NAME "OpenMix") set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL ON) set(CPACK_NSIS_MODIFY_PATH OFF) + # installer/uninstaller + Add/Remove Programs icons + set(CPACK_NSIS_MUI_ICON "${CMAKE_SOURCE_DIR}/packaging/openmix.ico") + set(CPACK_NSIS_MUI_UNIICON "${CMAKE_SOURCE_DIR}/packaging/openmix.ico") + set(CPACK_NSIS_INSTALLED_ICON_NAME "bin\\\\OpenMix.exe") + # start menu entry + desktop shortcut (removed again on uninstall) + set(CPACK_PACKAGE_EXECUTABLES "OpenMix" "OpenMix") + set(CPACK_NSIS_CREATE_ICONS_EXTRA + "CreateShortCut '$DESKTOP\\\\OpenMix.lnk' '$INSTDIR\\\\bin\\\\OpenMix.exe'") + set(CPACK_NSIS_DELETE_ICONS_EXTRA + "Delete '$DESKTOP\\\\OpenMix.lnk'") + # offer to launch the app from the installer's finish page + set(CPACK_NSIS_MUI_FINISHPAGE_RUN "bin\\\\OpenMix.exe") elseif(APPLE) set(CPACK_GENERATOR "DragNDrop") else() diff --git a/docs/AUTOUPDATE.md b/docs/AUTOUPDATE.md index 9cc0776..5ce9494 100644 --- a/docs/AUTOUPDATE.md +++ b/docs/AUTOUPDATE.md @@ -33,25 +33,39 @@ pipeline hinges on one signing key and a place to host the appcasts. the `sparkle:edSignature` in the macOS appcast. Without it, the appcast job logs a warning and macOS clients will not accept the update. -## Appcast hosting (GitHub Pages) +## Appcast + installer hosting (openmix.dev VPS) -The apps poll these URLs (set in `Info.plist.in` `SUFeedURL` and in -`AutoUpdater.cpp` for WinSparkle): +Everything is served from the VPS. The apps poll: -- macOS: `https://johnqherman.github.io/OpenMix/appcast-macos.xml` -- Windows: `https://johnqherman.github.io/OpenMix/appcast-windows.xml` +- macOS: `https://openmix.dev/appcast-macos.xml` (`Info.plist.in` `SUFeedURL`) +- Windows: `https://openmix.dev/appcast-windows.xml` (`AutoUpdater.cpp`) +- Linux notify-fallback: `https://openmix.dev/latest.json` (`UpdateChecker.cpp`) -The `appcast` job in `.github/workflows/build.yml` builds both files on every -`v*` tag and publishes them to the `gh-pages` branch (via -`peaceiris/actions-gh-pages`, `keep_files: true` so old entries survive). Enable -GitHub Pages for the repo, serving from the `gh-pages` branch root. +Installers live under `https://openmix.dev/download//`. The `appcast` job in +`.github/workflows/build.yml` builds the appcasts + `latest.json`, lays out the +installer tree, and `rsync`s the whole `site/` over SSH to the VPS webroot on +every `v*` tag. After upload it prunes `download/` to the newest 5 release dirs +(version-sorted); the appcasts and `latest.json` at the root are overwritten each +release. + +### Deploy secrets (GitHub Actions) + +| Secret | Value | +|--------|-------| +| `OPENMIX_DEPLOY_SSH_KEY` | private SSH key whose public half is in the deploy user's `authorized_keys` | +| `OPENMIX_DEPLOY_HOST` | `openmix.dev` (or the VPS IP) | +| `OPENMIX_DEPLOY_USER` | deploy user (e.g. `deploy`) | +| `OPENMIX_DEPLOY_PATH` | webroot rsync target (e.g. `/var/www/openmix.dev`) | + +A GitHub Release is still created as a mirror, but the app updater points only at +openmix.dev. ## Release flow 1. Bump `VERSION` / `project(... VERSION x.y.z)` in `CMakeLists.txt`. 2. Tag and push: `git tag vx.y.z && git push origin vx.y.z`. 3. CI: `build-*` jobs make installers, `release` attaches them to the GitHub - Release, `appcast` signs + publishes the appcasts to `gh-pages`. + Release, `appcast` signs + rsyncs installers and appcasts to openmix.dev. 4. Installed clients pick up the update on their next scheduled check. ## Code signing (required for a clean silent update) diff --git a/packaging/Info.plist.in b/packaging/Info.plist.in index a0f632c..c69d955 100644 --- a/packaging/Info.plist.in +++ b/packaging/Info.plist.in @@ -16,6 +16,8 @@ @PROJECT_VERSION@ CFBundlePackageType APPL + CFBundleIconFile + openmix NSHighResolutionCapable LSMinimumSystemVersion @@ -23,7 +25,7 @@ SUFeedURL - https://johnqherman.github.io/OpenMix/appcast-macos.xml + https://openmix.dev/appcast-macos.xml SUEnableAutomaticChecks SUAutomaticallyUpdate @@ -31,6 +33,6 @@ SUPublicEDKey - REPLACE_WITH_SPARKLE_ED_PUBLIC_KEY + Th9esMBuKy6nRfgTeOA7eeUr5Ms3fBaGtLZFI5hyH7c= diff --git a/packaging/openmix.icns b/packaging/openmix.icns new file mode 100644 index 0000000..2170875 Binary files /dev/null and b/packaging/openmix.icns differ diff --git a/packaging/openmix.ico b/packaging/openmix.ico new file mode 100644 index 0000000..b8d5b8c Binary files /dev/null and b/packaging/openmix.ico differ diff --git a/packaging/openmix.rc b/packaging/openmix.rc new file mode 100644 index 0000000..1e5e6fd --- /dev/null +++ b/packaging/openmix.rc @@ -0,0 +1 @@ +IDI_ICON1 ICON "openmix.ico" diff --git a/resources/icons/openmix.png b/resources/icons/openmix.png new file mode 100644 index 0000000..14c304c Binary files /dev/null and b/resources/icons/openmix.png differ diff --git a/resources/resources.qrc b/resources/resources.qrc index 277d6a9..98e5a91 100644 --- a/resources/resources.qrc +++ b/resources/resources.qrc @@ -2,6 +2,7 @@ styles/main.qss + icons/openmix.png icons/chevron-up.svg icons/chevron-down.svg diff --git a/src/app/AutoUpdater.cpp b/src/app/AutoUpdater.cpp index 6157b4c..6d7d673 100644 --- a/src/app/AutoUpdater.cpp +++ b/src/app/AutoUpdater.cpp @@ -17,7 +17,7 @@ namespace { // Per-OS appcast feeds published by CI to GitHub Pages. Sparkle/WinSparkle poll // these for new versions + signatures. constexpr const char* kWindowsAppcast = - "https://johnqherman.github.io/OpenMix/appcast-windows.xml"; + "https://openmix.dev/appcast-windows.xml"; } // namespace AutoUpdater::AutoUpdater(QObject* parent) : QObject(parent) {} diff --git a/src/app/UpdateChecker.cpp b/src/app/UpdateChecker.cpp index f20be50..2b6143a 100644 --- a/src/app/UpdateChecker.cpp +++ b/src/app/UpdateChecker.cpp @@ -10,8 +10,8 @@ namespace OpenMix { namespace { -constexpr const char* kLatestReleaseApi = - "https://api.github.com/repos/johnqherman/OpenMix/releases/latest"; +// small JSON published by CI: {"tag_name":"vX.Y.Z","html_url":"..."} +constexpr const char* kLatestReleaseApi = "https://openmix.dev/latest.json"; } UpdateChecker::UpdateChecker(QObject* parent) @@ -58,7 +58,7 @@ void UpdateChecker::checkForUpdates() { QString url = obj.value("html_url").toString(); if (url.isEmpty()) - url = "https://github.com/johnqherman/OpenMix/releases/latest"; + url = "https://openmix.dev/download"; if (isNewer(tag, QCoreApplication::applicationVersion())) emit updateAvailable(tag, url); diff --git a/src/main.cpp b/src/main.cpp index f1540ff..01fb138 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include "ui/WelcomeDialog.h" #include "ui/theme/Theme.h" #include +#include #include #include #include @@ -37,6 +38,7 @@ int main(int argc, char* argv[]) { QApplication::setApplicationName("OpenMix"); QApplication::setApplicationVersion("0.1.0"); QApplication::setOrganizationName("OpenMix"); + QApplication::setWindowIcon(QIcon(":/icons/openmix.png")); oclero::qlementine::icons::initializeIconTheme(); diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index a645063..786e41b 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -22,13 +22,17 @@ #include #include #include -#include #include #include #include namespace OpenMix { +namespace { +// narrowest a column may get when the viewport can't fit all natural widths +constexpr int MinChokedColumnWidth = 40; +} // namespace + CueListView::CueListView(Application* app, QWidget* parent) : QWidget(parent), m_app(app) { setupUi(); setupDelegates(); @@ -89,35 +93,33 @@ void CueListView::setupUi() { // headers m_tableView->verticalHeader()->setVisible(false); - m_tableView->horizontalHeader()->setStretchLastSection(true); m_tableView->setShowGrid(false); - // column widths (Cue wider to fit the ▶/→ standby markers) - m_tableView->setColumnWidth(CueTableModel::ColColor, 28); - m_tableView->setColumnWidth(CueTableModel::ColNumber, 78); - m_tableView->setColumnWidth(CueTableModel::ColName, 220); - m_tableView->setColumnWidth(CueTableModel::ColFx, 70); - m_tableView->setColumnWidth(CueTableModel::ColScene, 70); - m_tableView->setColumnWidth(CueTableModel::ColSnip, 70); - m_tableView->setColumnWidth(CueTableModel::ColExternal, 90); - m_tableView->setColumnWidth(CueTableModel::ColType, 90); - m_tableView->setColumnWidth(CueTableModel::ColGroup, 100); - m_tableView->setColumnWidth(CueTableModel::ColTags, 120); - m_tableView->setColumnWidth(CueTableModel::ColFade, 72); - - // per-DCA triplet columns: narrow, and hide the fx/pos sub-columns by default + // hide the per-DCA fx/pos sub-columns by default for (int c = CueTableModel::ColCount; c < m_model->columnCount(); ++c) { const int sub = m_model->dcaSubColumn(c); - m_tableView->setColumnWidth(c, sub == 0 ? 60 : 40); if (sub == 1 || sub == 2) m_tableView->setColumnHidden(c, true); } - // columns are user-resizable; remember widths across sessions - m_tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); - restoreColumnWidths(); - connect(m_tableView->horizontalHeader(), &QHeaderView::sectionResized, this, - [this](int, int, int) { saveColumnWidths(); }); + // columns auto-fit their contents; leftover space is shared, and columns only + // shrink below their natural width when the viewport can't fit them all + m_tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::Fixed); + m_relayoutTimer = new QTimer(this); + m_relayoutTimer->setSingleShot(true); + m_relayoutTimer->setInterval(0); + connect(m_relayoutTimer, &QTimer::timeout, this, &CueListView::relayoutColumns); + connect(m_proxyModel, &QAbstractItemModel::modelReset, this, + &CueListView::scheduleColumnRelayout); + connect(m_proxyModel, &QAbstractItemModel::rowsInserted, this, + &CueListView::scheduleColumnRelayout); + connect(m_proxyModel, &QAbstractItemModel::rowsRemoved, this, + &CueListView::scheduleColumnRelayout); + connect(m_proxyModel, &QAbstractItemModel::layoutChanged, this, + &CueListView::scheduleColumnRelayout); + connect(m_proxyModel, &QAbstractItemModel::dataChanged, this, + &CueListView::scheduleColumnRelayout); + scheduleColumnRelayout(); // right-click context menu for quick edits m_tableView->setContextMenuPolicy(Qt::CustomContextMenu); @@ -517,12 +519,14 @@ void CueListView::setRowHeight(int pixels) { void CueListView::setColumnVisible(int column, bool visible) { m_tableView->setColumnHidden(column, !visible); + scheduleColumnRelayout(); } void CueListView::setDcaSubColumnsVisible(int sub, bool visible) { for (int c = CueTableModel::ColCount; c < m_model->columnCount(); ++c) if (m_model->dcaSubColumn(c) == sub) m_tableView->setColumnHidden(c, !visible); + scheduleColumnRelayout(); } void CueListView::setEditingLocked(bool locked) { @@ -574,23 +578,50 @@ void CueListView::showContextMenu(const QPoint& pos) { menu.exec(m_tableView->viewport()->mapToGlobal(pos)); } -void CueListView::saveColumnWidths() { - QSettings s; - s.beginGroup("CueListColumnsV2"); - for (int c = 0; c < CueTableModel::ColCount; ++c) - s.setValue(QString::number(c), m_tableView->columnWidth(c)); - s.endGroup(); +void CueListView::scheduleColumnRelayout() { + if (m_relayoutTimer) + m_relayoutTimer->start(); } -void CueListView::restoreColumnWidths() { - QSettings s; - s.beginGroup("CueListColumnsV2"); - for (int c = 0; c < CueTableModel::ColCount; ++c) { - const int w = s.value(QString::number(c), 0).toInt(); - if (w > 0) - m_tableView->setColumnWidth(c, w); +void CueListView::relayoutColumns() { + QHeaderView* header = m_tableView->horizontalHeader(); + const int columnCount = m_proxyModel->columnCount(); + const int available = m_tableView->viewport()->width(); + if (columnCount <= 0 || available <= 0) + return; + + // natural width per visible column: whatever its contents and header need + // (QTableView re-declares sizeHintForColumn protected; the base keeps it public) + const QAbstractItemView* view = m_tableView; + QList columns; + QList natural; + int naturalTotal = 0; + for (int c = 0; c < columnCount; ++c) { + if (m_tableView->isColumnHidden(c)) + continue; + const int w = qMax(view->sizeHintForColumn(c), header->sectionSizeHint(c)); + columns.append(c); + natural.append(w); + naturalTotal += w; + } + if (columns.isEmpty() || naturalTotal <= 0) + return; + + // scale everything to the viewport: leftover space is shared proportionally, + // and columns only shrink below natural width when there isn't enough room + const bool choked = naturalTotal > available; + int used = 0; + for (int i = 0; i < columns.size(); ++i) { + int w = static_cast(qint64(natural[i]) * available / naturalTotal); + if (choked) { + // keep a usable floor; past that the horizontal scrollbar takes over + w = qMax(w, qMin(natural[i], MinChokedColumnWidth)); + } else if (i == columns.size() - 1) { + w = available - used; // hand rounding leftovers to the last column + } + m_tableView->setColumnWidth(columns[i], w); + used += w; } - s.endGroup(); } void CueListView::beginRenameSelected() { @@ -739,9 +770,11 @@ void CueListView::onTabNavigationRequested(const QModelIndex& fromIndex, bool fo } bool CueListView::eventFilter(QObject* watched, QEvent* event) { - // keep the empty-state hint sized to the viewport - if (watched == m_tableView->viewport() && event->type() == QEvent::Resize) + // keep the empty-state hint sized to the viewport and refit columns to it + if (watched == m_tableView->viewport() && event->type() == QEvent::Resize) { updateEmptyHint(); + scheduleColumnRelayout(); + } if (watched == m_tableView && event->type() == QEvent::FocusOut) { QFocusEvent* focusEvent = static_cast(event); diff --git a/src/ui/CueListView.h b/src/ui/CueListView.h index 5a7d3e5..970e13b 100644 --- a/src/ui/CueListView.h +++ b/src/ui/CueListView.h @@ -8,6 +8,7 @@ #include class QLabel; +class QTimer; namespace OpenMix { @@ -78,8 +79,8 @@ class CueListView : public QWidget { private: void setupUi(); void setupDelegates(); - void saveColumnWidths(); - void restoreColumnWidths(); + void scheduleColumnRelayout(); + void relayoutColumns(); void updateEmptyHint(); void createActions(); void editNextCell(bool forward); @@ -95,6 +96,7 @@ class CueListView : public QWidget { CueTableModel* m_model; CueFilterProxyModel* m_proxyModel; CueFilterBar* m_filterBar; + QTimer* m_relayoutTimer = nullptr; // coalesces column relayout requests int m_currentCueIndex = -1; int m_standbyCueIndex = -1; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0f3b27b..93a2a83 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -148,7 +148,7 @@ add_executable(test_osc_remote ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp ) -target_link_libraries(test_osc_remote PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_link_libraries(test_osc_remote PRIVATE Qt6::Core Qt6::Test openmix_liblo) target_include_directories(test_osc_remote PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME OscRemoteTest COMMAND test_osc_remote) @@ -156,7 +156,7 @@ add_executable(test_qlab_client test_qlab_client.cpp ${CMAKE_SOURCE_DIR}/src/app/QLabClient.cpp ) -target_link_libraries(test_qlab_client PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_link_libraries(test_qlab_client PRIVATE Qt6::Core Qt6::Test openmix_liblo) target_include_directories(test_qlab_client PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME QLabClientTest COMMAND test_qlab_client) @@ -164,7 +164,7 @@ add_executable(test_reaper_client test_reaper_client.cpp ${CMAKE_SOURCE_DIR}/src/app/ReaperClient.cpp ) -target_link_libraries(test_reaper_client PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_link_libraries(test_reaper_client PRIVATE Qt6::Core Qt6::Test openmix_liblo) target_include_directories(test_reaper_client PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME ReaperClientTest COMMAND test_reaper_client) @@ -172,7 +172,7 @@ add_executable(test_cueplayer_client test_cueplayer_client.cpp ${CMAKE_SOURCE_DIR}/src/app/CuePlayerClient.cpp ) -target_link_libraries(test_cueplayer_client PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_link_libraries(test_cueplayer_client PRIVATE Qt6::Core Qt6::Test openmix_liblo) target_include_directories(test_cueplayer_client PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME CuePlayerClientTest COMMAND test_cueplayer_client) @@ -197,7 +197,7 @@ add_executable(test_scs_client test_scs_client.cpp ${CMAKE_SOURCE_DIR}/src/app/ScsClient.cpp ) -target_link_libraries(test_scs_client PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_link_libraries(test_scs_client PRIVATE Qt6::Core Qt6::Test openmix_liblo) target_include_directories(test_scs_client PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME ScsClientTest COMMAND test_scs_client)