diff --git a/.clang-tidy b/.clang-tidy
index f33ac2e3..f9bb9ef8 100644
--- a/.clang-tidy
+++ b/.clang-tidy
@@ -1,11 +1,22 @@
---
Checks: >
-*,
+ misc-*,
+ -misc-no-recursion,
+ modernize-*,
+ -modernize-avoid-bind,
+ -modernize-avoid-c-arrays,
+ -modernize-use-trailing-return-type,
readability-*,
-readability-braces-around-statements,
-readability-implicit-bool-conversion,
-readability-identifier-length,
- -readability-magic-numbers
+ -readability-magic-numbers,
+ -readability-suspicious-call-argument
+
+WarningsAsErrors: >
+ readability-*,
+ -readability-function-cognitive-complexity
CheckOptions:
- {key: readability-identifier-naming.NamespaceCase, value: CamelCase}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 47a2a21e..36b199ce 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -20,16 +20,6 @@ jobs:
build_deb: false
defines: ""
- - name: "ubuntu_22.04"
- os: ubuntu-22.04
- generator: "Unix Makefiles"
- arch: ""
- target: traintastic-client
- jobs: 4
- build_type: Release
- build_deb: true
- defines: ""
-
- name: "ubuntu_24.04"
os: ubuntu-24.04
generator: "Unix Makefiles"
@@ -70,26 +60,6 @@ jobs:
build_deb: false
defines: ""
- - name: "macos-14"
- os: "macos-14"
- generator: "Unix Makefiles"
- arch: ""
- target: traintastic-client
- jobs: 3
- build_type: Release
- build_deb: false
- defines: ""
-
- - name: "macos-15"
- os: "macos-15"
- generator: "Unix Makefiles"
- arch: ""
- target: traintastic-client
- jobs: 3
- build_type: Release
- build_deb: false
- defines: ""
-
steps:
- uses: FranzDiebold/github-env-vars-action@v2
@@ -112,8 +82,16 @@ jobs:
uses: jurplel/install-qt-action@v4
with:
cache: true
+ modules: qtwebsockets
version: 6.5.*
+ - name: Set CMAKE_OSX_ARCHITECTURES
+ if: startswith(matrix.config.os, 'macos')
+ uses: actions/github-script@v7
+ with:
+ script: |
+ core.exportVariable('CMAKE_OSX_ARCHITECTURES', 'x86_64');
+
# Ubuntu only:
- name: apt update
if: startswith(matrix.config.os, 'ubuntu')
@@ -122,7 +100,7 @@ jobs:
# Ubuntu only:
- name: Install packages
if: startswith(matrix.config.os, 'ubuntu')
- run: sudo apt install qtbase5-dev qtbase5-dev-tools libqt5svg5-dev
+ run: sudo apt install qtbase5-dev qtbase5-dev-tools libqt5svg5-dev libqt5websockets5-dev
# All:
- name: Create Build Environment
@@ -173,6 +151,8 @@ jobs:
build-server:
name: server ${{matrix.config.name}}
runs-on: ${{matrix.config.os}}
+ env:
+ VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
strategy:
fail-fast: false
matrix:
@@ -186,33 +166,9 @@ jobs:
jobs: 4
build_type: Release
build_deb: false
- defines: "-DENABLE_CLANG_TIDY=ON"
- ccov: false
-
- - name: "windows_x64_mingw"
- os: windows-2022
- generator: "MinGW Makefiles"
- arch: ""
- toolset: ""
- target: all
- jobs: 4
- build_type: Release
- build_deb: false
defines: ""
ccov: false
- - name: "ubuntu_22.04"
- os: ubuntu-22.04
- generator: "Unix Makefiles"
- arch: ""
- toolset: ""
- target: all
- jobs: 4
- build_type: Release
- build_deb: true
- defines: "-DENABLE_CLANG_TIDY=ON -DINSTALL_SYSTEMD_SERVICE=ON"
- ccov: false
-
- name: "ubuntu_24.04"
os: ubuntu-24.04
generator: "Unix Makefiles"
@@ -222,11 +178,11 @@ jobs:
jobs: 4
build_type: Release
build_deb: true
- defines: "-DENABLE_CLANG_TIDY=ON -DINSTALL_SYSTEMD_SERVICE=ON"
+ defines: "-DINSTALL_SYSTEMD_SERVICE=ON"
ccov: false
- - name: "ubuntu_latest (debug+ccov)"
- os: ubuntu-latest
+ - name: "ubuntu_24.04 (debug+ccov)"
+ os: ubuntu-24.04
generator: "Unix Makefiles"
arch: ""
toolset: ""
@@ -234,7 +190,7 @@ jobs:
jobs: 4
build_type: Debug
build_deb: false
- defines: "-DENABLE_CLANG_TIDY=ON -DCODE_COVERAGE=ON"
+ defines: "-DCODE_COVERAGE=ON"
ccov: true
- name: "raspberrypios_arm7"
@@ -273,30 +229,6 @@ jobs:
defines: ""
ccov: false
- - name: "macos-14"
- os: "macos-14"
- generator: "Unix Makefiles"
- arch: ""
- toolset: ""
- target: all
- jobs: 3
- build_type: Release
- build_deb: false
- defines: ""
- ccov: false
-
- - name: "macos-15"
- os: "macos-15"
- generator: "Unix Makefiles"
- arch: ""
- toolset: ""
- target: all
- jobs: 3
- build_type: Release
- build_deb: false
- defines: ""
- ccov: false
-
steps:
- uses: FranzDiebold/github-env-vars-action@v2
@@ -306,6 +238,27 @@ jobs:
with:
submodules: recursive
+ - name: Export GitHub Actions cache environment variables
+ uses: actions/github-script@v7
+ with:
+ script: |
+ core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
+ core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
+
+ - name: Set VCPKG_ROOT
+ if: startswith(matrix.config.os, 'windows') || startswith(matrix.config.os, 'macos')
+ uses: actions/github-script@v7
+ with:
+ script: |
+ core.exportVariable('VCPKG_ROOT', process.env.VCPKG_INSTALLATION_ROOT || '');
+
+ - name: Set CMAKE_OSX_ARCHITECTURES
+ if: startswith(matrix.config.os, 'macos')
+ uses: actions/github-script@v7
+ with:
+ script: |
+ core.exportVariable('CMAKE_OSX_ARCHITECTURES', 'x86_64');
+
# Ubuntu only:
- name: apt update
if: startswith(matrix.config.os, 'ubuntu')
@@ -408,10 +361,6 @@ jobs:
fail-fast: false
matrix:
config:
- - name: "ubuntu_22.04"
- os: ubuntu-22.04
- defines: ""
-
- name: "ubuntu_24.04"
os: ubuntu-24.04
defines: ""
@@ -594,12 +543,14 @@ jobs:
uses: actions/download-artifact@v4
with:
pattern: traintastic-client-deb-*
+ merge-multiple: true
path: ${{github.workspace}}/dist/${{env.CI_REF_NAME_SLUG}}/${{github.run_number}}
- name: Download artifacts 3/6
uses: actions/download-artifact@v4
with:
pattern: traintastic-server-deb-*
+ merge-multiple: true
path: ${{github.workspace}}/dist/${{env.CI_REF_NAME_SLUG}}/${{github.run_number}}
- name: Download artifacts 4/6
@@ -618,6 +569,7 @@ jobs:
uses: actions/download-artifact@v4
with:
pattern: traintastic-data-deb-*
+ merge-multiple: true
path: ${{github.workspace}}/dist/${{env.CI_REF_NAME_SLUG}}/${{github.run_number}}
- name: "Download artifact: manual-lua"
diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt
index 17b695c3..1c0caefa 100644
--- a/client/CMakeLists.txt
+++ b/client/CMakeLists.txt
@@ -90,17 +90,17 @@ file(GLOB SOURCES
"thirdparty/QtWaitingSpinner/*.hpp"
"thirdparty/QtWaitingSpinner/*.cpp")
-find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core Widgets Network Svg Xml)
+find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core Widgets Network Svg Xml WebSockets)
if(QT_VERSION_MAJOR EQUAL 5)
- find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Network Svg Xml)
+ find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Network Svg Xml WebSockets)
else()
- find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Network Svg Xml SvgWidgets)
+ find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Widgets Network Svg Xml SvgWidgets WebSockets)
endif()
message(STATUS "Found Qt ${QT_VERSION}")
target_sources(traintastic-client PRIVATE ${SOURCES})
-target_link_libraries(traintastic-client PRIVATE Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Widgets Qt${QT_VERSION_MAJOR}::Network Qt${QT_VERSION_MAJOR}::Svg Qt${QT_VERSION_MAJOR}::Xml)
+target_link_libraries(traintastic-client PRIVATE Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Widgets Qt${QT_VERSION_MAJOR}::Network Qt${QT_VERSION_MAJOR}::Svg Qt${QT_VERSION_MAJOR}::Xml Qt${QT_VERSION_MAJOR}::WebSockets)
if(QT_VERSION_MAJOR GREATER 5)
target_link_libraries(traintastic-client PRIVATE Qt${QT_VERSION_MAJOR}::SvgWidgets)
endif()
diff --git a/client/data/wizard/add_interface.json b/client/data/wizard/add_interface.json
index 17133b43..58213ef1 100644
--- a/client/data/wizard/add_interface.json
+++ b/client/data/wizard/add_interface.json
@@ -333,11 +333,13 @@
"protocol_dr5000_usb": {
"title": "$wizard.add_interface.protocol:title$",
"text": "$wizard.add_interface.protocol:text$",
+ "bottom_text": "$wizard.add_interface.protocol_dr5000_usb:bottom_text$",
"type": "radio",
"options": [
{
- "name": "LocoNet",
+ "name": "LocoNet ($wizard.add_interface:recommended$)",
"next": "serial_port",
+ "checked": true,
"actions": {
"create_interface": {
"class_id": "interface.loconet",
diff --git a/client/gfx/dark/circle/add.svg b/client/gfx/dark/circle/add.svg
new file mode 100644
index 00000000..67907247
--- /dev/null
+++ b/client/gfx/dark/circle/add.svg
@@ -0,0 +1,94 @@
+
+
+
+
diff --git a/client/gfx/dark/dark.qrc b/client/gfx/dark/dark.qrc
index 2984284b..d1beb1e5 100644
--- a/client/gfx/dark/dark.qrc
+++ b/client/gfx/dark/dark.qrc
@@ -95,5 +95,7 @@
zone.svg
clear_persistent_variables.svg
highlight_zone.svg
+ swap.svg
+ circle/add.svg
diff --git a/client/gfx/dark/swap.svg b/client/gfx/dark/swap.svg
new file mode 100644
index 00000000..0221211b
--- /dev/null
+++ b/client/gfx/dark/swap.svg
@@ -0,0 +1,116 @@
+
+
+
+
diff --git a/client/src/board/boardareawidget.cpp b/client/src/board/boardareawidget.cpp
index 916fa0dc..3030a5ca 100644
--- a/client/src/board/boardareawidget.cpp
+++ b/client/src/board/boardareawidget.cpp
@@ -68,14 +68,14 @@ constexpr QRect updateTileRect(const int x, const int y, const int w, const int
}
-BoardAreaWidget::BoardAreaWidget(BoardWidget& board, QWidget* parent) :
+BoardAreaWidget::BoardAreaWidget(std::shared_ptr board, QWidget* parent) :
QWidget(parent),
m_colorScheme{&BoardColorScheme::dark},
- m_board{board},
- m_boardLeft{board.board().getProperty("left")},
- m_boardTop{board.board().getProperty("top")},
- m_boardRight{board.board().getProperty("right")},
- m_boardBottom{board.board().getProperty("bottom")},
+ m_board{std::move(board)},
+ m_boardLeft{m_board->getProperty("left")},
+ m_boardTop{m_board->getProperty("top")},
+ m_boardRight{m_board->getProperty("right")},
+ m_boardBottom{m_board->getProperty("bottom")},
m_grid{Grid::Dot},
m_zoomLevel{0},
m_blockHighlight{MainWindow::instance->blockHighlight()},
@@ -108,7 +108,7 @@ BoardAreaWidget::BoardAreaWidget(BoardWidget& board, QWidget* parent) :
update();
});
- for(const auto& [l, object] : m_board.board().tileObjects())
+ for(const auto& [l, object] : m_board->tileObjects())
tileObjectAdded(l.x, l.y, object);
settingsChanged();
@@ -124,7 +124,7 @@ void BoardAreaWidget::tileObjectAdded(int16_t x, int16_t y, const ObjectPtr& obj
{
try
{
- const TileData& tileData = m_board.board().tileData().at(l);
+ const TileData& tileData = m_board->tileData().at(l);
update(updateTileRect(l.x - boardLeft(), l.y - boardTop(), tileData.width(), tileData.height(), getTileSize()));
}
catch(...)
@@ -139,7 +139,7 @@ void BoardAreaWidget::tileObjectAdded(int16_t x, int16_t y, const ObjectPtr& obj
connect(property, &BaseProperty::valueChanged, this, handler);
};
- switch(m_board.board().getTileId(l))
+ switch(m_board->getTileId(l))
{
case TileId::RailTurnoutLeft45:
case TileId::RailTurnoutLeft90:
@@ -204,6 +204,7 @@ void BoardAreaWidget::tileObjectAdded(int16_t x, int16_t y, const ObjectPtr& obj
case TileId::RailTunnel:
case TileId::RailOneWay:
case TileId::RailLink:
+ case TileId::HiddenRailCrossOver:
case TileId::ReservedForFutureExpension:
break;
@@ -319,7 +320,7 @@ void BoardAreaWidget::setMouseMoveTileSizeMax(uint8_t width, uint8_t height)
TurnoutPosition BoardAreaWidget::getTurnoutPosition(const TileLocation& l) const
{
- if(ObjectPtr object = m_board.board().getTileObject(l))
+ if(ObjectPtr object = m_board->getTileObject(l))
if(const auto* p = object->getProperty("position"))
return p->toEnum();
return TurnoutPosition::Unknown;
@@ -327,7 +328,7 @@ TurnoutPosition BoardAreaWidget::getTurnoutPosition(const TileLocation& l) const
SensorState BoardAreaWidget::getSensorState(const TileLocation& l) const
{
- if(ObjectPtr object = m_board.board().getTileObject(l))
+ if(ObjectPtr object = m_board->getTileObject(l))
if(const auto* p = object->getProperty("state"))
return p->toEnum();
return SensorState::Unknown;
@@ -335,7 +336,7 @@ SensorState BoardAreaWidget::getSensorState(const TileLocation& l) const
DirectionControlState BoardAreaWidget::getDirectionControlState(const TileLocation& l) const
{
- if(ObjectPtr object = m_board.board().getTileObject(l))
+ if(ObjectPtr object = m_board->getTileObject(l))
if(const auto* p = object->getProperty("state"))
return p->toEnum();
return DirectionControlState::Both;
@@ -343,7 +344,7 @@ DirectionControlState BoardAreaWidget::getDirectionControlState(const TileLocati
SignalAspect BoardAreaWidget::getSignalAspect(const TileLocation& l) const
{
- if(ObjectPtr object = m_board.board().getTileObject(l))
+ if(ObjectPtr object = m_board->getTileObject(l))
if(const auto* p = object->getProperty("aspect"))
return p->toEnum();
return SignalAspect::Unknown;
@@ -351,7 +352,7 @@ SignalAspect BoardAreaWidget::getSignalAspect(const TileLocation& l) const
Color BoardAreaWidget::getColor(const TileLocation& l) const
{
- if(ObjectPtr object = m_board.board().getTileObject(l))
+ if(ObjectPtr object = m_board->getTileObject(l))
if(const auto* p = object->getProperty("color"))
return p->toEnum();
return Color::None;
@@ -359,7 +360,7 @@ Color BoardAreaWidget::getColor(const TileLocation& l) const
DecouplerState BoardAreaWidget::getDecouplerState(const TileLocation& l) const
{
- if(ObjectPtr object = m_board.board().getTileObject(l))
+ if(ObjectPtr object = m_board->getTileObject(l))
if(const auto* p = object->getProperty("state"))
return p->toEnum();
return DecouplerState::Deactivated;
@@ -367,7 +368,7 @@ DecouplerState BoardAreaWidget::getDecouplerState(const TileLocation& l) const
bool BoardAreaWidget::getNXButtonEnabled(const TileLocation& l) const
{
- if(auto object = std::dynamic_pointer_cast(m_board.board().getTileObject(l)))
+ if(auto object = std::dynamic_pointer_cast(m_board->getTileObject(l)))
{
return object->getPropertyValueBool("enabled", false);
}
@@ -376,7 +377,7 @@ bool BoardAreaWidget::getNXButtonEnabled(const TileLocation& l) const
bool BoardAreaWidget::getNXButtonPressed(const TileLocation& l) const
{
- if(auto object = std::dynamic_pointer_cast(m_board.board().getTileObject(l)))
+ if(auto object = std::dynamic_pointer_cast(m_board->getTileObject(l)))
{
return object->isPressed();
}
@@ -391,11 +392,11 @@ TileLocation BoardAreaWidget::pointToTileLocation(const QPoint& p)
QString BoardAreaWidget::getTileToolTip(const TileLocation& l) const
{
- const auto tileId = m_board.board().getTileId(l);
+ const auto tileId = m_board->getTileId(l);
if(isRailTurnout(tileId))
{
- if(auto turnout = m_board.board().getTileObject(l))
+ if(auto turnout = m_board->getTileObject(l))
{
QString text = "" + turnout->getPropertyValueString("name") + "";
if(auto* position = turnout->getProperty("position")) /*[[likely]]*/
@@ -407,7 +408,7 @@ QString BoardAreaWidget::getTileToolTip(const TileLocation& l) const
}
else if(isRailSignal(tileId))
{
- if(auto signal = m_board.board().getTileObject(l))
+ if(auto signal = m_board->getTileObject(l))
{
QString text = "" + signal->getPropertyValueString("name") + "";
if(auto* aspect = signal->getProperty("aspect")) /*[[likely]]*/
@@ -419,7 +420,7 @@ QString BoardAreaWidget::getTileToolTip(const TileLocation& l) const
}
else if(tileId == TileId::RailSensor)
{
- if(auto sensor = m_board.board().getTileObject(l))
+ if(auto sensor = m_board->getTileObject(l))
{
QString text = "" + sensor->getPropertyValueString("name") + "";
if(auto* state = sensor->getProperty("state")) /*[[likely]]*/
@@ -431,7 +432,7 @@ QString BoardAreaWidget::getTileToolTip(const TileLocation& l) const
}
else if(tileId == TileId::RailBlock)
{
- if(auto block = m_board.board().getTileObject(l))
+ if(auto block = m_board->getTileObject(l))
{
QString text = "" + block->getPropertyValueString("name") + "";
if(auto* state = block->getProperty("state")) /*[[likely]]*/
@@ -443,7 +444,7 @@ QString BoardAreaWidget::getTileToolTip(const TileLocation& l) const
}
else if(tileId == TileId::RailDirectionControl)
{
- if(auto directionControl = m_board.board().getTileObject(l))
+ if(auto directionControl = m_board->getTileObject(l))
{
QString text = "" + directionControl->getPropertyValueString("name") + "";
if(auto* state = directionControl->getProperty("state")) /*[[likely]]*/
@@ -455,7 +456,7 @@ QString BoardAreaWidget::getTileToolTip(const TileLocation& l) const
}
else if(tileId == TileId::RailSensor)
{
- if(auto sensor = m_board.board().getTileObject(l))
+ if(auto sensor = m_board->getTileObject(l))
{
QString text = "" + sensor->getPropertyValueString("name") + "";
if(auto* state = sensor->getProperty("state")) /*[[likely]]*/
@@ -467,7 +468,7 @@ QString BoardAreaWidget::getTileToolTip(const TileLocation& l) const
}
else if(tileId == TileId::Switch)
{
- if(auto switch_ = m_board.board().getTileObject(l))
+ if(auto switch_ = m_board->getTileObject(l))
{
QString text = "" + switch_->getPropertyValueString("name") + "";
if(auto* value = switch_->getProperty("value")) /*[[likely]]*/
@@ -492,7 +493,7 @@ QString BoardAreaWidget::getTileToolTip(const TileLocation& l) const
else if(tileId == TileId::PushButton ||
tileId == TileId::RailNXButton)
{
- if(auto tile = m_board.board().getTileObject(l))
+ if(auto tile = m_board->getTileObject(l))
{
return "" + tile->getPropertyValueString("name") + "";
}
@@ -679,7 +680,7 @@ void BoardAreaWidget::paintEvent(QPaintEvent* event)
painter.save();
- for(auto it : m_board.board().tileData())
+ for(auto it : m_board->tileData())
if(it.first.x + it.second.width() - 1 >= tiles.left() && it.first.x <= tiles.right() &&
it.first.y + it.second.height() - 1 >= tiles.top() && it.first.y <= tiles.bottom())
{
@@ -740,7 +741,7 @@ void BoardAreaWidget::paintEvent(QPaintEvent* event)
case TileId::RailBlock:
{
- auto block = m_board.board().getTileObject(it.first);
+ auto block = m_board->getTileObject(it.first);
tilePainter.drawBlock(id, r, a, state & 0x01, state & 0x02, block);
if(auto itColors = m_blockHighlight.blockColors().find(block->getPropertyValueString("id"));
itColors != m_blockHighlight.blockColors().end() && !itColors->isEmpty())
@@ -783,7 +784,7 @@ void BoardAreaWidget::paintEvent(QPaintEvent* event)
case TileId::Label:
{
- if(auto label = m_board.board().getTileObject(it.first)) /*[[likely]]*/
+ if(auto label = m_board->getTileObject(it.first)) /*[[likely]]*/
{
tilePainter.drawLabel(r, a,
label->getPropertyValueString("text"),
@@ -798,7 +799,7 @@ void BoardAreaWidget::paintEvent(QPaintEvent* event)
break;
}
case TileId::Switch:
- if(auto sw = m_board.board().getTileObject(it.first)) /*[[likely]]*/
+ if(auto sw = m_board->getTileObject(it.first)) /*[[likely]]*/
{
tilePainter.drawSwitch(r,
sw->getPropertyValueBool("value", false),
@@ -893,7 +894,7 @@ void BoardAreaWidget::dragMoveEvent(QDragMoveEvent* event)
{
m_dragMoveTileLocation = l;
if(event->mimeData()->hasFormat(AssignTrainMimeData::mimeType) &&
- m_board.board().getTileId(l) == TileId::RailBlock)
+ m_board->getTileId(l) == TileId::RailBlock)
{
return event->accept();
}
@@ -909,12 +910,12 @@ void BoardAreaWidget::dropEvent(QDropEvent* event)
const TileLocation l = pointToTileLocation(event->position().toPoint());
#endif
- switch(m_board.board().getTileId(l))
+ switch(m_board->getTileId(l))
{
case TileId::RailBlock:
if(auto* assignTrain = dynamic_cast(event->mimeData()))
{
- if(auto tile = std::dynamic_pointer_cast(m_board.board().getTileObject(l)))
+ if(auto tile = std::dynamic_pointer_cast(m_board->getTileObject(l)))
{
if(auto* method = tile->getMethod("assign_train"))
{
diff --git a/client/src/board/boardareawidget.hpp b/client/src/board/boardareawidget.hpp
index 7d8f8359..c56a9b42 100644
--- a/client/src/board/boardareawidget.hpp
+++ b/client/src/board/boardareawidget.hpp
@@ -40,6 +40,7 @@
class BoardWidget;
class BlockHighlight;
+class Board;
class BoardAreaWidget : public QWidget
{
@@ -67,7 +68,7 @@ class BoardAreaWidget : public QWidget
protected:
static constexpr int boardMargin = 1; // tile
- BoardWidget& m_board;
+ std::shared_ptr m_board;
AbstractProperty* m_boardLeft;
AbstractProperty* m_boardTop;
AbstractProperty* m_boardRight;
@@ -132,7 +133,7 @@ class BoardAreaWidget : public QWidget
static constexpr int zoomLevelMin = -2;
static constexpr int zoomLevelMax = 15;
- BoardAreaWidget(BoardWidget& board, QWidget* parent = nullptr);
+ BoardAreaWidget(std::shared_ptr board, QWidget* parent = nullptr);
Grid grid() const { return m_grid; }
void nextGrid();
diff --git a/client/src/board/boardwidget.cpp b/client/src/board/boardwidget.cpp
index 055ca1a4..2c18cd7c 100644
--- a/client/src/board/boardwidget.cpp
+++ b/client/src/board/boardwidget.cpp
@@ -83,7 +83,7 @@ BoardWidget::BoardWidget(std::shared_ptr object, QWidget* parent) :
QWidget(parent),
m_object{std::move(object)},
m_nxManagerRequestId{Connection::invalidRequestId},
- m_boardArea{new BoardAreaWidget(*this, this)},
+ m_boardArea{new BoardAreaWidget(m_object, this)},
m_statusBar{new QStatusBar(this)},
m_statusBarMessage{new QLabel(this)},
m_statusBarCoords{new QLabel(this)},
diff --git a/client/src/board/tilepainter.cpp b/client/src/board/tilepainter.cpp
index 7c13a234..d78b45e9 100644
--- a/client/src/board/tilepainter.cpp
+++ b/client/src/board/tilepainter.cpp
@@ -177,6 +177,7 @@ void TilePainter::draw(TileId id, const QRectF& r, TileRotate rotate, bool isRes
break;
case TileId::None:
+ case TileId::HiddenRailCrossOver:
case TileId::ReservedForFutureExpension:
break;
}
diff --git a/client/src/dialog/connectdialog.cpp b/client/src/dialog/connectdialog.cpp
index e52f288e..1aee68a6 100644
--- a/client/src/dialog/connectdialog.cpp
+++ b/client/src/dialog/connectdialog.cpp
@@ -3,7 +3,7 @@
*
* This file is part of the traintastic source code.
*
- * Copyright (C) 2019-2021,2023 Reinder Feenstra
+ * Copyright (C) 2019-2021,2023-2024 Reinder Feenstra
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
@@ -56,8 +56,14 @@ ConnectDialog::ConnectDialog(QWidget* parent, const QString& url) :
m_password->setEchoMode(QLineEdit::Password);
m_connectAutomatically->setChecked(GeneralSettings::instance().connectAutomaticallyToDiscoveredServer.value());
+
+ #if QT_VERSION < QT_VERSION_CHECK(6, 7, 0)
connect(m_connectAutomatically, &QCheckBox::stateChanged, this,
[](int state)
+ #else
+ connect(m_connectAutomatically, &QCheckBox::checkStateChanged, this,
+ [](Qt::CheckState state)
+ #endif
{
GeneralSettings::instance().connectAutomaticallyToDiscoveredServer.setValue(state == Qt::Checked);
});
@@ -150,8 +156,10 @@ void ConnectDialog::socketReadyRead()
QString name = QString::fromUtf8(message.read());
QUrl url;
+ url.setScheme("ws");
url.setHost(host.toString());
url.setPort(port);
+ url.setPath("/client");
auto it = m_servers.find(url);
if(it == m_servers.end())
@@ -230,6 +238,12 @@ void ConnectDialog::serverTextChanged(const QString& text)
{
QString url{text};
m_url = QUrl::fromUserInput(url.remove(QRegularExpression("\\s*\\(.*\\)$")));
+ m_url.setScheme("ws");
+ m_url.setPath("/client");
+ if(m_url.port() == -1)
+ {
+ m_url.setPort(Connection::defaultPort);
+ }
m_connect->setEnabled(m_url.isValid());
}
diff --git a/client/src/dialog/objectselectlistdialog.cpp b/client/src/dialog/objectselectlistdialog.cpp
index 619b2c62..f7a1d9ca 100644
--- a/client/src/dialog/objectselectlistdialog.cpp
+++ b/client/src/dialog/objectselectlistdialog.cpp
@@ -33,6 +33,7 @@
#include "../widget/tablewidget.hpp"
#include "../widget/alertwidget.hpp"
#include
+#include
ObjectSelectListDialog::ObjectSelectListDialog(Method& method, bool multiSelect, QWidget* parent) :
ObjectSelectListDialog(static_cast(method), multiSelect, parent)
@@ -147,9 +148,16 @@ void ObjectSelectListDialog::acceptRows(const QModelIndexList& indexes)
{
if(auto* m = dynamic_cast(&m_item))
{
+ // Call method only once for each row, not for every index in row
+ std::unordered_set rowsDone;
+
for(const auto& index : indexes)
{
+ if(rowsDone.find(index.row()) != rowsDone.end())
+ continue;
+
callMethod(*m, nullptr, m_tableWidget->getRowObjectId(index.row()));
+ rowsDone.insert(index.row());
}
}
diff --git a/client/src/dialog/worldlistdialog.cpp b/client/src/dialog/worldlistdialog.cpp
index b1626482..b3f7b452 100644
--- a/client/src/dialog/worldlistdialog.cpp
+++ b/client/src/dialog/worldlistdialog.cpp
@@ -3,7 +3,7 @@
*
* This file is part of the traintastic source code.
*
- * Copyright (C) 2019-2020,2023 Reinder Feenstra
+ * Copyright (C) 2019-2020,2023,2025 Reinder Feenstra
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
@@ -25,8 +25,13 @@
#include
#include
#include
+#include
+#include
+#include
+#include
+#include
+#include
#include
-#include "../widget/tablewidget.hpp"
#include "../network/connection.hpp"
#include "../network/object.hpp"
#include "../network/tablemodel.hpp"
@@ -35,14 +40,70 @@
#include "../widget/alertwidget.hpp"
#include
+constexpr int columnUUID = 1;
+
+class WorldListItemDelegate : public QItemDelegate
+{
+public:
+ inline WorldListItemDelegate(QListView* parent)
+ : QItemDelegate(parent)
+ {
+ }
+
+ inline void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const final
+ {
+ static const QSize iconSize{40, 40};
+
+ auto* model = qobject_cast(parent())->model();
+ const auto name = model->data(model->index(index.row(), 0)).toString();
+ const auto uuid = model->data(model->index(index.row(), 1)).toString();
+
+ const auto r = option.rect.adjusted(5, 5, -5, -5);
+ const int iconOffset = (r.height() - iconSize.height()) / 2;
+
+ const auto palette = QApplication::palette();
+
+ if((option.state & QStyle::State_Selected) != 0)
+ {
+ painter->fillRect(option.rect, palette.brush(QPalette::Highlight));
+ }
+
+ QTextOption textOption;
+ textOption.setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
+
+ const auto classIcon = Theme::getIcon("world");
+ painter->drawPixmap(r.topLeft() + QPoint(iconOffset, iconOffset), classIcon.pixmap(iconSize));
+
+ painter->setPen(palette.color(QPalette::Disabled, QPalette::Text));
+ painter->drawText(r.adjusted(r.height() + 10, r.height() / 2, 0, 0), uuid, textOption);
+
+ painter->setPen(palette.color(QPalette::Active, QPalette::Text));
+ painter->drawText(r.adjusted(r.height() + 10, 0, 0, -r.height() / 2), name, textOption);
+
+ painter->setPen(QColor(0x80, 0x80, 0x80, 0x30));
+ painter->drawLine(option.rect.bottomLeft(), option.rect.bottomRight());
+ }
+
+ inline QSize sizeHint(const QStyleOptionViewItem& /*option*/, const QModelIndex& /*index*/) const final
+ {
+ return QSize(-1, 50);
+ }
+};
+
WorldListDialog::WorldListDialog(std::shared_ptr connection, QWidget* parent) :
QDialog(parent, Qt::Dialog | Qt::WindowTitleHint | Qt::WindowCloseButtonHint),
m_connection{std::move(connection)},
m_buttons{new QDialogButtonBox(this)},
- m_tableWidget{new TableWidget()}
+ m_search{new QLineEdit(this)},
+ m_list{new QListView(this)}
{
setWindowTitle(Locale::tr("qtapp.world_list_dialog:world_list"));
setWindowIcon(Theme::getIcon("world_load"));
+ resize(500, 400);
+
+ m_search->setPlaceholderText(Locale::tr("qtapp.world_list_dialog:search"));
+
+ m_list->setItemDelegate(new WorldListItemDelegate(m_list));
m_buttons->setStandardButtons(QDialogButtonBox::Open | QDialogButtonBox::Cancel);
m_buttons->button(QDialogButtonBox::Open)->setText(Locale::tr("qtapp.world_list_dialog:load"));
@@ -50,14 +111,15 @@ WorldListDialog::WorldListDialog(std::shared_ptr connection, QWidget
connect(m_buttons->button(QDialogButtonBox::Open), &QPushButton::clicked, this,
[this]()
{
- m_uuid = m_tableWidget->getRowObjectId(m_tableWidget->selectionModel()->selectedIndexes().first().row());
+ m_uuid = m_list->model()->data(m_list->model()->index(m_list->selectionModel()->selectedIndexes().first().row(), columnUUID)).toString();
accept();
});
m_buttons->button(QDialogButtonBox::Cancel)->setText(Locale::tr("qtapp.world_list_dialog:cancel"));
connect(m_buttons->button(QDialogButtonBox::Cancel), &QPushButton::clicked, this, &WorldListDialog::reject);
QVBoxLayout* layout = new QVBoxLayout();
- layout->addWidget(m_tableWidget);
+ layout->addWidget(m_search);
+ layout->addWidget(m_list);
layout->addWidget(m_buttons);
setLayout(layout);
@@ -78,20 +140,26 @@ WorldListDialog::WorldListDialog(std::shared_ptr connection, QWidget
{
m_requestId = Connection::invalidRequestId;
- m_tableWidget->setTableModel(tableModel);
- m_tableWidget->setSelectionBehavior(QAbstractItemView::SelectRows);
- connect(m_tableWidget->selectionModel(), &QItemSelectionModel::selectionChanged, this,
+ m_tableModel = tableModel;
+ m_tableModel->setRegionAll(true);
+ auto* filter = new QSortFilterProxyModel(this);
+ filter->setSourceModel(m_tableModel.get());
+ filter->setFilterCaseSensitivity(Qt::CaseInsensitive);
+ connect(m_search, &QLineEdit::textChanged, filter, &QSortFilterProxyModel::setFilterFixedString);
+ filter->setFilterFixedString(m_search->text());
+ m_list->setModel(filter);
+ m_list->setSelectionBehavior(QAbstractItemView::SelectRows);
+ connect(m_list->selectionModel(), &QItemSelectionModel::selectionChanged, this,
[this](const QItemSelection&, const QItemSelection&)
{
- m_buttons->button(QDialogButtonBox::Open)->setEnabled(m_tableWidget->selectionModel()->selectedRows().count() == 1);
+ m_buttons->button(QDialogButtonBox::Open)->setEnabled(m_list->selectionModel()->selectedRows().count() == 1);
});
- connect(m_tableWidget, &TableWidget::doubleClicked, this,
+ connect(m_list, &QListView::doubleClicked, this,
[this](const QModelIndex& index)
{
- m_uuid = m_tableWidget->getRowObjectId(index.row());
+ m_uuid = m_list->model()->data(m_list->model()->index(index.row(), columnUUID)).toString();
accept();
});
-
delete spinner;
}
else if(err)
diff --git a/client/src/dialog/worldlistdialog.hpp b/client/src/dialog/worldlistdialog.hpp
index 5f0798b5..fc3257e2 100644
--- a/client/src/dialog/worldlistdialog.hpp
+++ b/client/src/dialog/worldlistdialog.hpp
@@ -3,7 +3,7 @@
*
* This file is part of the traintastic source code.
*
- * Copyright (C) 2019-2020 Reinder Feenstra
+ * Copyright (C) 2019-2020,2025 Reinder Feenstra
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
@@ -25,9 +25,11 @@
#include
#include "../network/objectptr.hpp"
+#include "../network/tablemodelptr.hpp"
class QDialogButtonBox;
-class TableWidget;
+class QLineEdit;
+class QListView;
class Connection;
class WorldListDialog final : public QDialog
@@ -39,8 +41,10 @@ class WorldListDialog final : public QDialog
int m_requestId;
ObjectPtr m_object;
QDialogButtonBox* m_buttons; // TODO: m_buttonLoad;
- TableWidget* m_tableWidget;
+ QLineEdit* m_search;
+ QListView* m_list;
QString m_uuid;
+ TableModelPtr m_tableModel;
public:
explicit WorldListDialog(std::shared_ptr connection, QWidget* parent = nullptr);
diff --git a/client/src/main.cpp b/client/src/main.cpp
index 3e530ecc..f98045ed 100644
--- a/client/src/main.cpp
+++ b/client/src/main.cpp
@@ -26,6 +26,9 @@
#endif
#include
#include
+#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
+ #include
+#endif
#include
#include "mainwindow.hpp"
#include "settings/generalsettings.hpp"
@@ -124,9 +127,15 @@ int main(int argc, char* argv[])
if(logMissingStrings)
const_cast(Locale::instance.get())->enableMissingLogging();
- // Auto select icon set based on background color lightness:
- const qreal backgroundLightness = QApplication::style()->standardPalette().window().color().lightnessF();
- Theme::setIconSet(backgroundLightness < 0.5 ? Theme::IconSet::Dark : Theme::IconSet::Light);
+ // Detect light/dark:
+#if QT_VERSION < QT_VERSION_CHECK(6, 5, 0)
+ const QPalette defaultPalette;
+ const auto text = defaultPalette.color(QPalette::WindowText);
+ const auto window = defaultPalette.color(QPalette::Window);
+ Theme::setDark(text.lightness() > window.lightness());
+#else
+ Theme::setDark(QGuiApplication::styleHints()->colorScheme() == Qt::ColorScheme::Dark);
+#endif
MainWindow mw;
if(options.fullscreen)
diff --git a/client/src/mainwindow.cpp b/client/src/mainwindow.cpp
index ad5d296c..a380bad4 100644
--- a/client/src/mainwindow.cpp
+++ b/client/src/mainwindow.cpp
@@ -3,7 +3,7 @@
*
* This file is part of the traintastic source code.
*
- * Copyright (C) 2019-2024 Reinder Feenstra
+ * Copyright (C) 2019-2025 Reinder Feenstra
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
@@ -124,6 +124,7 @@ MainWindow::MainWindow(QWidget* parent) :
QAction* boardsAction;
QAction* trainsAction;
+ m_mdiArea->setBackground(palette().window().color().darker());
m_mdiArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_mdiArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
@@ -433,6 +434,7 @@ MainWindow::MainWindow(QWidget* parent) :
auto* tabs = new QTabWidget(m_trainAndRailVehiclesSubWindow);
tabs->addTab(new ObjectEditWidget("world.trains"), Locale::tr("world:trains"));
tabs->addTab(new ObjectEditWidget("world.rail_vehicles"), Locale::tr("world:rail_vehicles"));
+ tabs->addTab(new ObjectEditWidget("world.throttles"), Locale::tr("hardware:throttles"));
m_trainAndRailVehiclesSubWindow->setWidget(tabs);
m_mdiArea->addSubWindow(m_trainAndRailVehiclesSubWindow);
m_trainAndRailVehiclesSubWindow->setAttribute(Qt::WA_DeleteOnClose);
@@ -519,7 +521,7 @@ MainWindow::MainWindow(QWidget* parent) :
})->setShortcut(QKeySequence::HelpContents);
auto* subMenu = menu->addMenu(Locale::tr("qtapp.mainmenu:wizards"));
subMenu->addAction(Locale::tr("wizard.introduction:title"), this, &MainWindow::showIntroductionWizard);
- subMenu->addAction(Locale::tr("wizard.add_interface.welcome:title"), this, &MainWindow::showAddInterfaceWizard);
+ m_actionAddInterfaceWizard = subMenu->addAction(Locale::tr("wizard.add_interface.welcome:title"), this, &MainWindow::showAddInterfaceWizard);
//menu->addSeparator();
//menu->addAction(Locale::tr("qtapp.mainmenu:about_qt") + "...", qApp, &QApplication::aboutQt);
menu->addAction(Locale::tr("qtapp.mainmenu:about") + "...", this, &MainWindow::showAbout);
@@ -676,7 +678,8 @@ void MainWindow::changeEvent(QEvent* event)
void MainWindow::worldChanged()
{
- m_newWorldWizard.reset();
+ m_wizard.newWorld.reset();
+ m_wizard.addInterface.reset();
if(m_world)
m_mdiArea->closeAllSubWindows();
@@ -747,13 +750,13 @@ void MainWindow::worldChanged()
if(m_newWorldRequested && m_world)
{
m_newWorldRequested = false;
- m_newWorldWizard = std::make_unique(m_world, this);
- connect(m_newWorldWizard.get(), &NewWorldWizard::finished,
+ m_wizard.newWorld = std::make_unique(m_world, this);
+ connect(m_wizard.newWorld.get(), &NewWorldWizard::finished,
[this]()
{
- m_newWorldWizard.release()->deleteLater();
+ m_wizard.newWorld.release()->deleteLater();
});
- m_newWorldWizard->open();
+ m_wizard.newWorld->open();
}
}
@@ -946,17 +949,18 @@ IntroductionWizard* MainWindow::showIntroductionWizard()
return introductionWizard;
}
-AddInterfaceWizard* MainWindow::showAddInterfaceWizard()
+void MainWindow::showAddInterfaceWizard()
{
- if(!m_world) /*[[unlikely]]*/
+ if(m_world && !m_wizard.addInterface) /*[[likely]]*/
{
- return nullptr;
+ m_wizard.addInterface = std::make_unique(m_world, this);
+ connect(m_wizard.addInterface.get(), &AddInterfaceWizard::finished,
+ [this]()
+ {
+ m_wizard.addInterface.release()->deleteLater();
+ });
+ m_wizard.addInterface->open();
}
-
- auto* addInterfaceWizard = new AddInterfaceWizard(m_world, this);
- addInterfaceWizard->setAttribute(Qt::WA_DeleteOnClose);
- addInterfaceWizard->open();
- return addInterfaceWizard;
}
NewBoardWizard* MainWindow::showNewBoardWizard(const ObjectPtr& board)
@@ -1010,7 +1014,6 @@ void MainWindow::updateActions()
const bool connected = m_connection && m_connection->state() == Connection::State::Connected;
const bool haveWorld = connected && m_connection->world();
-
m_actionConnectToServer->setEnabled(!m_connection);
m_actionConnectToServer->setVisible(!connected);
m_actionDisconnectFromServer->setVisible(connected);
@@ -1034,6 +1037,7 @@ void MainWindow::updateActions()
m_actionServerShutdown->setEnabled(m && m->getAttributeBool(AttributeName::Enabled, false));
}
m_menuProgramming->setEnabled(haveWorld);
+ m_actionAddInterfaceWizard->setEnabled(haveWorld);
setMenuEnabled(m_menuWorld, haveWorld);
m_worldOnlineOfflineToolButton->setEnabled(haveWorld);
diff --git a/client/src/mainwindow.hpp b/client/src/mainwindow.hpp
index 65bfb272..e6be278b 100644
--- a/client/src/mainwindow.hpp
+++ b/client/src/mainwindow.hpp
@@ -58,7 +58,11 @@ class MainWindow final : public QMainWindow
ObjectPtr m_world;
bool m_newWorldRequested = false;
std::unique_ptr m_loadWorldDialog;
- std::unique_ptr m_newWorldWizard;
+ struct
+ {
+ std::unique_ptr addInterface;
+ std::unique_ptr newWorld;
+ } m_wizard;
int m_clockRequest;
ObjectPtr m_clock;
QSplitter* m_splitter;
@@ -103,6 +107,7 @@ class MainWindow final : public QMainWindow
QAction* m_actionServerShutdown;
QAction* m_actionServerLog;
QMenu* m_menuProgramming;
+ QAction* m_actionAddInterfaceWizard;
// Main toolbar:
QToolBar* m_toolbar;
QToolButton* m_worldOnlineOfflineToolButton;
@@ -146,7 +151,7 @@ class MainWindow final : public QMainWindow
const ObjectPtr& worldClock() const { return m_clock; }
IntroductionWizard* showIntroductionWizard();
- AddInterfaceWizard* showAddInterfaceWizard();
+ void showAddInterfaceWizard();
NewBoardWizard* showNewBoardWizard(const ObjectPtr& board);
void showLuaScriptsList();
diff --git a/client/src/mainwindow/mainwindowstatusbar.cpp b/client/src/mainwindow/mainwindowstatusbar.cpp
index add1065d..32be0ad1 100644
--- a/client/src/mainwindow/mainwindowstatusbar.cpp
+++ b/client/src/mainwindow/mainwindowstatusbar.cpp
@@ -133,7 +133,10 @@ void MainWindowStatusBar::updateStatuses()
m_mainWindow.connection()->cancelRequest(m_statusesRequest);
if(statuses->empty())
+ {
+ clearStatuses();
return;
+ }
m_statusesRequest = statuses->getObjects(0, statuses->size() - 1,
[this](const std::vector& objects, std::optional error)
diff --git a/client/src/network/connection.cpp b/client/src/network/connection.cpp
index b52bb87a..6e29eb4d 100644
--- a/client/src/network/connection.cpp
+++ b/client/src/network/connection.cpp
@@ -21,7 +21,7 @@
*/
#include "connection.hpp"
-#include
+#include
#include
#include
#include
@@ -130,23 +130,31 @@ inline static QStringList readObjectIdArray(const Message& message, const int le
Connection::Connection() :
QObject(),
- m_socket{new QTcpSocket(this)},
+ m_socket{new QWebSocket()},
m_state{State::Disconnected},
m_worldProperty{nullptr},
m_worldRequestId{invalidRequestId}
, m_serverLogTableModel{nullptr}
{
- connect(m_socket, &QTcpSocket::connected, this, &Connection::socketConnected);
- connect(m_socket, &QTcpSocket::disconnected, this, &Connection::socketDisconnected);
-#if QT_VERSION < QT_VERSION_CHECK(5, 15, 0)
- connect(m_socket, static_cast(&QTcpSocket::error), this, &Connection::socketError);
+ connect(m_socket, &QWebSocket::connected, this, &Connection::socketConnected);
+ connect(m_socket, &QWebSocket::disconnected, this, &Connection::socketDisconnected);
+#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
+ connect(m_socket, &QWebSocket::errorOccurred, this, &Connection::socketError);
#else
- connect(m_socket, &QTcpSocket::errorOccurred, this, &Connection::socketError);
+ connect(m_socket, static_cast(&QWebSocket::error), this, &Connection::socketError);
#endif
- m_socket->setSocketOption(QAbstractSocket::LowDelayOption, 1);
-
- connect(m_socket, &QTcpSocket::readyRead, this, &Connection::socketReadyRead);
+ connect(m_socket, &QWebSocket::binaryMessageReceived,
+ [this](const QByteArray& data)
+ {
+ const Message::Header& header = *reinterpret_cast(data.data());
+ auto message = std::make_shared(header);
+ if(header.dataSize != 0)
+ {
+ std::memcpy(message->data(), data.data() + sizeof(header), message->dataSize());
+ }
+ processMessage(message);
+ });
}
bool Connection::isDisconnected() const
@@ -172,12 +180,12 @@ void Connection::connectToHost(const QUrl& url, const QString& username, const Q
else
m_password = QCryptographicHash::hash(password.toUtf8(), QCryptographicHash::Sha256);
setState(State::Connecting);
- m_socket->connectToHost(url.host(), static_cast(url.port(defaultPort)));
+ m_socket->open(url);
}
void Connection::disconnectFromHost()
{
- m_socket->disconnectFromHost();
+ m_socket->close();
}
void Connection::cancelRequest(int requestId)
@@ -417,7 +425,7 @@ void Connection::releaseTableModel(TableModel* tableModel)
tableModel->m_handle = invalidHandle;
}
-void Connection::setTableModelRegion(TableModel* tableModel, int columnMin, int columnMax, int rowMin, int rowMax)
+void Connection::setTableModelRegion(TableModel* tableModel, uint32_t columnMin, uint32_t columnMax, uint32_t rowMin, uint32_t rowMax)
{
auto event = Message::newEvent(Message::Command::TableModelSetRegion);
event->write(tableModel->handle());
@@ -443,7 +451,8 @@ int Connection::getTileData(Board& object)
void Connection::send(std::unique_ptr& message)
{
Q_ASSERT(!message->isRequest());
- m_socket->write(static_cast(**message), message->size());
+ QByteArray bytes(static_cast(**message), message->size()); // Deep copy :(
+ m_socket->sendBinaryMessage(bytes); // sendBinaryMessage only supports QByteArray
}
void Connection::send(std::unique_ptr& message, std::function&)> callback)
@@ -451,7 +460,8 @@ void Connection::send(std::unique_ptr& message, std::functionisRequest());
Q_ASSERT(!m_requestCallback.contains(message->requestId()));
m_requestCallback[message->requestId()] = callback;
- m_socket->write(static_cast(**message), message->size());
+ QByteArray bytes(static_cast(**message), message->size()); // Deep copy :(
+ m_socket->sendBinaryMessage(bytes); // sendBinaryMessage only supports QByteArray
}
ObjectPtr Connection::readObject(const Message& message)
@@ -1050,14 +1060,14 @@ void Connection::socketConnected()
else
{
setState(State::ErrorNewSessionFailed);
- m_socket->disconnectFromHost();
+ m_socket->close();
}
});
}
else
{
setState(State::ErrorAuthenticationFailed);
- m_socket->disconnectFromHost();
+ m_socket->close();
}
});
}
@@ -1071,32 +1081,3 @@ void Connection::socketError(QAbstractSocket::SocketError)
{
setState(State::SocketError);
}
-
-void Connection::socketReadyRead()
-{
- while(m_socket->bytesAvailable() != 0)
- {
- if(!m_readBuffer.message) // read header
- {
- m_readBuffer.offset += m_socket->read(reinterpret_cast(&m_readBuffer.header) + m_readBuffer.offset, sizeof(m_readBuffer.header) - m_readBuffer.offset);
- if(m_readBuffer.offset == sizeof(m_readBuffer.header))
- {
- if(m_readBuffer.header.dataSize != 0)
- m_readBuffer.message = std::make_shared(m_readBuffer.header);
- else
- processMessage(std::make_shared(m_readBuffer.header));
- m_readBuffer.offset = 0;
- }
- }
- else // read data
- {
- m_readBuffer.offset += m_socket->read(reinterpret_cast(m_readBuffer.message->data()) + m_readBuffer.offset, m_readBuffer.message->dataSize() - m_readBuffer.offset);
- if(m_readBuffer.offset == m_readBuffer.message->dataSize())
- {
- processMessage(m_readBuffer.message);
- m_readBuffer.message.reset();
- m_readBuffer.offset = 0;
- }
- }
- }
-}
diff --git a/client/src/network/connection.hpp b/client/src/network/connection.hpp
index 58501a95..816c011d 100644
--- a/client/src/network/connection.hpp
+++ b/client/src/network/connection.hpp
@@ -3,7 +3,7 @@
*
* This file is part of the traintastic source code.
*
- * Copyright (C) 2019-2021,2023 Reinder Feenstra
+ * Copyright (C) 2019-2021,2023-2024 Reinder Feenstra
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
@@ -35,7 +35,7 @@
#include "objectptr.hpp"
#include "tablemodelptr.hpp"
-class QTcpSocket;
+class QWebSocket;
class ServerLogTableModel;
class Property;
class ObjectProperty;
@@ -73,7 +73,7 @@ class Connection : public QObject, public std::enable_shared_from_this)> callback);
void releaseTableModel(TableModel* tableModel);
- void setTableModelRegion(TableModel* tableModel, int columnMin, int columnMax, int rowMin, int rowMax);
+ void setTableModelRegion(TableModel* tableModel, uint32_t columnMin, uint32_t columnMax, uint32_t rowMin, uint32_t rowMax);
[[nodiscard]] int getTileData(Board& object);
diff --git a/client/src/network/tablemodel.cpp b/client/src/network/tablemodel.cpp
index 250d06b7..94c59c9a 100644
--- a/client/src/network/tablemodel.cpp
+++ b/client/src/network/tablemodel.cpp
@@ -3,7 +3,7 @@
*
* This file is part of the traintastic source code.
*
- * Copyright (C) 2019-2021,2023 Reinder Feenstra
+ * Copyright (C) 2019-2021,2023,2025 Reinder Feenstra
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
@@ -72,7 +72,16 @@ QString TableModel::getValue(int column, int row) const
return m_texts.value(ColumnRow(column, row));
}
-void TableModel::setRegion(int columnMin, int columnMax, int rowMin, int rowMax)
+void TableModel::setRegionAll(bool enable)
+{
+ m_regionAll = enable;
+ if(m_regionAll)
+ {
+ updateRegionAll();
+ }
+}
+
+void TableModel::setRegion(uint32_t columnMin, uint32_t columnMax, uint32_t rowMin, uint32_t rowMax)
{
if(m_region.columnMin != columnMin ||
m_region.columnMax != columnMax ||
@@ -94,6 +103,10 @@ void TableModel::setColumnHeaders(const QVector& values)
{
beginResetModel();
m_columnHeaders = values;
+ if(m_regionAll)
+ {
+ updateRegionAll();
+ }
endResetModel();
}
}
@@ -104,6 +117,22 @@ void TableModel::setRowCount(int value)
{
beginResetModel();
m_rowCount = value;
+ if(m_regionAll)
+ {
+ updateRegionAll();
+ }
endResetModel();
}
}
+
+void TableModel::updateRegionAll()
+{
+ if(columnCount() == 0 || rowCount() == 0)
+ {
+ setRegion(1, 0, 1, 0); // select nothing
+ }
+ else
+ {
+ setRegion(0, columnCount() - 1, 0, rowCount() - 1);
+ }
+}
diff --git a/client/src/network/tablemodel.hpp b/client/src/network/tablemodel.hpp
index 807f60fd..ce52194c 100644
--- a/client/src/network/tablemodel.hpp
+++ b/client/src/network/tablemodel.hpp
@@ -3,7 +3,7 @@
*
* This file is part of the traintastic source code.
*
- * Copyright (C) 2019-2021,2023-2024 Reinder Feenstra
+ * Copyright (C) 2019-2021,2023-2025 Reinder Feenstra
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
@@ -45,16 +45,20 @@ class TableModel final : public QAbstractTableModel
int m_rowCount;
struct Region
{
- int rowMin = 0;
- int rowMax = -1;
- int columnMin = 0;
- int columnMax = -1;
+ // Default to invalid region
+ uint32_t rowMin = 1;
+ uint32_t rowMax = 0;
+ uint32_t columnMin = 1;
+ uint32_t columnMax = 0;
} m_region;
+ bool m_regionAll = false;
QMap m_texts;
void setColumnHeaders(const QVector& values);
void setRowCount(int value);
+ void updateRegionAll();
+
public:
explicit TableModel(std::shared_ptr connection, Handle handle, const QString& classId, QObject* parent = nullptr);
~TableModel() final;
@@ -71,7 +75,8 @@ class TableModel final : public QAbstractTableModel
QString getRowObjectId(int row) const;
QString getValue(int column, int row) const;
- void setRegion(int columnMin, int columnMax, int rowMin, int rowMax);
+ void setRegionAll(bool enable);
+ void setRegion(uint32_t columnMin, uint32_t columnMax, uint32_t rowMin, uint32_t rowMax);
};
#endif
diff --git a/client/src/theme/theme.cpp b/client/src/theme/theme.cpp
index 68e546f8..8430837d 100644
--- a/client/src/theme/theme.cpp
+++ b/client/src/theme/theme.cpp
@@ -3,7 +3,7 @@
*
* This file is part of the traintastic source code.
*
- * Copyright (C) 2021,2023 Reinder Feenstra
+ * Copyright (C) 2021,2023-2024 Reinder Feenstra
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
@@ -23,6 +23,7 @@
#include "theme.hpp"
#include
#include
+#include
const QString iconPathDefault = QStringLiteral(":/");
const QString iconPathDark = QStringLiteral(":/dark/");
@@ -31,8 +32,20 @@ const QString iconExtension = QStringLiteral(".svg");
const std::array iconPathsDark = {&iconPathDark, &iconPathDefault, &iconPathLight};
const std::array iconPathsLight = {&iconPathLight, &iconPathDefault, &iconPathDark};
+bool Theme::s_isDark = false;
Theme::IconSet Theme::s_iconSet = Theme::IconSet::Light;
+bool Theme::isDark()
+{
+ return s_isDark;
+}
+
+void Theme::setDark(bool value)
+{
+ s_isDark = value;
+ setIconSet(value ? Theme::IconSet::Dark : Theme::IconSet::Light);
+}
+
void Theme::setIconSet(IconSet value)
{
s_iconSet = value;
diff --git a/client/src/theme/theme.hpp b/client/src/theme/theme.hpp
index 33753fb7..ac432064 100644
--- a/client/src/theme/theme.hpp
+++ b/client/src/theme/theme.hpp
@@ -3,7 +3,7 @@
*
* This file is part of the traintastic source code.
*
- * Copyright (C) 2021 Reinder Feenstra
+ * Copyright (C) 2021,2024 Reinder Feenstra
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
@@ -39,11 +39,15 @@ class Theme
private:
Theme() = delete;
+ static bool s_isDark;
static IconSet s_iconSet;
static const std::array& getIconPaths();
public:
+ static bool isDark();
+ static void setDark(bool value);
+
static void setIconSet(IconSet value);
static QString getIconFile(const QString& id);
diff --git a/client/src/widget/createwidget.cpp b/client/src/widget/createwidget.cpp
index 5eda036a..72f405a6 100644
--- a/client/src/widget/createwidget.cpp
+++ b/client/src/widget/createwidget.cpp
@@ -3,7 +3,7 @@
*
* This file is part of the traintastic source code.
*
- * Copyright (C) 2020-2024 Reinder Feenstra
+ * Copyright (C) 2020-2025 Reinder Feenstra
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
@@ -23,6 +23,7 @@
#include "createwidget.hpp"
#include "list/marklincanlocomotivelistwidget.hpp"
#include "objectlist/boardlistwidget.hpp"
+#include "objectlist/interfacelistwidget.hpp"
#include "objectlist/throttleobjectlistwidget.hpp"
#include "objectlist/trainlistwidget.hpp"
#include "objectlist/zoneblocklistwidget.hpp"
@@ -36,6 +37,7 @@
#include "propertydoublespinbox.hpp"
#include "propertyspinbox.hpp"
#include "propertylineedit.hpp"
+#include "propertypairoutputaction.hpp"
#include "../board/boardwidget.hpp"
#include "../network/object.hpp"
#include "../network/inputmonitor.hpp"
@@ -47,8 +49,10 @@ QWidget* createWidgetIfCustom(const ObjectPtr& object, QWidget* parent)
{
const QString& classId = object->classId();
- if(classId == "command_station_list")
- return new ObjectListWidget(object, parent); // todo remove
+ if(classId == "list.interface")
+ {
+ return new InterfaceListWidget(object, parent);
+ }
else if(classId == "decoder_list")
return new ThrottleObjectListWidget(object, parent); // todo remove
else if(classId == "controller_list")
@@ -127,6 +131,10 @@ QWidget* createWidget(Property& property, QWidget* parent)
break; // TODO
case ValueType::Enum:
+ if(property.enumName() == "pair_output_action")
+ {
+ return new PropertyPairOutputAction(property, parent);
+ }
return new PropertyComboBox(property, parent);
case ValueType::Integer:
diff --git a/client/src/widget/methodicon.cpp b/client/src/widget/methodicon.cpp
new file mode 100644
index 00000000..21b05f6f
--- /dev/null
+++ b/client/src/widget/methodicon.cpp
@@ -0,0 +1,95 @@
+/**
+ * client/src/widget/methodicon.cpp
+ *
+ * This file is part of the traintastic source code.
+ *
+ * Copyright (C) 2024-2025 Reinder Feenstra
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#include "methodicon.hpp"
+#include
+#include
+#include "../network/method.hpp"
+
+MethodIcon::MethodIcon(Method& method, QIcon icon, QWidget* parent) :
+ QLabel(parent),
+ m_method{method}
+{
+ setPixmap(icon.pixmap(32, 32));
+ setCursor(Qt::PointingHandCursor);
+ setEnabled(m_method.getAttributeBool(AttributeName::Enabled, true));
+ setVisible(m_method.getAttributeBool(AttributeName::Visible, true));
+ setToolTip(m_method.displayName());
+ connect(&m_method, &Method::attributeChanged, this,
+ [this](AttributeName name, const QVariant& value)
+ {
+ switch(name)
+ {
+ case AttributeName::Enabled:
+ setEnabled(value.toBool());
+ break;
+
+ case AttributeName::Visible:
+ setVisible(value.toBool());
+ break;
+
+ case AttributeName::DisplayName:
+ setToolTip(m_method.displayName());
+ break;
+
+ default:
+ break;
+ }
+ });
+}
+
+MethodIcon::MethodIcon(Method& item, QIcon icon, std::function triggered, QWidget* parent)
+ : MethodIcon(item, icon, parent)
+{
+ m_triggered = std::move(triggered);
+}
+
+void MethodIcon::mousePressEvent(QMouseEvent* event)
+{
+ if(event->button() == Qt::LeftButton)
+ {
+ m_mouseLeftButtonPressed = true;
+ }
+}
+
+void MethodIcon::mouseReleaseEvent(QMouseEvent* event)
+{
+ if(m_mouseLeftButtonPressed && event->button() == Qt::LeftButton)
+ {
+ m_mouseLeftButtonPressed = false;
+#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
+ if(rect().contains(event->localPos().toPoint())) // test if mouse release in widget
+#else
+ if(rect().contains(event->position().toPoint())) // test if mouse release in widget
+#endif
+ {
+ if(m_triggered)
+ {
+ m_triggered();
+ }
+ else
+ {
+ m_method.call();
+ }
+ }
+ }
+}
diff --git a/server/src/hardware/throttle/throttlefunction.hpp b/client/src/widget/methodicon.hpp
similarity index 54%
rename from server/src/hardware/throttle/throttlefunction.hpp
rename to client/src/widget/methodicon.hpp
index b40176d0..d44344ca 100644
--- a/server/src/hardware/throttle/throttlefunction.hpp
+++ b/client/src/widget/methodicon.hpp
@@ -1,9 +1,9 @@
/**
- * server/src/hardware/throttle/throttlefunction.hpp
+ * client/src/widget/methodicon.hpp
*
* This file is part of the traintastic source code.
*
- * Copyright (C) 2022 Reinder Feenstra
+ * Copyright (C) 2024-2025 Reinder Feenstra
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
@@ -20,32 +20,26 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-#ifndef TRAINTASTIC_SERVER_HARDWARE_THROTTLE_THROTTLEFUNCTION_HPP
-#define TRAINTASTIC_SERVER_HARDWARE_THROTTLE_THROTTLEFUNCTION_HPP
+#ifndef TRAINTASTIC_CLIENT_WIDGET_METHODICON_HPP
+#define TRAINTASTIC_CLIENT_WIDGET_METHODICON_HPP
-#include "../../core/object.hpp"
-#include "../../core/property.hpp"
-#include "../../core/method.hpp"
+#include
-class Throttle;
+class Method;
-class ThrottleFunction : public Object
+class MethodIcon : public QLabel
{
- CLASS_ID("throttle_function")
+ protected:
+ Method& m_method;
+ std::function m_triggered;
+ bool m_mouseLeftButtonPressed;
- private:
- Throttle& m_throttle;
+ void mousePressEvent(QMouseEvent* event) final;
+ void mouseReleaseEvent(QMouseEvent* event) final;
public:
- Property number;
- Property name;
- Property value;
- Method press;
- Method release;
-
- ThrottleFunction(Throttle& throttle, uint32_t number);
-
- std::string getObjectId() const final;
+ MethodIcon(Method& item, QIcon icon, QWidget* parent = nullptr);
+ MethodIcon(Method& item, QIcon icon, std::function triggered, QWidget* parent = nullptr);
};
#endif
diff --git a/client/src/widget/objectlist/interfacelistwidget.cpp b/client/src/widget/objectlist/interfacelistwidget.cpp
new file mode 100644
index 00000000..fece75db
--- /dev/null
+++ b/client/src/widget/objectlist/interfacelistwidget.cpp
@@ -0,0 +1,119 @@
+/**
+ * client/src/widget/objectlist/interfacelistwidget.cpp
+ *
+ * This file is part of the traintastic source code.
+ *
+ * Copyright (C) 2025 Reinder Feenstra
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#include "interfacelistwidget.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+#include "../methodicon.hpp"
+#include "../../theme/theme.hpp"
+#include "../../mainwindow.hpp"
+
+class InterfaceListItemDelegate : public QItemDelegate
+{
+public:
+ inline InterfaceListItemDelegate(QListView* parent)
+ : QItemDelegate(parent)
+ {
+ }
+
+ inline void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const final
+ {
+ static const QSize classIconSize{40, 40};
+ static const QSize stateIconSize{16, 16};
+
+ auto* model = qobject_cast(parent())->model();
+ const auto id = model->data(model->index(index.row(), 0)).toString().prepend('#');
+ const auto name = model->data(model->index(index.row(), 1)).toString();
+ const auto state = model->data(model->index(index.row(), 2)).toString();
+ const auto classId = model->data(model->index(index.row(), 3)).toString();
+
+ const auto r = option.rect.adjusted(5, 5, -5, -5);
+ const int iconOffset = (r.height() - classIconSize.height()) / 2;
+
+ const auto palette = QApplication::palette();
+
+ QTextOption textOption;
+ textOption.setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
+
+ const auto classIcon = Theme::getIconForClassId(classId);
+ if(!classIcon.isNull())
+ {
+ painter->drawPixmap(r.topLeft() + QPoint(iconOffset, iconOffset), classIcon.pixmap(classIconSize));
+ }
+
+ painter->setPen(palette.color(QPalette::Disabled, QPalette::Text));
+ painter->drawText(r.adjusted((classIcon.isNull() ? 0 : r.height() + 10), r.height() / 2, 0, 0), id, textOption);
+
+ painter->setPen(palette.color(QPalette::Active, QPalette::Text));
+ painter->drawText(r.adjusted((classIcon.isNull() ? 0 : r.height() + 10), 0, 0, -r.height() / 2), name, textOption);
+
+ textOption.setAlignment(Qt::AlignRight | Qt::AlignVCenter);
+ const auto stateText = Locale::tr(QString(EnumName::value).append(":").append(state));
+ const auto stateRect = r.adjusted(0, r.height() / 2, 0, 0);
+ const auto stateTextWidth = qCeil(painter->boundingRect(stateRect, stateText, textOption).width());
+ painter->drawText(stateRect, stateText, textOption);
+
+ auto stateIcon = QIcon(Theme::getIconFile(QString("interface_state.").append(state)));
+ painter->drawPixmap(
+ stateRect.topRight() - QPoint(stateTextWidth + stateIconSize.width() + 5, -(stateRect.height() - stateIconSize.height()) / 2),
+ stateIcon.pixmap(stateIconSize));
+
+ painter->setPen(QColor(0x80, 0x80, 0x80, 0x30));
+ painter->drawLine(option.rect.bottomLeft(), option.rect.bottomRight());
+ }
+
+ inline QSize sizeHint(const QStyleOptionViewItem& /*option*/, const QModelIndex& /*index*/) const final
+ {
+ return QSize(-1, 50);
+ }
+};
+
+InterfaceListWidget::InterfaceListWidget(const ObjectPtr& object, QWidget* parent)
+ : StackedObjectListWidget(object, parent)
+{
+ m_list->setItemDelegate(new InterfaceListItemDelegate(m_list));
+ m_listEmptyLabel->setText(Locale::tr("interface_list:list_is_empty"));
+
+ if(m_create) /*[[likely]]*/
+ {
+ m_create->setToolTip(Locale::tr("interface_list:create"));
+ }
+
+ if(m_createMenu) /*[[likely]]*/
+ {
+ m_createMenu->addSeparator();
+ m_createMenu->addAction(Theme::getIcon("wizard"), Locale::tr("list:setup_using_wizard") + "...",
+ []()
+ {
+ MainWindow::instance->showAddInterfaceWizard();
+ });
+ }
+}
diff --git a/client/src/widget/objectlist/interfacelistwidget.hpp b/client/src/widget/objectlist/interfacelistwidget.hpp
new file mode 100644
index 00000000..b994fccc
--- /dev/null
+++ b/client/src/widget/objectlist/interfacelistwidget.hpp
@@ -0,0 +1,34 @@
+/**
+ * client/src/widget/objectlist/interfacelistwidget.hpp
+ *
+ * This file is part of the traintastic source code.
+ *
+ * Copyright (C) 2025 Reinder Feenstra
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#ifndef TRAINTASTIC_CLIENT_WIDGET_OBJECTLIST_INTERFACELISTWIDGET_HPP
+#define TRAINTASTIC_CLIENT_WIDGET_OBJECTLIST_INTERFACELISTWIDGET_HPP
+
+#include "stackedobjectlistwidget.hpp"
+
+class InterfaceListWidget : public StackedObjectListWidget
+{
+public:
+ explicit InterfaceListWidget(const ObjectPtr& object, QWidget* parent = nullptr);
+};
+
+#endif
diff --git a/client/src/widget/objectlist/stackedobjectlistwidget.cpp b/client/src/widget/objectlist/stackedobjectlistwidget.cpp
new file mode 100644
index 00000000..6acc337e
--- /dev/null
+++ b/client/src/widget/objectlist/stackedobjectlistwidget.cpp
@@ -0,0 +1,291 @@
+/**
+ * client/src/widget/objectlist/stackedobjectlistwidget.cpp
+ *
+ * This file is part of the traintastic source code.
+ *
+ * Copyright (C) 2025 Reinder Feenstra
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#include "stackedobjectlistwidget.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include "../createwidget.hpp"
+#include "../tablewidget.hpp"
+#include "../methodicon.hpp"
+#include "../../mainwindow.hpp"
+#include "../../network/object.hpp"
+#include "../../network/method.hpp"
+#include "../../network/connection.hpp"
+#include "../../network/error.hpp"
+#include "../../network/callmethod.hpp"
+#include "../../network/tablemodel.hpp"
+#include "../../theme/theme.hpp"
+#include "../../misc/methodaction.hpp"
+
+namespace
+{
+ class StackedObjectListProxyModel final : public QIdentityProxyModel
+ {
+ public:
+ StackedObjectListProxyModel(QAbstractItemModel* sourceModel)
+ : QIdentityProxyModel()
+ {
+ setSourceModel(sourceModel);
+ }
+
+ QVariant data(const QModelIndex &index, int role) const final
+ {
+ if (role == Qt::ToolTipRole)
+ {
+ return Locale::tr("stacked_object_list:click_to_edit_ctrl_click_to_open_in_a_new_window");
+ }
+ return QIdentityProxyModel::data(index, role);
+ }
+ };
+}
+
+StackedObjectListWidget::StackedObjectListWidget(const ObjectPtr& object, QWidget* parent)
+ : QWidget(parent)
+ , m_object{object}
+ , m_navBar{new QToolBar(this)}
+ , m_stack{new QStackedWidget(this)}
+ , m_list{new QListView(this)}
+ , m_listEmptyLabel{new QLabel(Locale::tr("stacked_object_list:list_is_empty"), m_list)}
+ , m_requestId{Connection::invalidRequestId}
+{
+ m_navBar->hide();
+
+ m_navBar->addAction(Theme::getIcon("previous_page"), Locale::tr("stacked_object_list:back"), this, &StackedObjectListWidget::back);
+
+ m_navLabel = new QLabel(this);
+ m_navLabel->setAlignment(Qt::AlignCenter);
+ m_navLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+ m_navLabel->show();
+ m_navBar->addWidget(m_navLabel);
+
+ if(auto* method = object->getMethod("delete"))
+ {
+ m_actionRemove = new MethodAction(Theme::getIcon("delete"), *method,
+ [this]()
+ {
+ if(!m_listObjectId.isEmpty())
+ {
+ callMethod(m_actionRemove->method(), nullptr, m_listObjectId);
+ back();
+ }
+ });
+ m_navBar->addAction(m_actionRemove);
+ }
+
+ connect(m_stack, &QStackedWidget::currentChanged,
+ [this](int index)
+ {
+ m_navBar->setVisible(index > 0);
+ });
+
+ m_requestId = object->connection()->getTableModel(object,
+ [this](const TableModelPtr& tableModel, std::optional error)
+ {
+ m_requestId = Connection::invalidRequestId;
+
+ if(tableModel)
+ {
+ m_tableModel = tableModel;
+ m_tableModel->setRegionAll(true);
+ m_list->setModel(new StackedObjectListProxyModel(m_tableModel.get()));
+ connect(m_tableModel.get(), &TableModel::modelReset,
+ [this]()
+ {
+ m_listEmptyLabel->setVisible(m_tableModel->rowCount() == 0);
+ });
+ }
+ else if(error)
+ {
+ QMessageBox::critical(this, "Error", error->toString());
+ }
+ });
+
+ if(auto* create = object->getMethod("create"))
+ {
+ if(create->argumentTypes().size() == 0) // Create method witout argument
+ {
+ m_create = new MethodIcon(*create, Theme::getIcon("circle/add"), m_list);
+ }
+ else if(create->argumentTypes().size() == 1)
+ {
+ m_createMenu = new QMenu(this);
+ m_createMenu->installEventFilter(this);
+
+ QStringList classList = create->getAttribute(AttributeName::ClassList, QVariant()).toStringList();
+ for(const QString& classId : classList)
+ {
+ QAction* action = m_createMenu->addAction(Locale::tr("class_id:" + classId));
+ action->setData(classId);
+ connect(action, &QAction::triggered, this,
+ [this, create, action]()
+ {
+ cancelRequest();
+
+ m_requestId = create->call(action->data().toString(),
+ [this](const ObjectPtr& addedObject, std::optional error)
+ {
+ m_requestId = Connection::invalidRequestId;
+
+ if(addedObject)
+ {
+ show(addedObject);
+ }
+ else if(error)
+ {
+ QMessageBox::critical(this, "Error", error->toString());
+ }
+ });
+ });
+ }
+
+ m_create = new MethodIcon(*create, Theme::getIcon("circle/add"),
+ [this]()
+ {
+ m_createMenu->popup(m_create->mapToGlobal(m_create->rect().topRight()));
+ }, m_list);
+ }
+
+ m_create->setEnabled(create->getAttributeBool(AttributeName::Enabled, true));
+ if(!create->getAttributeBool(AttributeName::Visible, true))
+ {
+ m_create->hide();
+ }
+
+ m_create->installEventFilter(this);
+ }
+
+ m_list->installEventFilter(this);
+ m_list->setSelectionMode(QListView::NoSelection);
+ connect(m_list, &QListView::clicked,
+ [this](const QModelIndex &index)
+ {
+ if(!m_tableModel) /*[[unlikely]]*/
+ {
+ return;
+ }
+
+ const bool openInSubWindow = (QGuiApplication::queryKeyboardModifiers() & Qt::ControlModifier);
+
+ cancelRequest();
+
+ m_requestId = m_object->connection()->getObject(m_tableModel->getRowObjectId(index.row()),
+ [this, openInSubWindow](const ObjectPtr& selectedObject, std::optional error)
+ {
+ m_requestId = Connection::invalidRequestId;
+
+ if(selectedObject)
+ {
+ if(openInSubWindow)
+ {
+ MainWindow::instance->showObject(selectedObject);
+ }
+ else
+ {
+ show(selectedObject);
+ }
+ }
+ else if(error)
+ {
+ QMessageBox::critical(this, "Error", error->toString());
+ }
+ });
+ });
+
+ m_listEmptyLabel->installEventFilter(this);
+ m_listEmptyLabel->setWordWrap(true);
+
+ m_stack->addWidget(m_list);
+
+ auto* l = new QVBoxLayout();
+ l->setContentsMargins(0, 0, 0, 0);
+ l->addWidget(m_navBar);
+ l->addWidget(m_stack);
+ setLayout(l);
+}
+
+StackedObjectListWidget::~StackedObjectListWidget()
+{
+ cancelRequest();
+}
+
+bool StackedObjectListWidget::eventFilter(QObject* object, QEvent* event)
+{
+ if(m_listEmptyLabel->isVisible() && ((object == m_list && event->type() == QEvent::Resize) || (object == m_listEmptyLabel && event->type() == QEvent::Show)))
+ {
+ m_listEmptyLabel->setMaximumWidth(qRound(width() * 0.9f));
+ m_listEmptyLabel->adjustSize();
+ m_listEmptyLabel->setFixedHeight(m_listEmptyLabel->heightForWidth(m_listEmptyLabel->maximumWidth()));
+ m_listEmptyLabel->move((rect().bottomRight() - m_listEmptyLabel->rect().bottomRight()) / 2);
+ }
+ if(m_create && ((object == m_list && event->type() == QEvent::Resize) || (object == m_create && event->type() == QEvent::Show)))
+ {
+ auto pnt = m_create->rect().bottomRight();
+ pnt = m_list->rect().bottomRight() - pnt - pnt / 3;
+ m_create->move(pnt.x(), pnt.y());
+ }
+ if(m_createMenu && object == m_createMenu && event->type() == QEvent::Show)
+ {
+ m_createMenu->move(m_createMenu->pos() - QPoint{m_createMenu->width(), m_createMenu->height()});
+ return true;
+ }
+ return QWidget::eventFilter(object, event);
+}
+
+void StackedObjectListWidget::cancelRequest()
+{
+ if(m_requestId != Connection::invalidRequestId)
+ {
+ m_object->connection()->cancelRequest(m_requestId);
+ }
+}
+
+void StackedObjectListWidget::back()
+{
+ if(m_stack->currentIndex() > 0)
+ {
+ m_listObjectId.clear();
+ delete m_stack->currentWidget();
+ }
+}
+
+void StackedObjectListWidget::show(const ObjectPtr& listObject)
+{
+ if(auto* w = createWidget(listObject, this)) /*[[likely]]*/
+ {
+ m_listObjectId = listObject->getPropertyValueString("id");
+ connect(w, &QWidget::windowTitleChanged, m_navLabel, &QLabel::setText);
+ m_navLabel->setText(w->windowTitle());
+ m_stack->setCurrentIndex(m_stack->addWidget(w));
+ }
+}
diff --git a/client/src/widget/objectlist/stackedobjectlistwidget.hpp b/client/src/widget/objectlist/stackedobjectlistwidget.hpp
new file mode 100644
index 00000000..ce41187c
--- /dev/null
+++ b/client/src/widget/objectlist/stackedobjectlistwidget.hpp
@@ -0,0 +1,67 @@
+/**
+ * client/src/widget/objectlist/stackedobjectlistwidget.hpp
+ *
+ * This file is part of the traintastic source code.
+ *
+ * Copyright (C) 2025 Reinder Feenstra
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#ifndef TRAINTASTIC_CLIENT_WIDGET_OBJECTLIST_STACKEDOBJECTLISTWIDGET_HPP
+#define TRAINTASTIC_CLIENT_WIDGET_OBJECTLIST_STACKEDOBJECTLISTWIDGET_HPP
+
+#include
+
+#include "../../network/objectptr.hpp"
+#include "../../network/tablemodelptr.hpp"
+
+class QToolBar;
+class QStackedWidget;
+class QListView;
+class QLabel;
+class QMenu;
+class MethodIcon;
+class MethodAction;
+
+class StackedObjectListWidget : public QWidget
+{
+protected:
+ ObjectPtr m_object;
+ TableModelPtr m_tableModel;
+ QToolBar* m_navBar;
+ QLabel* m_navLabel;
+ QStackedWidget* m_stack;
+ QListView* m_list;
+ QLabel* m_listEmptyLabel;
+ MethodIcon* m_create = nullptr;
+ QMenu* m_createMenu = nullptr;
+ MethodAction* m_actionRemove = nullptr;
+ QString m_listObjectId;
+ int m_requestId;
+
+ void cancelRequest();
+
+ void back();
+ void show(const ObjectPtr& listObject);
+
+ bool eventFilter(QObject* object, QEvent* event) override;
+
+public:
+ explicit StackedObjectListWidget(const ObjectPtr& object, QWidget* parent = nullptr);
+ ~StackedObjectListWidget() override;
+};
+
+#endif
diff --git a/client/src/widget/outputmapwidget.cpp b/client/src/widget/outputmapwidget.cpp
index c583cccc..2e715512 100644
--- a/client/src/widget/outputmapwidget.cpp
+++ b/client/src/widget/outputmapwidget.cpp
@@ -27,14 +27,18 @@
#include
#include
#include
+#include
#include
+#include "createwidget.hpp"
#include "interfaceitemnamelabel.hpp"
#include "propertycheckbox.hpp"
#include "propertycombobox.hpp"
+#include "propertypairoutputaction.hpp"
#include "propertyspinbox.hpp"
#include "objectpropertycombobox.hpp"
#include "propertyaddresses.hpp"
#include "outputmapoutputactionwidget.hpp"
+#include "methodicon.hpp"
#include "../board/tilepainter.hpp"
#include "../board/getboardcolorscheme.hpp"
#include "../dialog/objectselectlistdialog.hpp"
@@ -117,6 +121,17 @@ OutputMapWidget::OutputMapWidget(ObjectPtr object, QWidget* parent)
l->addWidget(m_table);
+ if(auto* swapOutputs = m_object->getMethod("swap_outputs"))
+ {
+ m_swapOutputs = new MethodIcon(*swapOutputs, Theme::getIcon("swap"), m_table);
+ if(!swapOutputs->getAttributeBool(AttributeName::Visible, true))
+ {
+ m_swapOutputs->hide();
+ }
+ m_table->installEventFilter(this);
+ m_swapOutputs->installEventFilter(this);
+ }
+
setLayout(l);
if(auto* parentObject = m_object->getObjectProperty("parent"))
@@ -324,6 +339,17 @@ void OutputMapWidget::updateTableOutputColumns()
}
}
+bool OutputMapWidget::eventFilter(QObject* object, QEvent* event)
+{
+ if(m_swapOutputs && ((object == m_table && event->type() == QEvent::Resize) || (object == m_swapOutputs && event->type() == QEvent::Show)))
+ {
+ auto pnt = m_swapOutputs->rect().bottomRight();
+ pnt = m_table->rect().bottomRight() - pnt - pnt / 4;
+ m_swapOutputs->move(pnt.x(), pnt.y());
+ }
+ return QWidget::eventFilter(object, event);
+}
+
void OutputMapWidget::updateTableOutputActions(ObjectVectorProperty& property, int row)
{
if(!property.empty())
@@ -345,7 +371,7 @@ void OutputMapWidget::updateTableOutputActions(ObjectVectorProperty& property, i
{
if(auto* action = dynamic_cast(object->getProperty("action")))
{
- m_table->setCellWidget(row, column, new PropertyComboBox(*action, this));
+ m_table->setCellWidget(row, column, createWidget(*action, this));
}
else if(auto* aspect = dynamic_cast(object->getProperty("aspect")))
{
diff --git a/client/src/widget/outputmapwidget.hpp b/client/src/widget/outputmapwidget.hpp
index 59f5b1ff..563100e2 100644
--- a/client/src/widget/outputmapwidget.hpp
+++ b/client/src/widget/outputmapwidget.hpp
@@ -29,6 +29,7 @@
class QTableWidget;
class Method;
class MethodAction;
+class MethodIcon;
class AbstractProperty;
class AbstractVectorProperty;
class ObjectVectorProperty;
@@ -47,6 +48,7 @@ class OutputMapWidget : public QWidget
Property* m_ecosObject;
ObjectVectorProperty* m_items;
QTableWidget* m_table;
+ MethodIcon* m_swapOutputs = nullptr;
std::vector m_itemObjects;
std::vector> m_actions;
int m_getParentRequestId;
@@ -58,6 +60,8 @@ class OutputMapWidget : public QWidget
void updateKeyIcons();
void updateTableOutputColumns();
+ bool eventFilter(QObject* object, QEvent* event) override;
+
public:
explicit OutputMapWidget(ObjectPtr object, QWidget* parent = nullptr);
~OutputMapWidget() override;
diff --git a/client/src/widget/propertypairoutputaction.cpp b/client/src/widget/propertypairoutputaction.cpp
new file mode 100644
index 00000000..376c1ed1
--- /dev/null
+++ b/client/src/widget/propertypairoutputaction.cpp
@@ -0,0 +1,168 @@
+/**
+ * client/src/widget/propertypairoutputaction.cpp
+ *
+ * This file is part of the traintastic source code.
+ *
+ * Copyright (C) 2024 Reinder Feenstra
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#include "propertypairoutputaction.hpp"
+#include
+#include
+#include "../network/property.hpp"
+
+PropertyPairOutputAction::PropertyPairOutputAction(Property& property, QWidget* parent)
+ : QWidget(parent)
+ , m_property{property}
+{
+ assert(m_property.enumName() == "pair_output_action");
+
+ setFocusPolicy(Qt::StrongFocus);
+
+ connect(&m_property, &Property::valueChanged, this,
+ [this]()
+ {
+ update(rect());
+ });
+}
+
+PairOutputAction PropertyPairOutputAction::value() const
+{
+ return m_property.toEnum();
+}
+
+void PropertyPairOutputAction::setValue(PairOutputAction newValue)
+{
+ m_property.setValueEnum(newValue);
+}
+
+void PropertyPairOutputAction::toggleValue()
+{
+ switch(value())
+ {
+ case PairOutputAction::None:
+ setValue(PairOutputAction::First);
+ break;
+
+ case PairOutputAction::First:
+ setValue(PairOutputAction::Second);
+ break;
+
+ case PairOutputAction::Second:
+ default:
+ setValue(PairOutputAction::None);
+ break;
+ }
+}
+
+void PropertyPairOutputAction::toggleValue(PairOutputAction action)
+{
+ setValue(action == value() ? PairOutputAction::None : action);
+}
+
+void PropertyPairOutputAction::keyPressEvent(QKeyEvent* event)
+{
+ switch(event->key())
+ {
+ case Qt::Key_Enter:
+ case Qt::Key_Space:
+ toggleValue();
+ return;
+
+ case Qt::Key_1:
+ case Qt::Key_R:
+ toggleValue(PairOutputAction::First);
+ return;
+
+ case Qt::Key_2:
+ case Qt::Key_G:
+ toggleValue(PairOutputAction::Second);
+ return;
+ }
+ QWidget::keyPressEvent(event);
+}
+
+void PropertyPairOutputAction::mousePressEvent(QMouseEvent* event)
+{
+ if(event->button() == Qt::LeftButton)
+ {
+ m_mouseLeftClickPos = event->pos();
+ }
+}
+
+void PropertyPairOutputAction::mouseReleaseEvent(QMouseEvent* event)
+{
+ if(event->button() == Qt::LeftButton && m_mouseLeftClickPos)
+ {
+ const auto [first, second] = outputRects();
+
+ if(first.contains(*m_mouseLeftClickPos) && first.contains(event->pos()))
+ {
+ toggleValue(PairOutputAction::First);
+ }
+ else if(second.contains(*m_mouseLeftClickPos) && second.contains(event->pos()))
+ {
+ toggleValue(PairOutputAction::Second);
+ }
+
+ m_mouseLeftClickPos.reset();
+ }
+}
+
+void PropertyPairOutputAction::paintEvent(QPaintEvent* /*event*/)
+{
+ constexpr int thinkness = 2;
+ const QColor firstOnColor(Qt::red);
+ const QColor secondOnColor(Qt::green);
+ const auto textOnColor = palette().color(QPalette::Active, QPalette::WindowText);
+ const auto offColor = palette().color(QPalette::Disabled, QPalette::WindowText);
+ const auto [left, right] = outputRects();
+
+ QPainter painter(this);
+ painter.setRenderHint(QPainter::Antialiasing, true);
+
+ painter.save();
+
+ QPen p(painter.pen());
+ p.setWidth(thinkness);
+
+ p.setColor(value() == PairOutputAction::First ? firstOnColor : offColor);
+ painter.setPen(p);
+ painter.drawEllipse(left.adjusted(thinkness, thinkness, -thinkness, -thinkness));
+
+ p.setColor(value() == PairOutputAction::Second ? secondOnColor : offColor);
+ painter.setPen(p);
+ painter.drawEllipse(right.adjusted(thinkness, thinkness, -thinkness, -thinkness));
+
+ painter.restore();
+
+ painter.setPen(value() == PairOutputAction::First ? textOnColor : offColor);
+ painter.drawText(left, Qt::AlignCenter, "R");
+
+ painter.setPen(value() == PairOutputAction::Second ? textOnColor : offColor);
+ painter.drawText(right, Qt::AlignCenter, "G");
+}
+
+std::pair PropertyPairOutputAction::outputRects() const
+{
+ constexpr int margin = 1;
+ const int height = rect().height();
+ const int hCenter = rect().width() / 2;
+ return {
+ {hCenter - margin - height, 0, height, height},
+ {hCenter + margin, 0, height, height}};
+}
diff --git a/client/src/widget/propertypairoutputaction.hpp b/client/src/widget/propertypairoutputaction.hpp
new file mode 100644
index 00000000..6e00d983
--- /dev/null
+++ b/client/src/widget/propertypairoutputaction.hpp
@@ -0,0 +1,55 @@
+/**
+ * client/src/widget/propertypairoutputaction.hpp
+ *
+ * This file is part of the traintastic source code.
+ *
+ * Copyright (C) 2024 Reinder Feenstra
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#ifndef TRAINTASTIC_CLIENT_WIDGET_PROPERTYPAIROUTPUTACTION_HPP
+#define TRAINTASTIC_CLIENT_WIDGET_PROPERTYPAIROUTPUTACTION_HPP
+
+#include
+#include
+#include
+
+class Property;
+
+class PropertyPairOutputAction : public QWidget
+{
+private:
+ Property& m_property;
+ std::optional m_mouseLeftClickPos = std::nullopt;
+
+ std::pair outputRects() const;
+
+protected:
+ void keyPressEvent(QKeyEvent* event) final;
+ void mousePressEvent(QMouseEvent* event) final;
+ void mouseReleaseEvent(QMouseEvent* event) final;
+ void paintEvent(QPaintEvent* event) final;
+
+public:
+ PropertyPairOutputAction(Property& property, QWidget* parent = nullptr);
+
+ PairOutputAction value() const;
+ void setValue(PairOutputAction action);
+ void toggleValue();
+ void toggleValue(PairOutputAction output);
+};
+
+#endif
diff --git a/client/src/widget/tablewidget.cpp b/client/src/widget/tablewidget.cpp
index bcaa64c8..0da5e64d 100644
--- a/client/src/widget/tablewidget.cpp
+++ b/client/src/widget/tablewidget.cpp
@@ -136,19 +136,34 @@ void TableWidget::updateRegion()
int rowMin = qMax(topLeft.row(), 0);
int rowMax = indexAt(r.bottomLeft()).row();
- if(rowMax == -1)
+
+ if(rowCount == 0)
+ {
+ // Invalid region to represent empty model
+ rowMin = 1;
+ rowMax = 0;
+ }
+ else if(rowMax == -1)
rowMax = rowCount - 1;
else
rowMax = qMin(rowMax + 1, rowCount - 1);
int columnMin = qMax(topLeft.column(), 0);
int columnMax = indexAt(r.topRight()).column();
+
+ if(columnCount == 0)
+ {
+ // Invalid region to represent empty model
+ columnMin = 1;
+ columnMax = 0;
+ }
if(columnMax == -1)
columnMax = columnCount - 1;
else
columnMax = qMin(columnMax + 1, columnCount - 1);
- m_model->setRegion(columnMin, columnMax, rowMin, rowMax);
+ m_model->setRegion(uint32_t(columnMin), uint32_t(columnMax),
+ uint32_t(rowMin), uint32_t(rowMax));
}
void TableWidget::mouseMoveEvent(QMouseEvent* event)
diff --git a/client/src/wizard/jsonwizard.cpp b/client/src/wizard/jsonwizard.cpp
index eb788955..a898e5cb 100644
--- a/client/src/wizard/jsonwizard.cpp
+++ b/client/src/wizard/jsonwizard.cpp
@@ -225,13 +225,17 @@ class RadioPageJSON : public RadioPage, public PageJSON
void initializePage() override
{
- setTitleAndText(*static_cast(wizard()), this, m_pageData);
+ auto* jsonWizard = static_cast(wizard());
+
+ setTitleAndText(*jsonWizard, this, m_pageData);
for(const auto& option : m_pageData["options"].toArray())
{
auto item = option.toObject();
- addItem(static_cast(wizard())->translateAndReplaceVariables(item["name"].toString()), item["disabled"].toBool());
+ addItem(jsonWizard->translateAndReplaceVariables(item["name"].toString()), item["checked"].toBool(), item["disabled"].toBool());
}
+
+ setBottomText(jsonWizard->translateAndReplaceVariables(m_pageData["bottom_text"].toString()));
}
void cleanupPage() override
@@ -469,4 +473,4 @@ Properties JSONWizard::toProperties(const QJsonObject& object)
}
}
return properties;
-}
\ No newline at end of file
+}
diff --git a/client/src/wizard/page/radiopage.cpp b/client/src/wizard/page/radiopage.cpp
index 3d364615..88e9d0c0 100644
--- a/client/src/wizard/page/radiopage.cpp
+++ b/client/src/wizard/page/radiopage.cpp
@@ -24,11 +24,19 @@
#include
#include
#include
+#include
RadioPage::RadioPage(QWidget* parent)
: TextPage(parent)
, m_group{new QButtonGroup(this)}
+ , m_bottomText{new QLabel(this)}
{
+ m_bottomText->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
+ m_bottomText->setWordWrap(true);
+ setBottomText({});
+
+ static_cast(layout())->addStretch();
+ layout()->addWidget(m_bottomText);
}
int RadioPage::currentIndex() const
@@ -36,23 +44,25 @@ int RadioPage::currentIndex() const
return m_group->id(m_group->checkedButton());
}
-void RadioPage::addItem(const QString& label, bool disabled)
+void RadioPage::addItem(const QString& label, bool checked, bool disabled)
{
auto* button = new QRadioButton(label);
+ button->setChecked(checked && !disabled);
button->setDisabled(disabled);
m_group->addButton(button, m_group->buttons().size());
- layout()->addWidget(button);
+ static_cast(layout())->insertWidget(layout()->count() - 2, button);
}
void RadioPage::clear()
{
- while(layout()->count() > 1) // remove all but first (=text label)
+ for(auto* button : m_group->buttons())
{
- auto* item = layout()->itemAt(layout()->count() - 1);
- if(item->widget())
- {
- delete item->widget();
- }
- layout()->removeItem(item);
+ delete button;
}
}
+
+void RadioPage::setBottomText(const QString& text)
+{
+ m_bottomText->setText(text);
+ m_bottomText->setVisible(!text.isEmpty());
+}
diff --git a/client/src/wizard/page/radiopage.hpp b/client/src/wizard/page/radiopage.hpp
index 13cc175d..eb92f7f4 100644
--- a/client/src/wizard/page/radiopage.hpp
+++ b/client/src/wizard/page/radiopage.hpp
@@ -31,14 +31,17 @@ class RadioPage : public TextPage
{
protected:
QButtonGroup* m_group;
+ QLabel* m_bottomText;
public:
explicit RadioPage(QWidget* parent = nullptr);
int currentIndex() const;
- void addItem(const QString& label, bool disabled = false);
+ void addItem(const QString& label, bool checked = false, bool disabled = false);
void clear();
+
+ void setBottomText(const QString& text);
};
#endif
diff --git a/package/innosetup/traintastic.iss b/package/innosetup/traintastic.iss
index 1575e14e..23292d79 100644
--- a/package/innosetup/traintastic.iss
+++ b/package/innosetup/traintastic.iss
@@ -54,9 +54,6 @@ Name: "firewall_wlanmaus"; Description: "{cm:firewall_allow_wlanmaus_z21}"; Grou
[Files]
; Server
Source: "..\..\server\build\{#ServerExeName}"; DestDir: "{app}\server"; Flags: ignoreversion; Check: InstallServer
-Source: "..\..\server\thirdparty\lua5.4\bin\win64\lua54.dll"; DestDir: "{app}\server"; Flags: ignoreversion; Check: InstallServer
-Source: "..\..\server\thirdparty\libarchive\bin\archive.dll"; DestDir: "{app}\server"; Flags: ignoreversion; Check: InstallServer
-Source: "..\..\server\thirdparty\zlib\bin\zlib1.dll"; DestDir: "{app}\server"; Flags: ignoreversion; Check: InstallServer
; Client
Source: "..\..\client\build\Release\{#ClientExeName}"; DestDir: "{app}\client"; Flags: ignoreversion; Check: InstallClient
Source: "..\..\client\build\Release\*.dll"; DestDir: "{app}\client"; Flags: ignoreversion; Check: InstallClient
@@ -88,6 +85,11 @@ Type: files; Name: "{commonappdata}\traintastic\translations\en-us.txt"
Type: files; Name: "{commonappdata}\traintastic\translations\nl-nl.txt"
Type: files; Name: "{commonappdata}\traintastic\translations\de-de.txt"
Type: files; Name: "{commonappdata}\traintastic\translations\it-it.txt"
+; Delete unused DLLs, now statically linked (TODO: remove in 0.4)
+Type: files; Name: "{app}\server\lua53.dll"
+Type: files; Name: "{app}\server\lua54.dll"
+Type: files; Name: "{app}\server\archive.dll"
+Type: files; Name: "{app}\server\zlib1.dll"
[UninstallRun]
Filename: {sys}\netsh.exe; Parameters: "advfirewall firewall delete rule name=""Traintastic server (TCP)"""; Flags: runhidden; Check: InstallServer; Tasks: firewall_traintastic
@@ -107,7 +109,7 @@ Root: HKLM; Subkey: "{#CompanySubKey}"; Flags: uninsdeletekeyifempty
Root: HKLM; Subkey: "{#AppSubKey}"; Flags: uninsdeletekey
[INI]
-Filename: {commonappdata}\traintastic\traintastic-client.ini; Section: general_; Key: language; String: {code:GetTraintasticClientLanguage}; Flags: uninsdeleteentry uninsdeletesectionifempty;
+Filename: {commonappdata}\traintastic\traintastic-client.ini; Section: general_; Key: language; String: {code:GetTraintasticLanguage}; Flags: uninsdeleteentry uninsdeletesectionifempty;
[Code]
const
@@ -179,6 +181,7 @@ begin
ClientAndServerRadioButton.Checked := (Components = 'ClientAndServer');
ClientAndServerRadioButton.Font.Style := [fsBold];
ClientAndServerRadioButton.Height := ScaleY(23);
+ ClientAndServerRadioButton.Width := ComponentsPage.SurfaceWidth;
ClientAndServerRadioButton.Parent := ComponentsPage.Surface;
ClientAndServerRadioButton.OnClick := @ComponentRadioButtonClick;
@@ -195,6 +198,7 @@ begin
ClientOnlyRadioButton.Font.Style := [fsBold];
ClientOnlyRadioButton.Top := Lbl.Top + Lbl.Height + ScaleY(10);
ClientOnlyRadioButton.Height := ScaleY(23);
+ ClientOnlyRadioButton.Width := ComponentsPage.SurfaceWidth;
ClientOnlyRadioButton.Parent := ComponentsPage.Surface;
ClientOnlyRadioButton.OnClick := @ComponentRadioButtonClick;
@@ -206,7 +210,7 @@ begin
Lbl.Parent := ComponentsPage.Surface;
end;
-function GetTraintasticClientLanguage(Param: String) : String;
+function GetTraintasticLanguage(Param: String) : String;
begin
case ActiveLanguage of
'nl': Result := 'nl-nl';
@@ -219,6 +223,19 @@ begin
end;
end;
+procedure CurStepChanged(CurStep: TSetupStep);
+var
+ ServerSettingsFile: String;
+begin
+ if CurStep = ssPostInstall then begin
+ // Server: only write language if there is no setting file yet:
+ ServerSettingsFile := ExpandConstant('{localappdata}\traintastic\server\settings.json');
+ if not FileExists(ServerSettingsFile) then begin
+ SaveStringToFile(ServerSettingsFile, '{"language":"' + GetTraintasticLanguage('') + '"}', False);
+ end;
+ end
+end;
+
function VC2019RedistNeedsInstall: Boolean;
var
Version: String;
diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt
index 2371c176..5cf7ba3b 100644
--- a/server/CMakeLists.txt
+++ b/server/CMakeLists.txt
@@ -1,4 +1,4 @@
-cmake_minimum_required(VERSION 3.9)
+cmake_minimum_required(VERSION 3.18)
include(../shared/traintastic.cmake)
project(traintastic-server VERSION ${TRAINTASTIC_VERSION} DESCRIPTION "Traintastic server")
include(GNUInstallDirs)
@@ -23,7 +23,7 @@ endif()
add_executable(traintastic-server src/main.cpp src/options.hpp)
add_dependencies(traintastic-server traintastic-lang)
-set_target_properties(traintastic-server PROPERTIES CXX_STANDARD 17)
+set_target_properties(traintastic-server PROPERTIES CXX_STANDARD 20)
target_include_directories(traintastic-server PRIVATE
${CMAKE_CURRENT_BINARY_DIR}
../shared/src)
@@ -33,11 +33,20 @@ target_include_directories(traintastic-server SYSTEM PRIVATE
if(BUILD_TESTING)
add_subdirectory(thirdparty/catch2)
- set_target_properties(Catch2 PROPERTIES CXX_STANDARD 17)
+ set_target_properties(Catch2 PROPERTIES
+ CXX_STANDARD 20
+ CXX_CLANG_TIDY ""
+ )
+ if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
+ target_compile_options(Catch2 PRIVATE -Wno-restrict) # workaround GCC bug
+ endif()
add_executable(traintastic-server-test test/main.cpp)
add_dependencies(traintastic-server-test traintastic-lang)
target_compile_definitions(traintastic-server-test PRIVATE -DTRAINTASTIC_TEST)
- set_target_properties(traintastic-server-test PROPERTIES CXX_STANDARD 17)
+ set_target_properties(traintastic-server-test PROPERTIES
+ CXX_STANDARD 20
+ CXX_CLANG_TIDY ""
+ )
target_include_directories(traintastic-server-test PRIVATE
${CMAKE_CURRENT_BINARY_DIR}
../shared/src)
@@ -58,6 +67,7 @@ file(GLOB SOURCES
"src/board/nx/*.cpp"
"src/board/tile/*.hpp"
"src/board/tile/*.cpp"
+ "src/board/tile/hidden/*.cpp"
"src/board/tile/misc/*.hpp"
"src/board/tile/misc/*.cpp"
"src/board/tile/rail/*.hpp"
@@ -189,6 +199,38 @@ file(GLOB TEST_SOURCES
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -DENABLE_LOG_DEBUG")
+### VCPKG
+if(DEFINED ENV{VCPKG_ROOT})
+ message(STATUS "Using VCPKG (VCPKG_ROOT=$ENV{VCPKG_ROOT})")
+ if(WIN32)
+ set(VCPKG_TARGET_TRIPLET "x64-windows-static-md")
+ endif()
+ include($ENV{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake)
+endif()
+
+### RESOURCES ###
+
+include(cmake/add-resource.cmake)
+
+add_resource(resource-www
+ FILES
+ www/css/normalize.css
+ www/css/throttle.css
+ www/js/throttle.js
+ www/throttle.html
+)
+
+add_resource(resource-shared
+ BASE_DIR ../
+ FILES
+ shared/gfx/appicon.ico
+)
+
+add_dependencies(traintastic-server resource-www resource-shared)
+if(BUILD_TESTING)
+ add_dependencies(traintastic-server-test resource-www resource-shared)
+endif()
+
### OPTIONS ###
if(NO_LOCALHOST_ONLY_SETTING)
@@ -280,154 +322,30 @@ if(WIN32 AND NOT MSVC)
endif()
# boost
-if(LINUX)
- find_package(Boost 1.71 REQUIRED COMPONENTS program_options)
-
- target_include_directories(traintastic-server SYSTEM PRIVATE ${Boost_INCLUDE_DIRS})
- target_link_libraries(traintastic-server PRIVATE ${Boost_LIBRARIES})
- if(BUILD_TESTING)
- target_include_directories(traintastic-server-test SYSTEM PRIVATE ${Boost_INCLUDE_DIRS})
- target_link_libraries(traintastic-server-test PRIVATE ${Boost_LIBRARIES})
- endif()
-else()
- add_definitions(
- -DBOOST_ALL_NO_LIB
- -DBOOST_ERROR_CODE_HEADER_ONLY
- -DBOOST_CHRONO_HEADER_ONLY
- -DBOOST_ASIO_HEADER_ONLY
- -DBOOST_SYSTEM_NO_DEPRECATED)
-
- if(NOT MSVC)
- set_source_files_properties(
- thirdparty/boost/libs/program_options/src/cmdline.cpp
- thirdparty/boost/libs/program_options/src/config_file.cpp
- thirdparty/boost/libs/program_options/src/convert.cpp
- thirdparty/boost/libs/program_options/src/options_description.cpp
- thirdparty/boost/libs/program_options/src/parsers.cpp
- thirdparty/boost/libs/program_options/src/positional_options.cpp
- thirdparty/boost/libs/program_options/src/split.cpp
- thirdparty/boost/libs/program_options/src/utf8_codecvt_facet.cpp
- thirdparty/boost/libs/program_options/src/value_semantic.cpp
- thirdparty/boost/libs/program_options/src/variables_map.cpp
- thirdparty/boost/libs/program_options/src/winmain.cpp
- PROPERTIES
- COMPILE_FLAGS -Wno-shadow)
- endif()
-
- target_include_directories(traintastic-server SYSTEM PRIVATE thirdparty/boost)
- if(BUILD_TESTING)
- target_include_directories(traintastic-server-test SYSTEM PRIVATE thirdparty/boost)
- endif()
-
- file(GLOB SOURCES_BOOST "thirdparty/boost/libs/program_options/src/*.cpp")
- list(APPEND SOURCES ${SOURCES_BOOST})
+find_package(Boost 1.81 REQUIRED COMPONENTS program_options)
+target_include_directories(traintastic-server SYSTEM PRIVATE ${Boost_INCLUDE_DIRS})
+target_link_libraries(traintastic-server PRIVATE ${Boost_LIBRARIES})
+if(BUILD_TESTING)
+ target_include_directories(traintastic-server-test SYSTEM PRIVATE ${Boost_INCLUDE_DIRS})
+ target_link_libraries(traintastic-server-test PRIVATE ${Boost_LIBRARIES})
endif()
# zlib
-if(WIN32)
- set(ZLIB_INCLUDE_DIRS "thirdparty/zlib/include")
-
- if(MSVC)
- set(ZLIB_LIBRARIES zlib1)
- add_custom_command(TARGET traintastic-server PRE_LINK
- COMMAND lib "/def:${PROJECT_SOURCE_DIR}/thirdparty/zlib/bin/zlib1.def" /out:zlib1.lib /machine:x64)
- add_custom_command(TARGET traintastic-server-test PRE_LINK
- COMMAND lib "/def:${PROJECT_SOURCE_DIR}/thirdparty/zlib/bin/zlib1.def" /out:zlib1.lib /machine:x64)
- else()
- # MinGW can directly link .dll without import lib
- set(ZLIB_LIBRARIES "${PROJECT_SOURCE_DIR}/thirdparty/zlib/bin/zlib1.dll")
- endif()
-
- # copy zlib1.dll to build directory:
- add_custom_command(TARGET traintastic-server POST_BUILD
- COMMAND ${CMAKE_COMMAND} -E copy "${PROJECT_SOURCE_DIR}/thirdparty/zlib/bin/zlib1.dll" .)
-else()
- find_package(ZLIB REQUIRED)
-endif()
-target_include_directories(traintastic-server PRIVATE ${ZLIB_INCLUDE_DIRS})
-target_link_libraries(traintastic-server PRIVATE ${ZLIB_LIBRARIES})
+find_package(ZLIB REQUIRED)
+target_link_libraries(traintastic-server PRIVATE ZLIB::ZLIB)
if(BUILD_TESTING)
- target_include_directories(traintastic-server-test PRIVATE ${ZLIB_INCLUDE_DIRS})
- target_link_libraries(traintastic-server-test PRIVATE ${ZLIB_LIBRARIES})
+ target_link_libraries(traintastic-server-test PRIVATE ZLIB::ZLIB)
endif()
# libarchive
-if(WIN32)
- set(LibArchive_INCLUDE_DIRS "thirdparty/libarchive/include")
-
- if(MSVC)
- set(LibArchive_LIBRARIES archive)
- add_custom_command(TARGET traintastic-server PRE_LINK
- COMMAND lib "/def:${PROJECT_SOURCE_DIR}/thirdparty/libarchive/bin/archive.def" /out:archive.lib /machine:x64)
- add_custom_command(TARGET traintastic-server-test PRE_LINK
- COMMAND lib "/def:${PROJECT_SOURCE_DIR}/thirdparty/libarchive/bin/archive.def" /out:archive.lib /machine:x64)
- else()
- # MinGW can directly link .dll without import lib
- set(LibArchive_LIBRARIES "${PROJECT_SOURCE_DIR}/thirdparty/libarchive/bin/archive.dll")
- endif()
-
- # copy archive.dll to build directory:
- add_custom_command(TARGET traintastic-server POST_BUILD
- COMMAND ${CMAKE_COMMAND} -E copy "${PROJECT_SOURCE_DIR}/thirdparty/libarchive/bin/archive.dll" .)
-elseif(APPLE)
- find_path(LibArchive_INCLUDE_DIRS
- NAMES archive.h
- PATHS
- "/usr/local/opt/libarchive/include" # x86_64
- "/opt/homebrew/opt/libarchive/include" # arm64
- )
- find_library(LibArchive_LIBRARIES
- NAMES archive libarchive
- PATHS
- "/usr/local/opt/libarchive/lib/" # x86_64
- "/opt/homebrew/opt/libarchive/lib" # arm64
- )
-else()
- find_package(LibArchive REQUIRED)
-endif()
-target_include_directories(traintastic-server PRIVATE ${LibArchive_INCLUDE_DIRS})
-target_link_libraries(traintastic-server PRIVATE ${LibArchive_LIBRARIES})
+find_package(LibArchive REQUIRED)
+target_link_libraries(traintastic-server PRIVATE LibArchive::LibArchive)
if(BUILD_TESTING)
- target_include_directories(traintastic-server-test PRIVATE ${LibArchive_INCLUDE_DIRS})
- target_link_libraries(traintastic-server-test PRIVATE ${LibArchive_LIBRARIES})
+ target_link_libraries(traintastic-server-test PRIVATE LibArchive::LibArchive)
endif()
-# liblua5.4
-if(WIN32)
- add_definitions(-DLUA_BUILD_AS_DLL)
- set(LUA_INCLUDE_DIR "thirdparty/lua5.4/include")
-
- if(MSVC)
- set(LUA_LIBRARIES lua54)
- add_custom_command(TARGET traintastic-server PRE_LINK
- COMMAND lib "/def:${PROJECT_SOURCE_DIR}/thirdparty/lua5.4/bin/win64/lua54.def" /out:lua54.lib /machine:x64)
- add_custom_command(TARGET traintastic-server-test PRE_LINK
- COMMAND lib "/def:${PROJECT_SOURCE_DIR}/thirdparty/lua5.4/bin/win64/lua54.def" /out:lua54.lib /machine:x64)
- else()
- # MinGW can directly link .dll without import lib
- set(LUA_LIBRARIES "${PROJECT_SOURCE_DIR}/thirdparty/lua5.4/bin/win64/lua54.dll")
- endif()
-
-
- # copy lua54.dll to build directory, to be able to run the tests:
- add_custom_command(TARGET traintastic-server-test POST_BUILD
- COMMAND ${CMAKE_COMMAND} -E copy "${PROJECT_SOURCE_DIR}/thirdparty/lua5.4/bin/win64/lua54.dll" .)
-elseif(APPLE)
- find_path(LUA_INCLUDE_DIR
- NAMES lua.h
- PATHS
- "/usr/local/opt/lua@5.4/include/lua" # x86_64
- "/opt/homebrew/opt/lua@5.4/include/lua" # arm64
- )
- find_library(LUA_LIBRARIES
- NAMES lua5.4 liblua5.4
- PATHS
- "/usr/local/opt/lua@5.4/lib" # x86_64
- "/opt/homebrew/opt/lua@5.4/lib" # arm64
- )
-else()
- find_package(Lua 5.4 REQUIRED)
-endif()
+# lua
+find_package(Lua REQUIRED)
target_include_directories(traintastic-server PRIVATE ${LUA_INCLUDE_DIR})
target_link_libraries(traintastic-server PRIVATE ${LUA_LIBRARIES})
if(BUILD_TESTING)
diff --git a/server/FindLua.cmake b/server/FindLua.cmake
deleted file mode 100644
index 782f648d..00000000
--- a/server/FindLua.cmake
+++ /dev/null
@@ -1,122 +0,0 @@
-# Locate Lua library
-# This module defines
-# LUA_EXECUTABLE, if found
-# LUA_FOUND, if false, do not try to link to Lua
-# LUA_LIBRARIES
-# LUA_INCLUDE_DIR, where to find lua.h
-# LUA_VERSION_STRING, the version of Lua found (since CMake 2.8.8)
-#
-# Note that the expected include convention is
-# #include "lua.h"
-# and not
-# #include
-# This is because, the lua location is not standardized and may exist
-# in locations other than lua/
-
-#=============================================================================
-# Copyright 2007-2009 Kitware, Inc.
-# Modified to support Lua 5.2 by LuaDist 2012
-# Modified to support Lua 5.3 by Reinder Feenstra 2019
-# Modified to support Lua 5.4 by Reinder Feenstra 2024
-#
-# Distributed under the OSI-approved BSD License (the "License");
-# see accompanying file Copyright.txt for details.
-#
-# This software is distributed WITHOUT ANY WARRANTY; without even the
-# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
-# See the License for more information.
-#=============================================================================
-# (To distribute this file outside of CMake, substitute the full
-# License text for the above reference.)
-#
-# The required version of Lua can be specified using the
-# standard syntax, e.g. FIND_PACKAGE(Lua 5.1)
-# Otherwise the module will search for any available Lua implementation
-
-# Always search for non-versioned lua first (recommended)
-SET(_POSSIBLE_LUA_INCLUDE include include/lua)
-SET(_POSSIBLE_LUA_EXECUTABLE lua)
-SET(_POSSIBLE_LUA_LIBRARY lua)
-
-# Determine possible naming suffixes (there is no standard for this)
-IF(Lua_FIND_VERSION_MAJOR AND Lua_FIND_VERSION_MINOR)
- SET(_POSSIBLE_SUFFIXES "${Lua_FIND_VERSION_MAJOR}${Lua_FIND_VERSION_MINOR}" "${Lua_FIND_VERSION_MAJOR}.${Lua_FIND_VERSION_MINOR}" "-${Lua_FIND_VERSION_MAJOR}.${Lua_FIND_VERSION_MINOR}")
-ELSE(Lua_FIND_VERSION_MAJOR AND Lua_FIND_VERSION_MINOR)
- SET(_POSSIBLE_SUFFIXES "54" "5.4" "-5.4" "53" "5.3" "-5.3" "52" "5.2" "-5.2" "51" "5.1" "-5.1")
-ENDIF(Lua_FIND_VERSION_MAJOR AND Lua_FIND_VERSION_MINOR)
-
-# Set up possible search names and locations
-FOREACH(_SUFFIX ${_POSSIBLE_SUFFIXES})
- LIST(APPEND _POSSIBLE_LUA_INCLUDE "include/lua${_SUFFIX}")
- LIST(APPEND _POSSIBLE_LUA_EXECUTABLE "lua${_SUFFIX}")
- LIST(APPEND _POSSIBLE_LUA_LIBRARY "lua${_SUFFIX}")
-ENDFOREACH(_SUFFIX)
-
-# Find the lua executable
-FIND_PROGRAM(LUA_EXECUTABLE
- NAMES ${_POSSIBLE_LUA_EXECUTABLE}
-)
-
-# Find the lua header
-FIND_PATH(LUA_INCLUDE_DIR lua.h
- HINTS
- $ENV{LUA_DIR}
- PATH_SUFFIXES ${_POSSIBLE_LUA_INCLUDE}
- PATHS
- ~/Library/Frameworks
- /Library/Frameworks
- /usr/local
- /usr
- /sw # Fink
- /opt/homebrew # MacOS Apple Silicone
- /opt/local # DarwinPorts
- /opt/csw # Blastwave
- /opt
-)
-
-# Find the lua library
-FIND_LIBRARY(LUA_LIBRARY
- NAMES ${_POSSIBLE_LUA_LIBRARY}
- HINTS
- $ENV{LUA_DIR}
- PATH_SUFFIXES lib64 lib
- PATHS
- ~/Library/Frameworks
- /Library/Frameworks
- /usr/local
- /usr
- /sw
- /opt/homebrew # MacOS Apple Silicone
- /opt/local
- /opt/csw
- /opt
-)
-
-IF(LUA_LIBRARY)
- # include the math library for Unix
- IF(UNIX AND NOT APPLE)
- FIND_LIBRARY(LUA_MATH_LIBRARY m)
- SET( LUA_LIBRARIES "${LUA_LIBRARY};${LUA_MATH_LIBRARY}" CACHE STRING "Lua Libraries")
- # For Windows and Mac, don't need to explicitly include the math library
- ELSE(UNIX AND NOT APPLE)
- SET( LUA_LIBRARIES "${LUA_LIBRARY}" CACHE STRING "Lua Libraries")
- ENDIF(UNIX AND NOT APPLE)
-ENDIF(LUA_LIBRARY)
-
-# Determine Lua version
-IF(LUA_INCLUDE_DIR AND EXISTS "${LUA_INCLUDE_DIR}/lua.h")
- FILE(STRINGS "${LUA_INCLUDE_DIR}/lua.h" lua_version_str REGEX "^#define[ \t]+LUA_RELEASE[ \t]+\"Lua .+\"")
-
- STRING(REGEX REPLACE "^#define[ \t]+LUA_RELEASE[ \t]+\"Lua ([^\"]+)\".*" "\\1" LUA_VERSION_STRING "${lua_version_str}")
- UNSET(lua_version_str)
-ENDIF()
-
-INCLUDE(FindPackageHandleStandardArgs)
-# handle the QUIETLY and REQUIRED arguments and set LUA_FOUND to TRUE if
-# all listed variables are TRUE
-FIND_PACKAGE_HANDLE_STANDARD_ARGS(Lua
- REQUIRED_VARS LUA_LIBRARIES LUA_INCLUDE_DIR
- VERSION_VAR LUA_VERSION_STRING)
-
-MARK_AS_ADVANCED(LUA_INCLUDE_DIR LUA_LIBRARIES LUA_LIBRARY LUA_MATH_LIBRARY LUA_EXECUTABLE)
-
diff --git a/server/cmake/add-resource.cmake b/server/cmake/add-resource.cmake
new file mode 100644
index 00000000..2b8a294f
--- /dev/null
+++ b/server/cmake/add-resource.cmake
@@ -0,0 +1,40 @@
+#
+# This file is part of the traintastic source code.
+# See .
+#
+# Copyright (C) 2024 Reinder Feenstra
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+
+function(add_resource TARGET_NAME)
+ cmake_parse_arguments(PARSE_ARG "" "BASE_DIR" "FILES" ${ARGN})
+ if(PARSE_ARG_BASE_DIR)
+ set(PARSE_ARG_BASE_DIR "${CMAKE_SOURCE_DIR}/${PARSE_ARG_BASE_DIR}")
+ else()
+ set(PARSE_ARG_BASE_DIR "${CMAKE_SOURCE_DIR}")
+ endif()
+ foreach(INPUT_FILE ${PARSE_ARG_FILES})
+ set(OUTPUT_FILE ${CMAKE_BINARY_DIR}/resource/${INPUT_FILE}.hpp)
+ add_custom_command(
+ OUTPUT ${OUTPUT_FILE}
+ COMMAND Python3::Interpreter ${CMAKE_SOURCE_DIR}/cmake/generateresourceheader.py ${PARSE_ARG_BASE_DIR} ${INPUT_FILE} ${OUTPUT_FILE}
+ DEPENDS ${CMAKE_SOURCE_DIR}/cmake/generateresourceheader.py ${PARSE_ARG_BASE_DIR}/${INPUT_FILE}
+ COMMENT "Generating resource header resource/${INPUT_FILE}.hpp"
+ )
+ list(APPEND OUTPUT_HEADERS ${OUTPUT_FILE})
+ endforeach()
+ add_custom_target(${TARGET_NAME} ALL DEPENDS ${OUTPUT_HEADERS})
+endfunction()
diff --git a/server/cmake/code-coverage.cmake b/server/cmake/code-coverage.cmake
index 601e58a1..334b8d68 100644
--- a/server/cmake/code-coverage.cmake
+++ b/server/cmake/code-coverage.cmake
@@ -380,7 +380,7 @@ function(target_code_coverage TARGET_NAME)
COMMAND ${LCOV_PATH} --directory ${CMAKE_BINARY_DIR} --zerocounters
COMMAND $ ${target_code_coverage_ARGS}
COMMAND
- ${LCOV_PATH} --directory ${CMAKE_BINARY_DIR} --base-directory
+ ${LCOV_PATH} --ignore-errors mismatch --directory ${CMAKE_BINARY_DIR} --base-directory
${CMAKE_SOURCE_DIR} --capture ${EXTERNAL_OPTION} --output-file
${COVERAGE_INFO}
COMMAND ${EXCLUDE_COMMAND}
diff --git a/server/cmake/generateresourceheader.py b/server/cmake/generateresourceheader.py
new file mode 100644
index 00000000..04a0ef89
--- /dev/null
+++ b/server/cmake/generateresourceheader.py
@@ -0,0 +1,88 @@
+#
+# This file is part of the traintastic source code.
+# See .
+#
+# Copyright (C) 2024-2025 Reinder Feenstra
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+
+import sys
+import os
+import re
+import textwrap
+
+if len(sys.argv) != 4:
+ print(f"Usage: {sys.argv[0]}