From c8e74dfa706647cf785c7e6c811731d8945e49c6 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 22 Oct 2023 04:49:19 -0400 Subject: [PATCH 01/65] [WASimClient] Fix incoming data size check for variable requests which are less than 4 bytes in size. --- src/WASimClient/WASimClient.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/WASimClient/WASimClient.cpp b/src/WASimClient/WASimClient.cpp index 8aa2668..1227029 100644 --- a/src/WASimClient/WASimClient.cpp +++ b/src/WASimClient/WASimClient.cpp @@ -1248,7 +1248,8 @@ class WASimClient::Private case SIMCONNECT_RECV_ID_CLIENT_DATA: { SIMCONNECT_RECV_CLIENT_DATA* data = (SIMCONNECT_RECV_CLIENT_DATA*)pData; LOG_TRC << LOG_SC_RCV_CLIENT_DATA(data); - const size_t dataSize = (size_t)pData->dwSize + 4 - sizeof(SIMCONNECT_RECV_CLIENT_DATA); // dwSize reports 4 bytes less than actual size of SIMCONNECT_RECV_CLIENT_DATA + // dwSize always under-reports by 4 bytes when sizeof(SIMCONNECT_RECV_CLIENT_DATA) is subtracted, and the minimum reported size is 4 bytes even for 0-3 bytes of actual data. + const size_t dataSize = (size_t)pData->dwSize + 4 - sizeof(SIMCONNECT_RECV_CLIENT_DATA); switch (data->dwRequestID) { case DATA_REQ_RESPONSE: { @@ -1355,12 +1356,11 @@ class WASimClient::Private LOG_WRN << "DataRequest ID " << data->dwRequestID - SIMCONNECTID_LAST << " not found in tracked requests."; return; } - // be paranoid - if (dataSize != tr->dataSize) { + // be paranoid; note that the reported pData->dwSize is never less than 4 bytes. + if (dataSize < tr->dataSize) { LOG_CRT << "Invalid data result size! Expected " << tr->dataSize << " but got " << dataSize; return; } - //unique_lock lock(mtxRequests); unique_lock datalock(tr->m_dataMutex); memcpy(tr->data.data(), (void*)&data->dwData, tr->dataSize); tr->lastUpdate = chrono::duration_cast(chrono::system_clock::now().time_since_epoch()).count(); From c73d10e9146aa63527e9c4aec5ae0a519d37b77c Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 22 Oct 2023 04:50:38 -0400 Subject: [PATCH 02/65] [WASimClient] Remove logged version mismatch warning on Ping response. --- src/WASimClient/WASimClient.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/WASimClient/WASimClient.cpp b/src/WASimClient/WASimClient.cpp index 1227029..fa2176e 100644 --- a/src/WASimClient/WASimClient.cpp +++ b/src/WASimClient/WASimClient.cpp @@ -1387,8 +1387,6 @@ class WASimClient::Private serverVersion = data->dwData; serverLastSeen = Clock::now(); LOG_DBG << "Got ping response at " << Utilities::timePointToString(serverLastSeen.load()) << " with version " << STREAM_HEX8(serverVersion); - if (serverVersion != WSMCMND_VERSION) - LOG_WRN << "Server version " << STREAM_HEX8(serverVersion) << " does not match WASimClient version " << STREAM_HEX8(WSMCMND_VERSION); break; default: From e2925694cb7c7a0fd20f2079b8afc00098f9d58d Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 22 Oct 2023 05:33:14 -0400 Subject: [PATCH 03/65] [DataRequest] Fix that simVarIndex wasn't always initialized; Consolidate all initialization to default constructor and chain the overloads. --- src/include/WASimCommander.h | 40 +++++++++++++++--------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/src/include/WASimCommander.h b/src/include/WASimCommander.h index 9c4ed50..8fd7660 100644 --- a/src/include/WASimCommander.h +++ b/src/include/WASimCommander.h @@ -128,18 +128,18 @@ namespace WASimCommander { uint32_t requestId; ///< Unique ID for the request, subsequent use of this ID overwrites any previous request definition (but size may not grow). uint32_t valueSize; ///< Byte size of stored value; can also be one of the predefined DATA_TYPE_* constants. \sa WASimCommander::DATA_TYPE_INT8, etc - float deltaEpsilon = 0.0f; ///< Minimum change in numeric value required to trigger an update. The default of `0.0` is to send updates only if the value changes, but even on the smallest changes. + float deltaEpsilon; ///< Minimum change in numeric value required to trigger an update. The default of `0.0` is to send updates only if the value changes, but even on the smallest changes. /// Setting this to some positive value can be especially useful for high-precision floating-point numbers which may fluctuate within an insignifcant range, /// but may be used with any numeric value (for integer value types, only the integer part of the epsilon value is considered). /// Conversely, to send data updates _every time_ the value is read, and skip any comparison check altogether, set this to a negative value like `-1.0`. ///< \note For the positive epsilon settings to work, the `valueSize` must be set to one of the predefined `DATA_TYPE_*` constants. - uint32_t interval = 0; ///< How many `UpdatePeriod` period's should elapse between checks. eg. 500ms or 10 ticks. + uint32_t interval; ///< How many `UpdatePeriod` period's should elapse between checks. eg. 500ms or 10 ticks. /// Zero means to check at every `period`, `1` means every other `period`, etc. WSE::UpdatePeriod period; ///< How often to read/calculate this value. WSE::RequestType requestType; ///< Named variable or calculated value. WSE::CalcResultType calcResultType; ///< Expected calculator result type. uint8_t simVarIndex; ///< Some SimVars require an index for access, default is 0. - char varTypePrefix = 'L'; ///< Variable type prefix for named variables. Types: 'L' (local), 'A' (SimVar) and 'T' (Token, not an actual GaugeAPI prefix) are checked using respecitive GaugeAPI methods. + char varTypePrefix; ///< Variable type prefix for named variables. Types: 'L' (local), 'A' (SimVar) and 'T' (Token, not an actual GaugeAPI prefix) are checked using respecitive GaugeAPI methods. char nameOrCode[STRSZ_REQ] = {0}; ///< Variable name or full calculator string. char unitName[STRSZ_UNIT] = {0}; ///< Unit name for named variables (optional to override variable's default units). Only 'L' and 'A' variable types support unit specifiers. // 1088/1088 B (packed/unpacked), 8/16 B aligned @@ -152,9 +152,14 @@ namespace WASimCommander WSE::CalcResultType calcResultType = WSE::CalcResultType::Double, WSE::UpdatePeriod period = WSE::UpdatePeriod::Tick, const char * nameOrCode = nullptr, - const char * unitName = nullptr - ) - : requestId(requestId), valueSize(valueSize), period(period), requestType(requestType), calcResultType(calcResultType) + const char * unitName = nullptr, + char varTypePrefix = 'L', + float deltaEpsilon = 0.0f, + uint8_t interval = 0, + uint8_t simVarIndex = 0 + ) : + requestId(requestId), valueSize(valueSize), deltaEpsilon(deltaEpsilon), interval(interval), period(period), + requestType(requestType), calcResultType(calcResultType), simVarIndex(simVarIndex), varTypePrefix(varTypePrefix) { if (nameOrCode) setNameOrCode(nameOrCode); @@ -165,29 +170,18 @@ namespace WASimCommander /// Constructs a request for a named variable (`requestType = RequestType::Named`) with optional update period, interval, and epsilon values. explicit DataRequest(uint32_t requestId, char variableType, const char *variableName, uint32_t valueSize, WSE::UpdatePeriod period = WSE::UpdatePeriod::Tick, uint32_t interval = 0, float deltaEpsilon = 0.0f) : - requestId(requestId), valueSize(valueSize), deltaEpsilon(deltaEpsilon), interval(interval), period(period), requestType(WSE::RequestType::Named), varTypePrefix(variableType) - { - if (variableName) - setNameOrCode(variableName); - } + DataRequest(requestId, valueSize, WSE::RequestType::Named, WSE::CalcResultType::None, period, variableName, nullptr, variableType, deltaEpsilon, interval) + { } /// Constructs a request for a named Simulator Variable (`requestType = RequestType::Named` and `varTypePrefix = 'A'`) with optional update period, interval, and epsilon values. explicit DataRequest(uint32_t requestId, const char *simVarName, const char *unitName, uint8_t simVarIndex, uint32_t valueSize, WSE::UpdatePeriod period = WSE::UpdatePeriod::Tick, uint32_t interval = 0, float deltaEpsilon = 0.0f) : - requestId(requestId), valueSize(valueSize), deltaEpsilon(deltaEpsilon), interval(interval), period(period), requestType(WSE::RequestType::Named), simVarIndex(simVarIndex), varTypePrefix('A') - { - if (simVarName) - setNameOrCode(simVarName); - if (unitName) - setUnitName(unitName); - } + DataRequest(requestId, valueSize, WSE::RequestType::Named, WSE::CalcResultType::None, period, simVarName, unitName, 'A', deltaEpsilon, interval, simVarIndex) + { } /// Constructs a calculator code request (`requestType = RequestType::Calculated`) with optional update period, interval, and epsilon values. explicit DataRequest(uint32_t requestId, WSE::CalcResultType resultType, const char *calculatorCode, uint32_t valueSize, WSE::UpdatePeriod period = WSE::UpdatePeriod::Tick, uint32_t interval = 0, float deltaEpsilon = 0.0f) : - requestId(requestId), valueSize(valueSize), deltaEpsilon(deltaEpsilon), interval(interval), period(period), requestType(WSE::RequestType::Calculated), calcResultType(resultType) - { - if (calculatorCode) - setNameOrCode(calculatorCode); - } + DataRequest(requestId, valueSize, WSE::RequestType::Calculated, resultType, period, calculatorCode, nullptr, 'Q', deltaEpsilon, interval) + { } void setNameOrCode(const char *name) { setCharArrayValue(nameOrCode, STRSZ_REQ, name); } ///< Set the `nameOrCode` member using a const char array. void setUnitName(const char *name) { setCharArrayValue(unitName, STRSZ_UNIT, name); } ///< Set the `unitName` member using a const char array. From 7c9c3431510c257b879f7e6dbd7dbfeb6c7a8f65 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 22 Oct 2023 14:10:18 -0400 Subject: [PATCH 04/65] [WASimModule] Fix new compiler warnings about `move` being unqualified (why?); Update issue URL. --- src/WASimModule/WASimModule.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/WASimModule/WASimModule.cpp b/src/WASimModule/WASimModule.cpp index b2bcdca..9881a2b 100644 --- a/src/WASimModule/WASimModule.cpp +++ b/src/WASimModule/WASimModule.cpp @@ -215,7 +215,7 @@ struct calcResult_t string sVal; void setF(const FLOAT64 val) { fVal = val; resultSize = sizeof(FLOAT64); resultMemberIndex = 0; } void setI(const SINT32 val) { iVal = val; resultSize = sizeof(SINT32); resultMemberIndex = 1; } - void setS(const string &&val) { sVal = move(val); sVal.resize(strSize); resultSize = strSize; resultMemberIndex = 2; } + void setS(const string &&val) { sVal = std::move(val); sVal.resize(strSize); resultSize = strSize; resultMemberIndex = 2; } }; typedef map clientMap_t; @@ -464,7 +464,7 @@ Client *getOrCreateClient(uint32_t clientId) registerClientKeyEventDataArea(&c); // move client record into map - Client *pC = &g_mClients.emplace(clientId, move(c)).first->second; + Client *pC = &g_mClients.emplace(clientId, std::move(c)).first->second; // save mappings of the command and request data IDs (which SimConnect sends us) to the client record; for lookup in message dispatch. g_mDefinitionIds.emplace(piecewise_construct, forward_as_tuple(c.cddID_command), forward_as_tuple(RecordType::CommandData, pC)); // no try_emplace? g_mDefinitionIds.emplace(piecewise_construct, forward_as_tuple(c.cddID_request), forward_as_tuple(RecordType::RequestData, pC)); @@ -1199,7 +1199,7 @@ bool addOrUpdateRequest(Client *c, const DataRequest *const req) } // calculated value, update compiled string if needed // NOTE: compiling code for format_calculator_string() doesn't seem to work as advertised in the docs, see: - // https://devsupport.flightsimulator.com/questions/9513/gauge-calculator-code-precompile-with-code-meant-f.html + // https://devsupport.flightsimulator.com/t/gauge-calculator-code-precompile-with-code-meant-for-format-calculator-string-reports-format-errors/4457 else if (tr->calcResultType != CalcResultType::Formatted && tr->calcBytecode.empty()) { // assume the command has changed and re-compile PCSTRINGZ pCompiled = nullptr; From 8c7724e60ed94e622d5ee2669cf7e000031c2c18 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 22 Oct 2023 14:18:28 -0400 Subject: [PATCH 05/65] [WASimModule] Fix binary data representation in results for named variable requests with 1-4 byte integer value sizes (`int8` - `int32` types) -- the result data would be encoded as a float type instead. --- src/WASimModule/WASimModule.cpp | 44 +++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/src/WASimModule/WASimModule.cpp b/src/WASimModule/WASimModule.cpp index 9881a2b..3d32194 100644 --- a/src/WASimModule/WASimModule.cpp +++ b/src/WASimModule/WASimModule.cpp @@ -1077,17 +1077,45 @@ bool updateRequestValue(const Client *c, TrackedRequest *tr, bool compareCheck = // double case 0: // convert the value if necessary for proper binary representation - if (tr->dataSize == sizeof(float)) - data = &(f32 = (float)res.fVal); - else if (tr->valueSize == DATA_TYPE_INT64) // better way? - data = &(i64 = (int64_t)res.fVal); - else - data = &res.fVal; + switch (tr->valueSize) { + // ordered most to least likely + case DATA_TYPE_DOUBLE: + case sizeof(double): + data = &res.fVal; + break; + case DATA_TYPE_FLOAT: + case sizeof(float): + data = &(f32 = (float)res.fVal); + break; + case DATA_TYPE_INT32: + case 3: + data = &(res.iVal = (int32_t)res.fVal); + break; + case DATA_TYPE_INT8: + case 1: + data = &(res.iVal = (int8_t)res.fVal); + break; + case DATA_TYPE_INT16: + case 2: + data = &(res.iVal = (int16_t)res.fVal); + break; + case DATA_TYPE_INT64: + // the widest integer any gauge API function returns is 48b (for token/MODULE_VAR) so 53b precision is OK here + data = &(i64 = (int64_t)res.fVal); + break; + default: + data = &res.fVal; + break; + } break; // int32 - case 1: data = &res.iVal; break; + case 1: + data = &res.iVal; + break; // string - case 2: data = (void *)res.sVal.data(); break; + case 2: + data = (void *)res.sVal.data(); + break; } if (compareCheck && tr->compareCheck && !memcmp(data, tr->data.data(), tr->dataSize)) { From f045e15007abd6b7b05b97c004a7a55488a33a9b Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 22 Oct 2023 14:22:19 -0400 Subject: [PATCH 06/65] [WASimModule] Update reference list of KEY events and aliases as of MSFS SDK v0.22.3.0. --- src/WASimModule/key_events.h | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/WASimModule/key_events.h b/src/WASimModule/key_events.h index b4ad340..f331d17 100644 --- a/src/WASimModule/key_events.h +++ b/src/WASimModule/key_events.h @@ -1899,6 +1899,17 @@ namespace WASimCommander { { "CYCLIC_LONGITUDINAL_DOWN", KEY_CYCLIC_LONGITUDINAL_DOWN }, { "CYCLIC_LONGITUDINAL_UP", KEY_CYCLIC_LONGITUDINAL_UP }, + // SDK 0.21.0.0 + { "ELECT_FUEL_PUMP_SET", KEY_ELECT_FUEL_PUMP_SET }, + + // SDK 0.22.3.0 + { "3RD_PARTY_WINDOW_OPEN_PRIMARY", KEY_3RD_PARTY_WINDOW_OPEN_PRIMARY }, + { "3RD_PARTY_WINDOW_OPEN_SECONDARY", KEY_3RD_PARTY_WINDOW_OPEN_SECONDARY }, + { "3RD_PARTY_WINDOW_MOVE_DOWN", KEY_3RD_PARTY_WINDOW_MOVE_DOWN }, + { "3RD_PARTY_WINDOW_MOVE_UP", KEY_3RD_PARTY_WINDOW_MOVE_UP }, + { "3RD_PARTY_WINDOW_VALIDATE", KEY_3RD_PARTY_WINDOW_VALIDATE }, + { "HELI_BEEP_SET", KEY_HELI_BEEP_SET }, + // Aliases for published Event IDs which do not match KEY IDs { "ADF1_WHOLE_DEC", KEY_ADF_WHOLE_DEC }, { "ADF1_WHOLE_INC", KEY_ADF_WHOLE_INC }, @@ -1916,14 +1927,15 @@ namespace WASimCommander { { "DECREASE_AUTOBRAKE_CONTROL", KEY_DEC_AUTOBRAKE_CONTROL }, { "DECREASE_DECISION_ALTITUDE_MSL", KEY_DECISION_ALTITUDE_MSL_DEC }, { "DECREASE_DECISION_HEIGHT", KEY_DECISION_HEIGHT_DEC }, - { "INCREASE_DECISION_ALTITUDE_MSL", KEY_DECISION_ALTITUDE_MSL_INC }, - { "INCREASE_DECISION_HEIGHT", KEY_DECISION_HEIGHT_INC }, - { "SET_DECISION_ALTITUDE_MSL", KEY_DECISION_ALTITUDE_MSL_SET }, + { "DECREASE_HELO_GOV_BEEP", KEY_HELI_BEEP_DECREASE }, { "FLIGHT_LEVEL_CHANGE", KEY_AP_FLIGHT_LEVEL_CHANGE }, { "FLIGHT_LEVEL_CHANGE_OFF", KEY_AP_FLIGHT_LEVEL_CHANGE_OFF }, { "FLIGHT_LEVEL_CHANGE_ON", KEY_AP_FLIGHT_LEVEL_CHANGE_ON }, { "HEADING_SLOT_INDEX_SET", KEY_AP_HEADING_SLOT_INDEX_SET }, { "INCREASE_AUTOBRAKE_CONTROL", KEY_INC_AUTOBRAKE_CONTROL }, + { "INCREASE_DECISION_ALTITUDE_MSL", KEY_DECISION_ALTITUDE_MSL_INC }, + { "INCREASE_DECISION_HEIGHT", KEY_DECISION_HEIGHT_INC }, + { "INCREASE_HELO_GOV_BEEP", KEY_HELI_BEEP_INCREASE }, { "KNEEBOARD_VIEW", KEY_KNEEBOARD }, { "MP_ACTIVATE_CHAT", KEY_MULTIPLAYER_ACTIVATE_CHAT }, { "MP_BROADCAST_VOICE_CAPTURE_START", KEY_MULTIPLAYER_BROADCAST_VOICE_CAPTURE_START }, @@ -1939,8 +1951,11 @@ namespace WASimCommander { { "PRESSURIZATION_PRESSURE_DUMP_SWTICH", KEY_PRESSURIZATION_PRESSURE_DUMP_SWITCH }, { "RELOAD_USER_AIRCRAFT", KEY_CONTROL_RELOAD_USER_AIRCRAFT }, { "REQUEST_FUEL_KEY", KEY_REQUEST_FUEL }, + { "ROTOR_AXIS_TAIL_ROTOR_SET", KEY_AXIS_TAIL_ROTOR_SET }, { "ROTOR_BRAKE_SET", KEY_AXIS_ROTOR_BRAKE_SET }, { "RPM_SLOT_INDEX_SET", KEY_AP_RPM_SLOT_INDEX_SET }, + { "SET_DECISION_ALTITUDE_MSL", KEY_DECISION_ALTITUDE_MSL_SET }, + { "SET_HELO_GOV_BEEP", KEY_HELI_BEEP_SET }, { "SET_REVERSE_THRUST_OFF", KEY_SET_THROTTLE_REVERSE_THRUST_OFF }, { "SET_REVERSE_THRUST_ON", KEY_SET_THROTTLE_REVERSE_THRUST_ON }, { "SPEED_SLOT_INDEX_SET", KEY_AP_SPEED_SLOT_INDEX_SET }, From 1a8d20930c09cb079a1ece6d0061b89596024711 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 22 Oct 2023 14:23:48 -0400 Subject: [PATCH 07/65] [WASimModule] Cosmetics: reformat key_events.h alias list. --- src/WASimModule/key_events.h | 120 +++++++++++++++++------------------ 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/src/WASimModule/key_events.h b/src/WASimModule/key_events.h index f331d17..befaa62 100644 --- a/src/WASimModule/key_events.h +++ b/src/WASimModule/key_events.h @@ -1911,67 +1911,67 @@ namespace WASimCommander { { "HELI_BEEP_SET", KEY_HELI_BEEP_SET }, // Aliases for published Event IDs which do not match KEY IDs - { "ADF1_WHOLE_DEC", KEY_ADF_WHOLE_DEC }, - { "ADF1_WHOLE_INC", KEY_ADF_WHOLE_INC }, - { "ALTITUDE_SLOT_INDEX_SET", KEY_AP_ALTITUDE_SLOT_INDEX_SET }, - { "ANTIDETONATION_TANK_VALVE_TOGGLE", KEY_TOGGLE_ANTIDETONATION_TANK_VALVE }, - { "AP_HEADING_BUG_SET_EX1", KEY_HEADING_BUG_SET_EX1 }, - { "AP_PANEL_MACH_HOLD_TOGGLE", KEY_AUTOPILOT_MACH_HOLD_CURRENT }, - { "AP_PANEL_SPEED_HOLD_TOGGLE", KEY_AUTOPILOT_AIRSPEED_HOLD_CURRENT }, - { "ATTITUDE_BARS_POSITION_DOWN", KEY_ATTITUDE_BARS_POSITION_DEC }, - { "ATTITUDE_BARS_POSITION_UP", KEY_ATTITUDE_BARS_POSITION_INC }, - { "ATTITUDE_CAGE_BUTTON", KEY_TOGGLE_ATTITUDE_CAGE }, - { "AUTORUDDER_TOGGLE", KEY_AUTOCOORD_TOGGLE }, - { "BACK_TO_FLY", KEY_NULL }, // Not a real Event ?? - { "COM_STBY_RADIO_SWAP", KEY_COM_STBY_RADIO_SWITCH_TO }, - { "DECREASE_AUTOBRAKE_CONTROL", KEY_DEC_AUTOBRAKE_CONTROL }, - { "DECREASE_DECISION_ALTITUDE_MSL", KEY_DECISION_ALTITUDE_MSL_DEC }, - { "DECREASE_DECISION_HEIGHT", KEY_DECISION_HEIGHT_DEC }, - { "DECREASE_HELO_GOV_BEEP", KEY_HELI_BEEP_DECREASE }, - { "FLIGHT_LEVEL_CHANGE", KEY_AP_FLIGHT_LEVEL_CHANGE }, - { "FLIGHT_LEVEL_CHANGE_OFF", KEY_AP_FLIGHT_LEVEL_CHANGE_OFF }, - { "FLIGHT_LEVEL_CHANGE_ON", KEY_AP_FLIGHT_LEVEL_CHANGE_ON }, - { "HEADING_SLOT_INDEX_SET", KEY_AP_HEADING_SLOT_INDEX_SET }, - { "INCREASE_AUTOBRAKE_CONTROL", KEY_INC_AUTOBRAKE_CONTROL }, - { "INCREASE_DECISION_ALTITUDE_MSL", KEY_DECISION_ALTITUDE_MSL_INC }, - { "INCREASE_DECISION_HEIGHT", KEY_DECISION_HEIGHT_INC }, - { "INCREASE_HELO_GOV_BEEP", KEY_HELI_BEEP_INCREASE }, - { "KNEEBOARD_VIEW", KEY_KNEEBOARD }, - { "MP_ACTIVATE_CHAT", KEY_MULTIPLAYER_ACTIVATE_CHAT }, - { "MP_BROADCAST_VOICE_CAPTURE_START", KEY_MULTIPLAYER_BROADCAST_VOICE_CAPTURE_START }, - { "MP_BROADCAST_VOICE_CAPTURE_STOP", KEY_MULTIPLAYER_BROADCAST_VOICE_CAPTURE_STOP }, - { "MP_CHAT", KEY_MULTIPLAYER_CHAT }, - { "MP_PAUSE_SESSION", KEY_MULTIPLAYER_PAUSE_SESSION }, - { "MP_PLAYER_CYCLE", KEY_MULTIPLAYER_PLAYER_CYCLE }, - { "MP_PLAYER_FOLLOW", KEY_MULTIPLAYER_PLAYER_FOLLOW }, - { "MP_TRANSFER_CONTROL", KEY_MULTIPLAYER_TRANSFER_CONTROL }, - { "MP_VOICE_CAPTURE_START", KEY_MULTIPLAYER_VOICE_CAPTURE_START }, - { "MP_VOICE_CAPTURE_STOP", KEY_MULTIPLAYER_VOICE_CAPTURE_STOP }, - { "NITROUS_TANK_VALVE_TOGGLE", KEY_TOGGLE_NITROUS_TANK_VALVE } , + { "ADF1_WHOLE_DEC", KEY_ADF_WHOLE_DEC }, + { "ADF1_WHOLE_INC", KEY_ADF_WHOLE_INC }, + { "ALTITUDE_SLOT_INDEX_SET", KEY_AP_ALTITUDE_SLOT_INDEX_SET }, + { "ANTIDETONATION_TANK_VALVE_TOGGLE", KEY_TOGGLE_ANTIDETONATION_TANK_VALVE }, + { "AP_HEADING_BUG_SET_EX1", KEY_HEADING_BUG_SET_EX1 }, + { "AP_PANEL_MACH_HOLD_TOGGLE", KEY_AUTOPILOT_MACH_HOLD_CURRENT }, + { "AP_PANEL_SPEED_HOLD_TOGGLE", KEY_AUTOPILOT_AIRSPEED_HOLD_CURRENT }, + { "ATTITUDE_BARS_POSITION_DOWN", KEY_ATTITUDE_BARS_POSITION_DEC }, + { "ATTITUDE_BARS_POSITION_UP", KEY_ATTITUDE_BARS_POSITION_INC }, + { "ATTITUDE_CAGE_BUTTON", KEY_TOGGLE_ATTITUDE_CAGE }, + { "AUTORUDDER_TOGGLE", KEY_AUTOCOORD_TOGGLE }, + { "BACK_TO_FLY", KEY_NULL }, // Not a real Event ?? + { "COM_STBY_RADIO_SWAP", KEY_COM_STBY_RADIO_SWITCH_TO }, + { "DECREASE_AUTOBRAKE_CONTROL", KEY_DEC_AUTOBRAKE_CONTROL }, + { "DECREASE_DECISION_ALTITUDE_MSL", KEY_DECISION_ALTITUDE_MSL_DEC }, + { "DECREASE_DECISION_HEIGHT", KEY_DECISION_HEIGHT_DEC }, + { "DECREASE_HELO_GOV_BEEP", KEY_HELI_BEEP_DECREASE }, + { "FLIGHT_LEVEL_CHANGE", KEY_AP_FLIGHT_LEVEL_CHANGE }, + { "FLIGHT_LEVEL_CHANGE_OFF", KEY_AP_FLIGHT_LEVEL_CHANGE_OFF }, + { "FLIGHT_LEVEL_CHANGE_ON", KEY_AP_FLIGHT_LEVEL_CHANGE_ON }, + { "HEADING_SLOT_INDEX_SET", KEY_AP_HEADING_SLOT_INDEX_SET }, + { "INCREASE_AUTOBRAKE_CONTROL", KEY_INC_AUTOBRAKE_CONTROL }, + { "INCREASE_DECISION_ALTITUDE_MSL", KEY_DECISION_ALTITUDE_MSL_INC }, + { "INCREASE_DECISION_HEIGHT", KEY_DECISION_HEIGHT_INC }, + { "INCREASE_HELO_GOV_BEEP", KEY_HELI_BEEP_INCREASE }, + { "KNEEBOARD_VIEW", KEY_KNEEBOARD }, + { "MP_ACTIVATE_CHAT", KEY_MULTIPLAYER_ACTIVATE_CHAT }, + { "MP_BROADCAST_VOICE_CAPTURE_START", KEY_MULTIPLAYER_BROADCAST_VOICE_CAPTURE_START }, + { "MP_BROADCAST_VOICE_CAPTURE_STOP", KEY_MULTIPLAYER_BROADCAST_VOICE_CAPTURE_STOP }, + { "MP_CHAT", KEY_MULTIPLAYER_CHAT }, + { "MP_PAUSE_SESSION", KEY_MULTIPLAYER_PAUSE_SESSION }, + { "MP_PLAYER_CYCLE", KEY_MULTIPLAYER_PLAYER_CYCLE }, + { "MP_PLAYER_FOLLOW", KEY_MULTIPLAYER_PLAYER_FOLLOW }, + { "MP_TRANSFER_CONTROL", KEY_MULTIPLAYER_TRANSFER_CONTROL }, + { "MP_VOICE_CAPTURE_START", KEY_MULTIPLAYER_VOICE_CAPTURE_START }, + { "MP_VOICE_CAPTURE_STOP", KEY_MULTIPLAYER_VOICE_CAPTURE_STOP }, + { "NITROUS_TANK_VALVE_TOGGLE", KEY_TOGGLE_NITROUS_TANK_VALVE } , { "PRESSURIZATION_PRESSURE_DUMP_SWTICH", KEY_PRESSURIZATION_PRESSURE_DUMP_SWITCH }, - { "RELOAD_USER_AIRCRAFT", KEY_CONTROL_RELOAD_USER_AIRCRAFT }, - { "REQUEST_FUEL_KEY", KEY_REQUEST_FUEL }, - { "ROTOR_AXIS_TAIL_ROTOR_SET", KEY_AXIS_TAIL_ROTOR_SET }, - { "ROTOR_BRAKE_SET", KEY_AXIS_ROTOR_BRAKE_SET }, - { "RPM_SLOT_INDEX_SET", KEY_AP_RPM_SLOT_INDEX_SET }, - { "SET_DECISION_ALTITUDE_MSL", KEY_DECISION_ALTITUDE_MSL_SET }, - { "SET_HELO_GOV_BEEP", KEY_HELI_BEEP_SET }, - { "SET_REVERSE_THRUST_OFF", KEY_SET_THROTTLE_REVERSE_THRUST_OFF }, - { "SET_REVERSE_THRUST_ON", KEY_SET_THROTTLE_REVERSE_THRUST_ON }, - { "SPEED_SLOT_INDEX_SET", KEY_AP_SPEED_SLOT_INDEX_SET }, - { "TOGGLE_AUTOFEATHER_ARM", KEY_TOGGLE_ARM_AUTOFEATHER }, - { "TOGGLE_DME", KEY_DME_TOGGLE } , - { "TOGGLE_PROPELLER_SYNC", KEY_TOGGLE_PROP_SYNC }, - { "TOGGLE_PUSHBACK", KEY_PUSHBACK_SET }, // ? - { "TOW_PLANE_REQUEST", KEY_REQUEST_TOW_PLANE }, - { "TRUE_AIRSPEED_CAL_DEC", KEY_TRUE_AIRSPEED_CALIBRATE_DEC }, - { "TRUE_AIRSPEED_CAL_INC", KEY_TRUE_AIRSPEED_CALIBRATE_INC }, - { "VARIOMETER_SOUND_TOGGLE", KEY_TOGGLE_VARIOMETER_SWITCH }, // ? - { "VERTICAL_SPEED_SET", KEY_AP_VS_SET }, - { "VIEW_AXIS_INDICATOR_CYCLE", KEY_AXIS_INDICATOR_CYCLE }, - { "VIEW_CAMERA_SELECT_START", KEY_VIEW_CAMERA_SELECT_STARTING }, - { "VIEW_WINDOW_TITLES_TOGGLE", KEY_WINDOW_TITLES_TOGGLE }, - { "VS_SLOT_INDEX_SET", KEY_AP_VS_SLOT_INDEX_SET }, + { "RELOAD_USER_AIRCRAFT", KEY_CONTROL_RELOAD_USER_AIRCRAFT }, + { "REQUEST_FUEL_KEY", KEY_REQUEST_FUEL }, + { "ROTOR_AXIS_TAIL_ROTOR_SET", KEY_AXIS_TAIL_ROTOR_SET }, + { "ROTOR_BRAKE_SET", KEY_AXIS_ROTOR_BRAKE_SET }, + { "RPM_SLOT_INDEX_SET", KEY_AP_RPM_SLOT_INDEX_SET }, + { "SET_DECISION_ALTITUDE_MSL", KEY_DECISION_ALTITUDE_MSL_SET }, + { "SET_HELO_GOV_BEEP", KEY_HELI_BEEP_SET }, + { "SET_REVERSE_THRUST_OFF", KEY_SET_THROTTLE_REVERSE_THRUST_OFF }, + { "SET_REVERSE_THRUST_ON", KEY_SET_THROTTLE_REVERSE_THRUST_ON }, + { "SPEED_SLOT_INDEX_SET", KEY_AP_SPEED_SLOT_INDEX_SET }, + { "TOGGLE_AUTOFEATHER_ARM", KEY_TOGGLE_ARM_AUTOFEATHER }, + { "TOGGLE_DME", KEY_DME_TOGGLE } , + { "TOGGLE_PROPELLER_SYNC", KEY_TOGGLE_PROP_SYNC }, + { "TOGGLE_PUSHBACK", KEY_PUSHBACK_SET }, // ? + { "TOW_PLANE_REQUEST", KEY_REQUEST_TOW_PLANE }, + { "TRUE_AIRSPEED_CAL_DEC", KEY_TRUE_AIRSPEED_CALIBRATE_DEC }, + { "TRUE_AIRSPEED_CAL_INC", KEY_TRUE_AIRSPEED_CALIBRATE_INC }, + { "VARIOMETER_SOUND_TOGGLE", KEY_TOGGLE_VARIOMETER_SWITCH }, // ? + { "VERTICAL_SPEED_SET", KEY_AP_VS_SET }, + { "VIEW_AXIS_INDICATOR_CYCLE", KEY_AXIS_INDICATOR_CYCLE }, + { "VIEW_CAMERA_SELECT_START", KEY_VIEW_CAMERA_SELECT_STARTING }, + { "VIEW_WINDOW_TITLES_TOGGLE", KEY_WINDOW_TITLES_TOGGLE }, + { "VS_SLOT_INDEX_SET", KEY_AP_VS_SLOT_INDEX_SET }, }; From ea2c6347750999d090ac28dc50216c2fd151eb27 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 22 Oct 2023 15:27:20 -0400 Subject: [PATCH 08/65] [CLI][WASimClient] Fix possible exception when assembling list lookup results dictionary in the off-case of duplicate keys. --- src/WASimClient_CLI/Structs.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WASimClient_CLI/Structs.h b/src/WASimClient_CLI/Structs.h index 1fa01ff..d282e94 100644 --- a/src/WASimClient_CLI/Structs.h +++ b/src/WASimClient_CLI/Structs.h @@ -446,7 +446,7 @@ namespace WASimCommander::CLI::Structs listType{(LookupItemType)r.listType}, result(r.result), list{gcnew ListCollectionType((int)r.list.size()) } { for (const auto &pr : r.list) - list->Add(pr.first, gcnew String(pr.second.c_str())); + list->TryAdd(pr.first, gcnew String(pr.second.c_str())); } }; From 3d4d380df3ad87472c5f73d959a3569763ae2f66 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 22 Oct 2023 15:31:16 -0400 Subject: [PATCH 09/65] [CLI] Improve documentation of .NET versions of WASimClient and some structs; Fixed `DataRequestRecord.tryConvert()` generic being undocumented; Added explicit note about client event handlers being called from separate thread (ref: https://github.com/mpaperno/WASimCommander/discussions/17#discussioncomment-7283038). --- src/WASimClient_CLI/Structs.h | 18 +++-- src/WASimClient_CLI/WASimClient_CLI.h | 99 +++++++++++++++++++-------- 2 files changed, 84 insertions(+), 33 deletions(-) diff --git a/src/WASimClient_CLI/Structs.h b/src/WASimClient_CLI/Structs.h index d282e94..2fce57b 100644 --- a/src/WASimClient_CLI/Structs.h +++ b/src/WASimClient_CLI/Structs.h @@ -250,16 +250,19 @@ namespace WASimCommander::CLI::Structs requestType(RequestType::Calculated), calcResultType(resultType), nameOrCode(calculatorCode) { } + /// Set the `nameOrCode` member using a `string` type value. void setNameOrCode(String ^nameOrCode) { this->nameOrCode = char_array(nameOrCode); } + /// Set the `unitName` member using a `string` type value. void setUnitName(String ^unitName) { this->unitName = char_array(unitName); } + /// Serializes this `DataRequest` to a string for debugging purposes. String ^ToString() override { String ^str = String::Format( @@ -319,11 +322,14 @@ namespace WASimCommander::CLI::Structs array ^data {}; ///< Value data array. - /// Tries to populate a value reference of the desired type and returns true or false + /// Tries to populate a value reference of the desired type `T` and returns true or false /// depending on if the conversion was valid (meaning the size of requested type matches the data size). /// If the conversion fails, result is default-initialized. - /// The requested type must be a `value` type (not reference) and be default-constructible, (eg. numerics, chars), or fixed-size arrays of such types. - generic where T : value class, gcnew() + /// The requested type (`T`) must be a `value` type (not reference) and be default-constructible, (eg. numerics, chars), or fixed-size arrays of such types. + generic +#if !DOXYGEN + where T : value class, gcnew() +#endif inline bool tryConvert([Out] T %result) { if (data->Length == (int)sizeof(T)) { @@ -349,7 +355,8 @@ namespace WASimCommander::CLI::Structs return true; } - // Implicit conversion operators for various types + /// \name Implicit conversion operators for various types. + /// \{ inline static operator double(DataRequestRecord ^dr) { return dr->toType(); } inline static operator float(DataRequestRecord ^dr) { return dr->toType(); } inline static operator int64_t(DataRequestRecord ^dr) { return dr->toType(); } @@ -361,6 +368,7 @@ namespace WASimCommander::CLI::Structs inline static operator int8_t(DataRequestRecord ^dr) { return dr->toType(); } inline static operator uint8_t(DataRequestRecord ^dr) { return dr->toType(); } inline static operator String ^(DataRequestRecord ^dr) { return dr->toType(); } + /// \} // can't get generic to work //generic where T : value class, gcnew() @@ -372,6 +380,8 @@ namespace WASimCommander::CLI::Structs // return ret; //} + /// Serializes this `DataRequestRecord` to string for debugging purposes. + /// To return the request's _value_ as a string, see `tryConvert()` or the `String ^()` operator. \sa DataRequest::ToString() String ^ToString() override { return String::Format( "{0}; DataRequestRecord {{Last Update: {1}; Data: {2}}}", diff --git a/src/WASimClient_CLI/WASimClient_CLI.h b/src/WASimClient_CLI/WASimClient_CLI.h index 33dd6a6..2e71fb6 100644 --- a/src/WASimClient_CLI/WASimClient_CLI.h +++ b/src/WASimClient_CLI/WASimClient_CLI.h @@ -52,18 +52,25 @@ namespace WASimCommander::CLI::Client /// /// The main difference is that callbacks from the C++ version are delivered here as managed Events, with defined delegate types to handle them. /// And unlike the callback system, the events can have multiple subscribers if needed. + /// + /// \note Events are delivered asyncronously from a separtely running thread. The event handlers should be reentrant since they could be callled at any time. \n + /// Typically, interactions with GUI components will not be possible directly from inside the event handlers -- use a `Dispatcher` to marshal GUI interactions + /// back to the main thread. public ref class WASimClient { public: // Delegates ----------------------------------- + /// \name Event handler delegate types + /// \{ delegate void ClientEventDelegate(ClientEvent ^); ///< Event delegate for Client events (`OnClientEvent`) delegate void ListResultsDelegate(ListResult ^); ///< Event delegate for delivering list results, eg. of local variables sent from Server (`OnListResults`). delegate void DataDelegate(DataRequestRecord ^); ///< Event delegate for subscription result data (`OnDataReceived`). delegate void LogDelegate(LogRecord ^, LogSource); ///< Event delegate for log entries (from both Client and Server) (`OnLogRecordReceived`). delegate void CommandResultDelegate(Command ^); ///< Event delegate for command responses returned from server (`OnCommandResult`). delegate void ResponseDelegate(Command ^); ///< Event delegate for all Command structures received from server (`OnResponseReceived`). + /// \} // Events ----------------------------------- @@ -83,18 +90,26 @@ namespace WASimCommander::CLI::Client #undef DELEGATE_DECL /// Construct a new client with the given ID. The ID must be unique among any other possible clients and cannot be zero. - /// See \refwcc{WASimClient::WASimClient()} for more details. + /// See \refwccc{WASimClient()} for more details. explicit WASimClient(UInt32 clientId); /// Construct a new client with the given ID and with initial settings read from the file specified in `configFile` (.ini format, see default file for example). - /// The client ID must be unique among any other possible clients and cannot be zero. See \refwcc{WASimClient::WASimClient()} for more details. + /// The client ID must be unique among any other possible clients and cannot be zero. See \refwccc{WASimClient()} for more details. explicit WASimClient(UInt32 clientId, String ^configFile); - /// This class implements a Disposable type object and should be disposed-of appropriately. +#if DOXYGEN + /// This class implements a Disposable type object and should be disposed-of appropriately by calling `client.Dispose()` when the instance is no longer needed. /// Any open network connections are automatically closed upon destruction, though it is better to close them yourself before deleting the client. + void Dispose(); +#else ~WASimClient(); - !WASimClient(); ///< \private + !WASimClient(); +#endif // Status ----------------------------------- + /// \name Network actions, status, and settings + /// \{ + + /// Get current connection status of this client. \sa WASimCommander::Client::ClientStatus ClientStatus status() { return (ClientStatus)m_client->status(); } /// Returns true if connected to the Simulator (SimConnect). bool isInitialized() { return m_client->isInitialized(); } @@ -109,92 +124,112 @@ namespace WASimCommander::CLI::Client /// Connect to the Simulator engine on a local connection. /// (optional) Maximum time to wait for response, in milliseconds. Zero (default) means to use the `defaultTimeout()` value. - /// \return See \refwcc{connectSimulator(uint32_t)} + /// \return See \refwccc{connectSimulator(uint32_t)} HR connectSimulator([Optional] Nullable timeout) { return (HR)m_client->connectSimulator(timeout.HasValue ? timeout.Value : 0); } /// Connect to the Simulator engine using a specific network configuration ID from a SimConnect.cfg file. The file must be in the same folder as the executable running the Client. /// network configuration ID from a SimConnect.cfg file. The file must be in the same folder as the executable running the Client. \n /// (optional) Maximum time to wait for response, in milliseconds. Zero (default) means to use the `defaultTimeout()` value. - /// \return See \refwcc{connectSimulator(int,uint32_t)} + /// \return See \refwccc{connectSimulator(int,uint32_t)} HR connectSimulator(int networkConfigId, [Optional] Nullable timeout) { return (HR)m_client->connectSimulator(networkConfigId, timeout.HasValue ? timeout.Value : 0); } + /// See \refwccc{disconnectSimulator()} void disconnectSimulator() { m_client->disconnectSimulator(); } + /// See \refwccc{pingServer()} uint32_t pingServer([Optional] Nullable timeout) { return m_client->pingServer(timeout.HasValue ? timeout.Value : 0); } + /// See \refwccc{connectServer()} HR connectServer([Optional] Nullable timeout) { return (HR)m_client->connectServer(timeout.HasValue ? timeout.Value : 0); } - void disconnectServer() { m_client->disconnectServer(); } + void disconnectServer() { m_client->disconnectServer(); } ///< See \refwccc{disconnectServer()} // Settings ----------------------------------- - uint32_t defaultTimeout() { return m_client->defaultTimeout(); } - void setDefaultTimeout(uint32_t ms) { m_client->setDefaultTimeout(ms); } - int networkConfigurationId() { return m_client->networkConfigurationId(); } - void setNetworkConfigurationId(int configId) { m_client->setNetworkConfigurationId(configId); } + uint32_t defaultTimeout() { return m_client->defaultTimeout(); } ///< See \refwccc{defaultTimeout()} + void setDefaultTimeout(uint32_t ms) { m_client->setDefaultTimeout(ms); } ///< See \refwccc{setDefaultTimeout()} + int networkConfigurationId() { return m_client->networkConfigurationId(); } ///< See \refwccc{networkConfigurationId()} + void setNetworkConfigurationId(int configId) { m_client->setNetworkConfigurationId(configId); } ///< See \refwccc{setNetworkConfigurationId()} + + /// \} + /// \name High level API + /// \{ // Calculator code ----------------------------------- - /// Execute calculator code without result + /// Execute calculator code without result \sa \refwccc{executeCalculatorCode()} HR executeCalculatorCode(String^ code) { return (HR)m_client->executeCalculatorCode(marshal_as(code)); } - /// Execute calculator code with a numeric result type. \private + /// Execute calculator code with a numeric result type. \sa \refwccc{executeCalculatorCode()} HR executeCalculatorCode(String^ code, CalcResultType resultType, [Out] double %pfResult); - /// Execute calculator code with a string result type. \private + /// Execute calculator code with a string result type. \sa \refwccc{executeCalculatorCode()} HR executeCalculatorCode(String^ code, CalcResultType resultType, [Out] String^ %psResult); - /// Execute calculator code with both numeric and string results. \private + /// Execute calculator code with both numeric and string results. \sa \refwccc{executeCalculatorCode()} HR executeCalculatorCode(String^ code, CalcResultType resultType, [Out] double %pfResult, [Out] String^ %psResult); // Variables accessors ------------------------------ + /// See \refwccc{getVariable()} HR getVariable(VariableRequest ^var, [Out] double %pfResult) { pin_ptr pf = &pfResult; return (HR)m_client->getVariable(var, pf); } + /// See \refwccc{getLocalVariable()} HR getLocalVariable(String ^variableName, [Out] double %pfResult) { return getVariable(gcnew VariableRequest(variableName), pfResult); } + /// See \refwccc{setVariable()} HR setVariable(VariableRequest ^var, const double value) { return (HR)m_client->setVariable(var, value); } + /// See \refwccc{setLocalVariable()} HR setLocalVariable(String ^variableName, const double value) { return (HR)m_client->setLocalVariable(marshal_as(variableName), value); } + /// See \refwccc{setOrCreateLocalVariable()} HR setOrCreateLocalVariable(String ^variableName, const double value) { return (HR)m_client->setOrCreateLocalVariable(marshal_as(variableName), value); } // Data subscriptions ------------------------------- - HR saveDataRequest(DataRequest ^request) { return (HR)m_client->saveDataRequest(request); } - HR removeDataRequest(const uint32_t requestId) { return (HR)m_client->removeDataRequest(requestId); } - HR updateDataRequest(uint32_t requestId) { return (HR)m_client->updateDataRequest(requestId); } + HR saveDataRequest(DataRequest ^request) { return (HR)m_client->saveDataRequest(request); } ///< See \refwccc{saveDataRequest()} + HR removeDataRequest(const uint32_t requestId) { return (HR)m_client->removeDataRequest(requestId); } ///< See \refwccc{removeDataRequest()} + HR updateDataRequest(uint32_t requestId) { return (HR)m_client->updateDataRequest(requestId); } ///< See \refwccc{updateDataRequest()} - DataRequestRecord ^dataRequest(uint32_t requestId) { return gcnew DataRequestRecord(m_client->dataRequest(requestId)); } - array ^dataRequests(); - array ^dataRequestIdsList(); + DataRequestRecord ^dataRequest(uint32_t requestId) { return gcnew DataRequestRecord(m_client->dataRequest(requestId)); } ///< See \refwccc{dataRequest()} + array ^dataRequests(); ///< See \refwccc{dataRequests()} + array ^dataRequestIdsList(); ///< See \refwccc{dataRequestIdsList()} - HR setDataRequestsPaused(bool paused) { return (HR)m_client->setDataRequestsPaused(paused); } + HR setDataRequestsPaused(bool paused) { return (HR)m_client->setDataRequestsPaused(paused); } ///< See \refwccc{setDataRequestsPaused()} // Custom Event registration -------------------------- - HR registerEvent(RegisteredEvent ^eventData) { return (HR)m_client->registerEvent(eventData); } - HR removeEvent(uint32_t eventId) { return (HR)m_client->removeEvent(eventId); } - HR transmitEvent(uint32_t eventId) { return (HR)m_client->transmitEvent(eventId); } + HR registerEvent(RegisteredEvent ^eventData) { return (HR)m_client->registerEvent(eventData); } ///< See \refwccc{registerEvent()} + HR removeEvent(uint32_t eventId) { return (HR)m_client->removeEvent(eventId); } ///< See \refwccc{removeEvent()} + HR transmitEvent(uint32_t eventId) { return (HR)m_client->transmitEvent(eventId); } ///< See \refwccc{transmitEvent()} - RegisteredEvent ^registeredEvent(uint32_t eventId) { return gcnew RegisteredEvent(m_client->registeredEvent(eventId)); } - array ^registeredEvents(); + RegisteredEvent ^registeredEvent(uint32_t eventId) { return gcnew RegisteredEvent(m_client->registeredEvent(eventId)); } ///< See \refwccc{registeredEvent()} + array ^registeredEvents(); ///< See \refwccc{registeredEvents()} // Simulator Key Events ------------------------------ + /// See \refwccc{sendKeyEvent(uint32_t, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) const} HR sendKeyEvent(uint32_t keyEventId, [Optional] Nullable v1, [Optional] Nullable v2, [Optional] Nullable v3, [Optional] Nullable v4, [Optional] Nullable v5) { return (HR)m_client->sendKeyEvent(keyEventId, v1.GetValueOrDefault(0), v2.GetValueOrDefault(0), v3.GetValueOrDefault(0), v4.GetValueOrDefault(0), v5.GetValueOrDefault(0)); } + /// See \refwccc{sendKeyEvent(const std::string&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t)} HR sendKeyEvent(String ^keyEventName, [Optional] Nullable v1, [Optional] Nullable v2, [Optional] Nullable v3, [Optional] Nullable v4, [Optional] Nullable v5) { return (HR)m_client->sendKeyEvent(marshal_as(keyEventName), v1.GetValueOrDefault(0), v2.GetValueOrDefault(0), v3.GetValueOrDefault(0), v4.GetValueOrDefault(0), v5.GetValueOrDefault(0)); } // Meta data retrieval -------------------------------- + /// See \refwccc{list()} HR list(LookupItemType itemsType) { return (HR)m_client->list((WSE::LookupItemType)itemsType); } + /// See \refwccc{lookup()} HR lookup(LookupItemType itemType, String ^itemName, [Out] Int32 %piResult) { pin_ptr pi = &piResult; return (HR)m_client->lookup((WSE::LookupItemType)itemType, marshal_as(itemName), pi); } - // Low level API -------------------------------- + /// \} + /// \name Low level API + /// \{ + /// See \refwccc{sendCommand()} HR sendCommand(Command ^command) { return (HR)m_client->sendCommand(command); } + /// See \refwccc{sendCommandWithResponse()} HR sendCommandWithResponse(Command ^command, [Out] Command^ %response, [Optional] Nullable timeout) { WASimCommander::Command resp {}; @@ -203,15 +238,21 @@ namespace WASimCommander::CLI::Client return (HR)hr; } - // Logging settings -------------------------------- + /// \} + /// \name Logging settings + /// \{ + /// See \refwccc{logLevel()} LogLevel logLevel(LogFacility facility, LogSource source) { return (LogLevel)m_client->logLevel((WSE::LogFacility)facility, (WASimCommander::Client::LogSource)source); } + /// See \refwccc{setLogLevel()} void setLogLevel(LogLevel level, LogFacility facility, LogSource source) { m_client->setLogLevel((WSE::LogLevel)level, (WSE::LogFacility)facility, (WASimCommander::Client::LogSource)source); } + /// \} + private: WASimCommander::Client::WASimClient *m_client = nullptr; From d571471f576edffdbfeb29eecbd1fe9a24cac200 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 22 Oct 2023 15:43:32 -0400 Subject: [PATCH 10/65] [docs] Add/adjust reference aliases in Doxyfile. --- docs/Doxyfile | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/Doxyfile b/docs/Doxyfile index 39357a5..743407e 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -13,11 +13,14 @@ MULTILINE_CPP_IS_BRIEF = YES TAB_SIZE = 2 ALIASES = \ "refwc{1}=\ref WASimCommander::\1 \"\1\"" \ - "refwce{1}=\ref WASimCommander::Enums::\1 \"\1\"" \ - "refwcc{1}=\ref WASimCommander::Client::\1 \"\1\"" \ - "refwcli{1}=\ref WASimCommander::CLI::\1 \"\1\"" \ - "refwclie{1}=\ref WASimCommander::CLI::Enums::\1 \"\1\"" \ - "refwclis{1}=\ref WASimCommander::CLI::Structs::\1 \"\1\"" \ + "refwce{1}=\ref WASimCommander::Enums::\1 \"Enums::\1\"" \ + "refwcc{1}=\ref WASimCommander::Client::\1 \"Client::\1\"" \ + "refwccc{1}=\ref WASimCommander::Client::WASimClient::\1 \"WASimClient::\1\"" \ + "refwcli{1}=\ref WASimCommander::CLI::\1 \"CLI::\1\"" \ + "refwclie{1}=\ref WASimCommander::CLI::Enums::\1 \"CLI::Enums::\1\"" \ + "refwclis{1}=\ref WASimCommander::CLI::Structs::\1 \"CLI::Structs::\1\"" \ + "refwclic{1}=\ref WASimCommander::CLI::Client::\1 \"CLI::Client::\1\"" \ + "refwclicc{1}=\ref WASimCommander::CLI::Client::WASimClient\1 \"CLI::Client::WASimClient::\1\"" \ "default{1}=\nDefault value is \c \1.\n" \ "reimp{1}=Reimplemented from \c \1." \ "reimp=Reimplemented from superclass." \ From 088b3a7fa213f7389c7fb1243956e57f4457ada5 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 22 Oct 2023 16:33:31 -0400 Subject: [PATCH 11/65] [WASimUI] Auto-populate data request size based on selected Unit type; Move Sim Var Index and Unit type selectors to appear before Data Size. --- src/WASimUI/Utils.h | 26 +++++++- src/WASimUI/WASimUI.cpp | 15 +++-- src/WASimUI/WASimUI.ui | 133 +++++++++++++++++++++------------------- 3 files changed, 102 insertions(+), 72 deletions(-) diff --git a/src/WASimUI/Utils.h b/src/WASimUI/Utils.h index ac8cb4f..8477157 100644 --- a/src/WASimUI/Utils.h +++ b/src/WASimUI/Utils.h @@ -48,8 +48,9 @@ and is also available at . namespace WASimUiNS { -namespace WSEnums = WASimCommander::Enums; -namespace WSCEnums = WASimCommander::Client; +namespace WS = WASimCommander; +namespace WSEnums = WS::Enums; +namespace WSCEnums = WS::Client; // Custom "+" operator for strong enum types to cast to underlying type. template ::value, bool> = true> @@ -206,6 +207,27 @@ class Utils return v; } + static int unitToMetaType(QString unit) + { + static const QStringList integralUnits { + "enum", "mask", "flags", "integer", + "position", "position 16k", "position 32k", "position 128", + "frequency bcd16", "frequency bcd32", "bco16", "bcd16", "bcd32", + "seconds", "minutes", "hours", "days", "years", + "celsius scaler 16k", "celsius scaler 256" + }; + static const QStringList boolUnits { "bool", "boolean" }; + + unit = unit.toLower().simplified(); + if (unit == "string") + return QMetaType::User + 256; + if (boolUnits.contains(unit)) + return QMetaType::Bool; + if (integralUnits.contains(unit)) + return QMetaType::Int; + return QMetaType::Double; + } + static bool isUnitBasedVariableType(const char type) { static const std::vector VAR_TYPES_UNIT_BASED = { 'A', 'C', 'E', 'L', 'P' }; return find(VAR_TYPES_UNIT_BASED.cbegin(), VAR_TYPES_UNIT_BASED.cend(), type) != VAR_TYPES_UNIT_BASED.cend(); diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index d6ce9cb..bd08fd1 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -196,7 +196,7 @@ class WASimUIPrivate ui->cbNameOrCode->setCurrentText(vtype == 'L' ? ui->cbLvars->currentText() : ui->cbVariableName->currentText()); ui->cbVariableType->setCurrentData(vtype); ui->cbUnitName->setCurrentText(ui->cbSetVarUnitName->currentText()); - ui->cbValueSize->setCurrentData(+QMetaType::Double); + ui->cbValueSize->setCurrentData(Utils::unitToMetaType(ui->cbUnitName->currentText())); } void sendKeyEventForm() @@ -240,6 +240,10 @@ class WASimUIPrivate //ui->lblUnit->setVisible(!isCalc); //ui->cbUnitName->setVisible(!isCalc); toggleRequestVariableType(); + if (isCalc) + ui->cbValueSize->setCurrentData(Utils::calcResultTypeToMetaType(CalcResultType(ui->cbRequestCalcResultType->currentData().toUInt()))); + else + ui->cbValueSize->setCurrentData(Utils::unitToMetaType(ui->cbUnitName->currentText())); } void toggleRequestVariableType() @@ -289,7 +293,6 @@ class WASimUIPrivate req.setNameOrCode(qPrintable(ui->cbNameOrCode->currentText())); req.setUnitName(qPrintable(ui->cbUnitName->currentText())); - int metaType = QMetaType::UnknownType; if (ui->cbValueSize->currentData().isValid()) req.valueSize = Utils::metaTypeToSimType(req.metaType = ui->cbValueSize->currentData().toInt()); else @@ -330,10 +333,6 @@ class WASimUIPrivate ui->cbUnitName->setCurrentText(req.unitName); ui->sbSimVarIndex->setValue(req.simVarIndex); } - if (req.metaType == QMetaType::UnknownType) - ui->cbValueSize->setCurrentText(QString("%1").arg(req.valueSize)); - else - ui->cbValueSize->setCurrentData(req.metaType); ui->cbNameOrCode->setCurrentText(req.nameOrCode); ui->cbPeriod->setCurrentData(+req.period); ui->sbInterval->setValue(req.interval); @@ -690,6 +689,10 @@ WASimUI::WASimUI(QWidget *parent) : connect(ui.bgrpRequestType, QOverload::of(&QButtonGroup::buttonToggled), this, [this](int,bool) { d->toggleRequestType(); }); // show/hide SimVar index spin box based on type of variable selected connect(ui.cbVariableType, &DataComboBox::currentDataChanged, this, [this](const QVariant&) { d->toggleRequestVariableType(); }); + // Connect the data request unit type selector to choose a default result size + connect(ui.cbUnitName, &DataComboBox::currentTextChanged, this, [this](const QString &data) { + ui.cbValueSize->setCurrentData(Utils::unitToMetaType(data)); + }); // Connect the data request calc result type selector to choose a default result size connect(ui.cbRequestCalcResultType, &DataComboBox::currentDataChanged, this, [this](const QVariant &data) { ui.cbValueSize->setCurrentData(Utils::calcResultTypeToMetaType(CalcResultType(data.toUInt()))); diff --git a/src/WASimUI/WASimUI.ui b/src/WASimUI/WASimUI.ui index b4eb07b..04f5d5a 100644 --- a/src/WASimUI/WASimUI.ui +++ b/src/WASimUI/WASimUI.ui @@ -392,15 +392,24 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + 4 + + 0 + + + 0 + 0 + + 0 + - + 0 @@ -408,65 +417,23 @@ Submitted requests will appear in the "Data Requests" window. Double-c - Result: + Idx: - + - Calculation Result Type + SimVar Index (if any, zero is blank) + + + true + + + - - - - - - - 0 - 0 - - - - Size: - - - - - - - - 0 - 0 - - - - Value Size in Bytes or a preset type.<p>(Save items by pressing Enter, Remove by right-clicking on them while the list is open.)</p> - - - true - - - - - - - 4 - - - 0 - - - 0 - - - 0 - - - 0 - @@ -496,8 +463,18 @@ Submitted requests will appear in the "Data Requests" window. Double-c + + + + + + 4 + + + 0 + - + 0 @@ -505,25 +482,48 @@ Submitted requests will appear in the "Data Requests" window. Double-c - Idx: + Result: - + - SimVar Index (if any, zero is blank) - - - true - - - + Calculation Result Type + + + + + 0 + 0 + + + + Size: + + + + + + + + 0 + 0 + + + + Value Size in Bytes or a preset type.<p>(Save items by pressing Enter, Remove by right-clicking on them while the list is open.)</p> + + + true + + + @@ -631,7 +631,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c QComboBox::InsertAtTop - + Name or ID @@ -947,6 +947,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c + 75 true @@ -1018,6 +1019,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c + 75 true @@ -1204,6 +1206,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c + 75 true @@ -1222,6 +1225,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c + 75 true @@ -1635,6 +1639,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c + 75 true From 13502b5e992aa92ad9b3adf6369847f4be2fbdde Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 22 Oct 2023 16:39:03 -0400 Subject: [PATCH 12/65] [WASimUI] Remove 'B' var type from selectors; Add "seconds" to unit selector; Sort 'L' vars list alphabetically; --- src/WASimUI/WASimUI.cpp | 1 + src/WASimUI/Widgets.h | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index bd08fd1..b6ea50e 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -1197,6 +1197,7 @@ void WASimUI::onListResults(const ListResult &list) ui.cbLvars->clear(); for (const auto &pair : list.list) ui.cbLvars->addItem(QString::fromStdString(pair.second), pair.first); + ui.cbLvars->model()->sort(0); } void WASimUI::closeEvent(QCloseEvent *ev) diff --git a/src/WASimUI/Widgets.h b/src/WASimUI/Widgets.h index 703c484..c9f8b14 100644 --- a/src/WASimUI/Widgets.h +++ b/src/WASimUI/Widgets.h @@ -267,7 +267,7 @@ class VariableTypeComboBox : public DataComboBox setToolTip(tr("Named variable type. Types marked with a * use Unit specifiers.")); addItem(tr("A: SimVar *"), 'A'); - addItem(tr("B: Input"), 'B'); + //addItem(tr("B: Input"), 'B'); // only for gauge modules addItem(tr("C: GPS *"), 'C'); addItem(tr("E: Env. *"), 'E'); addItem(tr("H: HTML"), 'H'); @@ -430,6 +430,7 @@ class UnitTypeComboBox : public DeletableItemsComboBox addItem(QStringLiteral("psi"), i++); addItem(QStringLiteral("radians"), i++); addItem(QStringLiteral("rpm"), i++); + addItem(QStringLiteral("seconds"), i++); addItem(QStringLiteral("string"), i++); addItem(QStringLiteral("volts"), i++); addItem(QStringLiteral("Watts"), i++); From 3d7c39b53f12d9cad2aa0ce0d688d60fc833b22b Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 22 Oct 2023 16:57:43 -0400 Subject: [PATCH 13/65] [WASimUI] Data Requests table: Move default position of Index column to follow var. name; Make all columns resizeable; Save header view state between uses; Fix display of char-sized data values (were interpreted as ASCII codes); DeltaEpsilong format now only hows minimum number of digits; Add tooltips for all values in the table; Add `RequestRecord::properties` for future meta data. --- src/WASimUI/RequestsModel.h | 31 +++++++++++++++++++++---------- src/WASimUI/Utils.h | 4 ++-- src/WASimUI/WASimUI.cpp | 17 ++++++++++++++++- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/WASimUI/RequestsModel.h b/src/WASimUI/RequestsModel.h index 8c6fdf5..d28ef12 100644 --- a/src/WASimUI/RequestsModel.h +++ b/src/WASimUI/RequestsModel.h @@ -47,18 +47,17 @@ struct RequestRecord : public WASimCommander::Client::DataRequestRecord uint32_t interval; UpdatePeriod period; RequestType requestType; - union { - CalcResultType calcResultType; - uint8_t simVarIndex; - }; + CalcResultType calcResultType; + uint8_t simVarIndex; char varTypePrefix; char nameOrCode[STRSZ_REQ]; char unitName[STRSZ_UNIT]; // From DataRequestRecord time_t lastUpdate; - uint32_t dataSize; + std::vector data; */ int metaType = QMetaType::UnknownType; + QVariantMap properties {}; using DataRequestRecord::DataRequestRecord; @@ -134,14 +133,14 @@ class RequestsModel : public QStandardItemModel private: Q_OBJECT - enum Roles { DataRole = Qt::UserRole + 1, MetaTypeRole }; public: + enum Roles { DataRole = Qt::UserRole + 1, MetaTypeRole, IdStringRole, PropertiesRole }; enum Columns { - COL_ID, COL_TYPE, COL_RES_TYPE, COL_NAME, COL_SIZE, COL_UNIT, COL_IDX, COL_PERIOD, COL_INTERVAL, COL_EPSILON, COL_VALUE, COL_TIMESATMP, COL_ENUM_END + COL_ID, COL_TYPE, COL_RES_TYPE, COL_NAME, COL_IDX, COL_UNIT, COL_SIZE, COL_PERIOD, COL_INTERVAL, COL_EPSILON, COL_VALUE, COL_TIMESATMP, COL_ENUM_END }; const QStringList columnNames = { - tr("ID"), tr("Type"), tr("Res/Var Type"), tr("Name or Code"), tr("Size"), tr("Unit"), tr("Idx"), tr("Period"), tr("Intvl"), tr("ΔΕ"), tr("Value"), tr("Last Updt.") + tr("ID"), tr("Type"), tr("Res/Var"), tr("Name or Code"), tr("Idx"), tr("Unit"), tr("Size"), tr("Period"), tr("Intvl"), tr("ΔΕ"), tr("Value"), tr("Last Updt.") }; RequestsModel(QObject *parent = nullptr) : @@ -171,12 +170,14 @@ class RequestsModel : public QStandardItemModel QVariant v = Utils::convertValueToType(dataType, res); item(row, COL_VALUE)->setText(v.toString()); item(row, COL_VALUE)->setData(v); + item(row, COL_VALUE)->setToolTip(item(row, COL_VALUE)->text()); // update timestamp column const auto ts = QDateTime::fromMSecsSinceEpoch(res.lastUpdate); const auto lastts = item(row, COL_TIMESATMP)->data().toULongLong(); const uint64_t tsDelta = lastts ? res.lastUpdate - lastts : 0; item(row, COL_TIMESATMP)->setText(QString("%1 (%2)").arg(ts.toString("hh:mm:ss.zzz")).arg(tsDelta)); + item(row, COL_TIMESATMP)->setToolTip(item(row, COL_TIMESATMP)->text()); item(row, COL_TIMESATMP)->setData(res.lastUpdate); qDebug() << "Saved result" << v << "for request ID" << res.requestId << "ts" << res.lastUpdate << "size" << res.data.size() << "type" << (QMetaType::Type)dataType << "data : " << QByteArray((const char *)res.data.data(), res.data.size()).toHex(':'); } @@ -204,6 +205,7 @@ class RequestsModel : public QStandardItemModel req.setUnitName(item(row, COL_UNIT)->data(DataRole).toByteArray().constData()); req.metaType = item(row, COL_ID)->data(MetaTypeRole).toInt(); + req.properties = item(row, COL_ID)->data(PropertiesRole).toMap(); //std::cout << req << std::endl; return req; @@ -219,9 +221,12 @@ class RequestsModel : public QStandardItemModel setItem(row, COL_ID, new QStandardItem(QString("%1").arg(req.requestId))); item(row, COL_ID)->setData(req.requestId, DataRole); item(row, COL_ID)->setData(req.metaType, MetaTypeRole); + item(row, COL_ID)->setData(req.properties, PropertiesRole); + item(row, COL_ID)->setData(req.properties.value("id"), IdStringRole); // for search setItem(row, COL_TYPE, new QStandardItem(WSEnums::RequestTypeNames[+req.requestType])); item(row, COL_TYPE)->setData(+req.requestType); + item(row, COL_TYPE)->setToolTip(item(row, COL_TYPE)->text()); if (req.requestType == WSEnums::RequestType::Calculated) { setItem(row, COL_RES_TYPE, new QStandardItem(WSEnums::CalcResultTypeNames[+req.calcResultType])); @@ -249,6 +254,8 @@ class RequestsModel : public QStandardItemModel item(row, COL_IDX)->setEnabled(false); } } + item(row, COL_RES_TYPE)->setToolTip(item(row, COL_RES_TYPE)->text()); + item(row, COL_UNIT)->setToolTip(item(row, COL_UNIT)->text()); item(row, COL_UNIT)->setData(QString(req.unitName)); if (req.metaType == QMetaType::UnknownType) @@ -258,21 +265,26 @@ class RequestsModel : public QStandardItemModel else setItem(row, COL_SIZE, new QStandardItem(QString("%1 (%2 B)").arg(QString(QMetaType::typeName(req.metaType)).replace("q", "")).arg(QMetaType::sizeOf(req.metaType)))); item(row, COL_SIZE)->setData(req.valueSize); + item(row, COL_SIZE)->setToolTip(item(row, COL_SIZE)->text()); setItem(row, COL_PERIOD, new QStandardItem(WSEnums::UpdatePeriodNames[+req.period])); + item(row, COL_PERIOD)->setToolTip(item(row, COL_PERIOD)->text()); item(row, COL_PERIOD)->setData(+req.period); setItem(row, COL_NAME, new QStandardItem(QString(req.nameOrCode))); + item(row, COL_NAME)->setToolTip(item(row, COL_NAME)->text()); + setItem(row, COL_INTERVAL, new QStandardItem(QString("%1").arg(req.interval))); if (req.metaType > QMetaType::UnknownType && req.metaType < QMetaType::User) { - setItem(row, COL_EPSILON, new QStandardItem(QString("%1").arg(req.deltaEpsilon, 0, 'f', 7))); + setItem(row, COL_EPSILON, new QStandardItem(QString::number(req.deltaEpsilon))); } else { setItem(row, COL_EPSILON, new QStandardItem(tr("N/A"))); item(row, COL_EPSILON)->setEnabled(false); } item(row, COL_EPSILON)->setData(req.deltaEpsilon); + item(row, COL_EPSILON)->setToolTip(item(row, COL_EPSILON)->text()); if (newRow) { setItem(row, COL_VALUE, new QStandardItem("???")); @@ -288,7 +300,6 @@ class RequestsModel : public QStandardItemModel const int row = findRequestRow(requestId); if (row > -1) removeRow(row); - qDebug() << requestId << row; } void removeRequests(const QList requestIds) diff --git a/src/WASimUI/Utils.h b/src/WASimUI/Utils.h index 8477157..9bd827d 100644 --- a/src/WASimUI/Utils.h +++ b/src/WASimUI/Utils.h @@ -174,11 +174,11 @@ class Utils break; case QMetaType::Char: case QMetaType::SChar: - v.setValue(qint8(res)); + v.setValue(qint16((int8_t)res)); // upcast for printing break; case QMetaType::UChar: case QMetaType::Bool: - v.setValue(quint8(res)); + v.setValue(quint16((uint8_t)res)); // upcast for printing break; case QMetaType::Short: v.setValue(qint16(res)); diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index b6ea50e..e66a4ed 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -532,6 +532,7 @@ class WASimUIPrivate QSettings set; set.setValue(QStringLiteral("mainWindowGeo"), q->saveGeometry()); set.setValue(QStringLiteral("mainWindowState"), q->saveState()); + set.setValue(QStringLiteral("requestsViewHeaderState"), ui->requestsView->horizontalHeader()->saveState()); set.setValue(QStringLiteral("eventsViewHeaderState"), ui->eventsView->horizontalHeader()->saveState()); set.setValue(QStringLiteral("logViewHeaderState"), ui->logView->horizontalHeader()->saveState()); @@ -553,6 +554,8 @@ class WASimUIPrivate q->restoreGeometry(set.value(QStringLiteral("mainWindowGeo")).toByteArray()); if (set.contains(QStringLiteral("mainWindowState"))) q->restoreState(set.value(QStringLiteral("mainWindowState")).toByteArray()); + if (set.contains(QStringLiteral("requestsViewHeaderState"))) + ui->requestsView->horizontalHeader()->restoreState(set.value(QStringLiteral("requestsViewHeaderState")).toByteArray()); if (set.contains(QStringLiteral("eventsViewHeaderState"))) ui->eventsView->horizontalHeader()->restoreState(set.value(QStringLiteral("eventsViewHeaderState")).toByteArray()); if (set.contains(QStringLiteral("logViewHeaderState"))) @@ -649,8 +652,20 @@ WASimUI::WASimUI(QWidget *parent) : // Set up the Requests table view ui.requestsView->setModel(d->reqModel); - ui.requestsView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + ui.requestsView->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); ui.requestsView->horizontalHeader()->setSectionsMovable(true); + ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_ID, 40); + ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_TYPE, 65); + ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_RES_TYPE, 55); + ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_NAME, 265); + ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_IDX, 30); + ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_UNIT, 55); + ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_SIZE, 85); + ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_PERIOD, 60); + ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_INTERVAL, 40); + ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_EPSILON, 60); + ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_VALUE, 70); + ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_TIMESATMP, 70); // connect double click action to populate the request editor form connect(ui.requestsView, &QTableView::doubleClicked, this, [this](const QModelIndex &idx) { d->populateRequestForm(idx); }); From 0e6ab44d380eccf9c7f8ff2b0a79dc1d1c0ffb1a Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 22 Oct 2023 17:01:21 -0400 Subject: [PATCH 14/65] [WASimUI] Add ability to toggle visibility of each main form area of the UI from the View menu; Choices are preserved between uses. --- src/WASimUI/WASimUI.cpp | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index e66a4ed..06949d5 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -55,6 +55,13 @@ class WASimUIPrivate { friend class WASimUI; Q_DECLARE_TR_FUNCTIONS(WASimUI) + + struct FormWidget { + QString name; + QWidget *w; + QAction *a; + }; + public: WASimUI *q; Ui::WASimUIClass *ui; @@ -69,6 +76,7 @@ class WASimUIPrivate QString lastRequestsFile; QString lastEventsFile; uint32_t nextCmdToken = 0x0000FFFF; + QVector formWidgets { }; WASimUIPrivate(WASimUI *q) : q(q), ui(&q->ui), reqModel(new RequestsModel(q)), @@ -536,6 +544,11 @@ class WASimUIPrivate set.setValue(QStringLiteral("eventsViewHeaderState"), ui->eventsView->horizontalHeader()->saveState()); set.setValue(QStringLiteral("logViewHeaderState"), ui->logView->horizontalHeader()->saveState()); + set.beginGroup(QStringLiteral("Widgets")); + for (const FormWidget &vw : qAsConst(formWidgets)) + set.setValue(vw.name, vw.a->isChecked()); + set.endGroup(); + set.setValue(QStringLiteral("useDarkTheme"), Utils::isDarkStyle()); set.setValue(QStringLiteral("lastRequestsFile"), lastRequestsFile); set.setValue(QStringLiteral("lastEventsFile"), lastEventsFile); @@ -561,6 +574,11 @@ class WASimUIPrivate if (set.contains(QStringLiteral("logViewHeaderState"))) ui->logView->horizontalHeader()->restoreState(set.value(QStringLiteral("logViewHeaderState")).toByteArray()); + set.beginGroup(QStringLiteral("Widgets")); + for (const FormWidget &vw : qAsConst(formWidgets)) + vw.a->setChecked(set.value(vw.name, vw.a->isChecked()).toBool()); + set.endGroup(); + const QString defaultFileLoc = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); lastRequestsFile = set.value(QStringLiteral("lastRequestsFile"), defaultFileLoc + QStringLiteral("/WASimUI-requests.ini")).toString(); lastEventsFile = set.value(QStringLiteral("lastEventsFile"), defaultFileLoc + QStringLiteral("/WASimUI-events.ini")).toString(); @@ -1114,7 +1132,7 @@ WASimUI::WASimUI(QWidget *parent) : QIcon wordWrapIcon(QStringLiteral("wrap_text.glyph")); wordWrapIcon.addFile(QStringLiteral("notes.glyph"), QSize(), QIcon::Normal, QIcon::On); - QAction *wordWrapLogWindowAct = new QAction(wordWrapIcon, tr("Word Wrap"), this); + QAction *wordWrapLogWindowAct = new QAction(wordWrapIcon, tr("Log Word Wrap"), this); wordWrapLogWindowAct->setToolTip(tr("Toggle word wrapping of the log window.")); wordWrapLogWindowAct->setCheckable(true); wordWrapLogWindowAct->setChecked(true); @@ -1137,6 +1155,23 @@ WASimUI::WASimUI(QWidget *parent) : QAction *viewAct = new QAction(QIcon(QStringLiteral("grid_view.glyph")), tr("View"), this); QMenu *viewMenu = new QMenu(tr("View"), this); + +#define WIDGET_VIEW_TOGGLE_ACTION(T, W, V) {\ + QAction *act = new QAction(tr("Show %1 Form").arg(T), this); \ + act->setCheckable(true); act->setChecked(V); \ + W->addAction(act); W->setWindowTitle(T); W->setVisible(V); \ + connect(act, &QAction::toggled, W, &QWidget::setVisible); \ + d->formWidgets.append({T, W, act}); \ + viewMenu->addAction(act); \ + } + WIDGET_VIEW_TOGGLE_ACTION(tr("Calculator Code"), ui.wCalcForm, true); + WIDGET_VIEW_TOGGLE_ACTION(tr("Variables"), ui.wVariables, true); + WIDGET_VIEW_TOGGLE_ACTION(tr("Lookup"), ui.wDataLookup, true); + WIDGET_VIEW_TOGGLE_ACTION(tr("Key Events"), ui.wKeyEvent, true); + WIDGET_VIEW_TOGGLE_ACTION(tr("API Command"), ui.wCommand, false); + WIDGET_VIEW_TOGGLE_ACTION(tr("Data Request Editor"), ui.wRequestForm, true); +#undef WIDGET_VIEW_TOGGLE_ACTION + viewMenu->addActions({ ui.dwRequests->toggleViewAction(), ui.dwEventsList->toggleViewAction(), ui.dwLog->toggleViewAction() }); viewAct->setMenu(viewMenu); From e16049ac69ff15cdcdd9084c7fdab6920a1ffba1 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Mon, 23 Oct 2023 01:05:27 -0400 Subject: [PATCH 15/65] [WASimModule] Restore ability to use Unit type specifiers when setting and getting Local vars. --- src/WASimModule/WASimModule.cpp | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/WASimModule/WASimModule.cpp b/src/WASimModule/WASimModule.cpp index 3d32194..a583216 100644 --- a/src/WASimModule/WASimModule.cpp +++ b/src/WASimModule/WASimModule.cpp @@ -634,7 +634,10 @@ bool getNamedVariableValue(char varType, calcResult_t &result) case 'L': if (result.varId < 0) return false; - result.setF(get_named_variable_value(result.varId)); + if (result.unitId > -1) + result.setF(get_named_variable_typed_value(result.varId, result.unitId)); + else + result.setF(get_named_variable_value(result.varId)); break; case 'A': @@ -758,7 +761,7 @@ bool parseVariableString(const char varType, const char *data, ID &varId, bool c { string_view svVar(data, strlen(data)), svUnit{}; - if (varType == 'A') { + if (varType != 'T') { // Check for unit type after variable name/id and comma const size_t idx = svVar.find(','); if (idx != string::npos) { @@ -766,12 +769,12 @@ bool parseVariableString(const char varType, const char *data, ID &varId, bool c svVar.remove_suffix(svVar.size() - idx); } // check for index value at end of SimVar name/ID - if (svVar.size() > 3) { + if (varType == 'A' && svVar.size() > 3) { const string_view &svIndex = svVar.substr(svVar.size() - 3); const size_t idx = svIndex.find(':'); if (idx != string::npos) { // strtoul returns zero if conversion fails, which works fine since zero is not a valid simvar index - if (varIndex) + if (!!varIndex) *varIndex = strtoul(svIndex.data() + idx + 1, nullptr, 10); svVar.remove_suffix(3 - idx); } @@ -792,7 +795,7 @@ bool parseVariableString(const char varType, const char *data, ID &varId, bool c if (varId < 0) return false; // check for unit specification - if (unitId && !svUnit.empty()) { + if (!!unitId && !svUnit.empty()) { // try to parse the string as a numeric ID result = from_chars(svUnit.data(), svUnit.data() + svUnit.size(), *unitId); // if number conversion failed, look up unit id @@ -1019,11 +1022,16 @@ void setVariable(const Client *c, const Command *const cmd) } ID varId{-1}; - if (!parseVariableString(varType, data, varId, (cmd->commandId == CommandId::SetCreate))) { + ENUM unitId{-1}; + if (!parseVariableString(varType, data, varId, (cmd->commandId == CommandId::SetCreate), &unitId)) { logAndNak(c, *cmd, ostringstream() << "Could not resolve Variable ID for Set command from string " << quoted(data)); return; } - set_named_variable_value(varId, value); + + if (unitId > -1) + set_named_variable_typed_value(varId, value, unitId); + else + set_named_variable_value(varId, value); sendAckNak(c, *cmd); } From 61a52674e0dff7e1f3e63ed73a0bed711bb2c479 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Mon, 23 Oct 2023 01:14:21 -0400 Subject: [PATCH 16/65] [WASimModule] Add ability to specify/set a default L var value and unit type in `GetCreate` command to use if the variable needs to be created; `GetCreate` and `SetCreate` commands for non-L types now silently fall back to `Get` and `Set` respectively; Fixed that command response for `GetCreate` was always sent as if responding to a `Get` command. --- src/WASimModule/WASimModule.cpp | 47 ++++++++++++++++++++++----------- src/include/enums_impl.h | 9 ++++--- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/WASimModule/WASimModule.cpp b/src/WASimModule/WASimModule.cpp index a583216..1e8d2bb 100644 --- a/src/WASimModule/WASimModule.cpp +++ b/src/WASimModule/WASimModule.cpp @@ -757,7 +757,7 @@ int getVariableId(char varType, const char *name, bool createLocal = false) // Parse a command string to find a variable name/unit/index and populates the respective reference params. // Lookups are done on var names, depending on varType, and unit strings, to attempt conversion to IDs. // Used by setVariable() and getVariable(). Only handles A/L/T var types (not needed for others). -bool parseVariableString(const char varType, const char *data, ID &varId, bool createLocal, ENUM *unitId = nullptr, uint8_t *varIndex = nullptr, string *varName = nullptr) +bool parseVariableString(const char varType, const char *data, ID &varId, bool createLocal, ENUM *unitId = nullptr, uint8_t *varIndex = nullptr, string *varName = nullptr, bool *existed = nullptr) { string_view svVar(data, strlen(data)), svUnit{}; @@ -784,13 +784,23 @@ bool parseVariableString(const char varType, const char *data, ID &varId, bool c if (svVar.empty()) return false; - if (varName) - *varName = string(svVar); // Try to parse the remaining var name string as a numeric ID auto result = from_chars(svVar.data(), svVar.data() + svVar.size(), varId); // if number conversion failed, look up variable id - if (result.ec != errc()) - varId = getVariableId(varType, string(svVar).c_str(), createLocal); + if (result.ec != errc()) { + const std::string vname(svVar); + if (createLocal && !!existed) { + varId = check_named_variable(vname.c_str()); + *existed = varId > -1; + if (!*existed) + varId = register_named_variable(vname.c_str()); + } + else { + varId = getVariableId(varType, vname.c_str(), createLocal); + } + if (!!varName) + *varName = vname; + } // failed to find a numeric id, whole input was invalid if (varId < 0) return false; @@ -970,29 +980,39 @@ void getVariable(const Client *c, const Command *const cmd) const char varType = char(cmd->uData); const char *data = cmd->sData; LOG_TRC << "getVariable(" << varType << ", " << quoted(data) << ") for client " << c->name; - if (cmd->commandId == CommandId::GetCreate && varType != 'L') { - logAndNak(c, *cmd, ostringstream() << "The GetCreate command only supports the 'L' variable type."); - return; - } + // Anything besides L/A/T type vars just gets converted to calc code. if (!Utilities::isIndexedVariableType(varType)) { const ostringstream codeStr = ostringstream() << "(" << varType << ':' << data << ')'; const Command execCmd(cmd->commandId, +CalcResultType::Double, codeStr.str().c_str(), 0.0, cmd->token); return execCalculatorCode(c, &execCmd); } + ID varId{-1}; ENUM unitId{-1}; uint8_t varIndex{0}; string varName; - if (!parseVariableString(varType, data, varId, (cmd->commandId == CommandId::GetCreate), &unitId, &varIndex, &varName)) { + bool existed = true; + if (!parseVariableString(varType, data, varId, (cmd->commandId == CommandId::GetCreate && varType == 'L'), &unitId, &varIndex, &varName, &existed)) { logAndNak(c, *cmd, ostringstream() << "Could not resolve Variable ID for Get command from string " << quoted(data)); return; } + // !existed can only happen for an L var if we just created it. In that case it has a default value and unit type (0.0, number). + if (!existed) { + if (unitId > -1) + set_named_variable_typed_value(varId, cmd->fData, unitId); + else if (cmd->fData != 0.0) + set_named_variable_value(varId, cmd->fData); + sendResponse(c, Command(CommandId::Ack, (uint32_t)CommandId::GetCreate, nullptr, cmd->fData, cmd->token)); + LOG_DBG << "getVariable(" << quoted(data) << ") created new variable for client " << c->name; + return; + } + calcResult_t res = calcResult_t { CalcResultType::Double, STRSZ_CMD, varId, unitId, varIndex, varName.c_str() }; if (!getNamedVariableValue(varType, res)) return logAndNak(c, *cmd, ostringstream() << "getNamedVariableValue() returned error result for code " << quoted(data)); - Command resp(CommandId::Ack, (uint32_t)CommandId::Get); + Command resp(CommandId::Ack, (uint32_t)cmd->commandId); resp.token = cmd->token; switch (res.resultMemberIndex) { case 0: resp.fData = res.fVal; break; @@ -1010,10 +1030,7 @@ void setVariable(const Client *c, const Command *const cmd) const char *data = cmd->sData; const double value = cmd->fData; LOG_TRC << "setVariable(" << varType << ", " << quoted(data) << ", " << value << ") for client " << c->name; - if (cmd->commandId == CommandId::SetCreate && varType != 'L') { - logAndNak(c, *cmd, ostringstream() << "The SetCreate command only supports the 'L' variable type."); - return; - } + // Anything besides an L var just gets converted to calc code. if (varType != 'L') { const ostringstream codeStr = ostringstream() << fixed << setprecision(7) << value << " (>" << varType << ':' << data << ')'; diff --git a/src/include/enums_impl.h b/src/include/enums_impl.h index 1bb8622..b4b0161 100644 --- a/src/include/enums_impl.h +++ b/src/include/enums_impl.h @@ -52,13 +52,16 @@ namespace WSMCMND_ENUM_NAMESPACE /// For example, a SimVar: ```uData = 'A'; sData = "PROP BETA:2,degrees";``` \n /// Other variables types can also be requested ('B', 'E', 'M', etc) but such requests are simply converted to a calculator string and processed as an `Exec` type command (using an `Exec` command directly may be more efficient).\n /// Result is returned with the `Ack` response in `fData` as a double-precision value. In case of failure a `Nak` is returned with possible error message in `sData`. - GetCreate, ///< Same as `Get` but creates the variable if it doesn't already exist (with `register_named_variable()` _Gauge API_). The returned value will be `0.0` (see `SetCreate` to assign a default value and `Lookup` to check if a variable exists). - /// **This only works with `L` (local) type variables.** + GetCreate, ///< Same as `Get` but creates a local 'L' variable if it doesn't already exist (with `register_named_variable()` _Gauge API_). Use `Lookup` command to check if a variable exists. + /// \n **Since v1.2:** If a variable is created, the value provided in `fData` will be used as the initial value of the variable, and will be returned as the result + /// (essentially providing a default value in this case). Previous versions would _not_ set a value (or unit type) on the variable after creating it and would return the default of `0.0`. \n + /// **Creating variables only works with `L` (local) types.** Since v1.2, for all other types this command will be handled the same as `Get`. Previous versions would return a `Nak`. Set, ///< Set a named local variable with optional unit type. `uData` is a char of the variable type, with default of 'L' for local vars. /// `sData` is the variable name or numeric ID (for local vars only), optionally followed by comma (`,`) and unit name (or numeric unit ID for local vars) (**no spaces**). The value to set is passed in `fData` member.\n /// For example, a SimVar: ```uData = 'A'; sData = "PROP RPM:2,rpm"; fData = 2200;```\n /// Other variables types can also be set this way ('A', 'H", 'K', etc) but such requests are simply converted to a calculator string and processed as an `Exec` type command (using an `Exec` command directly may be slightly more efficient). - SetCreate, ///< Same as `Set` but creates the variable first if it doesn't already exist (with `register_named_variable()` _Gauge API_). Use the `Lookup` command to check if a variable exists. **This only works with `L` (local) type variables.** + SetCreate, ///< Same as `Set` but creates a local 'L' variable if it doesn't already exist (with `register_named_variable()` _Gauge API_). Use the `Lookup` command to check if a variable exists. \n + /// **Creating variables only works with `L` (local) types.** Since v1.2, for all other types this command will be handled the same as `Get`. Previous versions would return a `Nak`. Exec, ///< Run calculator code contained in `sData` with `WASimCommander::CalcResultType` in `uData`. Result, if any, is returned with the `Ack` response, numeric types in `fData` and strings in `sData`. /// (Due to the way the _Gauge API_ calculator function works, a string result is typically also returned even when only a numeric result is requested.)\n\n /// In case of failure a `Nak` is returned with possible error message in `sData`. Note however that the _Gauge API_ functions often happily return a "success" status even when the actual thing you're trying to do fails. The only feedback From 3090d5344c3a34c62e81f61237fe1fd91f6b11c5 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Mon, 23 Oct 2023 03:24:46 -0400 Subject: [PATCH 17/65] [WASimClient] Restore ability to specify Unit type for L vars and support for GetCreate with default value/unit: - Add unit name parameter to `setLocalVariable()` and `setOrCreateLocalVariable()`; - Add `getOrCreateLocalVariable()`; - Add `VariableRequest::createLVar` property; - Add optional "create" flag and unit name to `VariableRequest()` c'tor overload; - Update docs; Be explicit about `executeCalculatorCode()` being non-blocking when no result is expected. --- src/WASimClient/WASimClient.cpp | 23 +++++++++------- src/include/client/WASimClient.h | 47 +++++++++++++++++++++----------- src/shared/utilities.h | 2 +- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/src/WASimClient/WASimClient.cpp b/src/WASimClient/WASimClient.cpp index fa2176e..b9fe620 100644 --- a/src/WASimClient/WASimClient.cpp +++ b/src/WASimClient/WASimClient.cpp @@ -911,7 +911,7 @@ class WASimClient::Private return sValue; } - HRESULT getVariable(const VariableRequest &v, double *result) + HRESULT getVariable(const VariableRequest &v, double *result, double dflt = 0.0) { const string sValue = buildVariableCommandString(v, false); if (sValue.empty() || sValue.length() >= STRSZ_CMD) @@ -919,7 +919,7 @@ class WASimClient::Private HRESULT hr; Command response; - if FAILED(hr = sendCommandWithResponse(Command(CommandId::Get, v.variableType, sValue.c_str()), &response)) + if FAILED(hr = sendCommandWithResponse(Command(v.createLVar && v.variableType == 'L' ? CommandId::GetCreate : CommandId::Get, v.variableType, sValue.c_str(), dflt), &response)) return hr; if (response.commandId != CommandId::Ack) { LOG_WRN << "Get Variable request for " << quoted(sValue) << " returned Nak response. Reason, if any: " << quoted(response.sData); @@ -930,12 +930,12 @@ class WASimClient::Private return S_OK; } - HRESULT setVariable(const VariableRequest &v, const double value, bool create = false) + HRESULT setVariable(const VariableRequest &v, const double value) { const string sValue = buildVariableCommandString(v, true); if (sValue.empty() || sValue.length() >= STRSZ_CMD) return E_INVALIDARG; - return sendServerCommand(Command(create ? CommandId::SetCreate : CommandId::Set, v.variableType, sValue.c_str(), value)); + return sendServerCommand(Command(v.createLVar && v.variableType == 'L' ? CommandId::SetCreate : CommandId::Set, v.variableType, sValue.c_str(), value)); } #pragma endregion @@ -1533,21 +1533,24 @@ HRESULT WASimClient::getVariable(const VariableRequest & variable, double * pfRe return d->getVariable(variable, pfResult); } -HRESULT WASimClient::getLocalVariable(const std::string &variableName, double * pfResult) { - return d->getVariable(VariableRequest('L', variableName), pfResult); +HRESULT WASimClient::getLocalVariable(const std::string &variableName, double *pfResult, const std::string &unitName) { + return d->getVariable(VariableRequest(variableName, false, unitName), pfResult); } +HRESULT WASimClient::getOrCreateLocalVariable(const std::string &variableName, double *pfResult, double defaultValue, const std::string &unitName) { + return d->getVariable(VariableRequest(variableName, true, unitName), pfResult, defaultValue); +} HRESULT WASimClient::setVariable(const VariableRequest & variable, const double value) { return d->setVariable(variable, value); } -HRESULT WASimClient::setLocalVariable(const std::string &variableName, const double value) { - return d->setVariable(VariableRequest('L', variableName), value, false); +HRESULT WASimClient::setLocalVariable(const std::string &variableName, const double value, const std::string &unitName) { + return d->setVariable(VariableRequest(variableName, false, unitName), value); } -HRESULT WASimClient::setOrCreateLocalVariable(const std::string &variableName, const double value) { - return d->setVariable(VariableRequest('L', variableName), value, true); +HRESULT WASimClient::setOrCreateLocalVariable(const std::string &variableName, const double value, const std::string &unitName) { + return d->setVariable(VariableRequest(variableName, true, unitName), value); } #pragma endregion diff --git a/src/include/client/WASimClient.h b/src/include/client/WASimClient.h index b82841f..c2b24b5 100644 --- a/src/include/client/WASimClient.h +++ b/src/include/client/WASimClient.h @@ -154,11 +154,12 @@ static const HRESULT E_TIMEOUT = /*ERROR_TIMEOUT*/ 1460L | (/*FACILI /// The unit name is ignored for all other variable types, and the `unitId` field is preferred if it is greater than -1. int variableId = -1; ///< Numeric ID of the variable to get/set. Overrides the `variableName` field if greater than -1. Only 'A', 'L', 'T' variable types can be referenced by numeric IDs. int unitId = -1; ///< Numeric ID of the Unit type to use in the get/set command. Overrides the `unitName` field if greater than -1. See usage notes for `unitName` about applicable variable types. - uint8_t simVarIndex = 0; ///< Optional index number for SimVars ('A') which require them. If using named variables, yhe index can also be included in the variable name string (after a colon `:`, as would be used in a calculator string). + uint8_t simVarIndex = 0; ///< Optional index number for SimVars ('A') which require them. If using named variables, yhe index can also be included in the variable name string (after a colon `:`, as would be used in a calculator string). + bool createLVar = false; ///< This flag indicates that the L var should be created if it doesn't already exist in the simulator. This applies for both "Set" and "Get" commands. - /// Default constructor, with optional parameters for variable type, name, unit name and SimVar index. - explicit VariableRequest(char variableType = 'L', const std::string &variableName = std::string(), const std::string &unitName = std::string(), uint8_t simVarIndex = 0) : - variableType{variableType}, variableName{variableName}, unitName{unitName}, simVarIndex(simVarIndex) { } + /// Default constructor, with optional parameters for variable type, name, unit name, SimVar index and `createLVar` flag. + explicit VariableRequest(char variableType = 'L', const std::string &variableName = std::string(), const std::string &unitName = std::string(), uint8_t simVarIndex = 0, bool createVariable = false) : + variableType{variableType}, variableName{variableName}, unitName{unitName}, simVarIndex(simVarIndex), createLVar{createVariable} { } /// Construct a variable request using numeric variable and (optionally) unit IDs, and optional SimVar index. explicit VariableRequest(char variableType, int variableId, int unitId = -1, uint8_t simVarIndex = 0) : variableType{variableType}, variableId{variableId}, unitId{unitId}, simVarIndex(simVarIndex) { } @@ -168,9 +169,10 @@ static const HRESULT E_TIMEOUT = /*ERROR_TIMEOUT*/ 1460L | (/*FACILI /// Construct a variable request for a Simulator Variable ('A') using numeric variable and unit IDs, with optional index parameter. explicit VariableRequest(int simVarId, int unitId, uint8_t simVarIndex = 0) : variableType{'A'}, variableId{simVarId}, unitId{unitId}, simVarIndex(simVarIndex) { } - /// Construct a variable request for a Local variable ('L') with the given name. - explicit VariableRequest(const std::string &localVarName) : - variableType{'L'}, variableName{localVarName} { } + /// Construct a variable request for a Local variable ('L') with the given name. `createVariable` will create the L var on the simulator if it doesn't exist yet + /// (for "Get" as well as "Set" commands). An optional unit name can also be provided. + explicit VariableRequest(const std::string &localVarName, bool createVariable = false, const std::string &unitName = std::string()) : + variableType{'L'}, variableName{localVarName}, unitName{unitName}, createLVar{createVariable} { } /// Construct a variable request for a Local variable ('L') with the given numeric ID. explicit VariableRequest(int localVarId) : variableType{'L'}, variableId{localVarId} { } @@ -304,8 +306,10 @@ static const HRESULT E_TIMEOUT = /*ERROR_TIMEOUT*/ 1460L | (/*FACILI /// if a numeric result is requested, the string result will also be populated). /// \param pfResult A pointer to an initialized variable of `double` to store the result into if `resultType` is `Enums::CalcResultType::Double` or `Enums::CalcResultType::Integer`. /// \param psResult A string pointer to store the string result into. The string version is typically populated even for numeric type requests, but definitely for `Enums::CalcResultType::String` or `Enums::CalcResultType::Formatted` type requests. - /// \return `S_OK` on success, `E_FAIL` if the server returned Nak response, `E_NOT_CONNECTED` if not connected to server, or `E_TIMEOUT` on general server communication failure. - /// \note This method blocks until either the Server responds or the timeout has expired. + /// \return `S_OK` on success, `E_NOT_CONNECTED` if not connected to server; \n + /// If a result is expected, may also return `E_FAIL` if the server returned Nak response, or `E_TIMEOUT` on general server communication failure. + /// \note _If_ a result is expected (`resultType` != `Enums::CalcResultType::None`) then this method blocks until either the Server responds or the timeout has expired (see `defaultTimeout()`). + /// To request calculated results in a non-blocking fashion, use a data request instead. /// /// If you need to execute the same code multiple times, it would be more efficient to save the code as either a data request (for code returning values) or a registered event (for code not returning values). /// The advantage is that in those cases the calculator string is pre-compiled to byte code and saved once, then each invocation of the _Gauge API_ calculator functions uses the more efficient byte code version. @@ -325,12 +329,21 @@ static const HRESULT E_TIMEOUT = /*ERROR_TIMEOUT*/ 1460L | (/*FACILI /// \note This method blocks until either the Server responds or the timeout has expired. /// \sa \refwcc{VariableRequest}, \refwce{CommandId::Get}, defaultTimeout(), setDefaultTimeout() HRESULT getVariable(const VariableRequest &variable, double *pfResult); - /// A convenience version of getVariable(VariableRequest('L', variableName), pfResult). See `getVariable()` for details. + /// A convenience version of `getVariable(VariableRequest(variableName, false, unitName), pfResult)`. See `getVariable()` and `VariableRequest` for details. + /// \param variableName Name of the local variable. + /// \param pfResult Pointer to a double precision variable to hold the result. + /// \param unitName Optional unit specifier to use. Most Local vars do not specify a unit and default to a generic "number" type. + /// \return `S_OK` on success, `E_INVALIDARG` on parameter validation errors, `E_NOT_CONNECTED` if not connected to server, `E_TIMEOUT` on server communication failure, or `E_FAIL` if server returns a Nak response. + /// \note This method blocks until either the Server responds or the timeout has expired. \sa defaultTimeout(), setDefaultTimeout() + HRESULT getLocalVariable(const std::string &variableName, double *pfResult, const std::string &unitName = std::string()); + /// Gets the value of a local variable just like `getLocalVariable()` but will also create the variable on the simulator if it doesn't already exist. /// \param variableName Name of the local variable. /// \param pfResult Pointer to a double precision variable to hold the result. + /// \param defaultValue The L var will be created on the simulator if it doesn't exist yet using this initial value (and this same value will be returned in `pfResult`). + /// \param unitName Optional unit specifier to use. Most Local vars do not specify a unit and default to a generic "number" type. /// \return `S_OK` on success, `E_INVALIDARG` on parameter validation errors, `E_NOT_CONNECTED` if not connected to server, `E_TIMEOUT` on server communication failure, or `E_FAIL` if server returns a Nak response. /// \note This method blocks until either the Server responds or the timeout has expired. \sa defaultTimeout(), setDefaultTimeout() - HRESULT getLocalVariable(const std::string &variableName, double *pfResult); + HRESULT getOrCreateLocalVariable(const std::string &variableName, double *pfResult, double defaultValue = 0.0, const std::string &unitName = std::string()); /// Set a Variable value by name, with optional named unit type. Although any settable variable type can set this way, it is primarily useful for local (`L`) variables which can be set via dedicated _Gauge API_ functions /// (`set_named_variable_value()` and `set_named_variable_typed_value()`). Other variables types can also be set this way ('A', 'H", 'K', etc) but such requests are simply converted to a calculator string and @@ -340,19 +353,21 @@ static const HRESULT E_TIMEOUT = /*ERROR_TIMEOUT*/ 1460L | (/*FACILI /// \return `S_OK` on success, `E_INVALIDARG` on parameter validation errors, `E_NOT_CONNECTED` if not connected to server, or `E_FAIL` on general failure (unlikely). /// \sa \refwce{CommandId::Set} HRESULT setVariable(const VariableRequest &variable, const double value); - /// A convenience version of `setVariable(VariableRequest('L', variableName), value)`. See `setVariable()` for details. + /// A convenience version of `setVariable()` for Local variable types. Equivalent to `setVariable(VariableRequest(variableName, false, unitName), value)`. See `setVariable()` and `VariableRequest` for details. /// \param variableName Name of the local variable. /// \param value The value to set. + /// \param unitName Optional unit specifier to use. Most Local vars do not specify a unit and default to a generic "number" type. /// \return `S_OK` on success, `E_INVALIDARG` on parameter validation errors, `E_NOT_CONNECTED` if not connected to server, or `E_FAIL` on general failure (unlikely). - HRESULT setLocalVariable(const std::string &variableName, const double value); + HRESULT setLocalVariable(const std::string &variableName, const double value, const std::string &unitName = std::string()); /// Set a Local Variable value by variable name, creating it first if it does not already exist. This first calls the `register_named_variable()` _Gauge API_ function to get the ID from the name, - /// which creates the variable if it doesn't exist. The returned ID (new or existing) is then used to set the value. Unit type cannot be specified when creating/using custom local variables in this fashion. - /// Use the `lookup()` method to check for the existence of a variable name. + /// which creates the variable if it doesn't exist. The returned ID (new or existing) is then used to set the value. Use the `lookup()` method to check for the existence of a variable name. + /// Equivalent to `setVariable(VariableRequest(variableName, true, unitName), value)`. See `setVariable()` and `VariableRequest` for details. /// \param variableName Name of the local variable. /// \param value The value to set. Becomes the intial value if the variable is created. + /// \param unitName Optional unit specifier to use. Most Local vars do not specify a unit and default to a generic "number" type. /// \return `S_OK` on success, `E_INVALIDARG` on parameter validation errors, `E_NOT_CONNECTED` if not connected to server, or `E_FAIL` on general failure (unlikely). /// \sa \refwcc{VariableRequest}, \refwce{CommandId::SetCreate} - HRESULT setOrCreateLocalVariable(const std::string &variableName, const double value); + HRESULT setOrCreateLocalVariable(const std::string &variableName, const double value, const std::string &unitName = std::string()); // Data subscriptions ------------------------------- diff --git a/src/shared/utilities.h b/src/shared/utilities.h index 6208fce..e388b43 100644 --- a/src/shared/utilities.h +++ b/src/shared/utilities.h @@ -85,7 +85,7 @@ namespace WASimCommander { } static bool isUnitBasedVariableType(const char type) { - static const std::vector VAR_TYPES_UNIT_BASED = { 'A', 'C', 'E', 'P' }; + static const std::vector VAR_TYPES_UNIT_BASED = { 'A', 'C', 'E', 'L', 'P' }; return find(VAR_TYPES_UNIT_BASED.cbegin(), VAR_TYPES_UNIT_BASED.cend(), type) != VAR_TYPES_UNIT_BASED.cend(); } From 82ea4252bd25423bbeab354799d6be41f053880e Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Mon, 23 Oct 2023 03:25:59 -0400 Subject: [PATCH 18/65] [WASimClient] Add async option to `saveDataRequest()`. --- src/WASimClient/WASimClient.cpp | 12 ++++++------ src/include/client/WASimClient.h | 11 ++++++++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/WASimClient/WASimClient.cpp b/src/WASimClient/WASimClient.cpp index b9fe620..1463c34 100644 --- a/src/WASimClient/WASimClient.cpp +++ b/src/WASimClient/WASimClient.cpp @@ -971,14 +971,14 @@ class WASimClient::Private // Writes DataRequest data to the corresponding CDA and waits for an Ack/Nak from server. // If the DataRequest::requestType == None, the request will be deleted by the server and the response wait is skipped. - HRESULT sendDataRequest(const DataRequest &req) + HRESULT sendDataRequest(const DataRequest &req, bool async) { HRESULT hr; if FAILED(hr = writeDataRequest(req)) return hr; // check if just deleting an existing request and don't wait around for that response - if (req.requestType == RequestType::None) + if (async || req.requestType == RequestType::None) return hr; shared_ptr cv = make_shared(); @@ -1033,7 +1033,7 @@ class WASimClient::Private return SimConnectHelper::removeClientDataDefinition(hSim, tr->dataId); } - HRESULT addOrUpdateRequest(const DataRequest &req) + HRESULT addOrUpdateRequest(const DataRequest &req, bool async) { if (req.requestType == RequestType::None) return removeRequest(req.requestId); @@ -1082,7 +1082,7 @@ class WASimClient::Private hr = registerDataRequestArea(tr, isNewRequest, dataAllocationChanged); if SUCCEEDED(hr) { // send the request and wait for Ack; Request may timeout or return a Nak. - hr = sendDataRequest(req); + hr = sendDataRequest(req, async); } if (FAILED(hr) && isNewRequest) { // delete a new request if anything failed @@ -1557,8 +1557,8 @@ HRESULT WASimClient::setOrCreateLocalVariable(const std::string &variableName, c #pragma region Data Requests ---------------------------------------------- -HRESULT WASimClient::saveDataRequest(const DataRequest &request) { - return d->addOrUpdateRequest(request); +HRESULT WASimClient::saveDataRequest(const DataRequest &request, bool async) { + return d->addOrUpdateRequest(request, async); } HRESULT WASimClient::removeDataRequest(const uint32_t requestId) { diff --git a/src/include/client/WASimClient.h b/src/include/client/WASimClient.h index c2b24b5..be1e4f3 100644 --- a/src/include/client/WASimClient.h +++ b/src/include/client/WASimClient.h @@ -373,10 +373,15 @@ static const HRESULT E_TIMEOUT = /*ERROR_TIMEOUT*/ 1460L | (/*FACILI /// Add a new `WASimCommander::DataRequest` or update an existing one with the same `DataRequest::requestId`. If the client is not currently connected to the server, the request is queued until the next connection is established. /// \param request The `WASimCommander::DataRequest` structure to process. See `WASimCommander::DataRequest` documentation for details of the structure members. - /// \return `S_OK` on success, `E_INVALIDARG` if there is a problem with the `DataRequest` contents; If currently connected to the server, may also return `E_FAIL` if the server returned `Nak` response, or `E_TIMEOUT` on general server communication failure. - /// \note If currently connected to the server, this method will block until either the Server responds or the timeout has expired (see `defaultTimeout()`). + /// \param async `true` to wait for a response from the server before returning, or `false` (default) to wait for an `Ack`/`Nak` response. See return values and the Note below for more details. + /// \return `S_OK` on success, `E_INVALIDARG` if there is a problem with the `DataRequest` contents. \n + /// If currently connected to the server and `async` is `false`, may also return `E_FAIL` if the server returned `Nak` response, or `E_TIMEOUT` on general server communication failure. + /// \note If currently connected to the server and the `async` param is `false`, this method will block until either the Server responds or the timeout has expired (see `defaultTimeout()`). + /// \par Tracking async calls + /// To track the status of an async request, set a callback function with `setCommandResultCallback()`. The server should respond with an \refwce{CommandId::Ack} or \refwce{CommandId::Nak} + /// \refwc{Command} where the `uData` value is \refwce{CommandId::Subscribe} and the \refwc{Command::token} will be the `requestId` value from the given `request` struct. /// \sa \refwc{DataRequest} \refwce{CommandId::Subscribe}, removeDataRequest(), updateDataRequest() - HRESULT saveDataRequest(const DataRequest &request); + HRESULT saveDataRequest(const DataRequest &request, bool async = false); /// Remove a previously-added `DataRequest`. This clears the subscription and any tracking/meta data from both server and client sides. /// Using this method is effectively the same as calling `dataRequest()` with a `DataRequest` of type `RequestType::None`. /// \param requestId ID of the request to remove. From 0a30646d0ae985580d67ed40c8a441a0f5a0ba17 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Mon, 23 Oct 2023 03:37:55 -0400 Subject: [PATCH 19/65] [CLI][WASimClient] Changes to correspond with C++ base version: - Unit type can now be specified for L vars; - Added new client method and `VariableRequest` c'tor overloads to facilitate specifying unit types for L vars; - The "create L var" option can now also be specified via `VariableRequest` struct; - Added `getOrCreateLocalVariable()` methods to allow specifying a default value and unit type for "get" requests; - Added `saveDataRequestAsync()` method which returns w/out waiting for server response. --- src/WASimClient_CLI/Structs.h | 11 ++++++++++- src/WASimClient_CLI/WASimClient_CLI.h | 22 +++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/WASimClient_CLI/Structs.h b/src/WASimClient_CLI/Structs.h index 2fce57b..b83aae0 100644 --- a/src/WASimClient_CLI/Structs.h +++ b/src/WASimClient_CLI/Structs.h @@ -504,6 +504,7 @@ namespace WASimCommander::CLI::Structs int variableId { -1 }; int unitId { -1 }; Byte simVarIndex { 0 }; + bool createLVar = false; VariableRequest() {} /// Construct a variable request for specified variable type ('A', 'L', etc) and variable name. @@ -527,6 +528,14 @@ namespace WASimCommander::CLI::Structs /// Construct a variable request a Local variable ('L') with the specified name. explicit VariableRequest(String ^localVariableName) : variableType{'L'}, variableName{localVariableName} { } + /// Construct a variable request for a Local ('L') variable with the specified name. + /// `createVariable` will create the L var on the simulator if it doesn't exist yet (for "Get" as well as "Set" commands). An optional unit name can also be provided. + explicit VariableRequest(String ^localVariableName, bool createVariable) : + variableType{'L'}, variableName{localVariableName}, createLVar{createVariable} { } + /// Construct a variable request for a Local ('L') variable with the specified name. + /// `createVariable` will create the L var on the simulator if it doesn't exist yet (for "Get" as well as "Set" commands). An unit name can also be provided with this overload. + explicit VariableRequest(String ^localVariableName, bool createVariable, String ^unitName) : + variableType{'L'}, variableName{localVariableName}, unitName{unitName}, createLVar{createVariable} { } /// Construct a variable request a Local variable ('L') with the specified numeric ID. explicit VariableRequest(int localVariableId) : variableType{'L'}, variableId{localVariableId} { } @@ -542,7 +551,7 @@ namespace WASimCommander::CLI::Structs inline operator WASimCommander::Client::VariableRequest() { marshal_context mc; - WASimCommander::Client::VariableRequest r((char)variableType, mc.marshal_as(variableName), mc.marshal_as(unitName), simVarIndex); + WASimCommander::Client::VariableRequest r((char)variableType, mc.marshal_as(variableName), mc.marshal_as(unitName), simVarIndex, createLVar); r.variableId = variableId; r.unitId = unitId; return r; diff --git a/src/WASimClient_CLI/WASimClient_CLI.h b/src/WASimClient_CLI/WASimClient_CLI.h index 2e71fb6..8ccde9e 100644 --- a/src/WASimClient_CLI/WASimClient_CLI.h +++ b/src/WASimClient_CLI/WASimClient_CLI.h @@ -171,17 +171,37 @@ namespace WASimCommander::CLI::Client } /// See \refwccc{getLocalVariable()} HR getLocalVariable(String ^variableName, [Out] double %pfResult) { return getVariable(gcnew VariableRequest(variableName), pfResult); } + /// See \refwccc{getLocalVariable()} + HR getLocalVariable(String ^variableName, String ^unitName, [Out] double %pfResult) { return getVariable(gcnew VariableRequest(variableName, false, unitName), pfResult); } + /// \sa \refwccc{getOrCreateLocalVariable()} + HR getOrCreateLocalVariable(String ^variableName, double defaultValue, [Out] double %pfResult) { + pin_ptr pf = &pfResult; + return (HR)m_client->getOrCreateLocalVariable(marshal_as(variableName), pf, defaultValue); + } + /// \sa \refwccc{getOrCreateLocalVariable()} + HR getOrCreateLocalVariable(String ^variableName, String ^unitName, double defaultValue, [Out] double %pfResult) { + pin_ptr pf = &pfResult; + return (HR)m_client->getOrCreateLocalVariable(marshal_as(variableName), pf, defaultValue, marshal_as(unitName)); + } /// See \refwccc{setVariable()} HR setVariable(VariableRequest ^var, const double value) { return (HR)m_client->setVariable(var, value); } /// See \refwccc{setLocalVariable()} HR setLocalVariable(String ^variableName, const double value) { return (HR)m_client->setLocalVariable(marshal_as(variableName), value); } + HR setLocalVariable(String ^variableName, String ^unitName, const double value) { + return (HR)m_client->setLocalVariable(marshal_as(variableName), value, marshal_as(unitName)); + } /// See \refwccc{setOrCreateLocalVariable()} HR setOrCreateLocalVariable(String ^variableName, const double value) { return (HR)m_client->setOrCreateLocalVariable(marshal_as(variableName), value); } + /// See \refwccc{setOrCreateLocalVariable()} + HR setOrCreateLocalVariable(String ^variableName, String ^unitName, const double value) { + return (HR)m_client->setOrCreateLocalVariable(marshal_as(variableName), value, marshal_as(unitName)); + } // Data subscriptions ------------------------------- - HR saveDataRequest(DataRequest ^request) { return (HR)m_client->saveDataRequest(request); } ///< See \refwccc{saveDataRequest()} + HR saveDataRequest(DataRequest ^request) { return (HR)m_client->saveDataRequest(request); } ///< See \refwccc{saveDataRequest()} as used with `async = false` + HR saveDataRequestAsync(DataRequest ^request) { return (HR)m_client->saveDataRequest(request, true); } ///< See \refwccc{saveDataRequest()} as used with `async = true` HR removeDataRequest(const uint32_t requestId) { return (HR)m_client->removeDataRequest(requestId); } ///< See \refwccc{removeDataRequest()} HR updateDataRequest(uint32_t requestId) { return (HR)m_client->updateDataRequest(requestId); } ///< See \refwccc{updateDataRequest()} From 862b455f6b48fefa8560c040a4a1966aecda09e4 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Mon, 23 Oct 2023 04:01:40 -0400 Subject: [PATCH 20/65] [WASimUI] Allow unit type specifier for Local vars; Added "Get or Create" action for L vars. --- src/WASimUI/WASimUI.cpp | 67 ++++++--- src/WASimUI/WASimUI.ui | 324 +++++++++++++++++++++------------------- src/WASimUI/Widgets.h | 4 +- 3 files changed, 217 insertions(+), 178 deletions(-) diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index 06949d5..a6c23cb 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -165,7 +165,7 @@ class WASimUIPrivate ui->leLookupResult->setText(tr("Lookup failed.")); } - void getLocalVar() + void getLocalVar(bool create = false) { if (!checkConnected()) return; @@ -176,14 +176,19 @@ class WASimUIPrivate return; } double result; - const HRESULT hr = client->getVariable(VariableRequest(vtype, varName.toStdString(), ui->cbSetVarUnitName->currentText().toStdString(), ui->sbGetSetSimVarIndex->value()), &result); + HRESULT hr; + if (vtype == 'L' && create) + hr = client->getOrCreateLocalVariable(varName.toStdString(), &result, ui->dsbSetVarValue->value(), ui->cbSetVarUnitName->currentText().toStdString()); + else + hr = client->getVariable(VariableRequest(vtype, varName.toStdString(), ui->cbSetVarUnitName->currentText().toStdString(), ui->sbGetSetSimVarIndex->value()), &result); if (hr == S_OK) ui->leVarResult->setText(QString("%1").arg(result, 0, 'f', 7)); else ui->leVarResult->setText(QString("Error: 0x%1").arg((quint32)hr, 8, 16, QChar('0'))); } - void setLocalVar(bool create = false) { + void setLocalVar(bool create = false) + { const char vtype = ui->cbGetSetVarType->currentData().toChar().toLatin1(); const QString &varName = vtype == 'L' ? ui->cbLvars->currentText() : ui->cbVariableName->currentText(); if (varName.isEmpty()) { @@ -191,11 +196,27 @@ class WASimUIPrivate return; } if (vtype == 'L' && create) - client->setOrCreateLocalVariable(varName.toStdString(), ui->dsbSetVarValue->value()); + client->setOrCreateLocalVariable(varName.toStdString(), ui->dsbSetVarValue->value(), ui->cbSetVarUnitName->currentText().toStdString()); else client->setVariable(VariableRequest(vtype, varName.toStdString(), ui->cbSetVarUnitName->currentText().toStdString(), ui->sbGetSetSimVarIndex->value()), ui->dsbSetVarValue->value()); } + void toggleSetGetVariableType() + { + const QChar vtype = ui->cbGetSetVarType->currentData().toChar(); + bool isLocal = vtype == 'L'; + ui->wLocalVarsForm->setVisible(isLocal); + ui->wOtherVarsForm->setVisible(!isLocal); + ui->wGetSetSimVarIndex->setVisible(!isLocal && vtype == 'A'); // sim var index box visible only for... simvars! + ui->btnSetCreate->setVisible(isLocal); + ui->btnGetCreate->setVisible(isLocal); + bool hasUnit = ui->cbGetSetVarType->currentText().contains('*'); + ui->cbSetVarUnitName->setVisible(hasUnit); + ui->lblSetVarUnit->setVisible(hasUnit); + if (isLocal) + ui->cbSetVarUnitName->setCurrentText(""); + } + void copyLocalVarToRequest() { setRequestFormId(-1); @@ -256,13 +277,16 @@ class WASimUIPrivate void toggleRequestVariableType() { + const QChar type = ui->cbVariableType->currentData().toChar(); bool isCalc = ui->rbRequestType_Calculated->isChecked(); - bool needIdx = !isCalc && ui->cbVariableType->currentData().toChar() == 'A'; + bool needIdx = !isCalc && type == 'A'; ui->lblIndex->setVisible(needIdx); ui->sbSimVarIndex->setVisible(needIdx); bool hasUnit = !isCalc && ui->cbVariableType->currentText().contains('*'); ui->lblUnit->setVisible(hasUnit); ui->cbUnitName->setVisible(hasUnit); + if (type == 'L') + ui->cbUnitName->setCurrentText(""); } void setRequestFormId(uint32_t id) @@ -716,8 +740,10 @@ WASimUI::WASimUI(QWidget *parent) : ui.wRequestForm->setProperty("requestId", -1); ui.wCalcForm->setProperty("eventId", -1); - // Update the Data Request form UI based on default type. + // Update the Data Request form UI based on default types. d->toggleRequestType(); + d->toggleRequestVariableType(); + // Connect the request type radio buttons to toggle the UI. connect(ui.bgrpRequestType, QOverload::of(&QButtonGroup::buttonToggled), this, [this](int,bool) { d->toggleRequestType(); }); // show/hide SimVar index spin box based on type of variable selected @@ -847,6 +873,8 @@ WASimUI::WASimUI(QWidget *parent) : // Variables section actions + d->toggleSetGetVariableType(); + // Request Local Vars list QAction *reloadLVarsAct = new QAction(QIcon(QStringLiteral("autorenew.glyph")), tr("Reload L.Vars"), this); reloadLVarsAct->setToolTip(tr("Reload Local Variables")); @@ -874,12 +902,19 @@ WASimUI::WASimUI(QWidget *parent) : ui.btnSetVar->setDefaultAction(setVarAct); // Set or Create local variable - QAction *setCreateVarAct = new QAction(QIcon(QStringLiteral("overlay=\\align=AlignRight\\scale=.95\\fg=#17dde8\\add/send.glyph")), tr("Set/Create Variable"), this); + QAction *setCreateVarAct = new QAction(QIcon(QStringLiteral("overlay=\\align=AlignRight\\fg=#17dd29\\add/send.glyph")), tr("Set/Create Variable"), this); setCreateVarAct->setToolTip(tr("Set Or Create Local Variable.")); setCreateVarAct->setDisabled(true); connect(setCreateVarAct, &QAction::triggered, this, [this]() { d->setLocalVar(true); }); ui.btnSetCreate->setDefaultAction(setCreateVarAct); + // Get or Create local variable + QAction *getCreateVarAct = new QAction(QIcon(QStringLiteral("overlay=\\align=AlignLeft\\fg=#17dd29\\add/rotate=180/send.glyph")), tr("Get/Create Variable"), this); + getCreateVarAct->setToolTip(tr("Get Or Create Local Variable. The specified value and unit will be used as defaults if the variable is created.")); + getCreateVarAct->setDisabled(true); + connect(getCreateVarAct, &QAction::triggered, this, [this]() { d->getLocalVar(true); }); + ui.btnGetCreate->setDefaultAction(getCreateVarAct); + // Copy LVar as new Data Request QAction *copyVarAct = new QAction(QIcon(QStringLiteral("move_to_inbox.glyph")), tr("Copy to Data Request"), this); copyVarAct->setToolTip(tr("Copy Variable to new Data Request")); @@ -888,12 +923,13 @@ WASimUI::WASimUI(QWidget *parent) : ui.btnCopyLVarToRequest->setDefaultAction(copyVarAct); auto updateLocalVarsFormState = [=](const QString &) { - const bool en = (ui.wLocalVarsForm->isVisible() && !ui.cbLvars->currentText().isEmpty()) || - (ui.wOtherVarsForm->isVisible() && !ui.cbVariableName->currentText().isEmpty()); + const bool isLocal = ui.wLocalVarsForm->isVisible(); + const bool en = !(isLocal ? ui.cbLvars->currentText().isEmpty() : ui.cbVariableName->currentText().isEmpty()); getVarAct->setEnabled(en); setVarAct->setEnabled(en); - setCreateVarAct->setEnabled(en); copyVarAct->setEnabled(en); + setCreateVarAct->setEnabled(en && isLocal); + getCreateVarAct->setEnabled(en && isLocal); }; // Connect variable selector to enable/disable relevant actions @@ -901,15 +937,8 @@ WASimUI::WASimUI(QWidget *parent) : connect(ui.cbVariableName, &QComboBox::currentTextChanged, this, updateLocalVarsFormState); // connect to variable type combo box to switch between views for local vars vs. everything else - connect(ui.cbGetSetVarType, &DataComboBox::currentDataChanged, this, [=](const QVariant &vtype) { - bool isLocal = vtype.toChar() == 'L'; - ui.wLocalVarsForm->setVisible(isLocal); - ui.wOtherVarsForm->setVisible(!isLocal); - ui.wGetSetSimVarIndex->setVisible(!isLocal && vtype.toChar() == 'A'); // sim var index box visible only for... simvars! - ui.btnSetCreate->setVisible(isLocal); - bool hasUnit = ui.cbGetSetVarType->currentText().contains('*'); - ui.cbSetVarUnitName->setVisible(hasUnit); - ui.lblSetVarUnit->setVisible(hasUnit); + connect(ui.cbGetSetVarType, &DataComboBox::currentDataChanged, this, [=](const QVariant &) { + d->toggleSetGetVariableType(); updateLocalVarsFormState(QString()); }); diff --git a/src/WASimUI/WASimUI.ui b/src/WASimUI/WASimUI.ui index 04f5d5a..97f8077 100644 --- a/src/WASimUI/WASimUI.ui +++ b/src/WASimUI/WASimUI.ui @@ -858,7 +858,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c Variables - + 4 @@ -874,6 +874,143 @@ Submitted requests will appear in the "Data Requests" window. Double-c 8 + + + + + 0 + 0 + + + + + 75 + true + + + + Get + + + + + + + + 0 + 0 + + + + + + + + Set + + + Qt::ToolButtonIconOnly + + + false + + + + + + + 6 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + + 0 + 0 + + + + Qt::ClickFocus + + + Result of a Variable Get request will appear here. + + + false + + + false + + + Variable Get result... + + + true + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 24 + 20 + + + + + + + + + + Get + + + Qt::ToolButtonIconOnly + + + + + + + + 0 + 0 + + + + + 75 + true + + + + Set + + + @@ -1046,65 +1183,6 @@ Submitted requests will appear in the "Data Requests" window. Double-c - - - - Get - - - Qt::ToolButtonIconOnly - - - - - - - Set - - - Qt::ToolButtonIconOnly - - - false - - - - - - - Set/Create - - - Qt::ToolButtonIconOnly - - - false - - - - - - - - 0 - 0 - - - - Unit: - - - - - - - Copy - - - Qt::ToolButtonIconOnly - - - @@ -1196,132 +1274,64 @@ Submitted requests will appear in the "Data Requests" window. Double-c - - + + 0 0 - - - 75 - true - - - Set + Value: - - + + 0 0 - - - 75 - true - - - Get + Unit: - - - - - 0 - 0 - - + + - Value: + Set/Create - - - - - - - 0 - 0 - + + Qt::ToolButtonIconOnly + + + false - - - - 6 - - - 0 + + + + Copy - - 0 + + Qt::ToolButtonIconOnly - - 0 + + + + + + Get/Create - - 0 + + Qt::ToolButtonIconOnly - - - - true - - - - 0 - 0 - - - - Qt::ClickFocus - - - Result of a Variable Get request will appear here. - - - false - - - false - - - Variable Get result... - - - true - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 24 - 20 - - - - - + diff --git a/src/WASimUI/Widgets.h b/src/WASimUI/Widgets.h index c9f8b14..8f9f81d 100644 --- a/src/WASimUI/Widgets.h +++ b/src/WASimUI/Widgets.h @@ -264,7 +264,7 @@ class VariableTypeComboBox : public DataComboBox public: VariableTypeComboBox(QWidget *p = nullptr) : DataComboBox(p) { - setToolTip(tr("Named variable type. Types marked with a * use Unit specifiers.")); + setToolTip(tr("Named variable type. Types marked with a * use Unit specifiers. Most 'L' vars will ignore the Unit (default is 'number').")); addItem(tr("A: SimVar *"), 'A'); //addItem(tr("B: Input"), 'B'); // only for gauge modules @@ -273,7 +273,7 @@ class VariableTypeComboBox : public DataComboBox addItem(tr("H: HTML"), 'H'); //addItem(tr("I: Instr."), 'I'); // only for gauge modules addItem(tr("K: Key"), 'K'); - addItem(tr("L: Local"), 'L'); + addItem(tr("L: Local *"), 'L'); addItem(tr("M: Mouse"), 'M'); //addItem(tr("O: Comp."), 'O'); // only for gauge modules //addItem(tr("R: Resource"), 'R'); // strings only, can't be read with "Get" command or set at all From d8f11144c50cd22fd6c3eb3235ed2d740b0f533b Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Mon, 23 Oct 2023 04:10:53 -0400 Subject: [PATCH 21/65] [WASimUI] Add button to clear data requests form; Disable the request form "Save" button after deleting a request that is currently in the editor; Change "Console" log destination name to "Log Window" and adjust related tooltips; Cosmetics: Move logging and lookup actions to own sections. --- src/WASimUI/WASimUI.cpp | 69 +++++++++++++++++++++++++++-------------- src/WASimUI/WASimUI.ui | 32 ++++++++++++++----- 2 files changed, 69 insertions(+), 32 deletions(-) diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index a6c23cb..a189c14 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -371,6 +371,18 @@ class WASimUIPrivate ui->dsbDeltaEpsilon->setValue(req.deltaEpsilon); } + void clearRequestForm() + { + setRequestFormId(-1); + ui->cbNameOrCode->setCurrentText(""); + ui->cbUnitName->setCurrentText(""); + ui->cbValueSize->setCurrentText(""); + ui->sbSimVarIndex->setValue(0); + ui->cbPeriod->setCurrentData(+UpdatePeriod::Tick); + ui->sbInterval->setValue(0); + ui->dsbDeltaEpsilon->setValue(0.0); + } + void removeRequests(const QModelIndexList &list) { for (const QModelIndex &idx : list) { @@ -768,25 +780,14 @@ WASimUI::WASimUI(QWidget *parent) : // connect the Data Request save/add buttons connect(ui.pbAddRequest, &QPushButton::clicked, this, [this]() { d->handleRequestForm(false); }); connect(ui.pbUpdateRequest, &QPushButton::clicked, this, [this]() { d->handleRequestForm(true); }); + connect(ui.pbClearRequest, &QPushButton::clicked, this, [this]() { d->clearRequestForm(); }); - // Set and connect Log Level combo boxes for Client and Server logging levels - ui.cbLogLevelCallback->setProperties(d->client->logLevel( LogFacility::Remote, LogSource::Client), LogFacility::Remote, LogSource::Client); - ui.cbLogLevelFile->setProperties(d->client->logLevel( LogFacility::File, LogSource::Client), LogFacility::File, LogSource::Client); - ui.cbLogLevelConsole->setProperties(d->client->logLevel( LogFacility::Console, LogSource::Client), LogFacility::Console, LogSource::Client); - ui.cbLogLevelServer->setProperties(d->client->logLevel( LogFacility::Remote, LogSource::Server), LogFacility::Remote, LogSource::Server); - ui.cbLogLevelServerFile->setProperties( (LogLevel)-1, LogFacility::File, LogSource::Server); // unknown level at startup - ui.cbLogLevelServerConsole->setProperties( (LogLevel)-1, LogFacility::Console, LogSource::Server); // unknown level at startup - // Since the LogLevelComboBox types store the facility and source properties (which we just set), we can use one event handler for all of them. - auto setLogLevel = [=](LogLevel level) { - if (LogLevelComboBox *cb = qobject_cast(sender())) - d->client->setLogLevel(level, cb->facility(), cb->source()); - }; - connect(ui.cbLogLevelCallback, &LogLevelComboBox::levelChanged, this, setLogLevel); - connect(ui.cbLogLevelFile, &LogLevelComboBox::levelChanged, this, setLogLevel); - connect(ui.cbLogLevelConsole, &LogLevelComboBox::levelChanged, this, setLogLevel); - connect(ui.cbLogLevelServer, &LogLevelComboBox::levelChanged, this, setLogLevel); - connect(ui.cbLogLevelServerFile, &LogLevelComboBox::levelChanged, this, setLogLevel); - connect(ui.cbLogLevelServerConsole, &LogLevelComboBox::levelChanged, this, setLogLevel); + // connect to requests model row removed to check if the current editor needs to be reset, otherwise the "Save" button stays active and re-adds a deleted request. + connect(d->reqModel, &RequestsModel::rowsRemoved, this, [this](const QModelIndex &, int first, int last) { + const int current = ui.wRequestForm->property("requestId").toInt(); + if (current >= first && current <= last) + d->setRequestFormId(-1); + }); // Connect our own signals for client callback handling in a thread-safe manner, marshaling back to GUI thread as needed. // The "signal" methods are "emitted" by the Client as callbacks, registered in Private::setupClient(). @@ -881,12 +882,6 @@ WASimUI::WASimUI(QWidget *parent) : connect(reloadLVarsAct, &QAction::triggered, this, [this]() { d->refreshLVars(); }); ui.btnList->setDefaultAction(reloadLVarsAct); - // Lookup a variable/unit ID - QAction *lookupItemAct = new QAction(QIcon(QStringLiteral("search.glyph")), tr("Lookup"), this); - lookupItemAct->setToolTip(tr("Query server for ID of named item (Lookup command).")); - connect(lookupItemAct, &QAction::triggered, this, [this]() { d->lookupItem(); }); - ui.btnVarLookup->setDefaultAction(lookupItemAct); - // Get local variable value QAction *getVarAct = new QAction(QIcon(QStringLiteral("rotate=180/send.glyph")), tr("Get Variable"), this); getVarAct->setToolTip(tr("Get Variable Value.")); @@ -942,6 +937,13 @@ WASimUI::WASimUI(QWidget *parent) : updateLocalVarsFormState(QString()); }); + // Other forms + + // Lookup action + QAction *lookupItemAct = new QAction(QIcon(QStringLiteral("search.glyph")), tr("Lookup"), this); + lookupItemAct->setToolTip(tr("Query server for ID of named item (Lookup command).")); + connect(lookupItemAct, &QAction::triggered, this, [this]() { d->lookupItem(); }); + ui.btnVarLookup->setDefaultAction(lookupItemAct); // Send Key Event action QAction *sendKeyEventAct = new QAction(QIcon(QStringLiteral("send.glyph")), tr("Send Key Event"), this); @@ -1090,6 +1092,25 @@ WASimUI::WASimUI(QWidget *parent) : // Logging window actions + // Set and connect Log Level combo boxes for Client and Server logging levels + ui.cbLogLevelCallback->setProperties(d->client->logLevel( LogFacility::Remote, LogSource::Client), LogFacility::Remote, LogSource::Client); + ui.cbLogLevelFile->setProperties(d->client->logLevel( LogFacility::File, LogSource::Client), LogFacility::File, LogSource::Client); + ui.cbLogLevelConsole->setProperties(d->client->logLevel( LogFacility::Console, LogSource::Client), LogFacility::Console, LogSource::Client); + ui.cbLogLevelServer->setProperties(d->client->logLevel( LogFacility::Remote, LogSource::Server), LogFacility::Remote, LogSource::Server); + ui.cbLogLevelServerFile->setProperties( (LogLevel)-1, LogFacility::File, LogSource::Server); // unknown level at startup + ui.cbLogLevelServerConsole->setProperties( (LogLevel)-1, LogFacility::Console, LogSource::Server); // unknown level at startup + // Since the LogLevelComboBox types store the facility and source properties (which we just set), we can use one event handler for all of them. + auto setLogLevel = [=](LogLevel level) { + if (LogLevelComboBox *cb = qobject_cast(sender())) + d->client->setLogLevel(level, cb->facility(), cb->source()); + }; + connect(ui.cbLogLevelCallback, &LogLevelComboBox::levelChanged, this, setLogLevel); + connect(ui.cbLogLevelFile, &LogLevelComboBox::levelChanged, this, setLogLevel); + connect(ui.cbLogLevelConsole, &LogLevelComboBox::levelChanged, this, setLogLevel); + connect(ui.cbLogLevelServer, &LogLevelComboBox::levelChanged, this, setLogLevel); + connect(ui.cbLogLevelServerFile, &LogLevelComboBox::levelChanged, this, setLogLevel); + connect(ui.cbLogLevelServerConsole, &LogLevelComboBox::levelChanged, this, setLogLevel); + QAction *filterErrorsAct = new QAction(QIcon(Utils::iconNameForLogLevel(LogLevel::Error)), tr("Toggle Errors"), this); filterErrorsAct->setToolTip(tr("Toggle visibility of Error-level log messages.")); filterErrorsAct->setCheckable(true); diff --git a/src/WASimUI/WASimUI.ui b/src/WASimUI/WASimUI.ui index 97f8077..6412cec 100644 --- a/src/WASimUI/WASimUI.ui +++ b/src/WASimUI/WASimUI.ui @@ -80,7 +80,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c 6 - + @@ -178,6 +178,22 @@ Submitted requests will appear in the "Data Requests" window. Double-c + + + + + 0 + 0 + + + + Add new request record from current form entries. + + + Clear + + + @@ -1710,7 +1726,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c 8 - + 0 @@ -1916,7 +1932,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c 8 - + 0 @@ -2029,7 +2045,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - Callback: + Log Window: @@ -2234,7 +2250,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - Set Server Callback Log Level + Set Server Callback Log Level (output directed to this log window). @@ -2286,7 +2302,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - Set Client Callback Log Level + Set Client Callback Log Level (output directed to this log window) @@ -2348,7 +2364,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - Callback: + Log Window: @@ -2399,7 +2415,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c 2 - + 0 From 000a01c9dbbfcc8361cdc33bda963039e89b58fa Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Mon, 23 Oct 2023 04:49:53 -0400 Subject: [PATCH 22/65] [WASimUI] Set context menu policy to widget's actions for all main form/table widgets. --- src/WASimUI/WASimUI.ui | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/WASimUI/WASimUI.ui b/src/WASimUI/WASimUI.ui index 6412cec..16831fd 100644 --- a/src/WASimUI/WASimUI.ui +++ b/src/WASimUI/WASimUI.ui @@ -48,6 +48,9 @@ + + Qt::ActionsContextMenu + <p>Request Submission Form</p> <p>Use this form to submit new or modified data requests to the WASimModule server. @@ -547,6 +550,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c + + Qt::ActionsContextMenu + <p>Form for triggering Key events with up to 5 data values. Events can be specified by name or ID.</p @@ -730,6 +736,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c + + Qt::ActionsContextMenu + <p>This form allows sending any arbitrary command to the WASimModule. This is for testing low-level API functions, not generally useful in most cases. Refer to API documentation for details.</p> @@ -867,6 +876,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c + + Qt::ActionsContextMenu + <p>This form is for working with all types of variables. It can be used to get current values and also set values (on variables which allow that). The form adapts to the type of variable selected.</p> <p>Variable definitions from this form can be copied to the Data Requests form and converted to a recurring data query.</p> @@ -1360,6 +1372,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c 0 + + Qt::ActionsContextMenu + <p>This form is for evaluating RPN "calculator code" on the simulator via WASimModule. Evaluated code may or may not return a result of various types.</p> <p>Calculator code which returns a result can be copied to Data Requests and turned into a recurring query. Code which performs some kind of action can be turned into a Registered Event which can later be triggered by name or ID. In either of these cases, the code will be pre-compiled into a more efficient format on the server so that subsequent evaluation will be more efficient.</p> @@ -1581,6 +1596,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c + + Qt::ActionsContextMenu + <p>This form allows performing various meta data retrival functions, for example to get numeric IDs of named variables/events, or to check for their existence.</p> @@ -1733,6 +1751,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c 1 + + Qt::ActionsContextMenu + 5 @@ -1939,6 +1960,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c 3 + + Qt::ActionsContextMenu + 0 From 3562b868e8f4144232804f2f40ba08273a4fa762 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Mon, 23 Oct 2023 07:43:36 -0400 Subject: [PATCH 23/65] [WASimUI] Break logging console out into own class widget; No functional changes. --- src/WASimUI/LogConsole.cpp | 158 ++++++++++++ src/WASimUI/LogConsole.h | 34 +++ src/WASimUI/LogConsole.ui | 497 +++++++++++++++++++++++++++++++++++++ src/WASimUI/WASimUI.cpp | 152 +----------- src/WASimUI/WASimUI.h | 1 - src/WASimUI/WASimUI.ui | 463 +--------------------------------- 6 files changed, 712 insertions(+), 593 deletions(-) create mode 100644 src/WASimUI/LogConsole.cpp create mode 100644 src/WASimUI/LogConsole.h create mode 100644 src/WASimUI/LogConsole.ui diff --git a/src/WASimUI/LogConsole.cpp b/src/WASimUI/LogConsole.cpp new file mode 100644 index 0000000..5833f91 --- /dev/null +++ b/src/WASimUI/LogConsole.cpp @@ -0,0 +1,158 @@ +#include "LogConsole.h" + +#include "Utils.h" +#include "Widgets.h" + +#include "client/WASimClient.h" + +using namespace WASimUiNS; +using namespace WASimCommander; +using namespace WASimCommander::Client; +using namespace WASimCommander::Enums; + +LogConsole::LogConsole(QWidget *parent) + : QWidget(parent), + logModel{new LogRecordsModel(this)} +{ + setObjectName(QStringLiteral("LogConsole")); + + ui.setupUi(this); + + ui.logView->setModel(logModel); + ui.logView->sortByColumn(LogRecordsModel::COL_TS, Qt::AscendingOrder); + //ui.logView->horizontalHeader()->setSortIndicator(LogRecordsModel::COL_TS, Qt::AscendingOrder); + ui.logView->horizontalHeader()->setSortIndicatorShown(false); + ui.logView->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); + ui.logView->horizontalHeader()->setSectionsMovable(true); + ui.logView->horizontalHeader()->resizeSection(LogRecordsModel::COL_LEVEL, 70); + ui.logView->horizontalHeader()->resizeSection(LogRecordsModel::COL_TS, 110); + ui.logView->horizontalHeader()->resizeSection(LogRecordsModel::COL_SOURCE, 20); + ui.logView->horizontalHeader()->setToolTip(tr("Severity Level | Timestamp | Source | Message")); + + // Set and connect Log Level combo boxes for Client and Server logging levels + ui.cbLogLevelCallback->setProperties( (LogLevel)-1, LogFacility::Remote, LogSource::Client); + ui.cbLogLevelFile->setProperties( (LogLevel)-1, LogFacility::File, LogSource::Client); + ui.cbLogLevelConsole->setProperties( (LogLevel)-1, LogFacility::Console, LogSource::Client); + ui.cbLogLevelServer->setProperties( (LogLevel)-1, LogFacility::Remote, LogSource::Server); + ui.cbLogLevelServerFile->setProperties( (LogLevel)-1, LogFacility::File, LogSource::Server); // unknown level at startup + ui.cbLogLevelServerConsole->setProperties( (LogLevel)-1, LogFacility::Console, LogSource::Server); // unknown level at startup + // Since the LogLevelComboBox types store the facility and source properties (which we just set), we can use one event handler for all of them. + auto setLogLevel = [=](LogLevel level) { + if (LogLevelComboBox *cb = qobject_cast(sender())) + if (!!wsClient) + wsClient->setLogLevel(level, cb->facility(), cb->source()); + }; + connect(ui.cbLogLevelCallback, &LogLevelComboBox::levelChanged, this, setLogLevel); + connect(ui.cbLogLevelFile, &LogLevelComboBox::levelChanged, this, setLogLevel); + connect(ui.cbLogLevelConsole, &LogLevelComboBox::levelChanged, this, setLogLevel); + connect(ui.cbLogLevelServer, &LogLevelComboBox::levelChanged, this, setLogLevel); + connect(ui.cbLogLevelServerFile, &LogLevelComboBox::levelChanged, this, setLogLevel); + connect(ui.cbLogLevelServerConsole, &LogLevelComboBox::levelChanged, this, setLogLevel); + +#define FILTER_ACTION(LVL, NAME, BTN) { \ + QAction *act = new QAction(QIcon(Utils::iconNameForLogLevel(LogLevel::LVL)), tr("Toggle %1 Messages").arg(NAME), this); \ + act->setToolTip(tr("Toggle visibility of %1-level log messages.").arg(NAME)); \ + act->setCheckable(true); \ + act->setChecked(true); \ + ui.btnLogFilt_##BTN->setDefaultAction(act); \ + addAction(act); \ + connect(act, &QAction::triggered, this, [this](bool en) { logModel->setLevelFilter(LogLevel::LVL, !en); }); \ + } + FILTER_ACTION(Error, tr("Error"), ERR); + FILTER_ACTION(Warning, tr("Warning"), WRN); + FILTER_ACTION(Info, tr("Info"), INF); + FILTER_ACTION(Debug, tr("Debug"), DBG); + FILTER_ACTION(Trace, tr("Trace"), TRC); +#undef FILTER_ACTION + +#define FILTER_ACTION(SRC, NAME, BTN) { \ + QAction *act = new QAction(QIcon(Utils::iconNameForLogSource(+##SRC)), tr("Toggle %1 Records").arg(NAME), this); \ + act->setToolTip(tr("Toggle visibility of log messages from %1.").arg(NAME)); \ + act->setCheckable(true); \ + act->setChecked(true); \ + ui.btnLogFilt_##BTN->setDefaultAction(act); \ + addAction(act); \ + connect(act, &QAction::triggered, this, [this](bool en) { logModel->setSourceFilter(+##SRC, !en); }); \ + } + FILTER_ACTION(LogSource::Server, tr("Server"), Server); + FILTER_ACTION(LogSource::Client, tr("Client"), Client); + FILTER_ACTION(LogRecordsModel::LogSource::UI, tr("UI"), UI); +#undef FILTER_ACTION + + QIcon logPauseIcon(QStringLiteral("pause.glyph")); + logPauseIcon.addFile(QStringLiteral("play_arrow.glyph"), QSize(), QIcon::Normal, QIcon::On); + QAction *pauseLogScrollAct = new QAction(logPauseIcon, tr("Pause Log Scroll"), this); + pauseLogScrollAct->setToolTip(tr("

Toggle scrolling of the log window. Scrolling can also be paused by selecting a log entry row.

")); + pauseLogScrollAct->setCheckable(true); + ui.btnLogPause->setDefaultAction(pauseLogScrollAct); + addAction(pauseLogScrollAct); + connect(pauseLogScrollAct, &QAction::triggered, this, [this](bool en) { if (!en) ui.logView->selectionModel()->clear(); }); // clear log view selection on "un-pause" + + QAction *clearLogWindowAct = new QAction(QIcon(QStringLiteral("delete.glyph")), tr("Clear Log Window"), this); + clearLogWindowAct->setToolTip(tr("Clear the log window.")); + ui.btnLogClear->setDefaultAction(clearLogWindowAct); + addAction(clearLogWindowAct); + connect(clearLogWindowAct, &QAction::triggered, this, [this]() { logModel->clear(); }); + + QIcon wordWrapIcon(QStringLiteral("wrap_text.glyph")); + wordWrapIcon.addFile(QStringLiteral("notes.glyph"), QSize(), QIcon::Normal, QIcon::On); + QAction *wordWrapLogWindowAct = new QAction(wordWrapIcon, tr("Log Word Wrap"), this); + wordWrapLogWindowAct->setToolTip(tr("Toggle word wrapping of the log window.")); + wordWrapLogWindowAct->setCheckable(true); + wordWrapLogWindowAct->setChecked(true); + ui.btnLogWordWrap->setDefaultAction(wordWrapLogWindowAct); + addAction(wordWrapLogWindowAct); + connect(wordWrapLogWindowAct, &QAction::toggled, this, [this](bool chk) { ui.logView->setWordWrap(chk); ui.logView->resizeRowsToContents(); }); + + // connect the log model record added signal to make sure last record remains in view, unless scroll lock is enabled + connect(logModel, &LogRecordsModel::recordAdded, this, [=](const QModelIndex &i) { + // make sure log view scroll to bottom on insertions, unless a row is selected or scroll pause is set. + ui.logView->resizeRowToContents(i.row()); + if (!pauseLogScrollAct->isChecked() && !ui.logView->selectionModel()->hasSelection()) + ui.logView->scrollToBottom(/*i, QAbstractItemView::PositionAtBottom*/); + }); + // connect log viewer selection model to show pause button active while there is a selection + connect(ui.logView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=](const QItemSelection &sel, const QItemSelection&) { + pauseLogScrollAct->setChecked(!sel.isEmpty()); + }); + +} + +LogConsole::~LogConsole() +{} + +void LogConsole::setClient(WASimCommander::Client::WASimClient *c) { + wsClient = c; + // Log messages can go right to the log records model. Log messages may arrive at any time, possibly from different threads, + // so placing them into a model allow proper sorting (and filtering). + connect(this, &LogConsole::logMessageReady, logModel, &LogRecordsModel::addRecord, Qt::QueuedConnection); + c->setLogCallback([=](const LogRecord &l, LogSource s) { emit logMessageReady(l, +s); }); + + ui.cbLogLevelCallback->setLevel(wsClient->logLevel(LogFacility::Remote, LogSource::Client)); + ui.cbLogLevelFile->setLevel(wsClient->logLevel( LogFacility::File, LogSource::Client)); + ui.cbLogLevelConsole->setLevel(wsClient->logLevel( LogFacility::Console, LogSource::Client)); + ui.cbLogLevelServer->setLevel(wsClient->logLevel( LogFacility::Remote, LogSource::Server)); +} + +LogRecordsModel *LogConsole::getModel() const { return logModel; } + +void LogConsole::saveSettings(QSettings &set) const +{ + set.beginGroup(objectName()); + set.setValue(QStringLiteral("logViewHeaderState"), ui.logView->horizontalHeader()->saveState()); + set.endGroup(); +} + +void LogConsole::loadSettings(QSettings &set) +{ + set.beginGroup(objectName()); + if (set.contains(QStringLiteral("logViewHeaderState"))) + ui.logView->horizontalHeader()->restoreState(set.value(QStringLiteral("logViewHeaderState")).toByteArray()); + set.endGroup(); +} + +void LogConsole::logMessage(int level, const QString & msg) const +{ + LogRecord l((LogLevel)level, qPrintable(msg)); + emit logMessageReady(l, +LogRecordsModel::LogSource::UI); +} diff --git a/src/WASimUI/LogConsole.h b/src/WASimUI/LogConsole.h new file mode 100644 index 0000000..8883a0a --- /dev/null +++ b/src/WASimUI/LogConsole.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include "ui_LogConsole.h" +#include "LogRecordsModel.h" +#include "WASimCommander.h" + +class WASimCommander::Client::WASimClient; + +class LogConsole : public QWidget +{ + Q_OBJECT + +public: + LogConsole(QWidget *parent = nullptr); + ~LogConsole(); + + void setClient(WASimCommander::Client::WASimClient *c); + WASimUiNS::LogRecordsModel *getModel() const; + +public Q_SLOTS: + void saveSettings(QSettings &set) const; + void loadSettings(QSettings &set); + void logMessage(int level, const QString &msg) const; + +signals: + void logMessageReady(const WASimCommander::LogRecord &r, quint8 src) const; + +private: + Ui::LogConsole ui; + WASimUiNS::LogRecordsModel *logModel = nullptr; + WASimCommander::Client::WASimClient *wsClient = nullptr; +}; diff --git a/src/WASimUI/LogConsole.ui b/src/WASimUI/LogConsole.ui new file mode 100644 index 0000000..4fe52f9 --- /dev/null +++ b/src/WASimUI/LogConsole.ui @@ -0,0 +1,497 @@ + + + Copyright Maxim Paperno; all rights reserved. Licensed under GPL v3 (or later) + LogConsole + + + + 0 + 0 + 1000 + 395 + + + + + 0 + 3 + + + + Qt::ActionsContextMenu + + + Log Output + + + + 0 + + + 5 + + + 0 + + + 5 + + + 6 + + + + + + Courier New + 9 + + + + Log Records + + + QAbstractScrollArea::AdjustToContents + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + + 16 + 16 + + + + Qt::ElideMiddle + + + QAbstractItemView::ScrollPerItem + + + QAbstractItemView::ScrollPerPixel + + + Qt::DotLine + + + false + + + false + + + false + + + 20 + + + 60 + + + false + + + false + + + true + + + false + + + + + + + 5 + + + 6 + + + 2 + + + + + + 0 + 0 + + + + Log Window: + + + + + + + Pause + + + Qt::ToolButtonIconOnly + + + + + + + Server + + + Qt::ToolButtonIconOnly + + + false + + + + + + + UI + + + Qt::ToolButtonIconOnly + + + false + + + + + + + + 0 + 0 + + + + Console: + + + + + + + + 0 + 0 + + + + <p>Set Server File Log Level (note that until/unless set via this control, the initial level is unknown).</p> + + + + + + + Debug + + + Qt::ToolButtonIconOnly + + + false + + + + + + + + 0 + 0 + + + + File: + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 10 + 20 + + + + + + + + Info + + + Qt::ToolButtonIconOnly + + + false + + + + + + + Error + + + Qt::ToolButtonIconOnly + + + false + + + + + + + + 0 + 0 + + + + <p>Set Server Console Log Level (note that until/unless set via this control, the initial level is unknown).</p> + + + + + + + Warning + + + Qt::ToolButtonIconOnly + + + false + + + + + + + Client + + + Qt::ToolButtonIconOnly + + + false + + + + + + + Clear + + + Qt::ToolButtonIconOnly + + + + + + + + 0 + 0 + + + + Client + + + + + + + + 0 + 0 + + + + Set Server Callback Log Level (output directed to this log window). + + + + + + + + 0 + 0 + + + + File: + + + + + + + + 0 + 0 + + + + Set Client File Log Level + + + + + + + Qt::Horizontal + + + + 4 + 20 + + + + + + + + + 0 + 0 + + + + Set Client Callback Log Level (output directed to this log window) + + + + + + + + 0 + 0 + + + + Set Client Console Log Level + + + + + + + + 0 + 0 + + + + Server: + + + + + + + WW + + + Qt::ToolButtonIconOnly + + + + + + + + 0 + 0 + + + + Console: + + + + + + + + 0 + 0 + + + + Log Window: + + + + + + + Trace + + + Qt::ToolButtonIconOnly + + + false + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 10 + 20 + + + + + + + + + + + WASimUiNS::LogLevelComboBox + QComboBox +
Widgets.h
+
+
+ + +
diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index a189c14..90485b4 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -24,21 +24,22 @@ and is also available at . #include #include #include +#include #include #include #include #include #include -#include #include #include #include #include #include "WASimUI.h" -#include "RequestsModel.h" + #include "EventsModel.h" -#include "LogRecordsModel.h" +#include "LogConsole.h" +#include "RequestsModel.h" #include "Utils.h" #include "Widgets.h" @@ -69,7 +70,6 @@ class WASimUIPrivate StatusWidget *statWidget; RequestsModel *reqModel; EventsModel *eventsModel; - LogRecordsModel *logModel; QAction *initAct = nullptr; QAction *connectAct = nullptr; ClientStatus clientStatus = ClientStatus::Idle; @@ -81,7 +81,6 @@ class WASimUIPrivate WASimUIPrivate(WASimUI *q) : q(q), ui(&q->ui), reqModel(new RequestsModel(q)), eventsModel(new EventsModel(q)), - logModel(new LogRecordsModel(q)), statWidget(new StatusWidget(q)), client(new WASimClient(0xDEADBEEF)) { @@ -98,7 +97,6 @@ class WASimUIPrivate client->setCommandResultCallback(&WASimUI::commandResultReady, q); client->setDataCallback(&WASimUI::dataResultReady, q); client->setListResultsCallback(&WASimUI::listResults, q); - client->setLogCallback([=](const LogRecord &l, LogSource s) { emit q->logMessageReady(l, +s); }); } bool checkConnected() @@ -562,8 +560,7 @@ class WASimUIPrivate void logUiMessage(const QString &msg, CommandId cmd = CommandId::None, LogLevel level = LogLevel::Error) { - LogRecord l(level, qPrintable(msg)); - emit q->logMessageReady(l, +LogRecordsModel::LogSource::UI); + ui->wLogWindow->logMessage(+level, msg); if (cmd != CommandId::None) emit q->commandResultReady(Command(level == LogLevel::Error ? CommandId::Nak : CommandId::Ack, +cmd, qPrintable(msg))); } @@ -578,7 +575,8 @@ class WASimUIPrivate set.setValue(QStringLiteral("mainWindowState"), q->saveState()); set.setValue(QStringLiteral("requestsViewHeaderState"), ui->requestsView->horizontalHeader()->saveState()); set.setValue(QStringLiteral("eventsViewHeaderState"), ui->eventsView->horizontalHeader()->saveState()); - set.setValue(QStringLiteral("logViewHeaderState"), ui->logView->horizontalHeader()->saveState()); + + ui->wLogWindow->saveSettings(set); set.beginGroup(QStringLiteral("Widgets")); for (const FormWidget &vw : qAsConst(formWidgets)) @@ -607,8 +605,8 @@ class WASimUIPrivate ui->requestsView->horizontalHeader()->restoreState(set.value(QStringLiteral("requestsViewHeaderState")).toByteArray()); if (set.contains(QStringLiteral("eventsViewHeaderState"))) ui->eventsView->horizontalHeader()->restoreState(set.value(QStringLiteral("eventsViewHeaderState")).toByteArray()); - if (set.contains(QStringLiteral("logViewHeaderState"))) - ui->logView->horizontalHeader()->restoreState(set.value(QStringLiteral("logViewHeaderState")).toByteArray()); + + ui->wLogWindow->loadSettings(set); set.beginGroup(QStringLiteral("Widgets")); for (const FormWidget &vw : qAsConst(formWidgets)) @@ -733,16 +731,7 @@ WASimUI::WASimUI(QWidget *parent) : connect(ui.eventsView, &QTableView::doubleClicked, this, [this](const QModelIndex &idx) { d->populateEventForm(idx); }); // Set up the Log table view - ui.logView->setModel(d->logModel); - ui.logView->sortByColumn(LogRecordsModel::COL_TS, Qt::AscendingOrder); - //ui.logView->horizontalHeader()->setSortIndicator(LogRecordsModel::COL_TS, Qt::AscendingOrder); - ui.logView->horizontalHeader()->setSortIndicatorShown(false); - ui.logView->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); - ui.logView->horizontalHeader()->setSectionsMovable(true); - ui.logView->horizontalHeader()->resizeSection(LogRecordsModel::COL_LEVEL, 70); - ui.logView->horizontalHeader()->resizeSection(LogRecordsModel::COL_TS, 110); - ui.logView->horizontalHeader()->resizeSection(LogRecordsModel::COL_SOURCE, 20); - ui.logView->horizontalHeader()->setToolTip(tr("Severity Level | Timestamp | Source | Message")); + ui.wLogWindow->setClient(d->client); // Set initial state of Variables form, Local var type is default. ui.wOtherVarsForm->setVisible(false); @@ -795,10 +784,6 @@ WASimUI::WASimUI(QWidget *parent) : connect(this, &WASimUI::listResults, this, &WASimUI::onListResults, Qt::QueuedConnection); // Data updates can go right to the requests model. connect(this, &WASimUI::dataResultReady, d->reqModel, &RequestsModel::setRequestValue, Qt::QueuedConnection); - // Log messages can go right to the log records model. Log messages may arrive at any time, possibly from different threads, - // so placing them into a model allow proper sorting (and filtering). - connect(this, &WASimUI::logMessageReady, d->logModel, &LogRecordsModel::addRecord, Qt::QueuedConnection); - d->logUiMessage("Hello!", CommandId::Ack, LogLevel::Info); // Set up actions for triggering various events. Actions are typically mapped to UI elements like buttons and menu items and can be reused in multiple places. @@ -1090,117 +1075,6 @@ WASimUI::WASimUI(QWidget *parent) : }, Qt::QueuedConnection); - // Logging window actions - - // Set and connect Log Level combo boxes for Client and Server logging levels - ui.cbLogLevelCallback->setProperties(d->client->logLevel( LogFacility::Remote, LogSource::Client), LogFacility::Remote, LogSource::Client); - ui.cbLogLevelFile->setProperties(d->client->logLevel( LogFacility::File, LogSource::Client), LogFacility::File, LogSource::Client); - ui.cbLogLevelConsole->setProperties(d->client->logLevel( LogFacility::Console, LogSource::Client), LogFacility::Console, LogSource::Client); - ui.cbLogLevelServer->setProperties(d->client->logLevel( LogFacility::Remote, LogSource::Server), LogFacility::Remote, LogSource::Server); - ui.cbLogLevelServerFile->setProperties( (LogLevel)-1, LogFacility::File, LogSource::Server); // unknown level at startup - ui.cbLogLevelServerConsole->setProperties( (LogLevel)-1, LogFacility::Console, LogSource::Server); // unknown level at startup - // Since the LogLevelComboBox types store the facility and source properties (which we just set), we can use one event handler for all of them. - auto setLogLevel = [=](LogLevel level) { - if (LogLevelComboBox *cb = qobject_cast(sender())) - d->client->setLogLevel(level, cb->facility(), cb->source()); - }; - connect(ui.cbLogLevelCallback, &LogLevelComboBox::levelChanged, this, setLogLevel); - connect(ui.cbLogLevelFile, &LogLevelComboBox::levelChanged, this, setLogLevel); - connect(ui.cbLogLevelConsole, &LogLevelComboBox::levelChanged, this, setLogLevel); - connect(ui.cbLogLevelServer, &LogLevelComboBox::levelChanged, this, setLogLevel); - connect(ui.cbLogLevelServerFile, &LogLevelComboBox::levelChanged, this, setLogLevel); - connect(ui.cbLogLevelServerConsole, &LogLevelComboBox::levelChanged, this, setLogLevel); - - QAction *filterErrorsAct = new QAction(QIcon(Utils::iconNameForLogLevel(LogLevel::Error)), tr("Toggle Errors"), this); - filterErrorsAct->setToolTip(tr("Toggle visibility of Error-level log messages.")); - filterErrorsAct->setCheckable(true); - filterErrorsAct->setChecked(true); - ui.btnLogFilt_ERR->setDefaultAction(filterErrorsAct); - connect(filterErrorsAct, &QAction::triggered, this, [this](bool en) { d->logModel->setLevelFilter(LogLevel::Error, !en); }); - - QAction *filterWarningsAct = new QAction(QIcon(Utils::iconNameForLogLevel(LogLevel::Warning)), tr("Toggle Warnings"), this); - filterWarningsAct->setToolTip(tr("Toggle visibility of Warning-level log messages.")); - filterWarningsAct->setCheckable(true); - filterWarningsAct->setChecked(true); - ui.btnLogFilt_WRN->setDefaultAction(filterWarningsAct); - connect(filterWarningsAct, &QAction::triggered, this, [this](bool en) { d->logModel->setLevelFilter(LogLevel::Warning, !en); }); - - QAction *filterInfoAct = new QAction(QIcon(Utils::iconNameForLogLevel(LogLevel::Info)), tr("Toggle Info"), this); - filterInfoAct->setToolTip(tr("Toggle visibility of Information-level log messages.")); - filterInfoAct->setCheckable(true); - filterInfoAct->setChecked(true); - ui.btnLogFilt_INF->setDefaultAction(filterInfoAct); - connect(filterInfoAct, &QAction::triggered, this, [this](bool en) { d->logModel->setLevelFilter(LogLevel::Info, !en); }); - - QAction *filterDebugAct = new QAction(QIcon(Utils::iconNameForLogLevel(LogLevel::Debug)), tr("Toggle Debug"), this); - filterDebugAct->setToolTip(tr("Toggle visibility of Debug-level log messages.")); - filterDebugAct->setCheckable(true); - filterDebugAct->setChecked(true); - ui.btnLogFilt_DBG->setDefaultAction(filterDebugAct); - connect(filterDebugAct, &QAction::triggered, this, [this](bool en) { d->logModel->setLevelFilter(LogLevel::Debug, !en); }); - - QAction *filterTraceAct = new QAction(QIcon(Utils::iconNameForLogLevel(LogLevel::Trace)), tr("Toggle Traces"), this); - filterTraceAct->setToolTip(tr("Toggle visibility of Trace-level log messages.")); - filterTraceAct->setCheckable(true); - filterTraceAct->setChecked(true); - ui.btnLogFilt_TRC->setDefaultAction(filterTraceAct); - connect(filterTraceAct, &QAction::triggered, this, [this](bool en) { d->logModel->setLevelFilter(LogLevel::Trace, !en); }); - - QAction *filterServerAct = new QAction(QIcon(Utils::iconNameForLogSource(+LogSource::Server)), tr("Toggle Server Records"), this); - filterServerAct->setToolTip(tr("Toggle visibility of log messages from Server.")); - filterServerAct->setCheckable(true); - filterServerAct->setChecked(true); - ui.btnLogFilt_Server->setDefaultAction(filterServerAct); - connect(filterServerAct, &QAction::triggered, this, [this](bool en) { d->logModel->setSourceFilter(+LogSource::Server, !en); }); - - QAction *filterClientAct = new QAction(QIcon(Utils::iconNameForLogSource(+LogSource::Client)), tr("Toggle Client Records"), this); - filterClientAct->setToolTip(tr("Toggle visibility of log messages from Client.")); - filterClientAct->setCheckable(true); - filterClientAct->setChecked(true); - ui.btnLogFilt_Client->setDefaultAction(filterClientAct); - connect(filterClientAct, &QAction::triggered, this, [this](bool en) { d->logModel->setSourceFilter(+LogSource::Client, !en); }); - - QAction *filterUILogAct = new QAction(QIcon(Utils::iconNameForLogSource(+LogRecordsModel::LogSource::UI)), tr("Toggle UI Records"), this); - filterUILogAct->setToolTip(tr("Toggle visibility of log messages from this UI.")); - filterUILogAct->setCheckable(true); - filterUILogAct->setChecked(true); - ui.btnLogFilt_UI->setDefaultAction(filterUILogAct); - connect(filterUILogAct, &QAction::triggered, this, [this](bool en) { d->logModel->setSourceFilter(+LogRecordsModel::LogSource::UI, !en); }); - - QIcon logPauseIcon(QStringLiteral("pause.glyph")); - logPauseIcon.addFile(QStringLiteral("play_arrow.glyph"), QSize(), QIcon::Normal, QIcon::On); - QAction *pauseLogScrollAct = new QAction(logPauseIcon, tr("Pause Log Scroll"), this); - pauseLogScrollAct->setToolTip(tr("

Toggle scrolling of the log window. Scrolling can also be paused by selecting a log entry row.

")); - pauseLogScrollAct->setCheckable(true); - ui.btnLogPause->setDefaultAction(pauseLogScrollAct); - connect(pauseLogScrollAct, &QAction::triggered, this, [this](bool en) { if (!en) ui.logView->selectionModel()->clear(); }); // clear log view selection on "un-pause" - - QAction *clearLogWindowAct = new QAction(QIcon(QStringLiteral("delete.glyph")), tr("Clear Log Window"), this); - clearLogWindowAct->setToolTip(tr("Clear the log window.")); - ui.btnLogClear->setDefaultAction(clearLogWindowAct); - connect(clearLogWindowAct, &QAction::triggered, this, [this]() { d->logModel->clear(); }); - - QIcon wordWrapIcon(QStringLiteral("wrap_text.glyph")); - wordWrapIcon.addFile(QStringLiteral("notes.glyph"), QSize(), QIcon::Normal, QIcon::On); - QAction *wordWrapLogWindowAct = new QAction(wordWrapIcon, tr("Log Word Wrap"), this); - wordWrapLogWindowAct->setToolTip(tr("Toggle word wrapping of the log window.")); - wordWrapLogWindowAct->setCheckable(true); - wordWrapLogWindowAct->setChecked(true); - ui.btnLogWordWrap->setDefaultAction(wordWrapLogWindowAct); - connect(wordWrapLogWindowAct, &QAction::toggled, this, [this](bool chk) { ui.logView->setWordWrap(chk); ui.logView->resizeRowsToContents(); }); - - // connect the log model record added signal to make sure last record remains in view, unless scroll lock is enabled - connect(d->logModel, &LogRecordsModel::recordAdded, this, [=](const QModelIndex &i) { - // make sure log view scroll to bottom on insertions, unless a row is selected or scroll pause is set. - ui.logView->resizeRowToContents(i.row()); - if (!pauseLogScrollAct->isChecked() && !ui.logView->selectionModel()->hasSelection()) - ui.logView->scrollToBottom(/*i, QAbstractItemView::PositionAtBottom*/); - }); - // connect log viewer selection model to show pause button active while there is a selection - connect(ui.logView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=](const QItemSelection &sel, const QItemSelection&) { - pauseLogScrollAct->setChecked(!sel.isEmpty()); - }); - // Other UI-related actions QAction *viewAct = new QAction(QIcon(QStringLiteral("grid_view.glyph")), tr("View"), this); @@ -1240,9 +1114,6 @@ WASimUI::WASimUI(QWidget *parent) : // add all actions to this widget, for context menu and shortcut handling addActions({ d->initAct, pingAct, d->connectAct, - Utils::separatorAction(this), removeRequestsAct, updateRequestsAct, saveRequestsAct, loadRequestsAct, - Utils::separatorAction(this), removeEventsAct, updateEventsAct, saveEventsAct, loadEventsAct, - Utils::separatorAction(this), pauseLogScrollAct, clearLogWindowAct, wordWrapLogWindowAct, Utils::separatorAction(this), viewAct, styleAct, aboutAct, projectLinkAct }); @@ -1277,6 +1148,9 @@ WASimUI::WASimUI(QWidget *parent) : // now restore any saved settings d->readSettings(); styleAct->setChecked(Utils::isDarkStyle()); + + // Say Hi! + d->logUiMessage("Hello!", CommandId::Ack, LogLevel::Info); } void WASimUI::onClientEvent(const ClientEvent &ev) diff --git a/src/WASimUI/WASimUI.h b/src/WASimUI/WASimUI.h index 045258a..14ca1cd 100644 --- a/src/WASimUI/WASimUI.h +++ b/src/WASimUI/WASimUI.h @@ -37,7 +37,6 @@ class WASimUI : public QMainWindow void commandResultReady(const WASimCommander::Command &c); void listResults(const WASimCommander::Client::ListResult &list); void dataResultReady(const WASimCommander::Client::DataRequestRecord &r); - void logMessageReady(const WASimCommander::LogRecord &r, quint8 src); protected: void closeEvent(QCloseEvent *) override; diff --git a/src/WASimUI/WASimUI.ui b/src/WASimUI/WASimUI.ui index 16831fd..a64c66f 100644 --- a/src/WASimUI/WASimUI.ui +++ b/src/WASimUI/WASimUI.ui @@ -1953,7 +1953,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c 8 - + 0 @@ -1968,461 +1968,17 @@ Submitted requests will appear in the "Data Requests" window. Double-c 0 - 5 + 0 0 - 5 + 0 - 6 + 0 - - - - - Courier New - 9 - - - - Log Records - - - QAbstractScrollArea::AdjustToContents - - - QAbstractItemView::NoEditTriggers - - - QAbstractItemView::SingleSelection - - - QAbstractItemView::SelectRows - - - - 16 - 16 - - - - Qt::ElideMiddle - - - QAbstractItemView::ScrollPerItem - - - QAbstractItemView::ScrollPerPixel - - - Qt::DotLine - - - false - - - false - - - false - - - 20 - - - 60 - - - false - - - false - - - true - - - false - - - - - - - 5 - - - 6 - - - 2 - - - - - - 0 - 0 - - - - Log Window: - - - - - - - Pause - - - Qt::ToolButtonIconOnly - - - - - - - Server - - - Qt::ToolButtonIconOnly - - - false - - - - - - - UI - - - Qt::ToolButtonIconOnly - - - false - - - - - - - - 0 - 0 - - - - Console: - - - - - - - - 0 - 0 - - - - <p>Set Server File Log Level (note that until/unless set via this control, the initial level is unknown).</p> - - - - - - - Debug - - - Qt::ToolButtonIconOnly - - - false - - - - - - - - 0 - 0 - - - - File: - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - Info - - - Qt::ToolButtonIconOnly - - - false - - - - - - - Error - - - Qt::ToolButtonIconOnly - - - false - - - - - - - - 0 - 0 - - - - <p>Set Server Console Log Level (note that until/unless set via this control, the initial level is unknown).</p> - - - - - - - Warning - - - Qt::ToolButtonIconOnly - - - false - - - - - - - Client - - - Qt::ToolButtonIconOnly - - - false - - - - - - - Clear - - - Qt::ToolButtonIconOnly - - - - - - - - 0 - 0 - - - - Client - - - - - - - - 0 - 0 - - - - Set Server Callback Log Level (output directed to this log window). - - - - - - - - 0 - 0 - - - - File: - - - - - - - - 0 - 0 - - - - Set Client File Log Level - - - - - - - Qt::Horizontal - - - - 4 - 20 - - - - - - - - - 0 - 0 - - - - Set Client Callback Log Level (output directed to this log window) - - - - - - - - 0 - 0 - - - - Set Client Console Log Level - - - - - - - - 0 - 0 - - - - Server: - - - - - - - WW - - - Qt::ToolButtonIconOnly - - - - - - - - 0 - 0 - - - - Console: - - - - - - - - 0 - 0 - - - - Log Window: - - - - - - - Trace - - - Qt::ToolButtonIconOnly - - - false - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - -
@@ -2652,11 +2208,6 @@ Submitted requests will appear in the "Data Requests" window. Double-c QComboBox
Widgets.h
- - WASimUiNS::LogLevelComboBox - QComboBox -
Widgets.h
-
WASimUiNS::LookupTypeComboBox QComboBox @@ -2667,6 +2218,12 @@ Submitted requests will appear in the "Data Requests" window. Double-c QComboBox
Widgets.h
+ + LogConsole + QWidget +
LogConsole.h
+ 1 +
From 3bc01df7b907ebc63a3adefaea1ea30d35e4613e Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Mon, 23 Oct 2023 09:23:33 -0400 Subject: [PATCH 24/65] [WASimUI] Refactor actions with macro; Add all actions to their respective widgets. --- src/WASimUI/WASimUI.cpp | 145 +-- src/WASimUI/WASimUI.ui | 1958 +++++++++++++++++++-------------------- 2 files changed, 1010 insertions(+), 1093 deletions(-) diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index 90485b4..2985427 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -816,37 +816,26 @@ WASimUI::WASimUI(QWidget *parent) : connect(pingAct, &QAction::triggered, this, [this]() { d->client->pingServer(); }); +#define MAKE_ACTION(ACT, TTL, TT, ICN, BTN, W, M) \ + QAction *ACT = new QAction(QIcon(QStringLiteral(##ICN)), TTL, this); \ + ACT->setToolTip(TT); ui.##BTN->setDefaultAction(ACT); ui.##W->addAction(ACT); \ + connect(ACT, &QAction::triggered, this, [this]() { d->##M; }) + +#define MAKE_ACTION_D(ACT, TTL, TT, ICN, BTN, W, M) MAKE_ACTION(ACT, TTL, TT, ICN, BTN, W, M); ACT->setDisabled(true) +#define MAKE_ACTION_IT(ACT, TTL, TT, IT, ICN, BTN, W, M) MAKE_ACTION_D(ACT, TTL, TT, ICN, BTN, W, M); ACT->setIconText(IT) + // Calculator code actions // Exec calculator code - QAction *execCalcAct = new QAction(QIcon(QStringLiteral("IcoMoon-Free/calculator.glyph")), tr("Execute Calculator Code"), this); - execCalcAct->setToolTip(tr("Execute Calculator Code")); - execCalcAct->setDisabled(true); - connect(execCalcAct, &QAction::triggered, this, [this]() { d->runCalcCode(); }); - ui.btnCalc->setDefaultAction(execCalcAct); - + MAKE_ACTION_D(execCalcAct, tr("Execute Calculator Code"), tr("Execute Calculator Code."), "IcoMoon-Free/calculator.glyph", btnCalc, wCalcForm, runCalcCode()); // Register calculator code event - QAction *regEventAct = new QAction(QIcon(QStringLiteral("control_point.glyph")), tr("Register Event"), this); - regEventAct->setToolTip(tr("Register this calculator code as a new Event.")); - regEventAct->setDisabled(true); - connect(regEventAct, &QAction::triggered, this, [this]() { d->registerEvent(false); }); - ui.btnAddEvent->setDefaultAction(regEventAct); - + MAKE_ACTION_D(regEventAct, tr("Register Event"), tr("Register this calculator code as a new Event."), "control_point.glyph", btnAddEvent, wCalcForm, registerEvent(false)); // Save edited calculator code event - QAction *saveEventAct = new QAction(QIcon(QStringLiteral("edit.glyph")), tr("Update Event"), this); - saveEventAct->setToolTip(tr("Update existing event with new calculator code (name cannot be changed).")); - connect(saveEventAct, &QAction::triggered, this, [this]() { d->registerEvent(true); }); - saveEventAct->setDisabled(true); - ui.btnUpdateEvent->setDefaultAction(saveEventAct); - ui.btnUpdateEvent->setVisible(false); - + MAKE_ACTION_D(saveEventAct, tr("Update Event"), tr("Update existing event with new calculator code (name cannot be changed)."), "edit.glyph", btnUpdateEvent, wCalcForm, registerEvent(true)); // Copy calculator code as new Data Request - QAction *copyCalcAct = new QAction(QIcon(QStringLiteral("move_to_inbox.glyph")), tr("Copy to Data Request"), this); - copyCalcAct->setToolTip(tr("Copy Calculator Code to new Data Request")); - copyCalcAct->setDisabled(true); - connect(copyCalcAct, &QAction::triggered, this, [this]() { d->copyCalcCodeToRequest(); }); - ui.btnCopyCalcToRequest->setDefaultAction(copyCalcAct); + MAKE_ACTION_D(copyCalcAct, tr("Copy to Data Request"), tr("Copy Calculator Code to new Data Request."), "move_to_inbox.glyph", btnCopyCalcToRequest, wCalcForm, copyCalcCodeToRequest()); + ui.btnUpdateEvent->setVisible(false); // Connect variable selector to enable/disable relevant actions connect(ui.cbCalculatorCode, &QComboBox::currentTextChanged, this, [=](const QString &txt) { const bool en = !txt.isEmpty(); @@ -856,51 +845,24 @@ WASimUI::WASimUI(QWidget *parent) : copyCalcAct->setEnabled(en); }); - // Variables section actions d->toggleSetGetVariableType(); // Request Local Vars list - QAction *reloadLVarsAct = new QAction(QIcon(QStringLiteral("autorenew.glyph")), tr("Reload L.Vars"), this); - reloadLVarsAct->setToolTip(tr("Reload Local Variables")); - connect(reloadLVarsAct, &QAction::triggered, this, [this]() { d->refreshLVars(); }); - ui.btnList->setDefaultAction(reloadLVarsAct); - + MAKE_ACTION(reloadLVarsAct, tr("Reload L.Vars"), tr("Reload Local Variables."), "autorenew.glyph", btnList, wVariables, refreshLVars()); // Get local variable value - QAction *getVarAct = new QAction(QIcon(QStringLiteral("rotate=180/send.glyph")), tr("Get Variable"), this); - getVarAct->setToolTip(tr("Get Variable Value.")); - getVarAct->setDisabled(true); - connect(getVarAct, &QAction::triggered, this, [this]() { d->getLocalVar(); }); - ui.btnGetVar->setDefaultAction(getVarAct); - + MAKE_ACTION_D(getVarAct, tr("Get Variable"), tr("Get Variable Value."), "rotate=180/send.glyph", btnGetVar, wVariables, getLocalVar()); // Set variable value - QAction *setVarAct = new QAction(QIcon(QStringLiteral("send.glyph")), tr("Set Variable"), this); - setVarAct->setToolTip(tr("Set Variable Value.")); - setVarAct->setDisabled(true); - connect(setVarAct, &QAction::triggered, this, [this]() { d->setLocalVar(); }); - ui.btnSetVar->setDefaultAction(setVarAct); - + MAKE_ACTION_D(setVarAct, tr("Set Variable"), tr("Set Variable Value."), "send.glyph", btnSetVar, wVariables, setLocalVar()); // Set or Create local variable - QAction *setCreateVarAct = new QAction(QIcon(QStringLiteral("overlay=\\align=AlignRight\\fg=#17dd29\\add/send.glyph")), tr("Set/Create Variable"), this); - setCreateVarAct->setToolTip(tr("Set Or Create Local Variable.")); - setCreateVarAct->setDisabled(true); - connect(setCreateVarAct, &QAction::triggered, this, [this]() { d->setLocalVar(true); }); - ui.btnSetCreate->setDefaultAction(setCreateVarAct); - + MAKE_ACTION_D(setCreateVarAct, tr("Set/Create Variable"), tr("Set Or Create Local Variable."), "overlay=\\align=AlignRight\\fg=#17dd29\\add/send.glyph", btnSetCreate, wVariables, setLocalVar(true)); // Get or Create local variable - QAction *getCreateVarAct = new QAction(QIcon(QStringLiteral("overlay=\\align=AlignLeft\\fg=#17dd29\\add/rotate=180/send.glyph")), tr("Get/Create Variable"), this); - getCreateVarAct->setToolTip(tr("Get Or Create Local Variable. The specified value and unit will be used as defaults if the variable is created.")); - getCreateVarAct->setDisabled(true); - connect(getCreateVarAct, &QAction::triggered, this, [this]() { d->getLocalVar(true); }); - ui.btnGetCreate->setDefaultAction(getCreateVarAct); - + MAKE_ACTION_D(getCreateVarAct, tr("Get/Create Variable"), + tr("Get Or Create Local Variable. The specified value and unit will be used as defaults if the variable is created."), + "overlay=\\align=AlignLeft\\fg=#17dd29\\add/rotate=180/send.glyph", btnGetCreate, wVariables, getLocalVar(true)); // Copy LVar as new Data Request - QAction *copyVarAct = new QAction(QIcon(QStringLiteral("move_to_inbox.glyph")), tr("Copy to Data Request"), this); - copyVarAct->setToolTip(tr("Copy Variable to new Data Request")); - copyVarAct->setDisabled(true); - connect(copyVarAct, &QAction::triggered, this, [this]() { d->copyLocalVarToRequest(); }); - ui.btnCopyLVarToRequest->setDefaultAction(copyVarAct); + MAKE_ACTION_D(copyVarAct, tr("Copy to Data Request"), tr("Copy Variable to new Data Request."), "move_to_inbox.glyph", btnCopyLVarToRequest, wVariables, copyLocalVarToRequest()); auto updateLocalVarsFormState = [=](const QString &) { const bool isLocal = ui.wLocalVarsForm->isVisible(); @@ -925,42 +887,18 @@ WASimUI::WASimUI(QWidget *parent) : // Other forms // Lookup action - QAction *lookupItemAct = new QAction(QIcon(QStringLiteral("search.glyph")), tr("Lookup"), this); - lookupItemAct->setToolTip(tr("Query server for ID of named item (Lookup command).")); - connect(lookupItemAct, &QAction::triggered, this, [this]() { d->lookupItem(); }); - ui.btnVarLookup->setDefaultAction(lookupItemAct); - + MAKE_ACTION(lookupItemAct, tr("Lookup"), tr("Query server for ID of named item (Lookup command)."), "search.glyph", btnVarLookup, wDataLookup, lookupItem()); // Send Key Event action - QAction *sendKeyEventAct = new QAction(QIcon(QStringLiteral("send.glyph")), tr("Send Key Event"), this); - sendKeyEventAct->setToolTip(tr("Send the specified Key Event to the server.")); - connect(sendKeyEventAct, &QAction::triggered, this, [this]() { d->sendKeyEventForm(); }); - ui.btnKeyEventSend->setDefaultAction(sendKeyEventAct); - - + MAKE_ACTION(sendKeyEventAct, tr("Send Key Event"), tr("Send the specified Key Event to the server."), "send.glyph", btnKeyEventSend, wKeyEvent, sendKeyEventForm()); // Send Command action - QAction *sendCmdAct = new QAction(QIcon(QStringLiteral("keyboard_command_key.glyph")), tr("Send Command"), this); - sendCmdAct->setToolTip(tr("Send the selected Command to the server.")); - connect(sendCmdAct, &QAction::triggered, this, [this]() { d->sendCommandForm(); }); - ui.btnCmdSend->setDefaultAction(sendCmdAct); - + MAKE_ACTION(sendCmdAct, tr("Send Command"), tr("Send the selected Command to the server."), "keyboard_command_key.glyph", btnCmdSend, wCommand, sendCommandForm()); // Requests model view actions // Remove selected Data Request(s) from item model/view - QAction *removeRequestsAct = new QAction(QIcon(QStringLiteral("delete_forever.glyph")), tr("Remove Selected Data Request(s)"), this); - removeRequestsAct->setIconText(tr("Remove")); - removeRequestsAct->setToolTip(tr("Delete the selected Data Request(s).")); - removeRequestsAct->setDisabled(true); - ui.pbReqestsRemove->setDefaultAction(removeRequestsAct); - connect(removeRequestsAct, &QAction::triggered, this, [this]() { d->removeSelectedRequests(); }); - + MAKE_ACTION_IT(removeRequestsAct, tr("Remove Selected Data Request(s)"), tr("Delete the selected Data Request(s)."), tr("Remove"), "delete_forever.glyph", pbReqestsRemove, wRequests, removeSelectedRequests()); // Update data of selected Data Request(s) in item model/view - QAction *updateRequestsAct = new QAction(QIcon(QStringLiteral("refresh.glyph")), tr("Update Selected Data Request(s)"), this); - updateRequestsAct->setIconText(tr("Update")); - updateRequestsAct->setToolTip(tr("Request data update on selected Data Request(s).")); - updateRequestsAct->setDisabled(true); - ui.pbReqestsUpdate->setDefaultAction(updateRequestsAct); - connect(updateRequestsAct, &QAction::triggered, this, [this]() { d->updateSelectedRequests(); }); + MAKE_ACTION_IT(updateRequestsAct, tr("Update Selected Data Request(s)"), tr("Request data update on selected Data Request(s)."), tr("Update"), "refresh.glyph", pbReqestsUpdate, wRequests, updateSelectedRequests()); // Connect to table view selection model to en/disable the remove/update actions when selection changes. connect(ui.requestsView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=](const QItemSelection &sel, const QItemSelection &) { @@ -978,6 +916,8 @@ WASimUI::WASimUI(QWidget *parent) : pauseRequestsAct->setCheckable(true); pauseRequestsAct->setDisabled(true); ui.pbReqestsPause->setDefaultAction(pauseRequestsAct); + ui.wRequests->addAction(pauseRequestsAct); + connect(pauseRequestsAct, &QAction::triggered, this, [=](bool chk) { static const QIcon dataResumeIcon(QStringLiteral("play_arrow.glyph")); d->client->setDataRequestsPaused(chk); @@ -1002,6 +942,8 @@ WASimUI::WASimUI(QWidget *parent) : QAction *loadReplaceAct = loadRequestsMenu->addAction(QIcon(QStringLiteral("view_list.glyph")), tr("Replace Existing")); QAction *loadAppendAct = loadRequestsMenu->addAction(QIcon(QStringLiteral("playlist_add.glyph")), tr("Append to Existing")); ui.pbReqestsLoad->setDefaultAction(loadRequestsAct); + ui.wRequests->addAction(loadRequestsAct); + connect(loadReplaceAct, &QAction::triggered, this, [this]() { d->loadRequests(true); }); connect(loadAppendAct, &QAction::triggered, this, [this]() { d->loadRequests(false); }); connect(loadRequestsAct, &QAction::triggered, this, [=]() { if (!loadRequestsAct->menu()) d->loadRequests(true); }); @@ -1022,20 +964,9 @@ WASimUI::WASimUI(QWidget *parent) : // Registered calculator events model view actions // Remove selected Data Request(s) from item model/view - QAction *removeEventsAct = new QAction(QIcon(QStringLiteral("delete_forever.glyph")), tr("Remove Selected Event(s)"), this); - removeEventsAct->setIconText(tr("Remove")); - removeEventsAct->setToolTip(tr("Delete the selected Event(s).")); - removeEventsAct->setDisabled(true); - ui.pbEventsRemove->setDefaultAction(removeEventsAct); - connect(removeEventsAct, &QAction::triggered, this, [this]() { d->removeSelectedEvents(); }); - + MAKE_ACTION_IT(removeEventsAct, tr("Remove Selected Event(s)"), tr("Delete the selected Event(s)."), tr("Remove"), "delete_forever.glyph", pbEventsRemove, wEventsList, removeSelectedEvents()); // Update data of selected Data Request(s) in item model/view - QAction *updateEventsAct = new QAction(QIcon(QStringLiteral("rotate=180/play_for_work.glyph")), tr("Transmit Selected Event(s)"), this); - updateEventsAct->setIconText(tr("Transmit")); - updateEventsAct->setToolTip(tr("Trigger selected Event(s).")); - updateEventsAct->setDisabled(true); - ui.pbEventsTransmit->setDefaultAction(updateEventsAct); - connect(updateEventsAct, &QAction::triggered, this, [this]() { d->transmitSelectedEvents(); }); + MAKE_ACTION_IT(updateEventsAct, tr("Transmit Selected Event(s)"), tr("Trigger the selected Event(s)."), tr("Transmit"), "rotate=180/play_for_work.glyph", pbEventsTransmit, wEventsList, transmitSelectedEvents()); // Connect to table view selection model to en/disable the remove/update actions when selection changes. connect(ui.eventsView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=](const QItemSelection &sel, const QItemSelection &) { @@ -1044,12 +975,7 @@ WASimUI::WASimUI(QWidget *parent) : }); // Save current Events to a file - QAction *saveEventsAct = new QAction(QIcon(QStringLiteral("save.glyph")), tr("Save Events"), this); - saveEventsAct->setIconText(tr("Save")); - saveEventsAct->setToolTip(tr("Save current Events list to file.")); - saveEventsAct->setDisabled(true); - ui.pbEventsSave->setDefaultAction(saveEventsAct); - connect(saveEventsAct, &QAction::triggered, this, [this]() { d->saveEvents(); }); + MAKE_ACTION_IT(saveEventsAct, tr("Save Events"), tr("Save current Events list to file."), tr("Save"), "save.glyph", pbEventsSave, wEventsList, saveEvents()); // Load Events from a file. This is actually two actions: load and append to existing records + load and replace existing records. QAction *loadEventsAct = new QAction(QIcon(QStringLiteral("folder_open.glyph")), tr("Load Events"), this); @@ -1059,6 +985,8 @@ WASimUI::WASimUI(QWidget *parent) : QAction *replaceEventsAct = loadEventsMenu->addAction(QIcon(QStringLiteral("view_list.glyph")), tr("Replace Existing")); QAction *appendEventsAct = loadEventsMenu->addAction(QIcon(QStringLiteral("playlist_add.glyph")), tr("Append to Existing")); ui.pbEventsLoad->setDefaultAction(loadEventsAct); + ui.wEventsList->addAction(loadEventsAct); + connect(replaceEventsAct, &QAction::triggered, this, [this]() { d->loadEvents(true); }); connect(appendEventsAct, &QAction::triggered, this, [this]() { d->loadEvents(false); }); connect(loadEventsAct, &QAction::triggered, this, [=]() { if (!loadEventsAct->menu()) d->loadEvents(true); }); @@ -1074,6 +1002,9 @@ WASimUI::WASimUI(QWidget *parent) : saveEventsAct->setEnabled(rows > 0); }, Qt::QueuedConnection); +#undef MAKE_ACTION_IT +#undef MAKE_ACTION_D +#undef MAKE_ACTION // Other UI-related actions diff --git a/src/WASimUI/WASimUI.ui b/src/WASimUI/WASimUI.ui index a64c66f..1995b69 100644 --- a/src/WASimUI/WASimUI.ui +++ b/src/WASimUI/WASimUI.ui @@ -46,27 +46,19 @@ 8 - - + + Qt::ActionsContextMenu - <p>Request Submission Form</p> -<p>Use this form to submit new or modified data requests to the WASimModule server. -Submitted requests will appear in the "Data Requests" window. Double-click on an existing request in the list to edit it in this form.</p> -<p> This form can also be partially pre-populated by using the "copy to request" buttons in the Calculator Event and Variables forms.</p> + <p>This form is for working with all types of variables. It can be used to get current values and also set values (on variables which allow that). The form adapts to the type of variable selected.</p> +<p>Variable definitions from this form can be copied to the Data Requests form and converted to a recurring data query.</p> - Data Request - - - Qt::AlignBottom|Qt::AlignLeading|Qt::AlignLeft - - - false + Variables - + 4 @@ -80,304 +72,321 @@ Submitted requests will appear in the "Data Requests" window. Double-c 4 - 6 + 8 - - - - - - - 0 - 0 - - - - Update On: - - - - - - - Update Period - - - - - - - - 0 - 0 - - - - Interval: - - - + + + + + 0 + 0 + + + + + 75 + true + + + + Get + + + + + + + + 0 + 0 + + + + + + + + Set + + + Qt::ToolButtonIconOnly + + + false + + + + + + + 6 + + + 0 + + + 0 + + + 0 + + + 0 + - - - Update Interval (number of periods between updates, 0 for every period) - - - 999999999 - - - QAbstractSpinBox::DefaultStepType + + + true - - - - - + 0 0 - - ΔΕ: + + Qt::ClickFocus - - - - - Delta Epsilon, minimum change in value before request is updated (only for predefined value types Int/UInt/Float/Double). <p>Hold Ctrl and/or Shift keys to change stepping sizes.</p> - - - 7 + Result of a Variable Get request will appear here. - - -1.000000000000000 + + false - - 9999999.999997999519110 + + false - - 0.001000000000000 + + Variable Get result... - - QAbstractSpinBox::AdaptiveDecimalStepType + + true - + Qt::Horizontal - QSizePolicy::Preferred + QSizePolicy::Fixed - 50 - 10 + 24 + 20 - - - - - 0 - 0 - - - - Add new request record from current form entries. - - - Clear - - - - - - - false - - - - 0 - 0 - - - - Update the existing request record from current form entries. - - - Save - - - - - - - - 0 - 0 - - - - Add new request record from current form entries. - - - Add - - - - - - - 28 + + + + Get - - 0 + + Qt::ToolButtonIconOnly - - 0 + + + + + + + 0 + 0 + - - 8 + + + 75 + true + - - 0 + + Set + + + + + + + + 0 + 0 + + + + Qt::StrongFocus + + + The value to set the variable to. + + + 8 + + + -4294967295.999899864196777 + + + 4294969056.999899864196777 + + + QAbstractSpinBox::AdaptiveDecimalStepType + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 - - - - 0 - 0 - - - - Request Type: - - - - - - - - 0 - 0 - - - - <p>"Named" variables can be of various types but are always accessed with a unique name or ID.</p> - - - Named Variable - - - true - - - bgrpRequestType - + + + + 6 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 75 + true + + + + List + + + + + + + Qt::ClickFocus + + + Local Variables (press Refresh button to (re)load) + + + true + + + 25 + + + QComboBox::NoInsert + + + + + + + Refresh + + + Qt::ToolButtonIconOnly + + + false + + + + - - - - 0 - 0 - - - - <p>A cacluator code request can evaluate any kind of formula that returns a result.</p> - - - Calculator Code - - - false - - - bgrpRequestType - + + + + 6 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 75 + true + + + + Name + + + + + + + Qt::ClickFocus + + + Variable name + + + true + + + + - - - - Qt::Horizontal - - - QSizePolicy::Expanding - - - - 40 - 20 - - - - - - - - 4 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - ID: - - - - - - - - 0 - 0 - - - - ID of request currently in the editor. - - - New - - - - - - - + + - 6 + 0 0 @@ -392,18 +401,18 @@ Submitted requests will appear in the "Data Requests" window. Double-c 0 - - - - + - + 0 0 + + Qt::StrongFocus + - Variable Name or Calculator Code<p>(Save items by pressing Enter, Remove by right-clicking on them while the list is open.)</p> + Optional Unit Name for the value. Leave blank to use the default units of the variable.<p>(Save items by pressing Enter, Remove by right-clicking on them while the list is open.)</p> true @@ -411,140 +420,119 @@ Submitted requests will appear in the "Data Requests" window. Double-c - - - 4 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - Idx: - - - - - - - SimVar Index (if any, zero is blank) - - - true - - - - - - - - - - - 0 - 0 - - - - Unit: - - - - - - - - 0 - 0 - - - - Optional Unit Name for Named Variables<p>(Save items by pressing Enter, Remove by right-clicking on them while the list is open.)</p> - - - true - - - - - - - - - 4 - - - 0 - - - - - - 0 - 0 - - - - Result: - - - - - - - Calculation Result Type - - - - - - - - - - 0 - 0 - - - - Size: - - - - - + - + 0 0 - - Value Size in Bytes or a preset type.<p>(Save items by pressing Enter, Remove by right-clicking on them while the list is open.)</p> - - - true - + + + 3 + + + 6 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Idx: + + + + + + + SimVar Index (if any, zero is blank) + + + true + + + + + + + + + + + + 0 + 0 + + + + Value: + + + + + + + + 0 + 0 + + + + Unit: + + + + + + + Set/Create + + + Qt::ToolButtonIconOnly + + + false + + + + + + + Copy + + + Qt::ToolButtonIconOnly + + + + + + + Get/Create + + + Qt::ToolButtonIconOnly + + + @@ -691,172 +679,32 @@ Submitted requests will appear in the "Data Requests" window. Double-c -999999999 - - 999999999 - - - - - - - - 0 - 0 - - - - Integer data for first event value. - - - -999999999 - - - 999999999 - - - - - - - - 0 - 0 - - - - Send a Key Event to the simulator with up to 5 optional values. - - - Key Event Name/ID: - - - - -
- - - - - Qt::ActionsContextMenu - - - <p>This form allows sending any arbitrary command to the WASimModule. This is for testing low-level API functions, not generally useful in most cases. Refer to API documentation for details.</p> - - - Send WASimCommander API Command - - - - 4 - - - 4 - - - 4 - - - 4 - - - 3 - - - - - - 0 - 0 - - - - uData: - - - - - - - - 0 - 0 - - - - sData: - - - - - - - Integer value for command's `uData` field. - - - -999999999 - - - 999999999 - - - - - - - - - - Double floating point value for command's `fData` field. <p>Hold Ctrl and/or Shift keys to change stepping sizes.</p> - - - 7 - - - 9999999.999997999519110 - - - 0.001000000000000 - - - QAbstractSpinBox::AdaptiveDecimalStepType - - - - - - - String value for command's `sData` member. - - - 512 - - - - - - - Send - - - Qt::ToolButtonIconOnly + + 999999999 - - + + 0 0 - - fData: + + Integer data for first event value. + + + -999999999 + + + 999999999 - + 0 @@ -864,29 +712,31 @@ Submitted requests will appear in the "Data Requests" window. Double-c - <p>Send any command to the WASimModule server.</p><p>(Familiarity with the WASimCommander API is necessary for this to be particularly useful.)</p> + Send a Key Event to the simulator with up to 5 optional values. - Command + Key Event Name/ID: - - + + Qt::ActionsContextMenu - <p>This form is for working with all types of variables. It can be used to get current values and also set values (on variables which allow that). The form adapts to the type of variable selected.</p> -<p>Variable definitions from this form can be copied to the Data Requests form and converted to a recurring data query.</p> + <p>This form allows performing various meta data retrival functions, for example to get numeric IDs of named variables/events, or to check for their existence.</p> - Variables + Data Lookup - + + + 6 + 4 @@ -899,30 +749,24 @@ Submitted requests will appear in the "Data Requests" window. Double-c 4 - - 8 - - - + + 0 0 - - - 75 - true - + + Look up the ID of a named variable or unit. - Get + Lookup Type: - - + + 0 @@ -931,10 +775,23 @@ Submitted requests will appear in the "Data Requests" window. Double-c - - + + + + Qt::ClickFocus + + + Variable or Unit name to look up. + + + true + + + + + - Set + Lookup Qt::ToolButtonIconOnly @@ -944,133 +801,254 @@ Submitted requests will appear in the "Data Requests" window. Double-c - - - - 6 + + + + + 0 + 0 + - - 0 + + + 75 + true + - - 0 + + = - - 0 + + + + + + true - - 0 + + + 0 + 0 + + + + Qt::ClickFocus + + + Result of an item Lookup will appear here. + + + 16 + + + false + + + false + + + Lookup result... + + + true + + + + + + + + + Qt::ActionsContextMenu + + + <p>Request Submission Form</p> +<p>Use this form to submit new or modified data requests to the WASimModule server. +Submitted requests will appear in the "Data Requests" window. Double-click on an existing request in the list to edit it in this form.</p> +<p> This form can also be partially pre-populated by using the "copy to request" buttons in the Calculator Event and Variables forms.</p> + + + Data Request + + + Qt::AlignBottom|Qt::AlignLeading|Qt::AlignLeft + + + false + + + + 4 + + + 4 + + + 4 + + + 4 + + + 6 + + + + + + + + 0 + 0 + + + + Update On: + + + + + + + Update Period + + + + + + + + 0 + 0 + + + + Interval: + + + + + + + Update Interval (number of periods between updates, 0 for every period) + + + 999999999 + + + QAbstractSpinBox::DefaultStepType + + + + + + + + 0 + 0 + + + + ΔΕ: + + + + + + + Delta Epsilon, minimum change in value before request is updated (only for predefined value types Int/UInt/Float/Double). <p>Hold Ctrl and/or Shift keys to change stepping sizes.</p> + + + 7 + + + -1.000000000000000 + + + 9999999.999997999519110 + + + 0.001000000000000 + + + QAbstractSpinBox::AdaptiveDecimalStepType + + + - - - true + + + Qt::Horizontal + + + QSizePolicy::Preferred + + + + 50 + 10 + + + + + - + 0 0 - - Qt::ClickFocus - - Result of a Variable Get request will appear here. + Add new request record from current form entries. - - false + + Clear - + + + + + false - - Variable Get result... + + + 0 + 0 + - - true + + Update the existing request record from current form entries. + + + Save - - - Qt::Horizontal + + + + 0 + 0 + - - QSizePolicy::Fixed + + Add new request record from current form entries. - - - 24 - 20 - + + Add - + - - - - Get - - - Qt::ToolButtonIconOnly - - - - - - - - 0 - 0 - - - - - 75 - true - - - - Set - - - - - - - - 0 - 0 - - - - Qt::StrongFocus - - - The value to set the variable to. - - - 8 - - - -4294967295.999899864196777 - - - 4294969056.999899864196777 - - - QAbstractSpinBox::AdaptiveDecimalStepType - - - - - + + - 0 + 28 0 @@ -1079,142 +1057,138 @@ Submitted requests will appear in the "Data Requests" window. Double-c 0 - 0 + 8 0 - - - - 6 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 75 - true - - - - List - - - - - - - Qt::ClickFocus - - - Local Variables (press Refresh button to (re)load) - - - true - - - 25 - - - QComboBox::NoInsert - - - - - - - Refresh - - - Qt::ToolButtonIconOnly - - - false - - - - + + + + 0 + 0 + + + + Request Type: + + + + + + + + 0 + 0 + + + + <p>"Named" variables can be of various types but are always accessed with a unique name or ID.</p> + + + Named Variable + + + true + + + bgrpRequestType + + + + + + + + 0 + 0 + + + + <p>A cacluator code request can evaluate any kind of formula that returns a result.</p> + + + Calculator Code + + + false + + + bgrpRequestType + - - - - 6 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 75 - true - - - - Name - - - - - - - Qt::ClickFocus - - - Variable name - - - true - - - - - + + + Qt::Horizontal + + + QSizePolicy::Expanding + + + + 40 + 20 + + + + + + + + 4 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + ID: + + + + + + + + 0 + 0 + + + + ID of request currently in the editor. + + + New + + + + - - + + - 0 + 6 0 @@ -1229,18 +1203,18 @@ Submitted requests will appear in the "Data Requests" window. Double-c 0 - + + + + - + 0 0 - - Qt::StrongFocus - - Optional Unit Name for the value. Leave blank to use the default units of the variable.<p>(Save items by pressing Enter, Remove by right-clicking on them while the list is open.)</p> + Variable Name or Calculator Code<p>(Save items by pressing Enter, Remove by right-clicking on them while the list is open.)</p> true @@ -1248,119 +1222,140 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + + + 4 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Idx: + + + + + + + SimVar Index (if any, zero is blank) + + + true + + + + + + + + + + + 0 + 0 + + + + Unit: + + + + + + + + 0 + 0 + + + + Optional Unit Name for Named Variables<p>(Save items by pressing Enter, Remove by right-clicking on them while the list is open.)</p> + + + true + + + + + + + + + 4 + + + 0 + + + + + + 0 + 0 + + + + Result: + + + + + + + Calculation Result Type + + + + + + + - + + 0 + 0 + + + + Size: + + + + + + + 0 0 - - - 3 - - - 6 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - Idx: - - - - - - - SimVar Index (if any, zero is blank) - - - true - - - - - - - + + Value Size in Bytes or a preset type.<p>(Save items by pressing Enter, Remove by right-clicking on them while the list is open.)</p> + + + true + - - - - - 0 - 0 - - - - Value: - - - - - - - - 0 - 0 - - - - Unit: - - - - - - - Set/Create - - - Qt::ToolButtonIconOnly - - - false - - - - - - - Copy - - - Qt::ToolButtonIconOnly - - - - - - - Get/Create - - - Qt::ToolButtonIconOnly - - - @@ -1594,21 +1589,18 @@ Submitted requests will appear in the "Data Requests" window. Double-c - - + + Qt::ActionsContextMenu - <p>This form allows performing various meta data retrival functions, for example to get numeric IDs of named variables/events, or to check for their existence.</p> + <p>This form allows sending any arbitrary command to the WASimModule. This is for testing low-level API functions, not generally useful in most cases. Refer to API documentation for details.</p> - Data Lookup + Send WASimCommander API Command - - - 6 - + 4 @@ -1621,108 +1613,116 @@ Submitted requests will appear in the "Data Requests" window. Double-c 4 - - + + 3 + + + 0 0 - - Look up the ID of a named variable or unit. - - Lookup Type: + uData: - - + + 0 0 + + sData: + - - - - Qt::ClickFocus + + + + Integer value for command's `uData` field. + + + -999999999 + + + 999999999 + + + + + + + + + + Double floating point value for command's `fData` field. <p>Hold Ctrl and/or Shift keys to change stepping sizes.</p> + + + 7 + + + 9999999.999997999519110 + + + 0.001000000000000 + + + QAbstractSpinBox::AdaptiveDecimalStepType + + + + - Variable or Unit name to look up. + String value for command's `sData` member. - - true + + 512 - - + + - Lookup + Send Qt::ToolButtonIconOnly - - false - - - + + 0 0 - - - 75 - true - - - = + fData: - - - - true - + + - + 0 0 - - Qt::ClickFocus - - Result of an item Lookup will appear here. - - - 16 - - - false - - - false - - - Lookup result... + <p>Send any command to the WASimModule server.</p><p>(Familiarity with the WASimCommander API is necessary for this to be particularly useful.)</p> - - true + + Command @@ -1963,23 +1963,6 @@ Submitted requests will appear in the "Data Requests" window. Double-c Qt::ActionsContextMenu - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - @@ -2002,6 +1985,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c 0 + + Qt::ActionsContextMenu + 5 From 17791eefecc86454c031636a5da9c19d56e21139 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Mon, 23 Oct 2023 23:39:05 -0400 Subject: [PATCH 25/65] [WASimModule] Check for a valid unit ID for A var Get command and data requests; Add `requestId` to error logging and response output for data requests and add more info for Get command errors. --- src/WASimModule/WASimModule.cpp | 41 ++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/WASimModule/WASimModule.cpp b/src/WASimModule/WASimModule.cpp index 1e8d2bb..13960a5 100644 --- a/src/WASimModule/WASimModule.cpp +++ b/src/WASimModule/WASimModule.cpp @@ -641,7 +641,7 @@ bool getNamedVariableValue(char varType, calcResult_t &result) break; case 'A': - if (result.varId < 0) + if (result.varId < 0 || result.unitId < 0) return false; result.setF(aircraft_varget(result.varId, result.unitId, result.varIndex)); break; @@ -1009,9 +1009,14 @@ void getVariable(const Client *c, const Command *const cmd) return; } + if (unitId < 0 && varType == 'A') { + logAndNak(c, *cmd, ostringstream() << "Could not resolve Unit ID for Get command from string " << quoted(data)); + return; + } + calcResult_t res = calcResult_t { CalcResultType::Double, STRSZ_CMD, varId, unitId, varIndex, varName.c_str() }; if (!getNamedVariableValue(varType, res)) - return logAndNak(c, *cmd, ostringstream() << "getNamedVariableValue() returned error result for code " << quoted(data)); + return logAndNak(c, *cmd, ostringstream() << "getNamedVariableValue() returned error result for variable: " << quoted(data)); Command resp(CommandId::Ack, (uint32_t)cmd->commandId); resp.token = cmd->token; switch (res.resultMemberIndex) { @@ -1019,7 +1024,7 @@ void getVariable(const Client *c, const Command *const cmd) case 1: resp.fData = res.iVal; break; case 2: resp.setStringData(res.sVal.c_str()); break; default: - return logAndNak(c, *cmd, ostringstream() << "getNamedVariableValue() returned invalid result index " << res.resultMemberIndex); + return logAndNak(c, *cmd, ostringstream() << "getNamedVariableValue() for " << quoted(data) << " returned invalid result index: " << res.resultMemberIndex); } sendResponse(c, resp); } @@ -1184,7 +1189,7 @@ bool addOrUpdateRequest(Client *c, const DataRequest *const req) // check for empty name/code if (req->nameOrCode[0] == '\0') { - logAndNak(c, resp, ostringstream() << "Error in DataRequest ID: " << req->requestId << "; Parameter 'nameOrCode' cannot be empty."); + logAndNak(c, resp, ostringstream() << "Error in DataRequest ID " << req->requestId << ": Parameter 'nameOrCode' cannot be empty."); return false; } @@ -1198,29 +1203,29 @@ bool addOrUpdateRequest(Client *c, const DataRequest *const req) const SIMCONNECT_CLIENT_DATA_DEFINITION_ID newDataId = g_nextClienDataId++; // create a new data area and add definition if (!registerClientVariableDataArea(c, req->requestId, newDataId, actualValSize, req->valueSize)) { - logAndNak(c, resp, ostringstream() << "Failed to create ClientDataDefinition, check log messages."); + logAndNak(c, resp, ostringstream() << "Error in DataRequest ID " << req->requestId << ": Failed to create ClientDataDefinition, check log messages."); return false; } - + // this may change the request from a named to a calculated type for vars/string types which don't have native gauge API access functions. tr = &c->requests.emplace(piecewise_construct, forward_as_tuple(req->requestId), forward_as_tuple(*req, newDataId)).first->second; // no try_emplace? } else { // Existing request if (actualValSize > tr->dataSize) { - logAndNak(c, resp, ostringstream() << "Value size cannot be increased after request is created."); + logAndNak(c, resp, ostringstream() << "Error in DataRequest ID " << req->requestId << ": Value size cannot be increased after request is created."); return false; } // recreate data definition if necessary if (actualValSize != tr->dataSize) { // remove definition if FAILED(SimConnectHelper::removeClientDataDefinition(g_hSimConnect, tr->dataId)) { - logAndNak(c, resp, ostringstream() << "Failed to clear ClientDataDefinition, check log messages."); + logAndNak(c, resp, ostringstream() << "Error in DataRequest ID " << req->requestId << ": Failed to clear ClientDataDefinition, check log messages."); return false; } // add definition if FAILED(SimConnectHelper::addClientDataDefinition(g_hSimConnect, tr->dataId, req->valueSize)) { - logAndNak(c, resp, ostringstream() << "Failed to create ClientDataDefinition, check log messages."); + logAndNak(c, resp, ostringstream() << "Error in DataRequest ID " << req->requestId << ": Failed to create ClientDataDefinition, check log messages."); return false; } } @@ -1235,10 +1240,10 @@ bool addOrUpdateRequest(Client *c, const DataRequest *const req) tr->variableId = getVariableId(tr->varTypePrefix, tr->nameOrCode); if (tr->variableId < 0) { if (tr->varTypePrefix == 'T') { - LOG_WRN << "Token variable named " << quoted(tr->nameOrCode) << " was not found. Will fall back to initialize_var_by_name()."; + LOG_WRN << "Warning in DataRequest ID " << req->requestId << ": Token variable named " << quoted(tr->nameOrCode) << " was not found. Will fall back to initialize_var_by_name()."; } else { - LOG_WRN << "Variable named " << quoted(tr->nameOrCode) << " was not found, disabling updates."; + LOG_ERR << "Error in DataRequest ID " << req->requestId << ": Variable named " << quoted(tr->nameOrCode) << " was not found, disabling updates."; tr->period = UpdatePeriod::Never; } } @@ -1246,8 +1251,16 @@ bool addOrUpdateRequest(Client *c, const DataRequest *const req) // look up unit ID if we don't have one already if (tr->unitId < 0 && tr->unitName[0] != '\0') { tr->unitId = get_units_enum(tr->unitName); - if (tr->unitId < 0) - LOG_WRN << "Unit named " << quoted(tr->unitName) << " was not found."; + if (tr->unitId < 0) { + if (tr->varTypePrefix == 'A') { + LOG_ERR << "Error in DataRequest ID " << req->requestId << ": Unit named " << quoted(tr->unitName) << " was not found, disabling updates."; + tr->period = UpdatePeriod::Never; + } + // maybe an L var... unit is not technically required. + else { + LOG_WRN << "Warning in DataRequest ID " << req->requestId << ": Unit named " << quoted(tr->unitName) << " was not found, no unit type will be used."; + } + } } } // calculated value, update compiled string if needed @@ -1265,7 +1278,7 @@ bool addOrUpdateRequest(Client *c, const DataRequest *const req) } else { LOG_WRN << "Calculator string compilation failed. gauge_calculator_code_precompile() returned: " << boolalpha << ok - << "; size: " << pCompiledSize << "; Result null? " << (pCompiled == nullptr) << "; Original code : " << quoted(tr->nameOrCode); + << " for request ID " << tr->requestId << ". Size: " << pCompiledSize << "; Result null ? " << (pCompiled == nullptr) << "; Original code : " << quoted(tr->nameOrCode); } } // make sure any ms interval is >= our minimum tick time From 983e7ab609e81af81525ff84431b1c4557447d87 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Mon, 23 Oct 2023 23:43:42 -0400 Subject: [PATCH 26/65] [WASimModule] Add ability to return string type results for Get commands and Named data requests by converting them to calculator expressions on the server; - Also improves automatic conversion to calc code for other variable types by including the unit type, if given, and narrowing numeric results to integer types if needed. --- src/WASimModule/WASimModule.cpp | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/WASimModule/WASimModule.cpp b/src/WASimModule/WASimModule.cpp index 13960a5..a2591dd 100644 --- a/src/WASimModule/WASimModule.cpp +++ b/src/WASimModule/WASimModule.cpp @@ -131,11 +131,23 @@ struct TrackedRequest : DataRequest void checkRequestType() { - // Anything besides L/A/T type vars just gets converted to calc code. - if (requestType == RequestType::Named && variableId < 0 && !Utilities::isIndexedVariableType(varTypePrefix)) { - const ostringstream codeStr = ostringstream() << "(" << varTypePrefix << ':' << nameOrCode << ')'; + // Anything besides L/A/T type vars just gets converted to calc code, as well as any A vars with "string" unit type. + bool isString = false; + if (requestType == RequestType::Named && variableId < 0 && + (!Utilities::isIndexedVariableType(varTypePrefix) || (isString = (unitId < 0 && varTypePrefix == 'A' && !strcasecmp(unitName, "string")))) + ) { + ostringstream codeStr = ostringstream() << '(' << varTypePrefix << ':' << nameOrCode; + if (unitName[0] != '\0') + codeStr << ',' << unitName; + codeStr << ')'; setNameOrCode(codeStr.str().c_str()); requestType = RequestType::Calculated; + if (isString) + calcResultType = CalcResultType::String; + else if (valueSize > DATA_TYPE_INT64 || valueSize < 4) + calcResultType = CalcResultType::Integer; + else + calcResultType = CalcResultType::Double; } } @@ -981,10 +993,13 @@ void getVariable(const Client *c, const Command *const cmd) const char *data = cmd->sData; LOG_TRC << "getVariable(" << varType << ", " << quoted(data) << ") for client " << c->name; - // Anything besides L/A/T type vars just gets converted to calc code. - if (!Utilities::isIndexedVariableType(varType)) { + // Anything besides L/A/T type vars just gets converted to calc code. Also if a "string" type unit A var is requested. + size_t datalen; + bool isString = false; + if (!Utilities::isIndexedVariableType(varType) || (isString = varType == 'A' && (datalen = strlen(data)) > 6 && !strcasecmp(data + datalen-6, "string"))) { const ostringstream codeStr = ostringstream() << "(" << varType << ':' << data << ')'; - const Command execCmd(cmd->commandId, +CalcResultType::Double, codeStr.str().c_str(), 0.0, cmd->token); + CalcResultType ctype = isString ? CalcResultType::String : CalcResultType::Double; + const Command execCmd(cmd->commandId, +ctype, codeStr.str().c_str(), 0.0, cmd->token); return execCalculatorCode(c, &execCmd); } From 8e75eb8c087f5a39fee93c2b7d073500e4f14664 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Mon, 23 Oct 2023 23:57:52 -0400 Subject: [PATCH 27/65] [WASimClient] Add ability to return a string value with `getVariable()` to make use of new WASimModule feature. --- src/WASimClient/WASimClient.cpp | 10 ++++++---- src/include/client/WASimClient.h | 18 +++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/WASimClient/WASimClient.cpp b/src/WASimClient/WASimClient.cpp index 1463c34..2aaa706 100644 --- a/src/WASimClient/WASimClient.cpp +++ b/src/WASimClient/WASimClient.cpp @@ -911,7 +911,7 @@ class WASimClient::Private return sValue; } - HRESULT getVariable(const VariableRequest &v, double *result, double dflt = 0.0) + HRESULT getVariable(const VariableRequest &v, double *result, std::string *sResult = nullptr, double dflt = 0.0) { const string sValue = buildVariableCommandString(v, false); if (sValue.empty() || sValue.length() >= STRSZ_CMD) @@ -927,6 +927,8 @@ class WASimClient::Private } if (result) *result = response.fData; + if (sResult) + *sResult = response.sData; return S_OK; } @@ -1524,13 +1526,13 @@ HRESULT WASimClient::executeCalculatorCode(const std::string &code, CalcResultTy #pragma region Variable accessors ---------------------------------------------- -HRESULT WASimClient::getVariable(const VariableRequest & variable, double * pfResult) +HRESULT WASimClient::getVariable(const VariableRequest & variable, double * pfResult, std::string *psResult) { if (variable.variableId > -1 && !Utilities::isIndexedVariableType(variable.variableType)) { LOG_ERR << "Cannot get variable type '" << variable.variableType << "' by index."; return E_INVALIDARG; } - return d->getVariable(variable, pfResult); + return d->getVariable(variable, pfResult, psResult); } HRESULT WASimClient::getLocalVariable(const std::string &variableName, double *pfResult, const std::string &unitName) { @@ -1538,7 +1540,7 @@ HRESULT WASimClient::getLocalVariable(const std::string &variableName, double *p } HRESULT WASimClient::getOrCreateLocalVariable(const std::string &variableName, double *pfResult, double defaultValue, const std::string &unitName) { - return d->getVariable(VariableRequest(variableName, true, unitName), pfResult, defaultValue); + return d->getVariable(VariableRequest(variableName, true, unitName), pfResult, nullptr, defaultValue); } HRESULT WASimClient::setVariable(const VariableRequest & variable, const double value) { diff --git a/src/include/client/WASimClient.h b/src/include/client/WASimClient.h index be1e4f3..df0058c 100644 --- a/src/include/client/WASimClient.h +++ b/src/include/client/WASimClient.h @@ -321,20 +321,23 @@ static const HRESULT E_TIMEOUT = /*ERROR_TIMEOUT*/ 1460L | (/*FACILI // Variables accessors ------------------------------ /// Get a Variable value by name, with optional named unit type. This is primarily useful for local ('L') variables, SimVars ('A') and token variables ('T') which can be read via dedicated _Gauge API_ functions - /// (`get_named_variable_value()`/`get_named_variable_typed_value()`, `aircraft_varget()`, and `lookup_var()` respectively). Other variables types can also be set this way ('B', 'E', 'M', etc) but such requests are simply converted to a calculator string and - /// evaluated via the _Gauge API_ `execute_calculator_code()`. Using `WASimClient::executeCalculatorCode()` directly may be more efficient. + /// (`get_named_variable_value()`/`get_named_variable_typed_value()`, `aircraft_varget()`, and `lookup_var()` respectively). \n + /// Other variables types can also be set this way ('C', 'E', 'M', etc) but such requests are simply **converted to a calculator string** and evaluated via the _Gauge API_ `execute_calculator_code()`. \n + /// Likewise, requesting string-type variables using this method also ends up running a calculator expression on the server side. \n + /// In both cases, using `WASimClient::executeCalculatorCode()` directly may be more efficient. Also, unlike `executeCalculatorCode()`, this method will not return a string representation of a numeric value. /// \param variable See `VariableRequest` documentation for descriptions of the individual fields. - /// \param pfResult Pointer to a double precision variable to hold the result. + /// \param pfResult Pointer to a double precision variable to hold the numeric result. + /// \param psResult Pointer to a string type variable to hold a string-type result. See notes above regarding string types. /// \return `S_OK` on success, `E_INVALIDARG` on parameter validation errors, `E_NOT_CONNECTED` if not connected to server, `E_TIMEOUT` on server communication failure, or `E_FAIL` if server returns a Nak response. /// \note This method blocks until either the Server responds or the timeout has expired. /// \sa \refwcc{VariableRequest}, \refwce{CommandId::Get}, defaultTimeout(), setDefaultTimeout() - HRESULT getVariable(const VariableRequest &variable, double *pfResult); + HRESULT getVariable(const VariableRequest &variable, double *pfResult, std::string *psResult = nullptr); /// A convenience version of `getVariable(VariableRequest(variableName, false, unitName), pfResult)`. See `getVariable()` and `VariableRequest` for details. /// \param variableName Name of the local variable. /// \param pfResult Pointer to a double precision variable to hold the result. /// \param unitName Optional unit specifier to use. Most Local vars do not specify a unit and default to a generic "number" type. /// \return `S_OK` on success, `E_INVALIDARG` on parameter validation errors, `E_NOT_CONNECTED` if not connected to server, `E_TIMEOUT` on server communication failure, or `E_FAIL` if server returns a Nak response. - /// \note This method blocks until either the Server responds or the timeout has expired. \sa defaultTimeout(), setDefaultTimeout() + /// \note This method blocks until either the Server responds or the timeout has expired. \sa \refwce{CommandId::Get}, defaultTimeout(), setDefaultTimeout() HRESULT getLocalVariable(const std::string &variableName, double *pfResult, const std::string &unitName = std::string()); /// Gets the value of a local variable just like `getLocalVariable()` but will also create the variable on the simulator if it doesn't already exist. /// \param variableName Name of the local variable. @@ -342,7 +345,7 @@ static const HRESULT E_TIMEOUT = /*ERROR_TIMEOUT*/ 1460L | (/*FACILI /// \param defaultValue The L var will be created on the simulator if it doesn't exist yet using this initial value (and this same value will be returned in `pfResult`). /// \param unitName Optional unit specifier to use. Most Local vars do not specify a unit and default to a generic "number" type. /// \return `S_OK` on success, `E_INVALIDARG` on parameter validation errors, `E_NOT_CONNECTED` if not connected to server, `E_TIMEOUT` on server communication failure, or `E_FAIL` if server returns a Nak response. - /// \note This method blocks until either the Server responds or the timeout has expired. \sa defaultTimeout(), setDefaultTimeout() + /// \note This method blocks until either the Server responds or the timeout has expired. \sa \refwce{CommandId::GetCreate}, defaultTimeout(), setDefaultTimeout() HRESULT getOrCreateLocalVariable(const std::string &variableName, double *pfResult, double defaultValue = 0.0, const std::string &unitName = std::string()); /// Set a Variable value by name, with optional named unit type. Although any settable variable type can set this way, it is primarily useful for local (`L`) variables which can be set via dedicated _Gauge API_ functions @@ -358,6 +361,7 @@ static const HRESULT E_TIMEOUT = /*ERROR_TIMEOUT*/ 1460L | (/*FACILI /// \param value The value to set. /// \param unitName Optional unit specifier to use. Most Local vars do not specify a unit and default to a generic "number" type. /// \return `S_OK` on success, `E_INVALIDARG` on parameter validation errors, `E_NOT_CONNECTED` if not connected to server, or `E_FAIL` on general failure (unlikely). + /// \sa \refwce{CommandId::Set} HRESULT setLocalVariable(const std::string &variableName, const double value, const std::string &unitName = std::string()); /// Set a Local Variable value by variable name, creating it first if it does not already exist. This first calls the `register_named_variable()` _Gauge API_ function to get the ID from the name, /// which creates the variable if it doesn't exist. The returned ID (new or existing) is then used to set the value. Use the `lookup()` method to check for the existence of a variable name. @@ -366,7 +370,7 @@ static const HRESULT E_TIMEOUT = /*ERROR_TIMEOUT*/ 1460L | (/*FACILI /// \param value The value to set. Becomes the intial value if the variable is created. /// \param unitName Optional unit specifier to use. Most Local vars do not specify a unit and default to a generic "number" type. /// \return `S_OK` on success, `E_INVALIDARG` on parameter validation errors, `E_NOT_CONNECTED` if not connected to server, or `E_FAIL` on general failure (unlikely). - /// \sa \refwcc{VariableRequest}, \refwce{CommandId::SetCreate} + /// \sa \refwce{CommandId::SetCreate} HRESULT setOrCreateLocalVariable(const std::string &variableName, const double value, const std::string &unitName = std::string()); // Data subscriptions ------------------------------- From 0e54794b2ec8411f42d34a7696426724ffc5e932 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Tue, 24 Oct 2023 00:00:57 -0400 Subject: [PATCH 28/65] [CLI][WASimClient] Add `getVariable()` overloads for returning string type values; Move var request handling to common private handlers. --- src/WASimClient_CLI/WASimClient_CLI.cpp | 44 +++++++++++++++++++++++-- src/WASimClient_CLI/WASimClient_CLI.h | 25 +++++++------- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/src/WASimClient_CLI/WASimClient_CLI.cpp b/src/WASimClient_CLI/WASimClient_CLI.cpp index f234258..720e6f5 100644 --- a/src/WASimClient_CLI/WASimClient_CLI.cpp +++ b/src/WASimClient_CLI/WASimClient_CLI.cpp @@ -145,6 +145,24 @@ ref class WASimClient::Private return (HR)hr; } + inline HR getVariable(VariableRequest ^var, interior_ptr pfResult, interior_ptr psResult) + { + pin_ptr pf = pfResult; + std::string s { }; + HR ret = (HR)client->getVariable(var, pf, psResult ? &s : nullptr); + if (psResult) + *psResult = marshal_as(s); + return ret; + } + + inline HR getOrCreateLocalVariable(String ^ name, interior_ptr unit, double defaultValue, interior_ptr pfResult) + { + pin_ptr pf = pfResult; + if (unit) + return (HR)client->getOrCreateLocalVariable(marshal_as(name), pf, defaultValue, marshal_as((String ^)*unit)); + return (HR)client->getOrCreateLocalVariable(marshal_as(name), pf, defaultValue); + } + ~Private() { if (client) { @@ -197,18 +215,38 @@ WASimClient::!WASimClient() m_client = nullptr; } -inline HR WASimClient::executeCalculatorCode(String ^ code, CalcResultType resultType, [Out] double % pfResult) { +inline HR WASimClient::executeCalculatorCode(String ^ code, CalcResultType resultType, double %pfResult) { return d->executeCalculatorCode(code, resultType, &pfResult, nullptr); } -inline HR WASimClient::executeCalculatorCode(String ^ code, CalcResultType resultType, [Out] String ^% psResult) { +inline HR WASimClient::executeCalculatorCode(String ^ code, CalcResultType resultType, String^ %psResult) { return d->executeCalculatorCode(code, resultType, nullptr, &psResult); } -inline HR WASimClient::executeCalculatorCode(String ^ code, CalcResultType resultType, [Out] double % pfResult, [Out] String ^% psResult) { +inline HR WASimClient::executeCalculatorCode(String ^ code, CalcResultType resultType, double %pfResult, String^ %psResult) { return d->executeCalculatorCode(code, resultType, &pfResult, &psResult); } +inline HR WASimClient::getVariable(VariableRequest ^ var, double %pfResult) { + return d->getVariable(var, &pfResult, nullptr); +} + +inline HR WASimClient::getVariable(VariableRequest ^ var, String^ %psResult) { + return d->getVariable(var, nullptr, &psResult); +} + +inline HR WASimClient::getVariable(VariableRequest ^ var, double %pfResult, String^ %psResult) { + return d->getVariable(var, &pfResult, &psResult); +} + +inline HR WASimClient::getOrCreateLocalVariable(String ^variableName, double defaultValue, double %pfResult) { + return d->getOrCreateLocalVariable(variableName, nullptr, defaultValue, &pfResult); +} + +inline HR WASimClient::getOrCreateLocalVariable(String ^variableName, String ^unitName, double defaultValue, double %pfResult) { + return d->getOrCreateLocalVariable(variableName, &unitName, defaultValue, &pfResult); +} + inline array^ WASimClient::dataRequests() { const std::vector &res = m_client->dataRequests(); diff --git a/src/WASimClient_CLI/WASimClient_CLI.h b/src/WASimClient_CLI/WASimClient_CLI.h index 8ccde9e..d7ccff8 100644 --- a/src/WASimClient_CLI/WASimClient_CLI.h +++ b/src/WASimClient_CLI/WASimClient_CLI.h @@ -164,25 +164,24 @@ namespace WASimCommander::CLI::Client // Variables accessors ------------------------------ - /// See \refwccc{getVariable()} - HR getVariable(VariableRequest ^var, [Out] double %pfResult) { - pin_ptr pf = &pfResult; - return (HR)m_client->getVariable(var, pf); - } + /// Get the value of a variable with a numeric result type. This is the most typical use case since most variable types are numeric. \sa \refwccc{getVariable()} + HR getVariable(VariableRequest ^var, [Out] double %pfResult); + /// Get the value of a variable with a string result type. The request is executed as calculator code since that is the only way to get string results. + /// Note that only some 'A', 'C', and 'T' type variables can have a string value type in the first place. \sa \refwccc{getVariable()} + HR getVariable(VariableRequest ^var, [Out] String^ %psResult); + /// Get the value of a variable with _either_ a numeric or string result based on the unit type of the requested variable. + /// The request is executed as calculator code since that is the only way to get string results. Unlike `executeCalculatorCode()`, this method will not return a string representation of a numeric value. + /// Note that only some 'A', 'C', and 'T' type variables can have a string value type in the first place. \sa \refwccc{getVariable()} + HR getVariable(VariableRequest ^var, [Out] double %pfResult, [Out] String^ %psResult); ///< See \refwccc{getVariable()} + /// See \refwccc{getLocalVariable()} HR getLocalVariable(String ^variableName, [Out] double %pfResult) { return getVariable(gcnew VariableRequest(variableName), pfResult); } /// See \refwccc{getLocalVariable()} HR getLocalVariable(String ^variableName, String ^unitName, [Out] double %pfResult) { return getVariable(gcnew VariableRequest(variableName, false, unitName), pfResult); } /// \sa \refwccc{getOrCreateLocalVariable()} - HR getOrCreateLocalVariable(String ^variableName, double defaultValue, [Out] double %pfResult) { - pin_ptr pf = &pfResult; - return (HR)m_client->getOrCreateLocalVariable(marshal_as(variableName), pf, defaultValue); - } + HR getOrCreateLocalVariable(String ^variableName, double defaultValue, [Out] double %pfResult); /// \sa \refwccc{getOrCreateLocalVariable()} - HR getOrCreateLocalVariable(String ^variableName, String ^unitName, double defaultValue, [Out] double %pfResult) { - pin_ptr pf = &pfResult; - return (HR)m_client->getOrCreateLocalVariable(marshal_as(variableName), pf, defaultValue, marshal_as(unitName)); - } + HR getOrCreateLocalVariable(String ^variableName, String ^unitName, double defaultValue, [Out] double %pfResult); /// See \refwccc{setVariable()} HR setVariable(VariableRequest ^var, const double value) { return (HR)m_client->setVariable(var, value); } From 8bb2df74f2a2b391c6c1d87427448ccd6e4a0449 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Tue, 24 Oct 2023 00:03:06 -0400 Subject: [PATCH 29/65] [WASimUI] Add ability to request/display string values for Get Variable commands. --- src/WASimUI/WASimUI.cpp | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index 2985427..b236a07 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -174,15 +174,21 @@ class WASimUIPrivate return; } double result; + std::string sRes; HRESULT hr; if (vtype == 'L' && create) hr = client->getOrCreateLocalVariable(varName.toStdString(), &result, ui->dsbSetVarValue->value(), ui->cbSetVarUnitName->currentText().toStdString()); else - hr = client->getVariable(VariableRequest(vtype, varName.toStdString(), ui->cbSetVarUnitName->currentText().toStdString(), ui->sbGetSetSimVarIndex->value()), &result); - if (hr == S_OK) - ui->leVarResult->setText(QString("%1").arg(result, 0, 'f', 7)); - else - ui->leVarResult->setText(QString("Error: 0x%1").arg((quint32)hr, 8, 16, QChar('0'))); + hr = client->getVariable(VariableRequest(vtype, varName.toStdString(), ui->cbSetVarUnitName->currentText().toStdString(), ui->sbGetSetSimVarIndex->value()), &result, &sRes); + if (hr == S_OK) { + if (ui->cbSetVarUnitName->currentText() == QLatin1Literal("string")) + ui->leVarResult->setText(sRes.empty() ? tr("Result returned empty string") : QString::fromStdString(sRes)); + else + ui->leVarResult->setText(QString::number(result)); + return; + } + ui->leVarResult->setText(QString("Error: 0x%1").arg((quint32)hr, 8, 16, QChar('0'))); + logUiMessage(tr("Variable request failed."), CommandId::Get); } void setLocalVar(bool create = false) From 02f8f7fb53fd96ba925e7486999d4e9b0e9e2501 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Tue, 24 Oct 2023 00:04:28 -0400 Subject: [PATCH 30/65] [WASimUI] Add timestamp to status bar notification messages. --- src/WASimUI/Widgets.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/WASimUI/Widgets.h b/src/WASimUI/Widgets.h index 8f9f81d..569a00a 100644 --- a/src/WASimUI/Widgets.h +++ b/src/WASimUI/Widgets.h @@ -134,8 +134,11 @@ class CommandStatusWidget : public QWidget lo->setContentsMargins(8, 1, 10, 2); iconLabel = new QLabel(this); iconLabel->setFixedSize(iconSize, iconSize); + tsLabel = new QLabel(this); + tsLabel->setForegroundRole(QPalette::Link); textLabel = new QLabel(this); lo->addWidget(iconLabel, 0); + lo->addWidget(tsLabel, 0); lo->addWidget(textLabel, 1); icon.addFile("fg=green/thumb_up.glyph", QSize(), QIcon::Normal, QIcon::Off); @@ -154,11 +157,13 @@ class CommandStatusWidget : public QWidget const QIcon::Mode icnMode = resp.commandId == WSEnums::CommandId::Ack ? QIcon::Mode::Normal : QIcon::Mode::Active; iconLabel->setPixmap(icon.pixmap(iconSize, icnMode)); textLabel->setText(msg + details); + tsLabel->setText(QTime::currentTime().toString("[hh:mm:ss.zzz]")); } private: QIcon icon; QLabel *iconLabel; + QLabel *tsLabel; QLabel *textLabel; int iconSize; }; From 70e0ef31b01a1a772d9e49102e0a77ec6f3e928b Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Tue, 24 Oct 2023 03:07:45 -0400 Subject: [PATCH 31/65] [WASimModule] Prevent possible simulator hang on exit when quitting with active client(s) connections; - Check that client is connected before sending any responses; - Don't bother sending Ack on client's Disconnect command; - Remove vain attempt at graceful client disconnection on module reload (never worked anyway). --- src/WASimModule/WASimModule.cpp | 35 ++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/WASimModule/WASimModule.cpp b/src/WASimModule/WASimModule.cpp index a2591dd..2d57fde 100644 --- a/src/WASimModule/WASimModule.cpp +++ b/src/WASimModule/WASimModule.cpp @@ -249,7 +249,6 @@ steady_clock::time_point g_tpNextTick { steady_clock::now() }; SIMCONNECT_CLIENT_EVENT_ID g_nextClientEventId = SIMCONNECTID_LAST; SIMCONNECT_CLIENT_DATA_DEFINITION_ID g_nextClienDataId = SIMCONNECTID_LAST; bool g_triggersRegistered = false; -bool g_simConnectQuitEvent = false; #pragma endregion Globals //---------------------------------------------------------------------------- @@ -258,6 +257,8 @@ bool g_simConnectQuitEvent = false; bool sendResponse(const Client *c, const Command &cmd) { + if (c->status != ClientStatus::Connected) + return false; LOG_TRC << "Sending command to " << c->name << ": " << cmd; return INVOKE_SIMCONNECT( SetClientData, g_hSimConnect, @@ -291,6 +292,8 @@ bool sendPing(const Client *c) { bool sendLogRecord(const Client *c, const LogRecord &log) { + if (c->status != ClientStatus::Connected) + return false; //if (c->logLevel < LogLevel::Trace) LOG_TRC << "Sending LogRecord to " << c->name << ": " << log; return INVOKE_SIMCONNECT( SetClientData, g_hSimConnect, @@ -302,6 +305,8 @@ bool sendLogRecord(const Client *c, const LogRecord &log) bool writeRequestData(const Client *c, const TrackedRequest *tr, void *data) { + if (c->status != ClientStatus::Connected) + return false; LOG_TRC << "Writing request ID " << tr->requestId << " data for " << c->name << " to CDA / CDD ID " << tr->dataId << " of size " << tr->dataSize; return INVOKE_SIMCONNECT( SetClientData, g_hSimConnect, @@ -544,6 +549,12 @@ void disconnectAllClients(const char *message = nullptr) disconnectClient(&it.second, message, ClientStatus::Disconnected); } +void disableAllClients() +{ + for (auto &it : g_mClients) + it.second.status = ClientStatus::Disconnected; +} + // callback for logfault IdProxyHandler log handler void CALLBACK clientLogHandler(const uint32_t id, const logfault::Message &msg) { @@ -1554,8 +1565,7 @@ void processCommand(Client *c, const Command *const cmd) case CommandId::Disconnect: disconnectClient(c); - ackMsg = "Disconnected by Client command."; - break; + return; case CommandId::Ping: // just ACK the ping case CommandId::Connect: // client was already re-connected or we wouldn't be here, just ACK @@ -1652,8 +1662,7 @@ void CALLBACK dispatchMessage(SIMCONNECT_RECV* pData, DWORD cbData, void*) LOG_CRT << "Invalid DataRequest struct data size! Expected " << sizeof(DataRequest) << " but got " << dataSize; return; } - if (addOrUpdateRequest(c, reinterpret_cast(&data->dwData)) && !g_triggersRegistered) - registerTriggerEvents(); // start update loop + addOrUpdateRequest(c, reinterpret_cast(&data->dwData)); break; default: @@ -1671,8 +1680,9 @@ void CALLBACK dispatchMessage(SIMCONNECT_RECV* pData, DWORD cbData, void*) SimConnectHelper::logSimVersion(pData); break; + // This doesn't seem to ever actually happen... but I guess it may just start to one day ¯\_(ツ)_/¯ case SIMCONNECT_RECV_ID_QUIT: - g_simConnectQuitEvent = true; + disableAllClients(); LOG_DBG << "Received quit command from SimConnect."; break; @@ -1750,18 +1760,11 @@ MSFS_CALLBACK void module_init(void) MSFS_CALLBACK void module_deinit(void) { + // don't send out any more data at this point (especially logs!) as it may prevent the simulator from exiting (new in SU13, yay!) + disableAllClients(); LOG_INF << "Stopping " WSMCMND_PROJECT_NAME " " WSMCMND_SERVER_NAME; - - if (g_hSimConnect != INVALID_HANDLE_VALUE) { - // Unloading of module would typically only happen when simulator quits, except during development and manual project rebuild. - // The below code is just for that occasion. Normally any connected Clients should detect shutdown via the SimConnect Close event. - if (!g_simConnectQuitEvent) { - // Disconnect any clients (does not seem to actually send any data... SimConnect context destroyed?) - LOG_DBG << "SimConnect apparently didn't send Quit message... disconnecting any clients."; - disconnectAllClients("Server is shutting down."); - } + if (g_hSimConnect != INVALID_HANDLE_VALUE) SimConnect_Close(g_hSimConnect); - } g_hSimConnect = INVALID_HANDLE_VALUE; } From 90242ed494069aba5bcdad839914b9fcfc6521e2 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Tue, 24 Oct 2023 03:23:08 -0400 Subject: [PATCH 32/65] [WASimModule] Refactor how the tick loop's "Frame" event driver is suspended/resumed: - Fixes SimConnect error when trying to unsubscribe from the "Frame" event (cause unknown); - Use SimConnect_SetSystemEventState() to start or pause Frame event updates (no errors, yay!); - Pause the tick loop if last data request is removed or all clients have paused their updates; - Don't check for client timeout if data updates are paused; - Remove redundant condition check when automatically re-connecting a client. --- src/WASimModule/WASimModule.cpp | 69 +++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/src/WASimModule/WASimModule.cpp b/src/WASimModule/WASimModule.cpp index 2d57fde..1c42268 100644 --- a/src/WASimModule/WASimModule.cpp +++ b/src/WASimModule/WASimModule.cpp @@ -513,7 +513,9 @@ Client *connectClient(uint32_t id) return connectClient(getOrCreateClient(id)); } -void checkTriggerEventNeeded(); // forward, in Utility Functions just below +// forwards, in Utility Functions just below +void checkTriggerEventNeeded(); +void resumeTriggerEvent(); // marks client as disconnected or timed out and clears any saved requests/events void disconnectClient(Client *c, ClientStatus newStatus = ClientStatus::Disconnected) @@ -555,6 +557,23 @@ void disableAllClients() it.second.status = ClientStatus::Disconnected; } +std::string setSuspendClientDataUpdates(Client *c, bool suspend) +{ + if (c->pauseDataUpdates == suspend) + return suspend ? "Data Subscriptions already paused" : "Data Subscriptions already active"; + + if (suspend) { + c->pauseDataUpdates = true; + checkTriggerEventNeeded(); + return "Data Subscription updates suspended"; + } + + c->pauseDataUpdates = false; + if (!g_triggersRegistered && c->requests.size()) + resumeTriggerEvent(); + return "Data Subscription updates resumed"; +} + // callback for logfault IdProxyHandler log handler void CALLBACK clientLogHandler(const uint32_t id, const logfault::Message &msg) { @@ -590,19 +609,19 @@ bool setClientLogLevel(Client *c, LogLevel level) //---------------------------------------------------------------------------- // this runs once we have any requests to keep updated which essentially starts the tick() processing. -void registerTriggerEvents() +void resumeTriggerEvent() { // use "Frame" event as trigger for our tick() loop - if FAILED(INVOKE_SIMCONNECT(SubscribeToSystemEvent, g_hSimConnect, (SIMCONNECT_CLIENT_EVENT_ID)EVENT_FRAME, "Frame")) + if FAILED(INVOKE_SIMCONNECT(SetSystemEventState, g_hSimConnect, (SIMCONNECT_CLIENT_EVENT_ID)EVENT_FRAME, SIMCONNECT_STATE_ON)) return; g_triggersRegistered = true; LOG_INF << "DataRequest data update processing started."; } // and here is the opposite... if all clients disconnect we can stop the ticker loop. -void unregisterTriggerEvents() +void pauseTriggerEvent() { - if FAILED(INVOKE_SIMCONNECT(UnsubscribeFromSystemEvent, g_hSimConnect, (SIMCONNECT_CLIENT_EVENT_ID)EVENT_FRAME)) + if FAILED(INVOKE_SIMCONNECT(SetSystemEventState, g_hSimConnect, (SIMCONNECT_CLIENT_EVENT_ID)EVENT_FRAME, SIMCONNECT_STATE_OFF)) return; g_triggersRegistered = false; LOG_INF << "DataRequest update processing stopped."; @@ -614,10 +633,11 @@ void unregisterTriggerEvents() void checkTriggerEventNeeded() { for (const clientMap_t::value_type &it : g_mClients) { - if (it.second.status == ClientStatus::Connected) + const Client &c = it.second; + if (c.status == ClientStatus::Connected && !c.pauseDataUpdates && c.requests.size()) return; } - unregisterTriggerEvents(); + pauseTriggerEvent(); } bool execCalculatorCode(const char *code, calcResult_t &result, bool precompiled = false) @@ -1196,19 +1216,19 @@ bool removeRequest(Client *c, const uint32_t requestId) c->requests.erase(tr->requestId); LOG_DBG << "Deleted DataRequest ID " << requestId; sendAckNak(c, CommandId::Subscribe, true, requestId); + if (g_triggersRegistered && !c->requests.size()) + checkTriggerEventNeeded(); // check if anyone is still connected return true; } -// returns true if request has been scheduled *and* will require regular updates (period > UpdatePeriod::Once) +// returns true if request has been scheduled or removed bool addOrUpdateRequest(Client *c, const DataRequest *const req) { LOG_DBG << "Got DataRequest from Client " << c->name << ": " << *req; // request type of "None" actually means to delete an existing request - if (req->requestType == RequestType::None) { - removeRequest(c, req->requestId); - return false; // no updates needed - } + if (req->requestType == RequestType::None) + return removeRequest(c, req->requestId); // setup response Command for Ack/Nak const Command resp(CommandId::Subscribe, 0, nullptr, .0, req->requestId); @@ -1316,7 +1336,9 @@ bool addOrUpdateRequest(Client *c, const DataRequest *const req) updateRequestValue(c, tr, false); LOG_DBG << (isNewRequest ? "Added " : "Updated ") << *tr; - return (tr->period > UpdatePeriod::Once); + if (!g_triggersRegistered && tr->period > UpdatePeriod::Once && !c->pauseDataUpdates) + resumeTriggerEvent(); // start update loop + return true; } #pragma endregion Data Requests @@ -1479,9 +1501,9 @@ void tick() g_tpNextTick = now + milliseconds(TICK_PERIOD_MS); for (clientMap_t::value_type &cp : g_mClients) { - if (cp.second.status != ClientStatus::Connected) - continue; Client &c = cp.second; + if (c.status != ClientStatus::Connected || c.pauseDataUpdates) + continue; // check for timeout if (now >= c.nextTimeout) { disconnectClient(&c, "Client connection timed out.", ClientStatus::TimedOut); @@ -1492,8 +1514,7 @@ void tick() c.nextHearbeat = now + seconds(CONN_HEARTBEAT_SEC); sendPing(&c); } - if (c.pauseDataUpdates) - continue; + // process data requests for (requestMap_t::value_type &rp : c.requests) { TrackedRequest &r = rp.second; // check if update needed @@ -1547,8 +1568,7 @@ void processCommand(Client *c, const Command *const cmd) break; case CommandId::Subscribe: - c->pauseDataUpdates = !cmd->uData; - ackMsg = c->pauseDataUpdates ? "Data Subscription updates suspended" : "Data Subscription updates resumed"; + ackMsg = setSuspendClientDataUpdates(c, !cmd->uData); break; case CommandId::Update: @@ -1633,8 +1653,8 @@ void CALLBACK dispatchMessage(SIMCONNECT_RECV* pData, DWORD cbData, void*) } if (c->status == ClientStatus::Connected) updateClientTimeout(c); // update heartbeat timer - else if (!connectClient(c)) // assume client wants to re-connect if they're not already - break; // unlikely + else + connectClient(c); // assume client wants to re-connect if they're not already const size_t dataSize = (size_t)pData->dwSize + 4 - sizeof(SIMCONNECT_RECV_CLIENT_DATA); // dwSize reports 4 bytes less than actual size of SIMCONNECT_RECV_CLIENT_DATA switch (dr->type) { @@ -1749,6 +1769,13 @@ MSFS_CALLBACK void module_init(void) // register incoming Ping event and add to notification group (this is technically not "critical" to operation so do not exit on error here SimConnectHelper::newClientEvent(g_hSimConnect, CLI_EVENT_PING, string(EVENT_NAME_PING, strlen(EVENT_NAME_PING)), GROUP_DEFAULT); + // use "Frame" event as trigger for our tick() loop + if FAILED(hr = SimConnect_SubscribeToSystemEvent(g_hSimConnect, (SIMCONNECT_CLIENT_EVENT_ID)EVENT_FRAME, "Frame")) { + LOG_CRT << "SimConnect_SubscribeToSystemEvent failed with " << LOG_HR(hr);; + return; + } + pauseTriggerEvent(); // pause frame updates for now + // Go if FAILED(hr = SimConnect_CallDispatch(g_hSimConnect, dispatchMessage, nullptr)) { LOG_CRT << "SimConnect_CallDispatch failed with " << LOG_HR(hr);; From fe99bbb25c5dd907e8a4d513769759c4b430580f Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Fri, 27 Oct 2023 17:10:54 -0400 Subject: [PATCH 33/65] [WASimModule] Queue data requests with "Once" type update period if data updates are paused when the request is submitted. These requests will be sent when/if updates are resumed again by the client. Fixes that data would be sent anyway when the request is initially submitted, even if updates are paused. --- src/WASimModule/WASimModule.cpp | 65 ++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/src/WASimModule/WASimModule.cpp b/src/WASimModule/WASimModule.cpp index 1c42268..8ccc264 100644 --- a/src/WASimModule/WASimModule.cpp +++ b/src/WASimModule/WASimModule.cpp @@ -557,23 +557,6 @@ void disableAllClients() it.second.status = ClientStatus::Disconnected; } -std::string setSuspendClientDataUpdates(Client *c, bool suspend) -{ - if (c->pauseDataUpdates == suspend) - return suspend ? "Data Subscriptions already paused" : "Data Subscriptions already active"; - - if (suspend) { - c->pauseDataUpdates = true; - checkTriggerEventNeeded(); - return "Data Subscription updates suspended"; - } - - c->pauseDataUpdates = false; - if (!g_triggersRegistered && c->requests.size()) - resumeTriggerEvent(); - return "Data Subscription updates resumed"; -} - // callback for logfault IdProxyHandler log handler void CALLBACK clientLogHandler(const uint32_t id, const logfault::Message &msg) { @@ -1327,19 +1310,57 @@ bool addOrUpdateRequest(Client *c, const DataRequest *const req) << " for request ID " << tr->requestId << ". Size: " << pCompiledSize << "; Result null ? " << (pCompiled == nullptr) << "; Original code : " << quoted(tr->nameOrCode); } } - // make sure any ms interval is >= our minimum tick time - if (tr->period == UpdatePeriod::Millisecond) - tr->interval = max((time_t)tr->interval, TICK_PERIOD_MS); sendAckNak(c, resp, true); - if (tr->period != UpdatePeriod::Never) - updateRequestValue(c, tr, false); + + if (tr->period != UpdatePeriod::Never) { + // make sure any ms interval is >= our minimum tick time + if (tr->period == UpdatePeriod::Millisecond && tr->interval < TICK_PERIOD_MS) + tr->interval = TICK_PERIOD_MS; + // If updates are not paused then send the value now; this takes care of "Once" type requests as well. + if (!c->pauseDataUpdates) + updateRequestValue(c, tr, false); + // If they're paused and this is a "Once" type request, use the interval as a flag to indicate that an update + // is pending for this request, which is then handled in setSuspendClientDataUpdates(false) below to send the value. + else if (tr->period == UpdatePeriod::Once) + tr->interval = 1; + } LOG_DBG << (isNewRequest ? "Added " : "Updated ") << *tr; if (!g_triggersRegistered && tr->period > UpdatePeriod::Once && !c->pauseDataUpdates) resumeTriggerEvent(); // start update loop return true; } + +std::string setSuspendClientDataUpdates(Client *c, bool suspend) +{ + if (c->pauseDataUpdates == suspend) + return suspend ? "Data Subscriptions already paused" : "Data Subscriptions already active"; + + if (suspend) { + c->pauseDataUpdates = true; + checkTriggerEventNeeded(); + return "Data Subscription updates suspended"; + } + + // Check for any "Once" type requests which are still pending and send them. + // While we're at it we can also check if there are any data updates which need scheduling. + bool resume = false; + for (requestMap_t::value_type &rp : c->requests) { + if (rp.second.period >= UpdatePeriod::Tick) { + resume = true; + } + else if (rp.second.period == UpdatePeriod::Once && rp.second.interval == 1) { + rp.second.interval = 0; + updateRequestValue(c, &rp.second); + } + } + c->pauseDataUpdates = false; + if (resume && !g_triggersRegistered) + resumeTriggerEvent(); + return "Data Subscription updates resumed"; +} + #pragma endregion Data Requests #pragma region Registered Calculator Events ---------------------------------------------- From bea8bccba38fae987690d5af259f6f8b22fbc781 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Fri, 27 Oct 2023 17:22:09 -0400 Subject: [PATCH 34/65] [WASimClient] Save the request updates paused state (set with `setDataRequestsPaused()`) locally even if not connected to server; Send the paused state to server upon connection before sending any queued data requests. --- src/WASimClient/WASimClient.cpp | 12 +++++++++++- src/include/client/WASimClient.h | 5 ++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/WASimClient/WASimClient.cpp b/src/WASimClient/WASimClient.cpp index 2aaa706..c292199 100644 --- a/src/WASimClient/WASimClient.cpp +++ b/src/WASimClient/WASimClient.cpp @@ -234,6 +234,7 @@ class WASimClient::Private atomic_bool simConnected = false; atomic_bool serverConnected = false; atomic_bool logCDAcreated = false; + atomic_bool requestsPaused = false; HANDLE hSim = nullptr; HANDLE hSimEvent = nullptr; @@ -676,6 +677,8 @@ class WASimClient::Private listResult.reset(); // make sure server knows our desired log level and set up data area/request if needed updateServerLogLevel(); + // set update status of data requests before adding any, in case we don't actually want results yet + sendServerCommand(Command(CommandId::Subscribe, (requestsPaused ? 0 : 1))); // (re-)register (or delete) any saved DataRequests registerAllDataRequests(); // same with calculator events @@ -1610,7 +1613,14 @@ vector WASimClient::dataRequestIdsList() const } HRESULT WASimClient::setDataRequestsPaused(bool paused) const { - return d_const->sendServerCommand(Command(CommandId::Subscribe, (paused ? 0 : 1))); + if (isConnected()) { + HRESULT hr = d_const->sendServerCommand(Command(CommandId::Subscribe, (paused ? 0 : 1))); + if SUCCEEDED(hr) + d->requestsPaused = paused; + return hr; + } + d->requestsPaused = paused; + return S_OK; } #pragma endregion Data diff --git a/src/include/client/WASimClient.h b/src/include/client/WASimClient.h index df0058c..566c1a7 100644 --- a/src/include/client/WASimClient.h +++ b/src/include/client/WASimClient.h @@ -411,7 +411,10 @@ static const HRESULT E_TIMEOUT = /*ERROR_TIMEOUT*/ 1460L | (/*FACILI /// Enables or disables all data request subscription updates at the same time. Use this to temporarily suspend value update checks when they are not needed, but may be again in the future. /// This is a lot more efficient than disconnecting and re-connecting to the server, since all the data requests need to be re-created upon every new connection (similar to SimConnect itself). - /// \return `S_OK` on success, `E_NOT_CONNECTED` if not connected to server. + /// \since{v1.2} + /// This method can be called while not connected to the server. In this case the setting is saved and sent to the server upon next connection, before sending any data request subscriptions. + /// This way updates could be suspended upon initial connection, then re-enabled when the data is actually needed. + /// \return `S_OK` on success; If currently connected to the server, may also return `E_TIMEOUT` on general server communication failure. HRESULT setDataRequestsPaused(bool paused) const; // Custom Event registration -------------------------- From 3d552a15ed8f2dbbb749621bd1b0c1c6a3a98fb3 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Fri, 27 Oct 2023 19:09:30 -0400 Subject: [PATCH 35/65] [WASimUI][ActionPushButton] Fix some possible issues and minor optimizations. --- src/WASimUI/ActionPushButton.cpp | 30 +++++++++++++++++++++++------- src/WASimUI/ActionPushButton.h | 1 + 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/WASimUI/ActionPushButton.cpp b/src/WASimUI/ActionPushButton.cpp index 990b08a..f40660a 100644 --- a/src/WASimUI/ActionPushButton.cpp +++ b/src/WASimUI/ActionPushButton.cpp @@ -34,6 +34,7 @@ #include #include #include +#include ActionPushButton::ActionPushButton(QAction *defaultAction, QWidget *parent) : QPushButton(parent) @@ -74,17 +75,15 @@ void ActionPushButton::paintEvent(QPaintEvent *e) QStylePainter p(this); QStyleOptionButton option; initStyleOption(&option); - if (!menu() && m_defaultAction && m_defaultAction->menu()) + if (!menu() && m_menuFromAction) option.features |= QStyleOptionButton::HasMenu; p.drawControl(QStyle::CE_PushButton, option); } void ActionPushButton::nextCheckState() { - if (!!m_defaultAction) - m_defaultAction->trigger(); - else - QPushButton::nextCheckState(); + if (!m_defaultAction && isCheckable()) + setChecked(!isChecked()); } void ActionPushButton::updateFromAction(QAction *action) @@ -123,8 +122,13 @@ void ActionPushButton::setDefaultAction(QAction *action) if (m_defaultAction == action) return; - if (m_menuFromAction && m_defaultAction->menu()) - disconnect(action->menu()->menuAction(), 0, this, 0); + bool hadDefault = !!m_defaultAction; + if (!!m_defaultAction) { + disconnect(this, &QPushButton::clicked, this, &ActionPushButton::onClicked); + if (m_menuFromAction && m_defaultAction->menu()) + disconnect(m_defaultAction->menu()->menuAction(), 0, this, 0); + } + m_menuFromAction = false; m_defaultAction = action; if (!action) @@ -133,6 +137,18 @@ void ActionPushButton::setDefaultAction(QAction *action) if (!actions().contains(action)) addAction(action); updateFromAction(action); + + connect(this, &QPushButton::clicked, this, &ActionPushButton::onClicked); +} + +void ActionPushButton::onClicked(bool checked) +{ + if (!m_defaultAction) + return; + if (isCheckable()) + m_defaultAction->setChecked(checked); + else + m_defaultAction->trigger(); } void ActionPushButton::onActionTriggered() diff --git a/src/WASimUI/ActionPushButton.h b/src/WASimUI/ActionPushButton.h index ba72bca..260f31c 100644 --- a/src/WASimUI/ActionPushButton.h +++ b/src/WASimUI/ActionPushButton.h @@ -75,6 +75,7 @@ class ActionPushButton : public QPushButton private slots: void updateFromAction(QAction *action); + void onClicked(bool checked = false); void onActionTriggered(); private: From f7e296be6537400c165a3c6c563821bfbeb83cbd Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Fri, 27 Oct 2023 19:11:04 -0400 Subject: [PATCH 36/65] [WASimUI][DataComboBox] Allow adding string type data values directly instead of needing to convert to variant list. --- src/WASimUI/DataComboBox.h | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/WASimUI/DataComboBox.h b/src/WASimUI/DataComboBox.h index 51e4a6f..0aa9293 100644 --- a/src/WASimUI/DataComboBox.h +++ b/src/WASimUI/DataComboBox.h @@ -82,8 +82,24 @@ class DataComboBox: public QComboBox } //! Add items with corresponding data from \a items map, using the specified \a role. If \a role is \c -1 (default) then the \p defaultRole is used. - void addItems(const QMap &items, int role = -1) + void addItems(const QMap &items, int role = -1) { + addItems(items.keys(), items.values(), role); + } + + //! Add items with names from \a texts, with corresponding data from \a datas list, using the specified \a role. If \a role is \c -1 (default) then the \p defaultRole is used. + void addItems(const QStringList &texts, const QStringList &datas, int role = -1) { + if (role < 0) + role = m_role; + const int c = count(); + for (int i=0; i < texts.count(); ++i) { + addItem(texts.at(i)); + setItemData(i + c, datas.value(i, texts.at(i)), role); + } + } + + //! Add items with corresponding data from \a items map, using the specified \a role. If \a role is \c -1 (default) then the \p defaultRole is used. + void addItems(const QMap &items, int role = -1) { addItems(items.keys(), items.values(), role); } From 7409e3f497d667bddd78af41fa10bf900769bfa9 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Fri, 27 Oct 2023 19:16:59 -0400 Subject: [PATCH 37/65] [WASimUI][EventsModel] Reuse existing items if possible when updating data; Remove `flattenIndexList()`. --- src/WASimUI/EventsModel.h | 35 +++++++++++++++++------------------ src/WASimUI/WASimUI.cpp | 4 ++-- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/WASimUI/EventsModel.h b/src/WASimUI/EventsModel.h index d16a332..e0c1f43 100644 --- a/src/WASimUI/EventsModel.h +++ b/src/WASimUI/EventsModel.h @@ -103,7 +103,6 @@ class EventsModel : public QStandardItemModel EventRecord req(-1); if (row >= rowCount()) return req; - QStandardItem *idItem = item(row, COL_ID); req.eventId = item(row, COL_ID)->data(DataRole).toUInt(); req.code = item(row, COL_CODE)->text().toStdString(); req.name = item(row, COL_NAME)->text().toStdString(); @@ -116,12 +115,9 @@ class EventsModel : public QStandardItemModel if (row < 0) row = rowCount(); - setItem(row, COL_ID, new QStandardItem(QString("%1").arg(req.eventId))); - item(row, COL_ID)->setData(req.eventId, DataRole); - - setItem(row, COL_CODE, new QStandardItem(QString::fromStdString(req.code))); - setItem(row, COL_NAME, new QStandardItem(QString::fromStdString(req.name))); - + setOrCreateItem(row, COL_ID, QString::number(req.eventId), req.eventId); + setOrCreateItem(row, COL_CODE, QString::fromStdString(req.code)); + setOrCreateItem(row, COL_NAME, QString::fromStdString(req.name)); return index(row, 0); } @@ -182,21 +178,24 @@ class EventsModel : public QStandardItemModel return ret; } - static inline QModelIndexList flattenIndexList(const QModelIndexList &list) +signals: + void rowCountChanged(int rows); + +protected: + QStandardItem *setOrCreateItem(int row, int col, const QString &text, const QVariant &data = QVariant()) { - QModelIndexList ret; - QModelIndex lastIdx; - for (const QModelIndex &idx : list) { - if (idx.column() == COL_ID && lastIdx.row() != idx.row() && idx.row() < idx.model()->rowCount()) - ret.append(idx); - lastIdx = idx; + QStandardItem *itm = item(row, col); + if (!itm){ + setItem(row, col, new QStandardItem()); + itm = item(row, col); } - return ret; + itm->setText(text); + itm->setToolTip(text); + if (data.isValid()) + itm->setData(data, DataRole); + return itm; } -signals: - void rowCountChanged(int rows); - private: uint32_t m_nextEventId = 0; diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index b236a07..eaba4fa 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -511,7 +511,7 @@ class WASimUIPrivate } void removeSelectedEvents() { - removeEvents(eventsModel->flattenIndexList(ui->eventsView->selectionModel()->selectedIndexes())); + removeEvents(ui->eventsView->selectionModel()->selectedRows(EventsModel::COL_ID)); } void removeAllEvents() { @@ -528,7 +528,7 @@ class WASimUIPrivate { if (!checkConnected()) return; - const QModelIndexList list = eventsModel->flattenIndexList(ui->eventsView->selectionModel()->selectedIndexes()); + const QModelIndexList list = ui->eventsView->selectionModel()->selectedRows(EventsModel::COL_ID); for (const QModelIndex &idx : list) client->transmitEvent(eventsModel->eventId(idx.row())); } From a1e6f1323ed724f53f6f6bfb80681b968fd36cd2 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Fri, 27 Oct 2023 19:25:53 -0400 Subject: [PATCH 38/65] [WASimUI][LogConsole] Fix setting pause button state based on selection; Remove QSettings from save/loadSettings(); Move to `WASimUi` namespace. --- src/WASimUI/LogConsole.cpp | 27 ++++++++++++++++++++++++--- src/WASimUI/LogConsole.h | 27 +++++++++++++++++++++++++-- src/WASimUI/WASimUI.cpp | 5 ++--- src/WASimUI/WASimUI.ui | 4 ++-- src/WASimUI/WASimUI.vcxproj | 3 +++ 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/WASimUI/LogConsole.cpp b/src/WASimUI/LogConsole.cpp index 5833f91..24a458e 100644 --- a/src/WASimUI/LogConsole.cpp +++ b/src/WASimUI/LogConsole.cpp @@ -1,3 +1,22 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + #include "LogConsole.h" #include "Utils.h" @@ -113,7 +132,7 @@ LogConsole::LogConsole(QWidget *parent) }); // connect log viewer selection model to show pause button active while there is a selection connect(ui.logView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=](const QItemSelection &sel, const QItemSelection&) { - pauseLogScrollAct->setChecked(!sel.isEmpty()); + pauseLogScrollAct->setChecked(ui.logView->selectionModel()->hasSelection()); }); } @@ -136,15 +155,17 @@ void LogConsole::setClient(WASimCommander::Client::WASimClient *c) { LogRecordsModel *LogConsole::getModel() const { return logModel; } -void LogConsole::saveSettings(QSettings &set) const +void LogConsole::saveSettings() const { + QSettings set; set.beginGroup(objectName()); set.setValue(QStringLiteral("logViewHeaderState"), ui.logView->horizontalHeader()->saveState()); set.endGroup(); } -void LogConsole::loadSettings(QSettings &set) +void LogConsole::loadSettings() { + QSettings set; set.beginGroup(objectName()); if (set.contains(QStringLiteral("logViewHeaderState"))) ui.logView->horizontalHeader()->restoreState(set.value(QStringLiteral("logViewHeaderState")).toByteArray()); diff --git a/src/WASimUI/LogConsole.h b/src/WASimUI/LogConsole.h index 8883a0a..6782cac 100644 --- a/src/WASimUI/LogConsole.h +++ b/src/WASimUI/LogConsole.h @@ -1,3 +1,22 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + #pragma once #include @@ -8,6 +27,8 @@ class WASimCommander::Client::WASimClient; +namespace WASimUiNS { + class LogConsole : public QWidget { Q_OBJECT @@ -20,8 +41,8 @@ class LogConsole : public QWidget WASimUiNS::LogRecordsModel *getModel() const; public Q_SLOTS: - void saveSettings(QSettings &set) const; - void loadSettings(QSettings &set); + void saveSettings() const; + void loadSettings(); void logMessage(int level, const QString &msg) const; signals: @@ -32,3 +53,5 @@ public Q_SLOTS: WASimUiNS::LogRecordsModel *logModel = nullptr; WASimCommander::Client::WASimClient *wsClient = nullptr; }; + +} diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index eaba4fa..08e2e93 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -581,8 +581,7 @@ class WASimUIPrivate set.setValue(QStringLiteral("mainWindowState"), q->saveState()); set.setValue(QStringLiteral("requestsViewHeaderState"), ui->requestsView->horizontalHeader()->saveState()); set.setValue(QStringLiteral("eventsViewHeaderState"), ui->eventsView->horizontalHeader()->saveState()); - - ui->wLogWindow->saveSettings(set); + ui->wLogWindow->saveSettings(); set.beginGroup(QStringLiteral("Widgets")); for (const FormWidget &vw : qAsConst(formWidgets)) @@ -612,7 +611,7 @@ class WASimUIPrivate if (set.contains(QStringLiteral("eventsViewHeaderState"))) ui->eventsView->horizontalHeader()->restoreState(set.value(QStringLiteral("eventsViewHeaderState")).toByteArray()); - ui->wLogWindow->loadSettings(set); + ui->wLogWindow->loadSettings(); set.beginGroup(QStringLiteral("Widgets")); for (const FormWidget &vw : qAsConst(formWidgets)) diff --git a/src/WASimUI/WASimUI.ui b/src/WASimUI/WASimUI.ui index 1995b69..9f973b7 100644 --- a/src/WASimUI/WASimUI.ui +++ b/src/WASimUI/WASimUI.ui @@ -1953,7 +1953,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c 8 - + 0 @@ -2205,7 +2205,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c
Widgets.h
- LogConsole + WASimUiNS::LogConsole QWidget
LogConsole.h
1 diff --git a/src/WASimUI/WASimUI.vcxproj b/src/WASimUI/WASimUI.vcxproj index 9716d0f..79e843a 100644 --- a/src/WASimUI/WASimUI.vcxproj +++ b/src/WASimUI/WASimUI.vcxproj @@ -260,9 +260,11 @@ + + @@ -294,6 +296,7 @@ + From b08b2207101e076abfdc437052bdf05738628421 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Fri, 27 Oct 2023 19:30:06 -0400 Subject: [PATCH 39/65] [WASimUI][DeletableItemsComboBox] `setClearButtonEnabled()` bool param now optional. --- src/WASimUI/Widgets.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WASimUI/Widgets.h b/src/WASimUI/Widgets.h index 569a00a..2993831 100644 --- a/src/WASimUI/Widgets.h +++ b/src/WASimUI/Widgets.h @@ -315,7 +315,7 @@ class DeletableItemsComboBox : public DataComboBox }); } - void setClearButtonEnabled(bool enabled) { lineEdit()->setClearButtonEnabled(enabled); } + void setClearButtonEnabled(bool enabled = true) { if (lineEdit()) lineEdit()->setClearButtonEnabled(enabled); } const QStringList editedItems() const { From 8875633f4ec9e0ae25035470304c15058a43b8e8 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Fri, 27 Oct 2023 20:15:06 -0400 Subject: [PATCH 40/65] [WASimUI] Create a dedicated `RequestsTableView` for data requests with column toggles and font size adjustment. --- src/WASimUI/RequestsTableView.h | 218 ++++++++++++++++++++++++++++++++ src/WASimUI/WASimUI.cpp | 33 ++--- src/WASimUI/WASimUI.ui | 49 +------ src/WASimUI/WASimUI.vcxproj | 1 + 4 files changed, 234 insertions(+), 67 deletions(-) create mode 100644 src/WASimUI/RequestsTableView.h diff --git a/src/WASimUI/RequestsTableView.h b/src/WASimUI/RequestsTableView.h new file mode 100644 index 0000000..26d4062 --- /dev/null +++ b/src/WASimUI/RequestsTableView.h @@ -0,0 +1,218 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + +#pragma once + +#include +#include +#include +#include +#include + +#include "RequestsModel.h" +#include "Widgets.h" + +namespace WASimUiNS +{ + +class CategoryDelegate : public QStyledItemDelegate +{ + Q_OBJECT + public: + QMap textDataMap {}; + + using QStyledItemDelegate::QStyledItemDelegate; + + QWidget *createEditor(QWidget *p, const QStyleOptionViewItem &opt, const QModelIndex &index) const override + { + DataComboBox *cb = new DataComboBox(p); + cb->addItems(textDataMap); + connect(cb, &DataComboBox::currentTextChanged, this, &CategoryDelegate::commit); + return cb; + } + + void setEditorData(QWidget *editor, const QModelIndex &index) const override { + editor->setProperty("currentText", index.data(Qt::EditRole)); + } + void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override { + model->setData(index, editor->property("currentText"), Qt::EditRole); + model->setData(index, editor->property("currentData"), Qt::ToolTipRole); + } + + void commit() { + if (DataComboBox *cb = qobject_cast(sender())) + emit commitData(cb); + } +}; + +class RequestsTableView : public QTableView +{ + Q_OBJECT + + public: + RequestsTableView(QWidget *parent) + : QTableView(parent), + m_cbCategoryDelegate{new CategoryDelegate(this)}, + m_defaultFontSize{font().pointSize()} + { + setObjectName(QStringLiteral("RequestsTableView")); + + setContextMenuPolicy(Qt::ActionsContextMenu); + setEditTriggers(QAbstractItemView::AllEditTriggers); + setSelectionMode(QAbstractItemView::ExtendedSelection); + setSelectionBehavior(QAbstractItemView::SelectRows); + setIconSize(QSize(16, 16)); + setVerticalScrollMode(QAbstractItemView::ScrollPerItem); + setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel); + setGridStyle(Qt::DotLine); + setSortingEnabled(true); + setWordWrap(false); + setCornerButtonEnabled(false); + verticalHeader()->setVisible(false); + + QHeaderView *hdr = horizontalHeader(); + hdr->setCascadingSectionResizes(false); + hdr->setMinimumSectionSize(20); + hdr->setDefaultSectionSize(80); + hdr->setHighlightSections(false); + hdr->setSortIndicatorShown(true); + hdr->setStretchLastSection(true); + hdr->setSectionsMovable(true); + hdr->setSectionResizeMode(QHeaderView::Interactive); + hdr->setContextMenuPolicy(Qt::ActionsContextMenu); + + m_fontActions.reserve(3); + m_fontActions.append(new QAction(QIcon("arrow_upward.glyph"), tr("Increase font size"), this)); + m_fontActions.append(new QAction(QIcon("restart_alt.glyph"), tr("Reset font size"), this)); + m_fontActions.append(new QAction(QIcon("arrow_downward.glyph"), tr("Decrease font size"), this)); + m_fontActions[0]->setShortcuts({ QKeySequence::ZoomIn, QKeySequence(Qt::ControlModifier | Qt::Key_Equal) }); + m_fontActions[1]->setShortcut(QKeySequence(Qt::ControlModifier | Qt::Key_0)); + m_fontActions[2]->setShortcut(QKeySequence::ZoomOut); + connect(m_fontActions[0], &QAction::triggered, this, &RequestsTableView::fontSizeInc); + connect(m_fontActions[1], &QAction::triggered, this, &RequestsTableView::fontSizeReset); + connect(m_fontActions[2], &QAction::triggered, this, &RequestsTableView::fontSizeDec); + } + + QHeaderView *header() const { return horizontalHeader(); } + QByteArray saveState() const { return header()->saveState(); } + const QList &fontActions() const { return m_fontActions; } + + QMenu *columnToggleActionsMenu(QWidget *parent) const { + QMenu *menu = new QMenu(tr("Toggle table columns"), parent); + menu->setIcon(QIcon(QStringLiteral("view_column.glyph"))); + menu->addActions(header()->actions()); + return menu; + } + + QMenu *fontActionsMenu(QWidget *parent) const { + QMenu *menu = new QMenu(tr("Adjust font size"), parent); + menu->setIcon(QIcon(QStringLiteral("format_size.glyph"))); + menu->addActions(m_fontActions); + return menu; + } + + public Q_SLOTS: + void setExportCategories(const QMap &map) { m_cbCategoryDelegate->textDataMap = map; } + void moveColumn(int from, int to) const { horizontalHeader()->moveSection(from, to); } + + void setModel(RequestsModel *model) + { + QTableView::setModel(model); + + QHeaderView *hdr = horizontalHeader(); + hdr->resizeSection(RequestsModel::COL_ID, 40); + hdr->resizeSection(RequestsModel::COL_TYPE, 65); + hdr->resizeSection(RequestsModel::COL_RES_TYPE, 55); + hdr->resizeSection(RequestsModel::COL_NAME, 265); + hdr->resizeSection(RequestsModel::COL_IDX, 30); + hdr->resizeSection(RequestsModel::COL_UNIT, 55); + hdr->resizeSection(RequestsModel::COL_SIZE, 85); + hdr->resizeSection(RequestsModel::COL_PERIOD, 60); + hdr->resizeSection(RequestsModel::COL_INTERVAL, 40); + hdr->resizeSection(RequestsModel::COL_EPSILON, 60); + hdr->resizeSection(RequestsModel::COL_VALUE, 70); + hdr->resizeSection(RequestsModel::COL_TIMESATMP, 70); + + hdr->resizeSection(RequestsModel::COL_META_ID, 130); + hdr->resizeSection(RequestsModel::COL_META_NAME, 175); + hdr->resizeSection(RequestsModel::COL_META_CAT, 110); + hdr->resizeSection(RequestsModel::COL_META_DEF, 70); + hdr->resizeSection(RequestsModel::COL_META_FMT, 50); + + setItemDelegateForColumn(RequestsModel::COL_META_CAT, m_cbCategoryDelegate); + + for (int i=0; i < model->columnCount(); ++i) { + QAction *act = new QAction(model->headerData(i, Qt::Horizontal).toString(), this); + act->setCheckable(true); + act->setChecked(!hdr->isSectionHidden(i)); + act->setProperty("col", i); + hdr->addAction(act); + + connect(act, &QAction::triggered, this, [=](bool chk) { + if (QAction *act = qobject_cast(sender())) { + int id = act->property("col").toInt(); + if (id > -1) + horizontalHeader()->setSectionHidden(id, !chk); + } + }); + } + + } + + void fontSizeReset() { + QFont f = font(); + if (f.pointSize() != m_defaultFontSize) { + f.setPointSize(m_defaultFontSize); + setFont(f); + resizeRowsToContents(); + } + } + void fontSizeInc() { + QFont f = font(); + f.setPointSize(f.pointSize() + 1); + setFont(f); + resizeRowsToContents(); + } + void fontSizeDec() { + if (font().pointSize() > 2) { + QFont f = font(); + f.setPointSize(f.pointSize() - 1); + setFont(f); + resizeRowsToContents(); + } + } + + bool restoreState(const QByteArray &state) + { + if (!model()) + return false; + QHeaderView *hdr = horizontalHeader(); + hdr->restoreState(state); + for (int i = 0; i < model()->columnCount() && i < hdr->actions().length(); ++i) + hdr->actions().at(i)->setChecked(!hdr->isSectionHidden(i)); + return true; + } + + private: + CategoryDelegate *m_cbCategoryDelegate; + QList m_fontActions { }; + int m_defaultFontSize; +}; + +} diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index 08e2e93..f1e1f64 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -579,8 +579,8 @@ class WASimUIPrivate QSettings set; set.setValue(QStringLiteral("mainWindowGeo"), q->saveGeometry()); set.setValue(QStringLiteral("mainWindowState"), q->saveState()); - set.setValue(QStringLiteral("requestsViewHeaderState"), ui->requestsView->horizontalHeader()->saveState()); set.setValue(QStringLiteral("eventsViewHeaderState"), ui->eventsView->horizontalHeader()->saveState()); + set.setValue(QStringLiteral("requestsViewState"), ui->requestsView->saveState()); ui->wLogWindow->saveSettings(); set.beginGroup(QStringLiteral("Widgets")); @@ -602,15 +602,10 @@ class WASimUIPrivate void readSettings() { QSettings set; - if (set.contains(QStringLiteral("mainWindowGeo"))) - q->restoreGeometry(set.value(QStringLiteral("mainWindowGeo")).toByteArray()); - if (set.contains(QStringLiteral("mainWindowState"))) - q->restoreState(set.value(QStringLiteral("mainWindowState")).toByteArray()); - if (set.contains(QStringLiteral("requestsViewHeaderState"))) - ui->requestsView->horizontalHeader()->restoreState(set.value(QStringLiteral("requestsViewHeaderState")).toByteArray()); - if (set.contains(QStringLiteral("eventsViewHeaderState"))) - ui->eventsView->horizontalHeader()->restoreState(set.value(QStringLiteral("eventsViewHeaderState")).toByteArray()); - + q->restoreGeometry(set.value(QStringLiteral("mainWindowGeo")).toByteArray()); + q->restoreState(set.value(QStringLiteral("mainWindowState")).toByteArray()); + ui->eventsView->horizontalHeader()->restoreState(set.value(QStringLiteral("eventsViewHeaderState")).toByteArray()); + ui->requestsView->restoreState(set.value(QStringLiteral("requestsViewState")).toByteArray()); ui->wLogWindow->loadSettings(); set.beginGroup(QStringLiteral("Widgets")); @@ -709,20 +704,6 @@ WASimUI::WASimUI(QWidget *parent) : // Set up the Requests table view ui.requestsView->setModel(d->reqModel); - ui.requestsView->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); - ui.requestsView->horizontalHeader()->setSectionsMovable(true); - ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_ID, 40); - ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_TYPE, 65); - ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_RES_TYPE, 55); - ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_NAME, 265); - ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_IDX, 30); - ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_UNIT, 55); - ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_SIZE, 85); - ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_PERIOD, 60); - ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_INTERVAL, 40); - ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_EPSILON, 60); - ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_VALUE, 70); - ui.requestsView->horizontalHeader()->resizeSection(RequestsModel::COL_TIMESATMP, 70); // connect double click action to populate the request editor form connect(ui.requestsView, &QTableView::doubleClicked, this, [this](const QModelIndex &idx) { d->populateRequestForm(idx); }); @@ -965,6 +946,10 @@ WASimUI::WASimUI(QWidget *parent) : pauseRequestsAct->setEnabled(rows > 0); }, Qt::QueuedConnection); + // Add column toggle and font size actions + ui.wRequests->addAction(ui.requestsView->columnToggleActionsMenu(this)->menuAction()); + ui.wRequests->addAction(ui.requestsView->fontActionsMenu(this)->menuAction()); + // Registered calculator events model view actions diff --git a/src/WASimUI/WASimUI.ui b/src/WASimUI/WASimUI.ui index 9f973b7..3fb6742 100644 --- a/src/WASimUI/WASimUI.ui +++ b/src/WASimUI/WASimUI.ui @@ -1774,52 +1774,10 @@ Submitted requests will appear in the "Data Requests" window. Double-c 5
- + Added Requests (double click to edit) - - QAbstractItemView::NoEditTriggers - - - true - - - QAbstractItemView::SelectRows - - - Qt::ElideMiddle - - - QAbstractItemView::ScrollPerPixel - - - QAbstractItemView::ScrollPerPixel - - - Qt::DotLine - - - true - - - false - - - false - - - 20 - - - 60 - - - false - - - false - @@ -2210,6 +2168,11 @@ Submitted requests will appear in the "Data Requests" window. Double-c
LogConsole.h
1 + + WASimUiNS::RequestsTableView + QTableView +
RequestsTableView.h
+
diff --git a/src/WASimUI/WASimUI.vcxproj b/src/WASimUI/WASimUI.vcxproj index 79e843a..a8c40cd 100644 --- a/src/WASimUI/WASimUI.vcxproj +++ b/src/WASimUI/WASimUI.vcxproj @@ -296,6 +296,7 @@ + From c793a447a0fb2b6d808785564e861e1a68c8b53a Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Fri, 27 Oct 2023 21:05:42 -0400 Subject: [PATCH 41/65] [WASimUI] Usability improvements on main UI: - Unified network connection actions/button into a single "Connect" action which fully toggles server connection, with sub-menu for the individual actions; - Assign shortcuts for most actions available in various parts of the UI; - Main toolbar now shows text next to icons; Removed project site button; - Fixes that the state of current item selections in tables wasn't always properly detected and buttons didn't get enabled/disabled when needed (eg. "Remove Requests" button); - Most actions/buttons which require a server connection to work are now disabled when not connected; - Last selected variable types and data request type are saved between sessions; - When loading data requests from a file, the requests are now sent to the client asynchronously, improving UI responsiveness; - Other misc. minor UI layout and design updates. --- src/WASimUI/RequestsModel.h | 38 ++- src/WASimUI/Utils.h | 2 +- src/WASimUI/WASimUI.cpp | 454 ++++++++++++++++++++++-------------- src/WASimUI/WASimUI.ui | 145 +++++++----- 4 files changed, 382 insertions(+), 257 deletions(-) diff --git a/src/WASimUI/RequestsModel.h b/src/WASimUI/RequestsModel.h index d28ef12..b600dd3 100644 --- a/src/WASimUI/RequestsModel.h +++ b/src/WASimUI/RequestsModel.h @@ -159,6 +159,21 @@ class RequestsModel : public QStandardItemModel return item(row, COL_ID)->data(DataRole).toUInt(); } + int findRequestRow(uint32_t requestId) const + { + if (rowCount()) { + const QModelIndexList src = match(index(0, COL_ID), DataRole, requestId, 1, Qt::MatchExactly); + //qDebug() << requestId << src << (!src.isEmpty() ? src.first() : QModelIndex()); + if (!src.isEmpty()) + return src.first().row(); + } + return -1; + } + + QModelIndexList allRequests() const { + return match(index(0, COL_ID), Qt::EditRole, "*", -1, Qt::MatchWildcard | Qt::MatchWrap); + } + void setRequestValue(const WASimCommander::Client::DataRequestRecord &res) { const int row = findRequestRow(res.requestId); @@ -352,35 +367,12 @@ class RequestsModel : public QStandardItemModel return ret; } - static inline QModelIndexList flattenIndexList(const QModelIndexList &list) - { - QModelIndexList ret; - QModelIndex lastIdx; - for (const QModelIndex &idx : list) { - if (idx.column() == COL_ID && lastIdx.row() != idx.row() && idx.row() < idx.model()->rowCount()) - ret.append(idx); - lastIdx = idx; - } - return ret; - } - signals: void rowCountChanged(int rows); private: uint32_t m_nextRequestId = 0; - int findRequestRow(uint32_t requestId) const - { - if (rowCount()) { - const QModelIndexList src = match(index(0, COL_ID), DataRole, requestId, 1, Qt::MatchExactly); - //qDebug() << requestId << src << (!src.isEmpty() ? src.first() : QModelIndex()); - if (!src.isEmpty()) - return src.first().row(); - } - return -1; - } - }; } diff --git a/src/WASimUI/Utils.h b/src/WASimUI/Utils.h index 9bd827d..fbd467e 100644 --- a/src/WASimUI/Utils.h +++ b/src/WASimUI/Utils.h @@ -410,7 +410,7 @@ class Utils palette.setColor(QPalette::All, QPalette::Highlight, cHlt); palette.setColor(QPalette::Disabled, QPalette::Highlight, QColor("#9Cbbd5ff")); - const QColor cLnkTxt("#6685ff"); + const QColor cLnkTxt("#5eb5ff"); palette.setColor(QPalette::All, QPalette::Link, cLnkTxt); palette.setColor(QPalette::Disabled, QPalette::Link, cLnkTxt.darker()); diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index f1e1f64..aef30a7 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -20,6 +20,7 @@ and is also available at . #include #include +#include #include #include #include @@ -28,7 +29,6 @@ and is also available at . #include #include #include -#include #include #include #include @@ -70,6 +70,7 @@ class WASimUIPrivate StatusWidget *statWidget; RequestsModel *reqModel; EventsModel *eventsModel; + QAction *toggleConnAct = nullptr; QAction *initAct = nullptr; QAction *connectAct = nullptr; ClientStatus clientStatus = ClientStatus::Idle; @@ -99,6 +100,8 @@ class WASimUIPrivate client->setListResultsCallback(&WASimUI::listResults, q); } + bool isConnected() const { return client->isConnected(); } + bool checkConnected() { if (client->isConnected()) @@ -107,6 +110,38 @@ class WASimUIPrivate return false; } + void pingServer() { + quint32 v = client->pingServer(); + if (v > 0) + logUiMessage(tr("Server responded to ping with version: %1").arg(v, 8, 16, QChar('0')), CommandId::Ping, LogLevel::Info); + else + logUiMessage(tr("Ping request timed out!"), CommandId::Ping); + } + + void toggleConnection(bool sim, bool wasim) + { + if (client->isConnected()) { + if (wasim) + client->disconnectServer(); + if (sim) + client->disconnectSimulator(); + return; + } + if (client->isInitialized() && !wasim) { + client->disconnectSimulator(); + return; + } + if (!client->isInitialized()) { + if (SUCCEEDED(client->connectSimulator()) && wasim) + client->connectServer(); + } + else if (wasim && !client->isConnected()) { + client->connectServer(); + } + } + + // Calculator Code form ------------------------------------------------- + void runCalcCode() { if (!checkConnected()) @@ -130,7 +165,6 @@ class WASimUIPrivate else { ui->leCalcResult->setText(tr("Execute failed, check log.")); } - } void copyCalcCodeToRequest() @@ -142,10 +176,16 @@ class WASimUIPrivate //ui->cbValueSize->setCurrentData(Utils::calcResultTypeToMetaType(CalcResultType(ui->cbCalcResultType->currentData().toUInt()))); } - void refreshLVars() { - client->list(); + void updateCalcCodeFormState(const QString &txt) { + const bool en = !txt.isEmpty(); + ui->btnCalc->defaultAction()->setEnabled(en && isConnected()); + ui->btnAddEvent->defaultAction()->setEnabled(en); + ui->btnUpdateEvent->defaultAction()->setEnabled(en); + ui->btnCopyCalcToRequest->defaultAction()->setEnabled(en); } + // Lookups form ------------------------------------------------- + void lookupItem() { if (!checkConnected()) @@ -163,6 +203,12 @@ class WASimUIPrivate ui->leLookupResult->setText(tr("Lookup failed.")); } + // Variables form ------------------------------------------------- + + void refreshLVars() { + client->list(); + } + void getLocalVar(bool create = false) { if (!checkConnected()) @@ -193,6 +239,8 @@ class WASimUIPrivate void setLocalVar(bool create = false) { + if (!checkConnected()) + return; const char vtype = ui->cbGetSetVarType->currentData().toChar().toLatin1(); const QString &varName = vtype == 'L' ? ui->cbLvars->currentText() : ui->cbVariableName->currentText(); if (varName.isEmpty()) { @@ -232,6 +280,19 @@ class WASimUIPrivate ui->cbValueSize->setCurrentData(Utils::unitToMetaType(ui->cbUnitName->currentText())); } + void updateLocalVarsFormState() { + const bool isLocal = ui->wLocalVarsForm->isVisible(); + const bool haveData = !(isLocal ? ui->cbLvars->currentText().isEmpty() : ui->cbVariableName->currentText().isEmpty()); + const bool en = haveData && isConnected(); + ui->btnGetVar->defaultAction()->setEnabled(en); + ui->btnSetVar->defaultAction()->setEnabled(en); + ui->btnSetCreate->defaultAction()->setEnabled(en && isLocal); + ui->btnGetCreate->defaultAction()->setEnabled(en && isLocal); + ui->btnCopyLVarToRequest->defaultAction()->setEnabled(haveData); + } + + // Key Events form ------------------------------------------------- + void sendKeyEventForm() { if (!checkConnected()) @@ -261,7 +322,6 @@ class WASimUIPrivate client->sendCommand(cmd); } - // Data Requests handling ------------------------------------------------- void toggleRequestType() @@ -298,11 +358,11 @@ class WASimUIPrivate ui->wRequestForm->setProperty("requestId", id); if ((int)id > -1) { ui->lblCurrentRequestId->setText(QString("%1").arg(id)); - ui->pbUpdateRequest->setEnabled(true); + ui->btnUpdateRequest->defaultAction()->setEnabled(true); } else { ui->lblCurrentRequestId->setText(tr("New")); - ui->pbUpdateRequest->setEnabled(false); + ui->btnUpdateRequest->defaultAction()->setEnabled(false); } } @@ -313,11 +373,15 @@ class WASimUIPrivate return; } - uint32_t requestId = ui->wRequestForm->property("requestId").toUInt(); - if ((int)requestId < 0 || !update) - requestId = reqModel->nextRequestId(); + RequestRecord req; + int row = -1; + if (update && ui->wRequestForm->property("requestId").toInt() > -1) + row = reqModel->findRequestRow(ui->wRequestForm->property("requestId").toUInt()); + if (row < 0) + req = RequestRecord(reqModel->nextRequestId()); + else + req = reqModel->getRequest(row); - RequestRecord req(requestId); req.requestType = ui->rbRequestType_Calculated->isChecked() ? RequestType::Calculated : RequestType::Named; if (req.requestType == RequestType::Calculated) { req.calcResultType = (CalcResultType)ui->cbRequestCalcResultType->currentData().toUInt(); @@ -344,7 +408,7 @@ class WASimUIPrivate //std::cout << req << std::endl; setRequestFormId(req.requestId); - if FAILED(client->saveDataRequest(req)) + if (FAILED(client->saveDataRequest(req)) && row < 0) return; const QModelIndex idx = reqModel->addRequest(req); ui->requestsView->selectRow(idx.row()); @@ -398,7 +462,7 @@ class WASimUIPrivate } void removeSelectedRequests() { - removeRequests(reqModel->flattenIndexList(ui->requestsView->selectionModel()->selectedIndexes())); + removeRequests(ui->requestsView->selectionModel()->selectedRows(RequestsModel::COL_ID)); } void removeAllRequests() { @@ -414,11 +478,20 @@ class WASimUIPrivate { if (!checkConnected()) return; - const QModelIndexList list = reqModel->flattenIndexList(ui->requestsView->selectionModel()->selectedIndexes()); + const QModelIndexList list = ui->requestsView->selectionModel()->selectedRows(RequestsModel::COL_ID); for (const QModelIndex &idx : list) client->updateDataRequest(reqModel->requestId(idx.row())); } + void pauseRequests(bool chk) { + static const QIcon dataResumeIcon(QStringLiteral("play_arrow.glyph")); + static const QIcon dataPauseIcon(QStringLiteral("pause.glyph")); + client->setDataRequestsPaused(chk); + ui->btnReqestsPause->defaultAction()->setIconText(chk ? tr("Resume") : tr("Suspend")); + // for some reason the checked icon "on" state doesn't work automatically like it should... + ui->btnReqestsPause->setIcon(chk ? dataResumeIcon : dataPauseIcon); + }; + void saveRequests() { if (!reqModel->rowCount()) @@ -442,11 +515,21 @@ class WASimUIPrivate removeAllRequests(); const QList &added = reqModel->loadFromFile(fname); for (const DataRequest &req : added) - client->saveDataRequest(req); + client->saveDataRequest(req, true); // async logUiMessage(tr("Loaded %1 Data Request(s) from file: %2").arg(added.count()).arg(fname), CommandId::Ack, LogLevel::Info); } + void toggleRequestButtonsState() + { + bool conn = isConnected(); + bool hasSel = ui->requestsView->selectionModel()->hasSelection(); + bool hasRecords = reqModel->rowCount() > 0; + ui->btnReqestsRemove->defaultAction()->setEnabled(hasSel); + ui->btnReqestsUpdate->defaultAction()->setEnabled(hasSel && conn); + ui->btnReqestsPause->defaultAction()->setEnabled(hasRecords); + ui->btnReqestsSave->defaultAction()->setEnabled(hasRecords); + } // Registered calc events ------------------------------------------------- @@ -561,6 +644,13 @@ class WASimUIPrivate logUiMessage(tr("Loaded %1 Data Request(s) from file: %2").arg(added.count()).arg(fname), CommandId::Ack, LogLevel::Info); } + void toggleEventButtonsState() + { + bool hasSel = ui->eventsView->selectionModel()->hasSelection(); + ui->btnEventsRemove->defaultAction()->setEnabled(hasSel); + ui->btnEventsTransmit->defaultAction()->setEnabled(hasSel && isConnected()); + } + // Utilities ------------------------------------------------- @@ -583,6 +673,7 @@ class WASimUIPrivate set.setValue(QStringLiteral("requestsViewState"), ui->requestsView->saveState()); ui->wLogWindow->saveSettings(); + // Visible form widgets set.beginGroup(QStringLiteral("Widgets")); for (const FormWidget &vw : qAsConst(formWidgets)) set.setValue(vw.name, vw.a->isChecked()); @@ -597,6 +688,17 @@ class WASimUIPrivate for (DeletableItemsComboBox *cb : editable) set.setValue(cb->objectName(), cb->editedItems()); set.endGroup(); + + // Variables form + set.beginGroup(ui->wLocalVarsForm->objectName()); + set.setValue(ui->cbGetSetVarType->objectName(), ui->cbGetSetVarType->currentData()); + set.endGroup(); + + // Requests form + set.beginGroup(ui->wRequests->objectName()); + set.setValue(ui->bgrpRequestType->objectName(), ui->rbRequestType_Named->isChecked()); + set.setValue(ui->cbVariableType->objectName(), ui->cbVariableType->currentData()); + set.endGroup(); } void readSettings() @@ -608,6 +710,7 @@ class WASimUIPrivate ui->requestsView->restoreState(set.value(QStringLiteral("requestsViewState")).toByteArray()); ui->wLogWindow->loadSettings(); + // Visible form widgets set.beginGroup(QStringLiteral("Widgets")); for (const FormWidget &vw : qAsConst(formWidgets)) vw.a->setChecked(set.value(vw.name, vw.a->isChecked()).toBool()); @@ -633,6 +736,17 @@ class WASimUIPrivate } } set.endGroup(); + + // Variables form + set.beginGroup(ui->wLocalVarsForm->objectName()); + ui->cbGetSetVarType->setCurrentData(set.value(ui->cbGetSetVarType->objectName(), ui->cbGetSetVarType->currentData())); + set.endGroup(); + + // Requests form + set.beginGroup(ui->wRequests->objectName()); + ui->rbRequestType_Named->setChecked(set.value(ui->bgrpRequestType->objectName(), ui->rbRequestType_Named->isChecked()).toBool()); + ui->cbVariableType->setCurrentData(set.value(ui->cbVariableType->objectName(), ui->cbVariableType->currentData())); + set.endGroup(); } }; @@ -642,6 +756,28 @@ class WASimUIPrivate // WASimUI // ------------------------------------------------------------- +// Action creation macros used below +#define GLYPH_STR(ICN) QStringLiteral(##ICN ".glyph") +#define GLYPH_ICON(ICN) QIcon(GLYPH_STR(ICN)) + +#define MAKE_ACTION_NC(ACT, TTL, ICN, BTN, W, TT) \ + QAction *ACT = new QAction(GLYPH_ICON(ICN), TTL, this); \ + ACT->setAutoRepeat(false); ACT->setToolTip(TT); ui.##BTN->setDefaultAction(ACT); ui.##W->addAction(ACT) + +#define MAKE_ACTION_CONN(ACT, M) connect(ACT, &QAction::triggered, this, [this](bool chk) { d->##M; }) +#define MAKE_ACTION_SCUT(ACT, KS) ACT->setShortcut(KS); ACT->setShortcutContext(Qt::WidgetWithChildrenShortcut) +#define MAKE_ACTION_ITXT(ACT, T) ACT->setIconText(" " + T) + +#define MAKE_ACTION(ACT, TTL, ICN, BTN, W, M, TT) MAKE_ACTION_NC(ACT, TTL, ICN, BTN, W, TT); MAKE_ACTION_CONN(ACT, M) + +#define MAKE_ACTION_D(ACT, TTL, ICN, BTN, W, M, TT) MAKE_ACTION(ACT, TTL, ICN, BTN, W, M, TT); ACT->setDisabled(true) +#define MAKE_ACTION_SC(ACT, TTL, ICN, BTN, W, M, TT, KS) MAKE_ACTION(ACT, TTL, ICN, BTN, W, M, TT); MAKE_ACTION_SCUT(ACT, KS) +#define MAKE_ACTION_SC_D(ACT, TTL, ICN, BTN, W, M, TT, KS) MAKE_ACTION_SC(ACT, TTL, ICN, BTN, W, M, TT, KS); ACT->setDisabled(true) +#define MAKE_ACTION_PB(ACT, TTL, IT, ICN, BTN, W, M, TT, KS) MAKE_ACTION_SC(ACT, TTL, ICN, BTN, W, M, TT, KS); MAKE_ACTION_ITXT(ACT, IT) +#define MAKE_ACTION_PB_D(ACT, TTL, IT, ICN, BTN, W, M, TT, KS) MAKE_ACTION_SC_D(ACT, TTL, ICN, BTN, W, M, TT, KS); MAKE_ACTION_ITXT(ACT, IT) +#define MAKE_ACTION_PB_NC(ACT, TTL, IT, ICN, BTN, W, TT) MAKE_ACTION_NC(ACT, TTL, ICN, BTN, W, TT); MAKE_ACTION_ITXT(ACT, IT) +// --------------------------------- + WASimUI::WASimUI(QWidget *parent) : QMainWindow(parent), d(new WASimUIPrivate(this)) @@ -752,14 +888,18 @@ WASimUI::WASimUI(QWidget *parent) : connect(ui.cbPeriod, &DataComboBox::currentDataChanged, this, [this](const QVariant &data) { ui.sbInterval->setEnabled(data.toUInt() >= +UpdatePeriod::Tick); }); + // connect the Data Request save/add buttons - connect(ui.pbAddRequest, &QPushButton::clicked, this, [this]() { d->handleRequestForm(false); }); - connect(ui.pbUpdateRequest, &QPushButton::clicked, this, [this]() { d->handleRequestForm(true); }); - connect(ui.pbClearRequest, &QPushButton::clicked, this, [this]() { d->clearRequestForm(); }); + MAKE_ACTION_PB(addReqAct, tr("Add Request"), tr("Add"), "add", btnAddRequest, wRequestForm, handleRequestForm(false), + tr("Add new request record from current form entries."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); + MAKE_ACTION_PB_D(saveReqAct, tr("Save Edited Request"), tr("Save"), "edit", btnUpdateRequest, wRequestForm, handleRequestForm(true), + tr("Update the existing request record from current form entries."), QKeySequence(Qt::ControlModifier | Qt::ShiftModifier | Qt::Key_Return)); + MAKE_ACTION_PB(updReqAct, tr("Clear Form"), tr("Clear"), "scale=.9/backspace", btnClearRequest, wRequestForm, clearRequestForm(), + tr("Reset the editor form to default values."), QKeySequence(Qt::ControlModifier | Qt::Key_Backspace)); // connect to requests model row removed to check if the current editor needs to be reset, otherwise the "Save" button stays active and re-adds a deleted request. - connect(d->reqModel, &RequestsModel::rowsRemoved, this, [this](const QModelIndex &, int first, int last) { - const int current = ui.wRequestForm->property("requestId").toInt(); + connect(d->reqModel, &RequestsModel::rowsAboutToBeRemoved, this, [this](const QModelIndex &, int first, int last) { + const int current = d->reqModel->findRequestRow(ui.wRequestForm->property("requestId").toInt()); if (current >= first && current <= last) d->setRequestFormId(-1); }); @@ -773,177 +913,134 @@ WASimUI::WASimUI(QWidget *parent) : // Set up actions for triggering various events. Actions are typically mapped to UI elements like buttons and menu items and can be reused in multiple places. + // Network connection actions + + // Toggle overall connection, both the SimConnect part and the WASimModule part. + QIcon connIco = GLYPH_ICON("link"); + connIco.addFile(GLYPH_STR("link_off"), QSize(), QIcon::Mode::Normal, QIcon::State::On); + d->toggleConnAct = new QAction(connIco, tr("Connect"), this); + d->toggleConnAct->setToolTip(tr("

Toggle connection to WASimModule Server. This affects both the simulator connection (SimConnect) and the main server.

" + "

Use the sub-menu for individual actions.

")); + d->toggleConnAct->setCheckable(true); + d->toggleConnAct->setShortcut(QKeySequence(Qt::Key_F2)); + connect(d->toggleConnAct, &QAction::triggered, this, [this]() { d->toggleConnection(true, true); }, Qt::QueuedConnection); + // Connect/Disconnect Simulator. - QIcon initIco(QStringLiteral("phone_in_talk.glyph")); - initIco.addFile(QStringLiteral("call_end.glyph"), QSize(), QIcon::Mode::Normal, QIcon::State::On); + QIcon initIco = GLYPH_ICON("phone_in_talk"); + initIco.addFile(GLYPH_STR("call_end"), QSize(), QIcon::Mode::Normal, QIcon::State::On); d->initAct = new QAction(initIco, tr("Connect to Simulator"), this); d->initAct->setCheckable(true); - connect(d->initAct, &QAction::triggered, this, [this]() { - if (d->client->isInitialized()) - d->client->disconnectSimulator(); - else - d->client->connectSimulator(); - }, Qt::QueuedConnection); + d->initAct->setShortcut(QKeySequence(Qt::Key_F5)); + connect(d->initAct, &QAction::triggered, this, [this]() { d->toggleConnection(true, false); }, Qt::QueuedConnection); // Connect/Disconnect WASim Server - QIcon connIco(QStringLiteral("link.glyph")); - connIco.addFile(QStringLiteral("link_off.glyph"), QSize(), QIcon::Mode::Normal, QIcon::State::On); d->connectAct = new QAction(connIco, tr("Connect to Server"), this); d->connectAct->setCheckable(true); - connect(d->connectAct, &QAction::triggered, this, [this]() { - if (d->client->isConnected()) - d->client->disconnectServer(); - else - d->client->connectServer(); - }, Qt::QueuedConnection); + d->connectAct->setShortcut(QKeySequence(Qt::Key_F6)); + connect(d->connectAct, &QAction::triggered, this, [this]() { d->toggleConnection(false, true); }, Qt::QueuedConnection); // Ping the server. - QAction *pingAct = new QAction(QIcon(QStringLiteral("leak_add.glyph")), tr("Ping Server"), this); - connect(pingAct, &QAction::triggered, this, [this]() { d->client->pingServer(); }); - - -#define MAKE_ACTION(ACT, TTL, TT, ICN, BTN, W, M) \ - QAction *ACT = new QAction(QIcon(QStringLiteral(##ICN)), TTL, this); \ - ACT->setToolTip(TT); ui.##BTN->setDefaultAction(ACT); ui.##W->addAction(ACT); \ - connect(ACT, &QAction::triggered, this, [this]() { d->##M; }) + QAction *pingAct = new QAction(GLYPH_ICON("leak_add"), tr("Ping Server"), this); + pingAct->setShortcut(QKeySequence(Qt::Key_F7)); + connect(pingAct, &QAction::triggered, this, [this]() { d->pingServer(); }); -#define MAKE_ACTION_D(ACT, TTL, TT, ICN, BTN, W, M) MAKE_ACTION(ACT, TTL, TT, ICN, BTN, W, M); ACT->setDisabled(true) -#define MAKE_ACTION_IT(ACT, TTL, TT, IT, ICN, BTN, W, M) MAKE_ACTION_D(ACT, TTL, TT, ICN, BTN, W, M); ACT->setIconText(IT) + QMenu *connectMenu = new QMenu(tr("Connection actions"), this); + connectMenu->setIcon(GLYPH_ICON("settings_remote")); + connectMenu->addActions({ d->initAct, pingAct, d->connectAct }); + //d->toggleConnAct->setMenu(connectMenu); // Calculator code actions // Exec calculator code - MAKE_ACTION_D(execCalcAct, tr("Execute Calculator Code"), tr("Execute Calculator Code."), "IcoMoon-Free/calculator.glyph", btnCalc, wCalcForm, runCalcCode()); + MAKE_ACTION_SC_D(execCalcAct, tr("Execute Calculator Code"), "IcoMoon-Free/calculator", btnCalc, wCalcForm, runCalcCode(), tr("Execute Calculator Code."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); // Register calculator code event - MAKE_ACTION_D(regEventAct, tr("Register Event"), tr("Register this calculator code as a new Event."), "control_point.glyph", btnAddEvent, wCalcForm, registerEvent(false)); + MAKE_ACTION_D(regEventAct, tr("Register Event"), "control_point", btnAddEvent, wCalcForm, registerEvent(false), tr("Register this calculator code as a new Event.")); // Save edited calculator code event - MAKE_ACTION_D(saveEventAct, tr("Update Event"), tr("Update existing event with new calculator code (name cannot be changed)."), "edit.glyph", btnUpdateEvent, wCalcForm, registerEvent(true)); + MAKE_ACTION_D(saveEventAct, tr("Update Event"), "edit", btnUpdateEvent, wCalcForm, registerEvent(true), tr("Update existing event with new calculator code (name cannot be changed).")); // Copy calculator code as new Data Request - MAKE_ACTION_D(copyCalcAct, tr("Copy to Data Request"), tr("Copy Calculator Code to new Data Request."), "move_to_inbox.glyph", btnCopyCalcToRequest, wCalcForm, copyCalcCodeToRequest()); + MAKE_ACTION_SC_D(copyCalcAct, tr("Copy to Data Request"), "move_to_inbox", btnCopyCalcToRequest, wCalcForm, copyCalcCodeToRequest(), + tr("Copy Calculator Code to new Data Request."), QKeySequence(Qt::ControlModifier | Qt::Key_Down)); ui.btnUpdateEvent->setVisible(false); // Connect variable selector to enable/disable relevant actions - connect(ui.cbCalculatorCode, &QComboBox::currentTextChanged, this, [=](const QString &txt) { - const bool en = !txt.isEmpty(); - execCalcAct->setEnabled(en); - regEventAct->setEnabled(en); - saveEventAct->setEnabled(en); - copyCalcAct->setEnabled(en); - }); + connect(ui.cbCalculatorCode, &QComboBox::currentTextChanged, this, [=](const QString &txt) { d->updateCalcCodeFormState(txt); }); // Variables section actions d->toggleSetGetVariableType(); // Request Local Vars list - MAKE_ACTION(reloadLVarsAct, tr("Reload L.Vars"), tr("Reload Local Variables."), "autorenew.glyph", btnList, wVariables, refreshLVars()); + MAKE_ACTION(reloadLVarsAct, tr("Reload L.Vars"), "autorenew", btnList, wVariables, refreshLVars(), tr("Reload Local Variables.")); // Get local variable value - MAKE_ACTION_D(getVarAct, tr("Get Variable"), tr("Get Variable Value."), "rotate=180/send.glyph", btnGetVar, wVariables, getLocalVar()); + MAKE_ACTION_SC_D(getVarAct, tr("Get Variable"), "rotate=180/send", btnGetVar, wVariables, getLocalVar(), tr("Get Variable Value."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); // Set variable value - MAKE_ACTION_D(setVarAct, tr("Set Variable"), tr("Set Variable Value."), "send.glyph", btnSetVar, wVariables, setLocalVar()); + MAKE_ACTION_SC_D(setVarAct, tr("Set Variable"), "send", btnSetVar, wVariables, setLocalVar(), tr("Set Variable Value."), QKeySequence(Qt::ControlModifier | Qt::ShiftModifier | Qt::Key_Return)); // Set or Create local variable - MAKE_ACTION_D(setCreateVarAct, tr("Set/Create Variable"), tr("Set Or Create Local Variable."), "overlay=\\align=AlignRight\\fg=#17dd29\\add/send.glyph", btnSetCreate, wVariables, setLocalVar(true)); + MAKE_ACTION_D(setCreateVarAct, tr("Set/Create Variable"), "overlay=\\align=AlignRight\\fg=#17dd29\\add/send", btnSetCreate, wVariables, setLocalVar(true), + tr("Set Or Create Local Variable.")); // Get or Create local variable - MAKE_ACTION_D(getCreateVarAct, tr("Get/Create Variable"), - tr("Get Or Create Local Variable. The specified value and unit will be used as defaults if the variable is created."), - "overlay=\\align=AlignLeft\\fg=#17dd29\\add/rotate=180/send.glyph", btnGetCreate, wVariables, getLocalVar(true)); + MAKE_ACTION_D(getCreateVarAct, tr("Get/Create Variable"), "overlay=\\align=AlignLeft\\fg=#17dd29\\add/rotate=180/send", btnGetCreate, wVariables, getLocalVar(true), + tr("Get Or Create Local Variable. The specified value and unit will be used as defaults if the variable is created.")); // Copy LVar as new Data Request - MAKE_ACTION_D(copyVarAct, tr("Copy to Data Request"), tr("Copy Variable to new Data Request."), "move_to_inbox.glyph", btnCopyLVarToRequest, wVariables, copyLocalVarToRequest()); - - auto updateLocalVarsFormState = [=](const QString &) { - const bool isLocal = ui.wLocalVarsForm->isVisible(); - const bool en = !(isLocal ? ui.cbLvars->currentText().isEmpty() : ui.cbVariableName->currentText().isEmpty()); - getVarAct->setEnabled(en); - setVarAct->setEnabled(en); - copyVarAct->setEnabled(en); - setCreateVarAct->setEnabled(en && isLocal); - getCreateVarAct->setEnabled(en && isLocal); - }; + MAKE_ACTION_SC_D(copyVarAct, tr("Copy to Data Request"), "move_to_inbox", btnCopyLVarToRequest, wVariables, copyLocalVarToRequest(), tr("Copy Variable to new Data Request."), + QKeySequence(Qt::ControlModifier | Qt::Key_Down)); // Connect variable selector to enable/disable relevant actions - connect(ui.cbLvars, &QComboBox::currentTextChanged, this, updateLocalVarsFormState); - connect(ui.cbVariableName, &QComboBox::currentTextChanged, this, updateLocalVarsFormState); + connect(ui.cbLvars, &QComboBox::currentTextChanged, this, [=]() { d->updateLocalVarsFormState(); }); + connect(ui.cbVariableName, &QComboBox::currentTextChanged, this, [=]() { d->updateLocalVarsFormState(); }); // connect to variable type combo box to switch between views for local vars vs. everything else connect(ui.cbGetSetVarType, &DataComboBox::currentDataChanged, this, [=](const QVariant &) { d->toggleSetGetVariableType(); - updateLocalVarsFormState(QString()); + d->updateLocalVarsFormState(); }); // Other forms // Lookup action - MAKE_ACTION(lookupItemAct, tr("Lookup"), tr("Query server for ID of named item (Lookup command)."), "search.glyph", btnVarLookup, wDataLookup, lookupItem()); + MAKE_ACTION_SC(lookupItemAct, tr("Lookup"), "search", btnVarLookup, wDataLookup, lookupItem(), tr("Query server for ID of named item (Lookup command)."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); // Send Key Event action - MAKE_ACTION(sendKeyEventAct, tr("Send Key Event"), tr("Send the specified Key Event to the server."), "send.glyph", btnKeyEventSend, wKeyEvent, sendKeyEventForm()); + MAKE_ACTION_SC(sendKeyEventAct, tr("Send Key Event"), "send", btnKeyEventSend, wKeyEvent, sendKeyEventForm(), tr("Send the specified Key Event to the server."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); // Send Command action - MAKE_ACTION(sendCmdAct, tr("Send Command"), tr("Send the selected Command to the server."), "keyboard_command_key.glyph", btnCmdSend, wCommand, sendCommandForm()); + MAKE_ACTION_SC(sendCmdAct, tr("Send Command"), "keyboard_command_key", btnCmdSend, wCommand, sendCommandForm(), tr("Send the selected Command to the server."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); // Requests model view actions // Remove selected Data Request(s) from item model/view - MAKE_ACTION_IT(removeRequestsAct, tr("Remove Selected Data Request(s)"), tr("Delete the selected Data Request(s)."), tr("Remove"), "delete_forever.glyph", pbReqestsRemove, wRequests, removeSelectedRequests()); + MAKE_ACTION_PB_D(removeRequestsAct, tr("Remove Selected Data Request(s)"), tr("Remove"), "fg=#c2d32e2e/delete_forever", btnReqestsRemove, wRequests, removeSelectedRequests(), + tr("Delete the selected Data Request(s)."), QKeySequence(Qt::ControlModifier | Qt::Key_D)); // Update data of selected Data Request(s) in item model/view - MAKE_ACTION_IT(updateRequestsAct, tr("Update Selected Data Request(s)"), tr("Request data update on selected Data Request(s)."), tr("Update"), "refresh.glyph", pbReqestsUpdate, wRequests, updateSelectedRequests()); + MAKE_ACTION_PB_D(updateRequestsAct, tr("Update Selected Data Request(s)"), tr("Update"), "refresh", btnReqestsUpdate, wRequests, updateSelectedRequests(), + tr("Request data update on selected Data Request(s)."), QKeySequence(Qt::ControlModifier | Qt::Key_R, QKeySequence::Refresh)); // Connect to table view selection model to en/disable the remove/update actions when selection changes. - connect(ui.requestsView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=](const QItemSelection &sel, const QItemSelection &) { - removeRequestsAct->setDisabled(sel.isEmpty()); - updateRequestsAct->setDisabled(sel.isEmpty()); - }); + connect(ui.requestsView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=]() { d->toggleRequestButtonsState(); }); // Pause/resume data updates of requests - QIcon dataPauseIcon(QStringLiteral("pause.glyph")); - dataPauseIcon.addFile(QStringLiteral("play_arrow.glyph"), QSize(), QIcon::Normal, QIcon::On); - //dataPauseIcon.addFile(QStringLiteral("play_arrow.glyph"), QSize(), QIcon::Active, QIcon::On); - QAction *pauseRequestsAct = new QAction(dataPauseIcon, tr("Toggle Updates"), this); - pauseRequestsAct->setIconText(tr("Suspend")); - pauseRequestsAct->setToolTip(tr("Temporarily pause all data value updates on Server side.")); + MAKE_ACTION_PB_D(pauseRequestsAct, tr("Toggle Updates"), tr("Suspend"), "pause", btnReqestsPause, wRequests, pauseRequests(chk), + tr("Temporarily pause all data value updates on Server side."), QKeySequence(Qt::ControlModifier | Qt::Key_U)); pauseRequestsAct->setCheckable(true); - pauseRequestsAct->setDisabled(true); - ui.pbReqestsPause->setDefaultAction(pauseRequestsAct); - ui.wRequests->addAction(pauseRequestsAct); - - connect(pauseRequestsAct, &QAction::triggered, this, [=](bool chk) { - static const QIcon dataResumeIcon(QStringLiteral("play_arrow.glyph")); - d->client->setDataRequestsPaused(chk); - pauseRequestsAct->setIconText(chk ? tr("Resume") : tr("Suspend")); - // for some reason the checked icon "on" state doesn't work automatically like it should... - ui.pbReqestsPause->setIcon(chk ? dataResumeIcon : dataPauseIcon); - }); // Save current Requests to a file - QAction *saveRequestsAct = new QAction(QIcon(QStringLiteral("save.glyph")), tr("Save Requests"), this); - saveRequestsAct->setIconText(tr("Save")); - saveRequestsAct->setToolTip(tr("Save current Requests list to file.")); - saveRequestsAct->setDisabled(true); - ui.pbReqestsSave->setDefaultAction(saveRequestsAct); - connect(saveRequestsAct, &QAction::triggered, this, [this]() { d->saveRequests(); }); + MAKE_ACTION_PB_D(saveRequestsAct, tr("Save Requests"), tr("Save"), "save", btnReqestsSave, wRequests, saveRequests(), tr("Save requests to a file for later use."), QKeySequence::Save); // Load Requests from a file. This is actually two actions: load and append to existing records + load and replace existing records. - QAction *loadRequestsAct = new QAction(QIcon(QStringLiteral("folder_open.glyph")), tr("Load Requests"), this); - loadRequestsAct->setIconText(tr("Load")); - loadRequestsAct->setToolTip(tr("Load saved Requests from file.")); + MAKE_ACTION_PB_NC(loadRequestsAct, tr("Load Requests"), tr("Load"), "folder_open", btnReqestsLoad, wRequests, + tr("

Load or Import Requests from a file.

Files can be in \"native\" WASimUI or MSFS Touch Portal Plugin formats. File type is detected automatically.

")); + loadRequestsAct->setShortcut(QKeySequence::Open); QMenu *loadRequestsMenu = new QMenu(tr("Requests Load Action"), this); - QAction *loadReplaceAct = loadRequestsMenu->addAction(QIcon(QStringLiteral("view_list.glyph")), tr("Replace Existing")); - QAction *loadAppendAct = loadRequestsMenu->addAction(QIcon(QStringLiteral("playlist_add.glyph")), tr("Append to Existing")); - ui.pbReqestsLoad->setDefaultAction(loadRequestsAct); - ui.wRequests->addAction(loadRequestsAct); - - connect(loadReplaceAct, &QAction::triggered, this, [this]() { d->loadRequests(true); }); - connect(loadAppendAct, &QAction::triggered, this, [this]() { d->loadRequests(false); }); + loadRequestsMenu->addAction(GLYPH_ICON("view_list"), tr("Replace Existing"), this, [this]() { d->loadRequests(true); }, QKeySequence(Qt::ControlModifier | Qt::ShiftModifier | Qt::Key_O)); + loadRequestsMenu->addAction(GLYPH_ICON("playlist_add."), tr("Append to Existing"), this, [this]() { d->loadRequests(false); }, QKeySequence(Qt::ControlModifier | Qt::AltModifier | Qt::Key_O)); connect(loadRequestsAct, &QAction::triggered, this, [=]() { if (!loadRequestsAct->menu()) d->loadRequests(true); }); - // Change the action type depending on number of current rows in data requests model, with or w/out a menu of Add/Replace Existing options. + // Change the load/save requests action type depending on number of current rows in data requests model, with or w/out a menu of Add/Replace Existing options. connect(d->reqModel, &RequestsModel::rowCountChanged, this, [=](int rows) { if (rows) { if (!loadRequestsAct->menu()) loadRequestsAct->setMenu(loadRequestsMenu); } - else if (loadRequestsAct->menu()) { + else if (loadRequestsAct->menu()) loadRequestsAct->setMenu(nullptr); - } - saveRequestsAct->setEnabled(rows > 0); - pauseRequestsAct->setEnabled(rows > 0); + d->toggleRequestButtonsState(); }, Qt::QueuedConnection); // Add column toggle and font size actions @@ -954,29 +1051,21 @@ WASimUI::WASimUI(QWidget *parent) : // Registered calculator events model view actions // Remove selected Data Request(s) from item model/view - MAKE_ACTION_IT(removeEventsAct, tr("Remove Selected Event(s)"), tr("Delete the selected Event(s)."), tr("Remove"), "delete_forever.glyph", pbEventsRemove, wEventsList, removeSelectedEvents()); + MAKE_ACTION_PB_D(removeEventsAct, tr("Remove Selected Event(s)"), tr("Remove"), "fg=#c2d32e2e/delete_forever", btnEventsRemove, wEventsList, removeSelectedEvents(), + tr("Delete the selected Event(s)."), QKeySequence(Qt::ControlModifier | Qt::Key_D)); // Update data of selected Data Request(s) in item model/view - MAKE_ACTION_IT(updateEventsAct, tr("Transmit Selected Event(s)"), tr("Trigger the selected Event(s)."), tr("Transmit"), "rotate=180/play_for_work.glyph", pbEventsTransmit, wEventsList, transmitSelectedEvents()); - + MAKE_ACTION_PB_D(updateEventsAct, tr("Transmit Selected Event(s)"), tr("Transmit"), "play_for_work", btnEventsTransmit, wEventsList, transmitSelectedEvents(), + tr("Trigger the selected Event(s)."), QKeySequence(Qt::ControlModifier | Qt::Key_T)); // Connect to table view selection model to en/disable the remove/update actions when selection changes. - connect(ui.eventsView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=](const QItemSelection &sel, const QItemSelection &) { - removeEventsAct->setDisabled(sel.isEmpty()); - updateEventsAct->setDisabled(sel.isEmpty()); - }); + connect(ui.eventsView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=]() { d->toggleEventButtonsState(); }); // Save current Events to a file - MAKE_ACTION_IT(saveEventsAct, tr("Save Events"), tr("Save current Events list to file."), tr("Save"), "save.glyph", pbEventsSave, wEventsList, saveEvents()); - + MAKE_ACTION_PB_D(saveEventsAct, tr("Save Events"), tr("Save"), "save", btnEventsSave, wEventsList, saveEvents(), tr("Save current Events list to file."), QKeySequence::Save); // Load Events from a file. This is actually two actions: load and append to existing records + load and replace existing records. - QAction *loadEventsAct = new QAction(QIcon(QStringLiteral("folder_open.glyph")), tr("Load Events"), this); - loadEventsAct->setIconText(tr("Load")); - loadEventsAct->setToolTip(tr("Load saved Events from file.")); + MAKE_ACTION_PB_NC(loadEventsAct, tr("Load Events"), tr("Load"), "folder_open", btnEventsLoad, wEventsList, tr("Load saved Events from file.")); QMenu *loadEventsMenu = new QMenu(tr("Events Load Action"), this); - QAction *replaceEventsAct = loadEventsMenu->addAction(QIcon(QStringLiteral("view_list.glyph")), tr("Replace Existing")); - QAction *appendEventsAct = loadEventsMenu->addAction(QIcon(QStringLiteral("playlist_add.glyph")), tr("Append to Existing")); - ui.pbEventsLoad->setDefaultAction(loadEventsAct); - ui.wEventsList->addAction(loadEventsAct); - + QAction *replaceEventsAct = loadEventsMenu->addAction(GLYPH_ICON("view_list"), tr("Replace Existing")); + QAction *appendEventsAct = loadEventsMenu->addAction(GLYPH_ICON("playlist_add"), tr("Append to Existing")); connect(replaceEventsAct, &QAction::triggered, this, [this]() { d->loadEvents(true); }); connect(appendEventsAct, &QAction::triggered, this, [this]() { d->loadEvents(false); }); connect(loadEventsAct, &QAction::triggered, this, [=]() { if (!loadEventsAct->menu()) d->loadEvents(true); }); @@ -986,56 +1075,52 @@ WASimUI::WASimUI(QWidget *parent) : if (!loadEventsAct->menu()) loadEventsAct->setMenu(loadEventsMenu); } - else if (loadEventsAct->menu()) { + else if (loadEventsAct->menu()) loadEventsAct->setMenu(nullptr); - } saveEventsAct->setEnabled(rows > 0); }, Qt::QueuedConnection); -#undef MAKE_ACTION_IT -#undef MAKE_ACTION_D -#undef MAKE_ACTION - // Other UI-related actions - QAction *viewAct = new QAction(QIcon(QStringLiteral("grid_view.glyph")), tr("View"), this); QMenu *viewMenu = new QMenu(tr("View"), this); + viewMenu->setIcon(GLYPH_ICON("grid_view")); + //viewMenu->menuAction()->setShortcut(QKeySequence(Qt::AltModifier | Qt::Key_M)); -#define WIDGET_VIEW_TOGGLE_ACTION(T, W, V) {\ +#define WIDGET_VIEW_TOGGLE_ACTION(T, W, V, K) {\ QAction *act = new QAction(tr("Show %1 Form").arg(T), this); \ - act->setCheckable(true); act->setChecked(V); \ + act->setAutoRepeat(false); act->setCheckable(true); act->setChecked(V); \ + act->setShortcut(QKeySequence(Qt::AltModifier | Qt::Key_##K)); \ W->addAction(act); W->setWindowTitle(T); W->setVisible(V); \ connect(act, &QAction::toggled, W, &QWidget::setVisible); \ - d->formWidgets.append({T, W, act}); \ - viewMenu->addAction(act); \ + d->formWidgets.append({T, W, act}); viewMenu->addAction(act); \ } - WIDGET_VIEW_TOGGLE_ACTION(tr("Calculator Code"), ui.wCalcForm, true); - WIDGET_VIEW_TOGGLE_ACTION(tr("Variables"), ui.wVariables, true); - WIDGET_VIEW_TOGGLE_ACTION(tr("Lookup"), ui.wDataLookup, true); - WIDGET_VIEW_TOGGLE_ACTION(tr("Key Events"), ui.wKeyEvent, true); - WIDGET_VIEW_TOGGLE_ACTION(tr("API Command"), ui.wCommand, false); - WIDGET_VIEW_TOGGLE_ACTION(tr("Data Request Editor"), ui.wRequestForm, true); + WIDGET_VIEW_TOGGLE_ACTION(tr("Calculator Code"), ui.wCalcForm, true, C) + WIDGET_VIEW_TOGGLE_ACTION(tr("Variables"), ui.wVariables, true, V) + WIDGET_VIEW_TOGGLE_ACTION(tr("Lookup"), ui.wDataLookup, true, L) + WIDGET_VIEW_TOGGLE_ACTION(tr("Key Events"), ui.wKeyEvent, true, K) + WIDGET_VIEW_TOGGLE_ACTION(tr("API Command"), ui.wCommand, false, A) + WIDGET_VIEW_TOGGLE_ACTION(tr("Data Request Editor"), ui.wRequestForm, true, R) #undef WIDGET_VIEW_TOGGLE_ACTION viewMenu->addActions({ ui.dwRequests->toggleViewAction(), ui.dwEventsList->toggleViewAction(), ui.dwLog->toggleViewAction() }); - viewAct->setMenu(viewMenu); - QAction *styleAct = new QAction(QIcon(QStringLiteral("style.glyph")), tr("Toggle Dark/Light Theme"), this); + QAction *styleAct = new QAction(GLYPH_ICON("style"), tr("Toggle Dark/Light Theme"), this); + styleAct->setIconText(tr("Theme")); styleAct->setCheckable(true); styleAct->setShortcut(tr("Alt+D")); connect(styleAct, &QAction::triggered, &Utils::toggleAppStyle); - QAction *aboutAct = new QAction(QIcon(QStringLiteral("info.glyph")), tr("About"), this); + QAction *aboutAct = new QAction(GLYPH_ICON("info"), tr("About"), this); aboutAct->setShortcut(QKeySequence::HelpContents); connect(aboutAct, &QAction::triggered, this, [this]() {Utils::about(this); }); - QAction *projectLinkAct = new QAction(QIcon(QStringLiteral("IcoMoon-Free/github.glyph")), tr("Project Site"), this); + QAction *projectLinkAct = new QAction(GLYPH_ICON("IcoMoon-Free/github"), tr("Project Site"), this); connect(projectLinkAct, &QAction::triggered, this, [this]() { QDesktopServices::openUrl(QUrl(WSMCMND_PROJECT_URL)); }); // add all actions to this widget, for context menu and shortcut handling addActions({ - d->initAct, pingAct, d->connectAct, - Utils::separatorAction(this), viewAct, styleAct, aboutAct, projectLinkAct + d->toggleConnAct, connectMenu->menuAction(), + Utils::separatorAction(this), viewMenu->menuAction(), styleAct, aboutAct, projectLinkAct }); @@ -1044,12 +1129,20 @@ WASimUI::WASimUI(QWidget *parent) : addToolBar(Qt::TopToolBarArea, toolbar); toolbar->setMovable(false); toolbar->setObjectName(QStringLiteral("TOOLBAR_MAIN")); - toolbar->setStyleSheet(QStringLiteral("QToolBar { border: 0; border-bottom: 1px solid palette(mid); spacing: 6px; } QToolBar::separator { background-color: palette(mid); width: 1px; padding: 0; margin: 6px 8px; }")); - toolbar->addActions({ d->initAct, pingAct, d->connectAct }); + toolbar->setStyleSheet(QStringLiteral( + "QToolBar { border: 0; border-bottom: 1px solid palette(mid); spacing: 6px; margin-left: 12px; }" + "QToolBar::separator { background-color: palette(mid); width: 1px; padding: 0; margin: 6px 8px; }" + )); + toolbar->addWidget(Utils::spacerWidget(Qt::Horizontal, 6)); + toolbar->addActions({ d->toggleConnAct }); toolbar->addSeparator(); - toolbar->addActions({ viewAct, styleAct, aboutAct, projectLinkAct }); + toolbar->addActions({ viewMenu->menuAction(), styleAct, aboutAct /*, projectLinkAct*/ }); // default toolbutton menu mode is lame - if (QToolButton *tb = qobject_cast(toolbar->widgetForAction(viewAct))) + if (QToolButton *tb = qobject_cast(toolbar->widgetForAction(d->toggleConnAct))) { + tb->setMenu(connectMenu); + tb->setPopupMode(QToolButton::MenuButtonPopup); + } + if (QToolButton *tb = qobject_cast(toolbar->widgetForAction(viewMenu->menuAction()))) tb->setPopupMode(QToolButton::InstantPopup); // Add the status widget to the toolbar, with spacers to right-align it with right padding. @@ -1076,13 +1169,24 @@ WASimUI::WASimUI(QWidget *parent) : void WASimUI::onClientEvent(const ClientEvent &ev) { - d->clientStatus = ev.status; d->statWidget->setStatus(ev); - d->statWidget->setServerVersion(d->client->serverVersion()); - d->initAct->setChecked(+ev.status & +ClientStatus::SimConnected); - d->initAct->setText(d->initAct->isChecked() ? tr("Disconnect Simulator") : tr("Connect to Simulator") ); - d->connectAct->setChecked(+ev.status & +ClientStatus::Connected); - d->connectAct->setText(+d->connectAct->isChecked() ? tr("Disconnect Server") : tr("Connect to Server") ); + int simConnected = (+ev.status & +ClientStatus::SimConnected); + int isConnected = (+ev.status & +ClientStatus::Connected); + d->toggleConnAct->setChecked(simConnected && isConnected); + d->toggleConnAct->setText(simConnected && isConnected ? tr("Disconnect") : simConnected ? tr("Connect Server") : tr("Connect")); + d->initAct->setChecked(simConnected); + d->initAct->setText(simConnected ? tr("Disconnect Simulator") : tr("Connect to Simulator")); + d->connectAct->setChecked(isConnected); + d->connectAct->setText(isConnected ? tr("Disconnect Server") : tr("Connect to Server") ); + if ((+d->clientStatus & +ClientStatus::Connected) != isConnected) { + if (isConnected) + d->statWidget->setServerVersion(d->client->serverVersion()); + d->updateCalcCodeFormState(ui.cbCalculatorCode->currentText()); + d->updateLocalVarsFormState(); + d->toggleRequestButtonsState(); + d->toggleEventButtonsState(); + } + d->clientStatus = ev.status; } void WASimUI::onListResults(const ListResult &list) diff --git a/src/WASimUI/WASimUI.ui b/src/WASimUI/WASimUI.ui index 3fb6742..0ed8389 100644 --- a/src/WASimUI/WASimUI.ui +++ b/src/WASimUI/WASimUI.ui @@ -17,6 +17,9 @@ WASimUI + + Qt::ToolButtonTextBesideIcon + QMainWindow::AllowNestedDocks|QMainWindow::AllowTabbedDocks|QMainWindow::AnimatedDocks @@ -894,7 +897,10 @@ Submitted requests will appear in the "Data Requests" window. Double-c 6
- + + + 4 + @@ -993,23 +999,36 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + 0 0 - - Add new request record from current form entries. - Clear - + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 20 + 1 + + + + + + false @@ -1019,25 +1038,19 @@ Submitted requests will appear in the "Data Requests" window. Double-c 0 - - Update the existing request record from current form entries. - Save - + 0 0 - - Add new request record from current form entries. - Add @@ -1789,19 +1802,19 @@ Submitted requests will appear in the "Data Requests" window. Double-c QLayout::SetMaximumSize - 0 + 10 0 - 0 + 10 0 - + false @@ -1817,7 +1830,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + false @@ -1833,7 +1846,23 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 40 + 20 + + + + + + false @@ -1862,7 +1891,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + 0 @@ -1875,7 +1904,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + false @@ -1979,9 +2008,6 @@ Submitted requests will appear in the "Data Requests" window. Double-c QAbstractItemView::SelectRows - - Qt::ElideMiddle - QAbstractItemView::ScrollPerPixel @@ -2023,47 +2049,47 @@ Submitted requests will appear in the "Data Requests" window. Double-c 4 - 0 + 6 0 - 0 + 6 0 - + false - - - 0 - 0 - - Remove + + + 24 + 24 + + - + false - - - 0 - 0 - - Update + + + 24 + 24 + + @@ -2080,32 +2106,35 @@ Submitted requests will appear in the "Data Requests" window. Double-c - - - - 0 - 0 - - + Load + + + 24 + 24 + + + + QToolButton::InstantPopup + - + false - - - 0 - 0 - - Save + + + 24 + 24 + + @@ -2117,6 +2146,11 @@ Submitted requests will appear in the "Data Requests" window. Double-c
+ + ActionPushButton + QPushButton +
ActionPushButton.h
+
WASimUiNS::DeletableItemsComboBox QComboBox @@ -2142,11 +2176,6 @@ Submitted requests will appear in the "Data Requests" window. Double-c QComboBox
Widgets.h
- - ActionPushButton - QPushButton -
ActionPushButton.h
-
WASimUiNS::UnitTypeComboBox QComboBox From ac091c62a8ebbcddbca93b102b9238bd87cc24b9 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Fri, 27 Oct 2023 21:12:32 -0400 Subject: [PATCH 42/65] [WASimUI][RequestsModel] Reuse existing items when possible instead of creating new ones; Add tooltips to all fields; --- src/WASimUI/RequestsModel.h | 121 ++++++++++++++++-------------------- 1 file changed, 54 insertions(+), 67 deletions(-) diff --git a/src/WASimUI/RequestsModel.h b/src/WASimUI/RequestsModel.h index b600dd3..c5a5ac2 100644 --- a/src/WASimUI/RequestsModel.h +++ b/src/WASimUI/RequestsModel.h @@ -116,7 +116,6 @@ struct RequestRecord : public WASimCommander::Client::DataRequestRecord r.varTypePrefix = pfx.at(0).toLatin1(); return in; } - }; Q_DECLARE_METATYPE(RequestRecord) @@ -135,7 +134,7 @@ class RequestsModel : public QStandardItemModel public: - enum Roles { DataRole = Qt::UserRole + 1, MetaTypeRole, IdStringRole, PropertiesRole }; + enum Roles { DataRole = Qt::UserRole + 1, MetaTypeRole, PropertiesRole }; enum Columns { COL_ID, COL_TYPE, COL_RES_TYPE, COL_NAME, COL_IDX, COL_UNIT, COL_SIZE, COL_PERIOD, COL_INTERVAL, COL_EPSILON, COL_VALUE, COL_TIMESATMP, COL_ENUM_END }; @@ -183,17 +182,19 @@ class RequestsModel : public QStandardItemModel // set data display const int dataType = item(row, COL_ID)->data(MetaTypeRole).toInt(); QVariant v = Utils::convertValueToType(dataType, res); - item(row, COL_VALUE)->setText(v.toString()); - item(row, COL_VALUE)->setData(v); - item(row, COL_VALUE)->setToolTip(item(row, COL_VALUE)->text()); + QStandardItem *itm = item(row, COL_VALUE); + itm->setText(v.toString()); + itm->setData(v, DataRole); + itm->setToolTip(itm->text()); // update timestamp column const auto ts = QDateTime::fromMSecsSinceEpoch(res.lastUpdate); - const auto lastts = item(row, COL_TIMESATMP)->data().toULongLong(); + itm = item(row, COL_TIMESATMP); + const auto lastts = itm->data().toULongLong(); const uint64_t tsDelta = lastts ? res.lastUpdate - lastts : 0; - item(row, COL_TIMESATMP)->setText(QString("%1 (%2)").arg(ts.toString("hh:mm:ss.zzz")).arg(tsDelta)); - item(row, COL_TIMESATMP)->setToolTip(item(row, COL_TIMESATMP)->text()); - item(row, COL_TIMESATMP)->setData(res.lastUpdate); + itm->setText(QString("%1 (%2)").arg(ts.toString("hh:mm:ss.zzz")).arg(tsDelta)); + itm->setToolTip(itm->text()); + itm->setData(res.lastUpdate, DataRole); qDebug() << "Saved result" << v << "for request ID" << res.requestId << "ts" << res.lastUpdate << "size" << res.data.size() << "type" << (QMetaType::Type)dataType << "data : " << QByteArray((const char *)res.data.data(), res.data.size()).toHex(':'); } @@ -233,78 +234,48 @@ class RequestsModel : public QStandardItemModel if (newRow) row = rowCount(); - setItem(row, COL_ID, new QStandardItem(QString("%1").arg(req.requestId))); - item(row, COL_ID)->setData(req.requestId, DataRole); - item(row, COL_ID)->setData(req.metaType, MetaTypeRole); - item(row, COL_ID)->setData(req.properties, PropertiesRole); - item(row, COL_ID)->setData(req.properties.value("id"), IdStringRole); // for search + QStandardItem *itm = setOrCreateItem(row, COL_ID, QString::number(req.requestId), req.requestId); + itm->setData(req.metaType, MetaTypeRole); + itm->setData(req.properties, PropertiesRole); - setItem(row, COL_TYPE, new QStandardItem(WSEnums::RequestTypeNames[+req.requestType])); - item(row, COL_TYPE)->setData(+req.requestType); - item(row, COL_TYPE)->setToolTip(item(row, COL_TYPE)->text()); + itm = setOrCreateItem(row, COL_TYPE, WSEnums::RequestTypeNames[+req.requestType], +req.requestType); if (req.requestType == WSEnums::RequestType::Calculated) { - setItem(row, COL_RES_TYPE, new QStandardItem(WSEnums::CalcResultTypeNames[+req.calcResultType])); - item(row, COL_RES_TYPE)->setData(+req.calcResultType); - setItem(row, COL_IDX, new QStandardItem(tr("N/A"))); - setItem(row, COL_UNIT, new QStandardItem(tr("N/A"))); - item(row, COL_IDX)->setEnabled(false); - item(row, COL_UNIT)->setEnabled(false); + setOrCreateItem(row, COL_RES_TYPE, WSEnums::CalcResultTypeNames[+req.calcResultType], +req.calcResultType); + setOrCreateItem(row, COL_IDX, tr("N/A"), false); + itm = setOrCreateItem(row, COL_UNIT, tr("N/A"), QString(req.unitName), false); } else { - setItem(row, COL_RES_TYPE, new QStandardItem(QString(req.varTypePrefix))); - item(row, COL_RES_TYPE)->setData(req.varTypePrefix); - if (Utils::isUnitBasedVariableType(req.varTypePrefix)) { - setItem(row, COL_UNIT, new QStandardItem(QString(req.unitName))); - } - else { - setItem(row, COL_UNIT, new QStandardItem(tr("N/A"))); - item(row, COL_UNIT)->setEnabled(false); - } - if (req.varTypePrefix == 'A') { - setItem(row, COL_IDX, new QStandardItem(QString("%1").arg(req.simVarIndex))); - } - else { - setItem(row, COL_IDX, new QStandardItem(tr("N/A"))); - item(row, COL_IDX)->setEnabled(false); - } + setOrCreateItem(row, COL_RES_TYPE, QString(req.varTypePrefix), req.varTypePrefix); + if (Utils::isUnitBasedVariableType(req.varTypePrefix)) + setOrCreateItem(row, COL_UNIT, req.unitName, QString(req.unitName)); + else + setOrCreateItem(row, COL_UNIT, tr("N/A"), QString(req.unitName), false); + if (req.varTypePrefix == 'A') + setOrCreateItem(row, COL_IDX, QString::number(req.simVarIndex)); + else + setOrCreateItem(row, COL_IDX, tr("N/A"), false); } - item(row, COL_RES_TYPE)->setToolTip(item(row, COL_RES_TYPE)->text()); - item(row, COL_UNIT)->setToolTip(item(row, COL_UNIT)->text()); - item(row, COL_UNIT)->setData(QString(req.unitName)); if (req.metaType == QMetaType::UnknownType) - setItem(row, COL_SIZE, new QStandardItem(QString("%1").arg(req.valueSize))); + setOrCreateItem(row, COL_SIZE, QString::number(req.valueSize), req.valueSize); else if (req.metaType > QMetaType::User) - setItem(row, COL_SIZE, new QStandardItem(QString("String (%1 B)").arg(req.metaType - QMetaType::User))); + setOrCreateItem(row, COL_SIZE, QString("String (%1 B)").arg(req.metaType - QMetaType::User), req.valueSize); else - setItem(row, COL_SIZE, new QStandardItem(QString("%1 (%2 B)").arg(QString(QMetaType::typeName(req.metaType)).replace("q", "")).arg(QMetaType::sizeOf(req.metaType)))); - item(row, COL_SIZE)->setData(req.valueSize); - item(row, COL_SIZE)->setToolTip(item(row, COL_SIZE)->text()); - - setItem(row, COL_PERIOD, new QStandardItem(WSEnums::UpdatePeriodNames[+req.period])); - item(row, COL_PERIOD)->setToolTip(item(row, COL_PERIOD)->text()); - item(row, COL_PERIOD)->setData(+req.period); + setOrCreateItem(row, COL_SIZE, QString("%1 (%2 B)").arg(QString(QMetaType::typeName(req.metaType)).replace("q", "")).arg(QMetaType::sizeOf(req.metaType)), req.valueSize); - setItem(row, COL_NAME, new QStandardItem(QString(req.nameOrCode))); - item(row, COL_NAME)->setToolTip(item(row, COL_NAME)->text()); + setOrCreateItem(row, COL_PERIOD, WSEnums::UpdatePeriodNames[+req.period], +req.period); + setOrCreateItem(row, COL_NAME, req.nameOrCode); + setOrCreateItem(row, COL_INTERVAL, QString::number(req.interval)); - setItem(row, COL_INTERVAL, new QStandardItem(QString("%1").arg(req.interval))); - - if (req.metaType > QMetaType::UnknownType && req.metaType < QMetaType::User) { - setItem(row, COL_EPSILON, new QStandardItem(QString::number(req.deltaEpsilon))); - } - else { - setItem(row, COL_EPSILON, new QStandardItem(tr("N/A"))); - item(row, COL_EPSILON)->setEnabled(false); - } - item(row, COL_EPSILON)->setData(req.deltaEpsilon); - item(row, COL_EPSILON)->setToolTip(item(row, COL_EPSILON)->text()); + if (req.metaType > QMetaType::UnknownType && req.metaType < QMetaType::User) + setOrCreateItem(row, COL_EPSILON, QString::number(req.deltaEpsilon), req.deltaEpsilon); + else + setOrCreateItem(row, COL_EPSILON, tr("N/A"), req.deltaEpsilon, false); if (newRow) { - setItem(row, COL_VALUE, new QStandardItem("???")); - setItem(row, COL_TIMESATMP, new QStandardItem("Never")); - item(row, COL_TIMESATMP)->setData(0); + setOrCreateItem(row, COL_VALUE, tr("???")); + setOrCreateItem(row, COL_TIMESATMP, tr("Never"), 0); } return index(row, 0); @@ -370,6 +341,22 @@ class RequestsModel : public QStandardItemModel signals: void rowCountChanged(int rows); + protected: + QStandardItem *setOrCreateItem(int row, int col, const QString &text, const QVariant &data = QVariant(), bool en = true, bool edit = false, const QString &tt = QString()) + { + QStandardItem *itm = item(row, col); + if (!itm){ + setItem(row, col, new QStandardItem()); + itm = item(row, col); + } + itm->setText(text); + itm->setToolTip(tt.isEmpty() ? text : tt); + itm->setEnabled(en); + itm->setEditable(edit); + if (data.isValid()) + itm->setData(data, DataRole); + return itm; + } private: uint32_t m_nextRequestId = 0; From b8a02557e9e0ec4d6a227d28e03570f6463e897b Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Fri, 27 Oct 2023 21:19:47 -0400 Subject: [PATCH 43/65] [WASimUI] Feature: Import and Export data requests in MSFS/SimConnect Touch Portal Plugin format; Adds new editor window option for reviewing/modifying exported records. --- src/WASimUI/RequestsExport.cpp | 310 +++++++++++++++++++++++++ src/WASimUI/RequestsExport.h | 65 ++++++ src/WASimUI/RequestsExport.ui | 413 +++++++++++++++++++++++++++++++++ src/WASimUI/RequestsFormat.h | 247 ++++++++++++++++++++ src/WASimUI/RequestsModel.h | 63 ++++- src/WASimUI/WASimUI.cpp | 53 ++++- src/WASimUI/WASimUI.vcxproj | 8 +- 7 files changed, 1148 insertions(+), 11 deletions(-) create mode 100644 src/WASimUI/RequestsExport.cpp create mode 100644 src/WASimUI/RequestsExport.h create mode 100644 src/WASimUI/RequestsExport.ui create mode 100644 src/WASimUI/RequestsFormat.h diff --git a/src/WASimUI/RequestsExport.cpp b/src/WASimUI/RequestsExport.cpp new file mode 100644 index 0000000..616c818 --- /dev/null +++ b/src/WASimUI/RequestsExport.cpp @@ -0,0 +1,310 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + +#include +#include +#include + +#include "RequestsExport.h" +#include "RequestsFormat.h" +#include "RequestsModel.h" +//#include "Widgets.h" + +using namespace WASimUiNS; + +static bool editFormEmpty(const Ui::RequestsExport ui) +{ + return ui.cbDefaultCategory->currentText().isEmpty() && + ui.leIdPrefix->text().isEmpty() && + ui.leFormat->text().isEmpty() && + ui.leDefault->text().isEmpty() && + (ui.cbReplWhat->currentText().isEmpty() || ui.cbReplCol->currentData().toInt() < 0); +} + +static void toggleEditFormBtn(const Ui::RequestsExport ui) +{ + bool en = !editFormEmpty(ui); + bool sel = ui.tableView->selectionModel()->hasSelection(); + ui.pbSetValues->defaultAction()->setEnabled(en && sel); + ui.pbClearValues->defaultAction()->setEnabled(en); + ui.pbRegen->menu()->setEnabled(sel); + ui.pbRegen->setEnabled(sel); + ui.pbExportSel->defaultAction()->setEnabled(sel); +} + + +RequestsExportWidget::RequestsExportWidget(RequestsModel *model, QWidget *parent) + : QWidget(parent) +{ + setObjectName(QStringLiteral("RequestsExportWidget")); + ui.setupUi(this); + + ui.tableView->setExportCategories(RequestsFormat::categoriesList()); + setModel(model); + + ui.cbDefaultCategory->addItem(0, ""); + const auto &cats = RequestsFormat::categoriesList(); + ui.cbDefaultCategory->addItems(cats.values(), cats.keys()); + + ui.cbReplCol->addItem("", -1); + for (int i = RequestsModel::COL_FIRST_META; i <= RequestsModel::COL_LAST_META; ++i) + if (i != RequestsModel::COL_META_CAT) + ui.cbReplCol->addItem(m_model->columnNames[i], i); + + ui.cbReplWhat->setPlaceholderText(tr("Search for...")); + ui.cbReplWhat->setClearButtonEnabled(); + ui.cbReplWith->setPlaceholderText(tr("Replace with...")); + ui.cbReplWith->setClearButtonEnabled(); + +#define MAKE_ACTION(ACT, TTL, ICN, BTN, M, TT, KS) \ + QAction *ACT = new QAction(QIcon(QStringLiteral(##ICN ".glyph")), TTL, this); ACT->setAutoRepeat(false); \ + ACT->setToolTip(TT); ui.##BTN->setDefaultAction(ACT); addAction(ACT); ACT->setShortcut(KS); \ + connect(ACT, &QAction::triggered, this, &RequestsExportWidget::M) +#define MAKE_ACTION_PB(ACT, TTL, IT, ICN, BTN, M, TT, KS) MAKE_ACTION(ACT, TTL, ICN, BTN, M, TT, KS); ACT->setIconText(" " + IT) +#define MAKE_ACTION_PD(ACT, TTL, IT, ICN, BTN, M, TT, KS) MAKE_ACTION_PB(ACT, TTL, IT, ICN, BTN, M, TT, KS); ACT->setDisabled(true) + + MAKE_ACTION_PB(exportAllAct, tr("Export All Request(s)"), tr("Export All"), "download_for_offline", pbExportAll, exportAll, + tr("Export all the Data Request(s) currently shown in the table."), QKeySequence::Save); + MAKE_ACTION_PD(exportSelAct, tr("Export Selected Request(s)"), tr("Export Selected"), "downloading", pbExportSel, exportSelected, + tr("Export only the Data Request(s) currently selected in the table."), QKeySequence(Qt::ControlModifier | Qt::ShiftModifier | Qt::Key_S)); + + MAKE_ACTION_PD(updateSelAct, tr("Update Selected Request(s)"), tr("Update Selected"), "edit_note", pbSetValues, updateBulk, + tr("

Bulk-update any selected Data Request(s) in the table. Only non-empty fields will be applied to their respective columns in the selected records.

" + "Warning! there is no way to undo bulk updates."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); + + MAKE_ACTION_PD(clearEditAct, tr("Clear Bulk Update Form"), tr("Clear Form"), "scale=.9/backspace", pbClearValues, clearForm, + tr("Reset all fields in the editor to empty default values."), QKeySequence(Qt::ControlModifier | Qt::Key_D)); + + MAKE_ACTION_PB(closeAct, tr("Close Window"), tr("Close"), "fg=blue/close", pbCancel, close, + tr("Close this export window. Any changes made here are preserved."), QKeySequence::Close); + + QMenu *updateMenu = new QMenu(tr("Regenerate Data..."), ui.pbRegen); + updateMenu->setIcon(QIcon(QStringLiteral("auto_mode.glyph"))); + updateMenu->setToolTip(tr("

Regenerate new export IDs or display names based on current values on selected request row(s).

")); + updateMenu->addAction(QIcon(QStringLiteral("format_list_numbered.glyph")), tr("Regenerate Export ID(s)"), this, &RequestsExportWidget::regenIds); + updateMenu->addAction(QIcon(QStringLiteral("title.glyph")), tr("Regenerate Display Name(s)"), this, &RequestsExportWidget::regenNames); + updateMenu->setDisabled(true); + + ui.pbRegen->setIcon(updateMenu->icon()); + ui.pbRegen->setMenu(updateMenu); + ui.pbRegen->setDisabled(true); + //ui.pbRegen->setHidden(true); + + addAction(updateMenu->menuAction()); + addAction(ui.tableView->columnToggleActionsMenu(this)->menuAction()); + addAction(ui.tableView->fontActionsMenu(this)->menuAction()); + + connect(ui.cbDefaultCategory, &DataComboBox::currentDataChanged, this, [&]() { toggleEditFormBtn(ui); }); + connect(ui.leIdPrefix, &QLineEdit::textChanged, this, [&]() { toggleEditFormBtn(ui); }); + connect(ui.leFormat, &QLineEdit::textChanged, this, [&]() { toggleEditFormBtn(ui); }); + connect(ui.leDefault, &QLineEdit::textChanged, this, [&]() { toggleEditFormBtn(ui); }); + connect(ui.cbReplCol, &DataComboBox::currentDataChanged, this, [&]() { toggleEditFormBtn(ui); }); + connect(ui.cbReplWhat, &QComboBox::currentTextChanged, this, [&]() { toggleEditFormBtn(ui); }); + + loadSettings(); + +} + +void RequestsExportWidget::setModel(RequestsModel *model) { + m_model = model; + ui.tableView->setModel(model); + if (!model) + return; + + ui.tableView->moveColumn(RequestsModel::COL_META_CAT, 0); + ui.tableView->moveColumn(RequestsModel::COL_META_ID, 1); + ui.tableView->moveColumn(RequestsModel::COL_META_NAME, 2); + + ui.tableView->hideColumn(RequestsModel::COL_ID); + ui.tableView->hideColumn(RequestsModel::COL_TYPE); + ui.tableView->hideColumn(RequestsModel::COL_SIZE); + ui.tableView->hideColumn(RequestsModel::COL_VALUE); + ui.tableView->hideColumn(RequestsModel::COL_TIMESATMP); + + ensureDefaultValues(); + + connect(ui.tableView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=](const QItemSelection &sel, const QItemSelection &) { + toggleEditFormBtn(ui); + int cnt = ui.tableView->selectionModel()->selectedRows().count(); + const QString txt(tr("Update %1 %2").arg(cnt).arg(cnt == 1 ? tr("Record") : tr("Records"))); + ui.pbSetValues->defaultAction()->setText(txt); + ui.pbSetValues->defaultAction()->setIconText(txt); + }); + +} + +void RequestsExportWidget::closeEvent(QCloseEvent *ev) { + saveSettings(); + ev->accept(); +} + +RequestsModel * RequestsExportWidget::model() const { return m_model; } + +void RequestsExportWidget::exportAll() { exportRecords(true); } +void RequestsExportWidget::exportSelected() { exportRecords(false); } + +void RequestsExportWidget::exportRecords(bool all) +{ + if ((all && !m_model->rowCount()) || (!all && !ui.tableView->selectionModel()->hasSelection())) + return; + + const QString &fname = QFileDialog::getSaveFileName(this, tr("Export %1 Requests").arg(all ? tr("All") : tr("Selected")), m_lastFile, QStringLiteral("INI files (*.ini)")); + if (fname.isEmpty()) + return; + + if (fname != m_lastFile) { + m_lastFile = fname; + Q_EMIT lastUsedFileChanged(fname); + } + + const QModelIndexList list = all ? m_model->allRequests() : ui.tableView->selectionModel()->selectedRows(RequestsModel::COL_ID); + RequestsFormat::exportToPluginFormat(m_model, list, fname); +} + +void RequestsExportWidget::updateBulk() +{ + const QModelIndexList &list = ui.tableView->selectionModel()->selectedRows(); + if (list.isEmpty() || editFormEmpty(ui)) + return; + QString cid = ui.cbDefaultCategory->currentData().toString(); + QString idp = ui.leIdPrefix->text(); + QString fmt = ui.leFormat->text(); + QString def = ui.leDefault->text(); + + for (const QModelIndex &r : list) { + if (!cid.isEmpty()) { + m_model->setData(m_model->index(r.row(), RequestsModel::COL_META_CAT), cid, Qt::EditRole); + m_model->setData(m_model->index(r.row(), RequestsModel::COL_META_CAT), ui.cbDefaultCategory->currentText(), Qt::ToolTipRole); + } + + if (!fmt.isEmpty()) { + if (fmt == "''" || fmt == "\"\"") + fmt.clear(); + m_model->setData(m_model->index(r.row(), RequestsModel::COL_META_FMT), fmt, Qt::EditRole); + m_model->setData(m_model->index(r.row(), RequestsModel::COL_META_FMT), fmt, Qt::ToolTipRole); + } + + if (!def.isEmpty()) { + if (def == "''" || def == "\"\"") + def.clear(); + m_model->setData(m_model->index(r.row(), RequestsModel::COL_META_DEF), def, Qt::EditRole); + m_model->setData(m_model->index(r.row(), RequestsModel::COL_META_DEF), def, Qt::ToolTipRole); + } + + if (!idp.isEmpty()) { + const QModelIndex col = m_model->index(r.row(), RequestsModel::COL_META_ID); + QString val = m_model->data(col, Qt::EditRole).toString(); + if (idp.startsWith('!')) + val.replace(QRegularExpression("^" + QRegularExpression::escape(idp.remove(0, 1))), ""); + else + val.prepend(idp); + m_model->setData(col, val, Qt::EditRole); + m_model->setData(col, val, Qt::ToolTipRole); + } + + if (!ui.cbReplWhat->currentText().isEmpty() && ui.cbReplCol->currentData().toInt() > -1) { + const QModelIndex col = m_model->index(r.row(), ui.cbReplCol->currentData().toInt()); + if (col.isValid()) { + QString val = m_model->data(col, Qt::EditRole).toString(); + val.replace(QRegularExpression(ui.cbReplWhat->currentText()), ui.cbReplWith->currentText()); + m_model->setData(col, val, Qt::EditRole); + m_model->setData(col, val, Qt::ToolTipRole); + } + } + + } +} + +void RequestsExportWidget::regenIds() +{ + const QModelIndexList &list = ui.tableView->selectionModel()->selectedRows(RequestsModel::COL_META_ID); + if (list.isEmpty()) + return; + RequestRecord req; + QString id; + for (const QModelIndex &idx : list) { + req = m_model->getRequest(idx.row()); + id = RequestsFormat::generateIdFromName(req); + if (req.properties.value("id").toString() != id) { + m_model->setData(idx, id, Qt::EditRole); + m_model->setData(idx, id, Qt::ToolTipRole); + } + } +} + +void RequestsExportWidget::regenNames() +{ + const QModelIndexList &list = ui.tableView->selectionModel()->selectedRows(RequestsModel::COL_META_NAME); + if (list.isEmpty()) + return; + RequestRecord req; + QString name; + for (const QModelIndex &idx : list) { + req = m_model->getRequest(idx.row()); + name = RequestsFormat::generateDefaultName(req); + if (req.properties.value("id").toString() != name) { + m_model->setData(idx, name, Qt::EditRole); + m_model->setData(idx, name, Qt::ToolTipRole); + } + } +} + +void RequestsExportWidget::ensureDefaultValues() +{ + for (int row = 0; row < m_model->rowCount(); ++row) { + RequestRecord req = m_model->getRequest(row); + RequestsFormat::generateRequiredProperties(req); + m_model->updateFromMetaData(row, req); + } +} + +void RequestsExportWidget::clearForm() +{ + ui.cbDefaultCategory->setCurrentIndex(0); + ui.leIdPrefix->clear(); + ui.leDefault->clear(); + ui.leFormat->clear(); + ui.cbReplCol->setCurrentData(-1); + toggleEditFormBtn(ui); +} + +void RequestsExportWidget::saveSettings() const +{ + QSettings s; + s.beginGroup(objectName()); + s.setValue(QStringLiteral("windowGeo"), saveGeometry()); + s.setValue(QStringLiteral("tableViewState"), ui.tableView->saveState()); + s.setValue(ui.cbReplWhat->objectName(), ui.cbReplWhat->editedItems()); + s.setValue(ui.cbReplWith->objectName(), ui.cbReplWhat->editedItems()); + s.endGroup(); +} + +void RequestsExportWidget::loadSettings() +{ + QSettings s; + s.beginGroup(objectName()); + if (s.contains(QStringLiteral("windowGeo"))) + restoreGeometry(s.value(QStringLiteral("windowGeo")).toByteArray()); + ui.tableView->restoreState(s.value(QStringLiteral("tableViewState")).toByteArray()); + if (s.contains(ui.cbReplWhat->objectName())) + ui.cbReplWhat->insertEditedItems(s.value(ui.cbReplWhat->objectName()).toStringList()); + if (s.contains(ui.cbReplWith->objectName())) + ui.cbReplWith->insertEditedItems(s.value(ui.cbReplWith->objectName()).toStringList()); + s.endGroup(); +} diff --git a/src/WASimUI/RequestsExport.h b/src/WASimUI/RequestsExport.h new file mode 100644 index 0000000..99f8ead --- /dev/null +++ b/src/WASimUI/RequestsExport.h @@ -0,0 +1,65 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + +#pragma once + +#include +#include "ui_RequestsExport.h" +#include "DataComboBox.h" + +namespace WASimUiNS { + +class RequestsModel; + +class RequestsExportWidget : public QWidget +{ + Q_OBJECT + +public: + explicit RequestsExportWidget(RequestsModel *model, QWidget *parent = nullptr); + RequestsModel *model() const; + +public Q_SLOTS: + void setLastUsedFile(const QString &fn) { m_lastFile = fn; }; + void exportAll(); + void exportSelected(); + +Q_SIGNALS: + void lastUsedFileChanged(const QString &fn); + +protected: + void closeEvent(QCloseEvent *) override; + +private: + void setModel(RequestsModel *model); + void exportRecords(bool all = true); + void ensureDefaultValues(); + void updateBulk(); + void regenIds(); + void regenNames(); + void clearForm(); + void saveSettings() const; + void loadSettings(); + + QString m_lastFile; + RequestsModel *m_model = nullptr; + Ui::RequestsExport ui; +}; + +} // WASimUiNS diff --git a/src/WASimUI/RequestsExport.ui b/src/WASimUI/RequestsExport.ui new file mode 100644 index 0000000..556831e --- /dev/null +++ b/src/WASimUI/RequestsExport.ui @@ -0,0 +1,413 @@ + + + RequestsExport + + + + 0 + 0 + 1229 + 760 + + + + Qt::ActionsContextMenu + + + Export Requests + + + + 6 + + + 6 + + + 6 + + + 10 + + + + + + 0 + 0 + + + + Bulk Update Selected Item(s) + + + + 8 + + + + + Format + + + + + + + <p>Set the formatting string on selected item(s).</p> +<p>To set the formatting to an empty value, enter two single or double quotes (<tt>''</tt> or <tt>&quot;&quot;</tt>).</p> + + + true + + + + + + + 20 + + + 10 + + + 10 + + + + + Clear Form + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + <p>Regenerate new export IDs or display names based on current values on selected request row(s).</p> + + + Regenerate... + + + + + + + Update Selected + + + + + + + + + Category + + + + + + + ID Prefix + + + + + + + Sets the sorting category on each selected item. + + + + + + + <p>Set the default value on selected item(s).</p> +<p>To clear the default value, enter two single or double quotes (<tt>''</tt> or <tt>&quot;&quot;</tt>).</p> + + + true + + + + + + + <p>Prepends the prefix to the ID of each selected item.</p> +<p>To remove a prefix (any string at the beginning of an ID), start the value here with a exclamation mark (<tt>!</tt>). For example: <tt>!MyPrefix_</tt></p> + + + true + + + + + + + Default Value + + + + + + + 0 + + + + + + 0 + 0 + + + + In + + + + + + + + 0 + 0 + + + + Select which column to search in. + + + + + + + + 0 + 0 + + + + replace + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + <p>What to replace. The comparison is case-sensitive. You may use regular expression syntax here.</p><p>Press Enter when done to save your entry for later selection. Saved items can be removed by right-clicking on them while the list is open.</p> + + + QComboBox::InsertAlphabetically + + + QComboBox::AdjustToContents + + + + + + + + 0 + 0 + + + + with + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + <p>Replacement string. Use backslashes (<tt>&#92;</tt>) for regular expression capture references, for example: <tt">&#92;1</tt> for first capture group.</p><p>Press Enter when done to save your entry for later selection. Saved items can be removed by right-clicking on them while the list is open.</p> + + + QComboBox::InsertAlphabetically + + + QComboBox::AdjustToContents + + + + + + + + + + + + + 0 + 0 + + + + + Segoe UI + + + + <html><head/><body><p>The produced INI file can be used directly in the plugin as a &quot;Variables Definition&quot; file to provide custom states. See the wiki article <a href="https://github.com/mpaperno/MSFSTouchPortalPlugin/wiki/Using-Custom-States-and-Simulator-Variables">Using Custom States and Simulator Variables</a> for more information.</p><p>The <i>Category</i>, <i>Export ID</i>, <i>Display Name</i>, <i>Default</i>, and <i>Format</i> columns can be edited by clicking on the respective field in the table. Apply changes in bulk to selected table row(s) using the form on the left (hover over form fields for more details). <span style=" font-weight:600;">Note</span>: there is no way to undo bulk edits.</p><p>The records will be exported in the same order as in the table below. Click headings to sort, drag to arrange, and right-click to toggle.</p></body></html> + + + Qt::AutoText + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + 10 + + + -1 + + + true + + + Qt::LinksAccessibleByMouse + + + + + + + + + + 10 + + + 10 + + + + + Close + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Export Selected + + + + + + + Export All + + + + + + + + + + 0 + 0 + + + + + Segoe UI Semibold + 75 + true + + + + <html><head/><body><p align="center"><span style=" font-size:10pt;">Export Data Requests to MSFS/SimConnect Touch Portal Plugin Format</span></p></body></html> + + + Qt::RichText + + + + + + + + + ActionPushButton + QPushButton +
ActionPushButton.h
+
+ + WASimUiNS::DeletableItemsComboBox + QComboBox +
Widgets.h
+
+ + WASimUiNS::RequestsTableView + QTableView +
RequestsTableView.h
+
+ + DataComboBox + QComboBox +
DataComboBox.h
+
+
+ + +
diff --git a/src/WASimUI/RequestsFormat.h b/src/WASimUI/RequestsFormat.h new file mode 100644 index 0000000..c10389d --- /dev/null +++ b/src/WASimUI/RequestsFormat.h @@ -0,0 +1,247 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + +#pragma once + +#include +#include +#include "RequestsModel.h" + +namespace WASimUiNS { + namespace RequestsFormat +{ + + static QMap categoriesList() + { + static QMap cats { + {"AutoPilot", "AutoPilot"}, + {"Camera", "Camera & Views"}, + {"Communication", "Radio & Navigation"}, + {"Electrical", "Electrical"}, + {"Engine", "Engine"}, + {"Environment", "Environment"}, + {"Failures", "Failures"}, + {"FlightInstruments", "Flight Instruments"}, + {"FlightSystems", "Flight Systems"}, + {"Fuel", "Fuel"}, + {"Miscellaneous", "Miscellaneous"}, + {"SimSystem", "Simulator System"}, + }; + return cats; + } + + static QString generateDefaultName(const RequestRecord &req) + { + QString name = req.requestType == WASimCommander::Enums::RequestType::Calculated ? + "CalculatorResult_" + QString::number(req.requestId) : QString(req.nameOrCode).simplified(); + if (req.varTypePrefix == 'A' && req.simVarIndex) + name += ' ' + QString::number(req.simVarIndex); + return name; + } + + static QString generateIdFromName(const RequestRecord &req) + { + static const QRegularExpression idRegex = QRegularExpression("(?:\\b|\\W|_)(\\w)"); + // convert ID to CamelCase + QString id = req.properties.value("name").toString().toLower() /*.replace(' ', '_')*/; + QRegularExpressionMatchIterator mi = idRegex.globalMatch(id); + while (mi.hasNext()) { + QRegularExpressionMatch m = mi.next(); + id.replace(m.capturedStart(), m.capturedLength(), m.captured().toUpper()); + } + id.replace(' ', ""); + if (req.varTypePrefix == 'A' && req.simVarIndex) + id += QString::number(req.simVarIndex); + return id; + } + + static void generateRequiredProperties(RequestRecord &req) + { + // friendly name + if (req.properties.value("name").toString().isEmpty()) + req.properties["name"] = generateDefaultName(req); + // required unique ID + if (req.properties.value("id").toString().isEmpty()) + req.properties["id"] = generateIdFromName(req); + // sorting category id and name + if (req.properties.value("categoryId").toString().isEmpty()) { + req.properties["categoryId"] = QStringLiteral("Miscellaneous"); + req.properties["category"] = QStringLiteral("Miscellaneous"); + } + // optional default value + if (req.properties.value("default").toString().isEmpty() && QString(req.unitName) != QLatin1Literal("string")) + req.properties["default"] = 0; + // optional formatting string + if (req.properties.value("format").toString().isEmpty() && (req.valueSize == WS::DATA_TYPE_FLOAT || req.valueSize == WS::DATA_TYPE_DOUBLE)) + req.properties["format"] = QStringLiteral("F2"); + } + + static QTextStream &addField(QTextStream &out, const char *key, const QVariant &value, bool quoted = false) { + out << key << " = "; + if (quoted) + out << '"'; + out << value.toString(); + if (quoted) + out << '"'; + return out << endl; + } + + static int exportToPluginFormat(RequestsModel *model, const QModelIndexList &rows, const QString &filepath) + { + //const QModelIndexList rows = selection ? selection->selectedRows(RequestsModel::COL_ID) : model->allRequests(); + if (rows.isEmpty()) + return 0; + + QSaveFile *f = new QSaveFile(filepath); + if (!f->open(QFile::WriteOnly | QFile::Truncate | QFile::Text)) + return 0; + QTextStream out(f); + + RequestRecord req; + QString tmp; + for (const QModelIndex &r : rows) { + req = model->getRequest(r.row()); + + generateRequiredProperties(req); + + out << '[' << req.properties.value("id").toString() << ']' << endl; + addField(out, "CategoryId", req.properties.value("categoryId")); + addField(out, "Name", req.properties.value("name"), true); + addField(out, "VariableType", QChar(req.varTypePrefix)); + + tmp = req.nameOrCode; + if (req.varTypePrefix == 'A' && req.simVarIndex) + tmp += ':' + QString::number(req.simVarIndex); + addField(out, "SimVarName", tmp, true); + + if (req.varTypePrefix == 'Q') + addField(out, "CalcResultType", Utils::getEnumName(req.calcResultType, WSEnums::CalcResultTypeNames)); + else + addField(out, "Unit", req.unitName, true); + + tmp = req.properties.value("default").toString(); + if (!tmp.isEmpty()) + addField(out, "DefaultValue", tmp, true); + + tmp = req.properties.value("format").toString(); + if (!tmp.isEmpty()) + addField(out, "StringFormat", tmp, true); + + if (req.period != WSEnums::UpdatePeriod::Tick) + addField(out, "UpdatePeriod", Utils::getEnumName(req.period, WSEnums::UpdatePeriodNames)); + if (req.interval) + addField(out, "UpdateInterval", req.interval); + if (req.deltaEpsilon && QString(req.unitName) != QLatin1Literal("string")) + addField(out, "DeltaEpsilon", QString::number(req.deltaEpsilon)); + + out << endl; + } + + f->commit(); + return rows.count(); + } + + + static QString cleanValue(const QSettings &s, const QString &key, const QString &def = QString()) + { + // QSettings doesn't properly strip trailing comments that start with '#' (';' are OK), so we have to do it.. :( + static const QRegularExpression stripTrailingComments("(? importFromPluginFormat(RequestsModel *model, const QString &filepath) + { + QSettings s(filepath, QSettings::IniFormat); + s.setAtomicSyncRequired(false); + + int enumIdx; + QList ret; + const QStringList list = s.childGroups(); + for (const QString &key : list) { + s.beginGroup(key); + if (!s.contains("Name") || !s.contains("SimVarName")) + continue; + + RequestRecord req; + QModelIndex ri = model->match(model->index(0, RequestsModel::COL_META_ID), Qt::EditRole, key, 1, Qt::MatchExactly | Qt::MatchWrap).value(0); + if (ri.isValid()) + req = model->getRequest(ri.row()); + else + req = RequestRecord(model->nextRequestId(), 'A', nullptr, 0); + + req.properties["id"] = key; + req.properties["name"] = s.value("Name"); + req.properties["categoryId"] = cleanValue(s, "CategoryId", "Miscellaneous").trimmed(); + req.properties["category"] = categoriesList().value(req.properties["categoryId"].toString()); + + if (s.contains("VariableType")) + req.varTypePrefix = qPrintable(cleanValue(s, "VariableType"))[0]; + + QString simVarName = cleanValue(s, "SimVarName").trimmed(); + + if (req.varTypePrefix == 'Q') { + req.requestType = WSEnums::RequestType::Calculated; + enumIdx = Utils::indexOfString(WSEnums::CalcResultTypeNames, qPrintable(cleanValue(s, "CalcResultType", "Double").trimmed())); + req.calcResultType = enumIdx > -1 ? (WSEnums::CalcResultType)enumIdx : WSEnums::CalcResultType::Double; + req.metaType = Utils::calcResultTypeToMetaType(req.calcResultType); + req.valueSize = Utils::metaTypeToSimType(req.metaType); + } + else { + req.requestType = WSEnums::RequestType::Named; + req.setUnitName(qPrintable(cleanValue(s, "Unit", "number").trimmed())); + req.metaType = Utils::unitToMetaType(req.unitName); + req.valueSize = Utils::metaTypeToSimType(req.metaType); + if (req.varTypePrefix == 'A' && simVarName.contains(':')) { + const QStringList ni = simVarName.split(':'); + if (ni.length() == 2) { + simVarName = ni.first(); + req.simVarIndex = ni.last().toInt(); + } + } + } + req.setNameOrCode(qPrintable(simVarName)); + + if (s.contains("UpdatePeriod")) { + enumIdx = Utils::indexOfString(WSEnums::UpdatePeriodNames, qPrintable(cleanValue(s, "UpdatePeriod").trimmed())); + if (enumIdx > -1) + req.calcResultType = (WSEnums::CalcResultType)enumIdx; + } + + if (s.contains("Interval")) + req.interval = s.value("Interval").toUInt(); + if (s.contains("DeltaEpsilon")) + req.deltaEpsilon = s.value("DeltaEpsilon").toFloat(); + + if (s.contains("StringFormat")) + req.properties["format"] = s.value("StringFormat"); + if (s.contains("DefaultValue")) + req.properties["default"] = s.value("DefaultValue"); + + model->addRequest(req); + ret << req; + s.endGroup(); + } + return ret; + } + + + } +} diff --git a/src/WASimUI/RequestsModel.h b/src/WASimUI/RequestsModel.h index c5a5ac2..b83377c 100644 --- a/src/WASimUI/RequestsModel.h +++ b/src/WASimUI/RequestsModel.h @@ -136,10 +136,45 @@ class RequestsModel : public QStandardItemModel public: enum Roles { DataRole = Qt::UserRole + 1, MetaTypeRole, PropertiesRole }; enum Columns { - COL_ID, COL_TYPE, COL_RES_TYPE, COL_NAME, COL_IDX, COL_UNIT, COL_SIZE, COL_PERIOD, COL_INTERVAL, COL_EPSILON, COL_VALUE, COL_TIMESATMP, COL_ENUM_END + COL_ID, + COL_TYPE, + COL_RES_TYPE, + COL_NAME, + COL_IDX, + COL_UNIT, + COL_SIZE, + COL_PERIOD, + COL_INTERVAL, + COL_EPSILON, + COL_VALUE, + COL_TIMESATMP, + COL_META_ID, + COL_META_NAME, + COL_META_CAT, + COL_META_DEF, + COL_META_FMT, + COL_ENUM_END, + COL_FIRST_META = COL_META_ID, + COL_LAST_META = COL_META_FMT, }; const QStringList columnNames = { - tr("ID"), tr("Type"), tr("Res/Var"), tr("Name or Code"), tr("Idx"), tr("Unit"), tr("Size"), tr("Period"), tr("Intvl"), tr("ΔΕ"), tr("Value"), tr("Last Updt.") + tr("ID"), + tr("Type"), + tr("Res/Var"), + tr("Name or Code"), + tr("Idx"), + tr("Unit"), + tr("Size"), + tr("Period"), + tr("Intvl"), + tr("ΔΕ"), + tr("Value"), + tr("Last Updt."), + tr("Export ID"), + tr("Display Name"), + tr("Category"), + tr("Default"), + tr("Format"), }; RequestsModel(QObject *parent = nullptr) : @@ -221,7 +256,13 @@ class RequestsModel : public QStandardItemModel req.setUnitName(item(row, COL_UNIT)->data(DataRole).toByteArray().constData()); req.metaType = item(row, COL_ID)->data(MetaTypeRole).toInt(); - req.properties = item(row, COL_ID)->data(PropertiesRole).toMap(); + + req.properties["id"] = item(row, COL_META_ID)->data(Qt::EditRole).toString(); + req.properties["name"] = item(row, COL_META_NAME)->data(Qt::EditRole).toString(); + req.properties["categoryId"] = item(row, COL_META_CAT)->data(Qt::EditRole).toString(); + req.properties["category"] = item(row, COL_META_CAT)->data(Qt::ToolTipRole).toString(); + req.properties["default"] = item(row, COL_META_DEF)->data(Qt::EditRole).toString(); + req.properties["format"] = item(row, COL_META_FMT)->data(Qt::EditRole).toString(); //std::cout << req << std::endl; return req; @@ -278,9 +319,21 @@ class RequestsModel : public QStandardItemModel setOrCreateItem(row, COL_TIMESATMP, tr("Never"), 0); } + updateFromMetaData(row, req); + return index(row, 0); } + void updateFromMetaData(int row, const RequestRecord &req) + { + // Meta data for exports + setOrCreateEditableItem(row, COL_META_ID, req.properties.value("id").toString()); + setOrCreateEditableItem(row, COL_META_NAME, req.properties.value("name").toString()); + setOrCreateEditableItem(row, COL_META_CAT, req.properties.value("categoryId").toString(), req.properties.value("category").toString()); + setOrCreateEditableItem(row, COL_META_DEF, req.properties.value("default").toString()); + setOrCreateEditableItem(row, COL_META_FMT, req.properties.value("format").toString()); + } + void removeRequest(const uint32_t requestId) { const int row = findRequestRow(requestId); @@ -357,6 +410,10 @@ class RequestsModel : public QStandardItemModel itm->setData(data, DataRole); return itm; } + QStandardItem *setOrCreateEditableItem(int row, int col, const QString &text, const QString &tt = QString()) { + return setOrCreateItem(row, col, text, QVariant(), true, true, tt); + } + private: uint32_t m_nextRequestId = 0; diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index aef30a7..9188def 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -39,6 +39,8 @@ and is also available at . #include "EventsModel.h" #include "LogConsole.h" +#include "RequestsExport.h" +#include "RequestsFormat.h" #include "RequestsModel.h" #include "Utils.h" #include "Widgets.h" @@ -70,6 +72,7 @@ class WASimUIPrivate StatusWidget *statWidget; RequestsModel *reqModel; EventsModel *eventsModel; + RequestsExportWidget *reqExportWidget = nullptr; QAction *toggleConnAct = nullptr; QAction *initAct = nullptr; QAction *connectAct = nullptr; @@ -492,7 +495,7 @@ class WASimUIPrivate ui->btnReqestsPause->setIcon(chk ? dataResumeIcon : dataPauseIcon); }; - void saveRequests() + void saveRequests(bool forExport = false) { if (!reqModel->rowCount()) return; @@ -501,10 +504,29 @@ class WASimUIPrivate if (fname.isEmpty()) return; lastRequestsFile = fname; - const int rows = reqModel->saveToFile(fname); + const int rows = forExport ? RequestsFormat::exportToPluginFormat(reqModel, reqModel->allRequests(), fname) : reqModel->saveToFile(fname); logUiMessage(tr("Saved %1 Data Request(s) to file: %2").arg(rows).arg(fname), CommandId::Ack, LogLevel::Info); } + void exportRequests() + { + if (!reqModel->rowCount()) + return; + + if (reqExportWidget) { + reqExportWidget->raise(); + return; + } + + reqExportWidget = new RequestsExportWidget(reqModel, q); + reqExportWidget->setWindowFlag(Qt::Dialog); + reqExportWidget->setAttribute(Qt::WA_DeleteOnClose); + reqExportWidget->setLastUsedFile(lastRequestsFile); + QObject::connect(reqExportWidget, &RequestsExportWidget::lastUsedFileChanged, [&](const QString &fn) { lastRequestsFile = fn; }); + QObject::connect(reqExportWidget, &QObject::destroyed, q, [=]() { reqExportWidget = nullptr; }); + reqExportWidget->show(); + } + void loadRequests(bool replace) { const QString &fname = QFileDialog::getOpenFileName(q, tr("Select a saved Requests file"), lastRequestsFile, QStringLiteral("INI files (*.ini)")); @@ -513,7 +535,17 @@ class WASimUIPrivate lastRequestsFile = fname; if (replace) removeAllRequests(); - const QList &added = reqModel->loadFromFile(fname); + + QFile f(fname); + if (!f.open(QFile::ReadOnly | QFile::Text)) { + logUiMessage(tr("Could not open file '%1' for reading").arg(fname), CommandId::Nak, LogLevel::Error); + return; + } + const QString first = f.readLine(); + f.close(); + const bool isNative = first.startsWith(QLatin1Literal("[Requests]")); + + const QList &added = isNative ? reqModel->loadFromFile(fname) : RequestsFormat::importFromPluginFormat(reqModel, fname); for (const DataRequest &req : added) client->saveDataRequest(req, true); // async @@ -839,7 +871,12 @@ WASimUI::WASimUI(QWidget *parent) : ui.cbNameOrCode->lineEdit()->setMaxLength(STRSZ_REQ); // Set up the Requests table view + ui.requestsView->setExportCategories(RequestsFormat::categoriesList()); ui.requestsView->setModel(d->reqModel); + // Hide unused columns + QHeaderView *hdr = ui.requestsView->header(); + for (int i = RequestsModel::COL_FIRST_META; i <= RequestsModel::COL_LAST_META; ++i) + hdr->hideSection(i); // connect double click action to populate the request editor form connect(ui.requestsView, &QTableView::doubleClicked, this, [this](const QModelIndex &idx) { d->populateRequestForm(idx); }); @@ -1022,7 +1059,15 @@ WASimUI::WASimUI(QWidget *parent) : pauseRequestsAct->setCheckable(true); // Save current Requests to a file - MAKE_ACTION_PB_D(saveRequestsAct, tr("Save Requests"), tr("Save"), "save", btnReqestsSave, wRequests, saveRequests(), tr("Save requests to a file for later use."), QKeySequence::Save); + MAKE_ACTION_PB_NC(saveRequestsAct, tr("Save Requests"), tr("Save"), "save", btnReqestsSave, wRequests, tr("Save requests to a file for later use or bring up a dialog with export options.")); + saveRequestsAct->setDisabled(true); + QMenu *saveRequestsMenu = new QMenu(tr("Requests Save Action"), this); + saveRequestsMenu->addAction(GLYPH_ICON("keyboard_command_key"), tr("In native WASimUI format"), this, [this]() { d->saveRequests(false); }, QKeySequence::Save); + saveRequestsMenu->addAction(GLYPH_ICON("overlay=align=AlignRight\\fg=#5eb5ff\\arrow_outward/touch_app"), tr("Export for Touch Portal Plugin with Editor..."), + this, [this]() { d->exportRequests(); }, QKeySequence(Qt::ControlModifier | Qt::ShiftModifier | Qt::Key_S)); + saveRequestsMenu->addAction(GLYPH_ICON("touch_app"), tr("Export for Touch Portal Plugin Directly"), + this, [this]() { d->saveRequests(true); }, QKeySequence(Qt::ControlModifier | Qt::ShiftModifier | Qt::AltModifier | Qt::Key_S)); + saveRequestsAct->setMenu(saveRequestsMenu); // Load Requests from a file. This is actually two actions: load and append to existing records + load and replace existing records. MAKE_ACTION_PB_NC(loadRequestsAct, tr("Load Requests"), tr("Load"), "folder_open", btnReqestsLoad, wRequests, diff --git a/src/WASimUI/WASimUI.vcxproj b/src/WASimUI/WASimUI.vcxproj index a8c40cd..262104b 100644 --- a/src/WASimUI/WASimUI.vcxproj +++ b/src/WASimUI/WASimUI.vcxproj @@ -260,11 +260,11 @@ - + - + @@ -296,8 +296,8 @@ - - + + From 606ada53d1ecd5276ccdce5b0e06e3a75a29010f Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sat, 28 Oct 2023 02:53:42 -0400 Subject: [PATCH 44/65] [WASimUI] Implement multi-column sorting for requests table. --- src/WASimUI/RequestsExport.ui | 4 +- src/WASimUI/RequestsTableView.h | 19 +- src/WASimUI/multisort_view/AlphanumComparer.h | 109 ++++++++++++ .../multisort_view/AlphanumSortProxyModel.h | 81 +++++++++ src/WASimUI/multisort_view/ColumnsSorter.h | 149 ++++++++++++++++ src/WASimUI/multisort_view/LICENSE | 166 ++++++++++++++++++ .../multisort_view/MultisortTableView.cpp | 90 ++++++++++ .../multisort_view/MultisortTableView.h | 39 ++++ src/WASimUI/multisort_view/README | 27 +++ 9 files changed, 678 insertions(+), 6 deletions(-) create mode 100644 src/WASimUI/multisort_view/AlphanumComparer.h create mode 100644 src/WASimUI/multisort_view/AlphanumSortProxyModel.h create mode 100644 src/WASimUI/multisort_view/ColumnsSorter.h create mode 100644 src/WASimUI/multisort_view/LICENSE create mode 100644 src/WASimUI/multisort_view/MultisortTableView.cpp create mode 100644 src/WASimUI/multisort_view/MultisortTableView.h create mode 100644 src/WASimUI/multisort_view/README diff --git a/src/WASimUI/RequestsExport.ui b/src/WASimUI/RequestsExport.ui index 556831e..62eb141 100644 --- a/src/WASimUI/RequestsExport.ui +++ b/src/WASimUI/RequestsExport.ui @@ -6,7 +6,7 @@ 0 0 - 1229 + 1288 760 @@ -288,7 +288,7 @@ - <html><head/><body><p>The produced INI file can be used directly in the plugin as a &quot;Variables Definition&quot; file to provide custom states. See the wiki article <a href="https://github.com/mpaperno/MSFSTouchPortalPlugin/wiki/Using-Custom-States-and-Simulator-Variables">Using Custom States and Simulator Variables</a> for more information.</p><p>The <i>Category</i>, <i>Export ID</i>, <i>Display Name</i>, <i>Default</i>, and <i>Format</i> columns can be edited by clicking on the respective field in the table. Apply changes in bulk to selected table row(s) using the form on the left (hover over form fields for more details). <span style=" font-weight:600;">Note</span>: there is no way to undo bulk edits.</p><p>The records will be exported in the same order as in the table below. Click headings to sort, drag to arrange, and right-click to toggle.</p></body></html> + <html><head/><body><p>The produced INI file can be used directly in the plugin as a &quot;Variables Definition&quot; file to provide custom states. See the wiki article <a href="https://github.com/mpaperno/MSFSTouchPortalPlugin/wiki/Using-Custom-States-and-Simulator-Variables">Using Custom States and Simulator Variables</a> for more information.</p><p>The <i>Category</i>, <i>Export ID</i>, <i>Display Name</i>, <i>Default</i>, and <i>Format</i> columns can be edited by clicking on the respective field in the table. Apply changes in bulk to selected table row(s) using the form on the left (hover over form fields for more details). <span style=" font-weight:600;">Note</span>: there is no way to undo bulk edits.</p><p>The records will be exported in the same order as in the table below (CTRL-click headings to sort by multiple columns).</p></body></html> Qt::AutoText diff --git a/src/WASimUI/RequestsTableView.h b/src/WASimUI/RequestsTableView.h index 26d4062..07b5373 100644 --- a/src/WASimUI/RequestsTableView.h +++ b/src/WASimUI/RequestsTableView.h @@ -25,6 +25,8 @@ and is also available at . #include #include +#include "multisort_view/MultisortTableView.h" + #include "RequestsModel.h" #include "Widgets.h" @@ -61,13 +63,13 @@ class CategoryDelegate : public QStyledItemDelegate } }; -class RequestsTableView : public QTableView +class RequestsTableView : public MultisortTableView { Q_OBJECT public: RequestsTableView(QWidget *parent) - : QTableView(parent), + : MultisortTableView(parent), m_cbCategoryDelegate{new CategoryDelegate(this)}, m_defaultFontSize{font().pointSize()} { @@ -91,11 +93,19 @@ class RequestsTableView : public QTableView hdr->setMinimumSectionSize(20); hdr->setDefaultSectionSize(80); hdr->setHighlightSections(false); - hdr->setSortIndicatorShown(true); + hdr->setSortIndicatorShown(false); hdr->setStretchLastSection(true); hdr->setSectionsMovable(true); hdr->setSectionResizeMode(QHeaderView::Interactive); hdr->setContextMenuPolicy(Qt::ActionsContextMenu); + hdr->setToolTip(tr( + "

" + "- CTRL-click to sort on multiple columns.
" + "- Right-click for menu to toggle column visibility.
" + "- Click-and-drag headings to re-arrange columns.
" + "- Double-click dividers to adjust column width to fit widest content.
" + "

" + )); m_fontActions.reserve(3); m_fontActions.append(new QAction(QIcon("arrow_upward.glyph"), tr("Increase font size"), this)); @@ -133,7 +143,7 @@ class RequestsTableView : public QTableView void setModel(RequestsModel *model) { - QTableView::setModel(model); + MultisortTableView::setModel(model); QHeaderView *hdr = horizontalHeader(); hdr->resizeSection(RequestsModel::COL_ID, 40); @@ -206,6 +216,7 @@ class RequestsTableView : public QTableView hdr->restoreState(state); for (int i = 0; i < model()->columnCount() && i < hdr->actions().length(); ++i) hdr->actions().at(i)->setChecked(!hdr->isSectionHidden(i)); + hdr->setSortIndicatorShown(false); return true; } diff --git a/src/WASimUI/multisort_view/AlphanumComparer.h b/src/WASimUI/multisort_view/AlphanumComparer.h new file mode 100644 index 0000000..a1d74bf --- /dev/null +++ b/src/WASimUI/multisort_view/AlphanumComparer.h @@ -0,0 +1,109 @@ +#ifndef ALPHANUMCOMPARER_H +#define ALPHANUMCOMPARER_H + +/*! + * \brief Natural (alpha-num) sorting + * \author Litkevich Yuriy + * \see http://www.forum.crossplatform.ru/index.php?showtopic=6244&st=0&p=44752&#entry44752 + */ + + +#include + + +class AlphanumComparer +{ +public: + static bool lessThan(const QString &s1, const QString &s2) + { + return compare( s1, s2 ) < 0 ; + } + +private: + /*! + * \fn compare - compare two strings + * \param l - left sring. + * \param r - right string. + * + * \return + * lr - result more than zero; + * l=r - result is zero. + */ + static int compare(QString l, QString r) + { + enum Mode { STRING, NUMBER } mode = STRING; + + int size; + if ( l.size() < r.size() ) + size = l.size(); + else + size = r.size(); + + int i = 0; + + // runing throught both strings to position "size-1" + while( i < size) { + if ( mode == STRING ) { + QChar lchar, rchar; + bool ldigit, rdigit; + while( i < size ) { + lchar = l.at( i ); + rchar = r.at( i ); + ldigit = lchar.isDigit(); + rdigit = rchar.isDigit(); + // if both simbols is numbers, using numbers state + if ( ldigit && rdigit ) { + mode = NUMBER; + break; + } + if ( ldigit ) return -1; + if ( rdigit ) return +1; + // both simbols are letters + if ( lchar < rchar ) return -1; + if ( lchar > rchar ) return +1; + // simbols are equal + i++; + } + } else { //mode == NUMBER + unsigned long long lnum = 0, rnum = 0; + int li = i, ri = i; // local indexes + int ld = 0, rd = 0; // numbers + + // make left number + while ( li < l.size() ) { + ld = l.at( li ).digitValue(); + if ( ld < 0 ) break; + lnum = lnum*10 + ld; + li++; + } + + // make right number + while( ri < r.size() ) { + rd = r.at( ri ).digitValue(); + if ( rd < 0 ) break; + rnum = rnum*10 + rd; + ri++; + } + + long long delta = lnum - rnum; + if ( delta ) return delta; + + // numbers are equal + mode = STRING; + if ( li <= ri ) + i=li; + else + i=ri; + } + } + // this is for situation when both strings to position "size-1" equals + if ( i < r.size() ) return -1; + if (i < l.size() ) return +1; + + // strings are full equal + return 0; + } +}; + +#endif // ALPHANUMCOMPARER_H diff --git a/src/WASimUI/multisort_view/AlphanumSortProxyModel.h b/src/WASimUI/multisort_view/AlphanumSortProxyModel.h new file mode 100644 index 0000000..bfc0bf7 --- /dev/null +++ b/src/WASimUI/multisort_view/AlphanumSortProxyModel.h @@ -0,0 +1,81 @@ +// Originally from https://github.com/dimkanovikov/MultisortTableView Licensed under GPL v3 + +#ifndef ALPHANUMSORTPROXYMODEL_H +#define ALPHANUMSORTPROXYMODEL_H + +#include +#include "AlphanumComparer.h" +#include "ColumnsSorter.h" + + +class AlphanumSortProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT +public: + explicit AlphanumSortProxyModel( QObject *parent = 0 ) : + QSortFilterProxyModel( parent ) { } + + // Reimplemented to show order of column in common sorting if needed + // and sorting icon of column + virtual QVariant headerData ( int section, + Qt::Orientation orientation, + int role = Qt::DisplayRole ) const + { + if( orientation == Qt::Horizontal ) { + switch ( role ) + { + //case Qt::DisplayRole: { + // QString header = sourceModel()->headerData( section, orientation ).toString(); + // if ( m_columnSorter.columnsCount() > 1 && + // m_columnSorter.columnOrder( section ) >= 0 ) { + // header.insert(0, QString::number( m_columnSorter.columnOrder( section ) + 1 ) + ": " ); + // } + // return header; + //} + case Qt::DecorationRole: + return m_columnSorter.columnIcon( section ); + default: + return QSortFilterProxyModel::headerData(section, orientation, role); + } + } + // Row number + return section + 1; + } + + // Sort column + void sortColumn ( int column, bool isModifierPressed = false ) + { + m_columnSorter.sortColumn( column, isModifierPressed ); + + // "count-1" becouse indexes are started from zero value + for( int i = m_columnSorter.columnsCount()-1; i >= 0; --i) { + int col = m_columnSorter.columnIndex( i ); + sort( col, m_columnSorter.columnSortOrder( col ) ); + } + } + + // Set icons to decorate sorted table headers + void setSortIcons( QIcon ascIcon, QIcon descIcon ) + { + m_columnSorter.setIcons( ascIcon, descIcon ); + } + + +protected: + // Reimplemented to use alphanum sorting + virtual bool lessThan ( const QModelIndex & left, + const QModelIndex & right ) const + { + QVariant leftData = sourceModel()->data(left), + rightData = sourceModel()->data(right); + + return AlphanumComparer::lessThan( leftData.toString(), + rightData.toString() ); + } + +private: + ColumnsSorter m_columnSorter; + +}; + +#endif // ALPHANUMSORTPROXYMODEL_H diff --git a/src/WASimUI/multisort_view/ColumnsSorter.h b/src/WASimUI/multisort_view/ColumnsSorter.h new file mode 100644 index 0000000..5b0d591 --- /dev/null +++ b/src/WASimUI/multisort_view/ColumnsSorter.h @@ -0,0 +1,149 @@ +// Originally from https://github.com/dimkanovikov/MultisortTableView Licensed under GPL v3 + +#ifndef COLUMNSSORTER_H +#define COLUMNSSORTER_H + +#include +#include +#include +#include + +// Class for sorting columns +// Stored dictionary of sorted columns and theirs sort order "m_sortedColumns" +// and list of sorted columns to simple handling order of sorting columns +class ColumnsSorter +{ +public: + ColumnsSorter() { } + + // Set icons to decorate sorted table headers + void setIcons( QIcon ascIcon, QIcon descIcon ) + { + m_ascIcon = ascIcon; + m_descIcon = descIcon; + } + + // Sort column + void sortColumn ( int column, bool isModifierPressed = false ) + { + // If key modifier to multicolumn sorting is pressed + // and column was sorted and nedded to sort only this column, + // i.e. before user click at this column header was sorted only + // this column, than we simple should change sort order of column. + // Else we should clear all sorted columns and sort current + // by defult sort order (ascending) + if ( !isModifierPressed ) { + if ( m_sortedColumns.contains( column ) && + m_sortedColumns.count() == 1) + changeSortDirection( column, 1 ); + else { + clearSortedColumns(); + addSortedColumn( column ); + } + } + // If key modifier to multicolumn sorting isn't pressed, than + // if column was sorted, we change theirs sort order, or if + // column wasn't sorted yet just sort it by defult sort order (ascending) + else { + if ( m_sortedColumns.contains( column ) ) { + // remove sorted columns on 3rd click, but not the last one. + if (m_sortedColumns.count() > 1 && m_sortedColumns.value( column ).activations >= 2) + removeSortedColumn(column); + else + changeSortDirection( column, 2 ); + } else { + addSortedColumn( column ); + } + } + } + + // Return column index + int columnIndex ( int columnOrder ) const + { + return m_sortedColumnsOrder.value( columnOrder ); + } + + // Return column order in list of sorted columns + int columnOrder ( int column ) const + { + return m_sortedColumnsOrder.indexOf( column ); + } + + // Return column sort order + Qt::SortOrder columnSortOrder ( int column ) const + { + return m_sortedColumns.value( column ).order; + } + + // Return column icon, if column wasn't sorted return QIcon() + QIcon columnIcon ( int column ) const + { + QIcon columnIcon; + if ( m_sortedColumns.contains( column ) ) { + if ( m_sortedColumns.value( column ).order == Qt::AscendingOrder ) + columnIcon = m_ascIcon; + else + columnIcon = m_descIcon; + } + return columnIcon; + } + + // Return count of sorted columns + int columnsCount () const + { + return m_sortedColumnsOrder.count(); + } + + +private: + struct SortTrack { + Qt::SortOrder order = Qt::AscendingOrder; + quint8 activations = 1; + }; + + // Dictionary of sorted columns: key - column index, value - sort order + QHash m_sortedColumns; + // List of sorted columns + QList m_sortedColumnsOrder; + // Icons do decorate sorted columns + QIcon m_ascIcon, + m_descIcon; + + + // Add column to list of sorted columns + // Assertions: + // Function don't check that column is already stored + void addSortedColumn ( int column ) + { + m_sortedColumns.insert( column, SortTrack()); + m_sortedColumnsOrder.append( column ); + } + + void removeSortedColumn ( int column ) + { + m_sortedColumns.remove( column ); + m_sortedColumnsOrder.removeAll( column ); + } + + // Change sort order of column + // Assertions: + // Function isn't check that column is already stored + void changeSortDirection ( int column, quint8 activations = 1 ) + { + Qt::SortOrder revertOrder = + m_sortedColumns.value( column ).order != Qt::AscendingOrder ? + Qt::AscendingOrder : + Qt::DescendingOrder; + + m_sortedColumns.insert( column, { revertOrder, activations } ); + } + + // Clear all stored columns + inline void clearSortedColumns() + { + m_sortedColumns.clear(); + m_sortedColumnsOrder.clear(); + } +}; + +#endif // COLUMNSSORTER_H diff --git a/src/WASimUI/multisort_view/LICENSE b/src/WASimUI/multisort_view/LICENSE new file mode 100644 index 0000000..341c30b --- /dev/null +++ b/src/WASimUI/multisort_view/LICENSE @@ -0,0 +1,166 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. + diff --git a/src/WASimUI/multisort_view/MultisortTableView.cpp b/src/WASimUI/multisort_view/MultisortTableView.cpp new file mode 100644 index 0000000..2ae77c8 --- /dev/null +++ b/src/WASimUI/multisort_view/MultisortTableView.cpp @@ -0,0 +1,90 @@ +// Originally from https://github.com/dimkanovikov/MultisortTableView Licensed under GPL v3 + +#include +#include +#include +#include + +#include "MultisortTableView.h" +#include "AlphanumSortProxyModel.h" + + +class HeaderProxyStyle : public QProxyStyle +{ +public: + using QProxyStyle::QProxyStyle; + void HeaderProxyStyle::drawControl(ControlElement el, const QStyleOption *opt, QPainter *p, const QWidget *w) const override + { + // Header label? + if (el == CE_HeaderLabel) { + if (QStyleOptionHeader *header = qstyleoption_cast(const_cast(opt))) { + if (!header->icon.isNull()) + header->direction = header->direction == Qt::RightToLeft ? Qt::LeftToRight : Qt::RightToLeft; + } + } + QProxyStyle::drawControl(el, opt, p, w); + } + + int pixelMetric(PixelMetric metric, const QStyleOption *option = nullptr, const QWidget *widget = nullptr) const { + if (metric == PM_SmallIconSize) + return 14; + return QProxyStyle::pixelMetric(metric, option, widget); + } +}; + + +MultisortTableView::MultisortTableView ( QWidget *parent ) : + QTableView ( parent ), + m_isSortingEnabled(false), + m_proxyModel(new AlphanumSortProxyModel(this)), + m_modifier(Qt::ControlModifier) +{ + // Default icons + setSortIcons(QIcon(QStringLiteral("scale=1.5/arrow_drop_up.glyph")), QIcon(QStringLiteral("scale=1.5/arrow_drop_down.glyph"))); + horizontalHeader()->setDefaultAlignment( Qt::AlignHCenter | Qt::AlignTop ); + HeaderProxyStyle *proxy = new HeaderProxyStyle(QApplication::style()); + proxy->setParent(horizontalHeader()); + horizontalHeader()->setStyle(proxy); + horizontalHeader()->setSortIndicatorShown(false); + QTableView::setSortingEnabled(false); + + // Handler to sorting table + connect(horizontalHeader(), &QHeaderView::sectionClicked, this, &MultisortTableView::headerClicked); +} + +// Set icons to decorate sorted table headers +void MultisortTableView::setSortIcons ( QIcon ascIcon, QIcon descIcon ) +{ + m_proxyModel->setSortIcons( ascIcon, descIcon ); +} + +// Set key modifier to handle multicolumn sorting +void MultisortTableView::setModifier ( Qt::KeyboardModifier modifier ) +{ + m_modifier = modifier; +} + + +// Reimplemented to self handling of sorting enable state +void MultisortTableView::setSortingEnabled( bool enable ) +{ + m_isSortingEnabled = enable; +} + +// Reimplemented to use AlphanumSortProxyModel +void MultisortTableView::setModel( QAbstractItemModel *model ) +{ + if ( model ) { + m_proxyModel->setSourceModel( model ); + QTableView::setModel( m_proxyModel ); + } +} + +// Handler to sort table +void MultisortTableView::headerClicked ( int column ) +{ + if ( m_isSortingEnabled ) { + bool isModifierPressed = QApplication::keyboardModifiers() & m_modifier; + m_proxyModel->sortColumn( column, isModifierPressed ); + } +} diff --git a/src/WASimUI/multisort_view/MultisortTableView.h b/src/WASimUI/multisort_view/MultisortTableView.h new file mode 100644 index 0000000..64b9fab --- /dev/null +++ b/src/WASimUI/multisort_view/MultisortTableView.h @@ -0,0 +1,39 @@ +// Originally from https://github.com/dimkanovikov/MultisortTableView Licensed under GPL v3 + +#ifndef MULTISORTTABLEVIEW_H +#define MULTISORTTABLEVIEW_H + +#include + +class AlphanumSortProxyModel; +class QIcon; +class HeaderProxyStyle; + +class MultisortTableView : public QTableView +{ + Q_OBJECT + +public: + explicit MultisortTableView ( QWidget *parent = 0 ); + + // Set icons to decorate sorted table headers + void setSortIcons ( QIcon ascIcon, QIcon descIcon ); + // Set key modifier to handle multicolumn sorting + void setModifier ( Qt::KeyboardModifier modifier ); + + virtual void setSortingEnabled ( bool enable ); + virtual void setModel ( QAbstractItemModel *model ); + +private: + // Sorting enable state + bool m_isSortingEnabled; + // ProxyModel to sorting columns + AlphanumSortProxyModel *m_proxyModel; + // Modifier to handle multicolumn sorting + Qt::KeyboardModifier m_modifier; + +private slots: + void headerClicked ( int column ); +}; + +#endif // MULTISORTTABLEVIEW_H diff --git a/src/WASimUI/multisort_view/README b/src/WASimUI/multisort_view/README new file mode 100644 index 0000000..5ad1c2e --- /dev/null +++ b/src/WASimUI/multisort_view/README @@ -0,0 +1,27 @@ +MultisortTableView is a Qt widget inherits from QTableView, which can sort table by miltiple columns. +To sort data their use AlphanumSortProxyModel inherits from QSortFilterProxyModel and sort data by alphanum algorithm (for more information about algorithm see http://www.davekoelle.com/alphanum.html). + +To use MulrisortTableView in your project next files needed to be include: +AlphanumComparer.h - comparer, which use alphanum alghoritm to compare values; +AlphanumSortProxyModel.h - proxy model, that used to sort data in TableView with alphanum alghoritm; +ColumnsSorter.h - helper, which strore information about sorted columns; +MultisortTableView.h, MultisortTableView.cpp - TableView, which used ColumnsSorter class and AplhanumSortProxyModel to sort data by multiple columns. + +MultisortTableView add two functions to QTableView API: +void setSortIcons( QIcon ascIcon, QIcon descIcon ) - set icons of sorting order (by default used QStyle::SP_ArrowUp and QStyle::SP_ArrowDown icons); +void setModifier ( Qt::KeyboardModifier modifier ) - set modifier to handling multicolumn sorting (by default used ControlModifier). + +How to use: +// Create a table model +QSqlTableModel *users = new QSqlTableModel; +users->setTable( "users" ); +users->select( ); +// Create and customize widget +MultisortTableView tableView; +tableView.setModifier( Qt::ShiftModifier ); +tableView.setSortingEnabled( true ); +tableView.setSortIcons( QIcon(":/icons/bullet_arrow_up.png"), + QIcon(":/icons/bullet_arrow_down.png") ); +tableView.setSelectionBehavior( QAbstractItemView::SelectRows ); +tableView.setModel( users ); +tableView.show(); \ No newline at end of file From 3e8959ffb9b912474ab92fad490996cdc87a9d78 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sat, 28 Oct 2023 02:55:55 -0400 Subject: [PATCH 45/65] [WASimUI][RequestsModel] Use shorter string for "N/A" column values and fix disabling those cells in some cases. --- src/WASimUI/RequestsModel.h | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/WASimUI/RequestsModel.h b/src/WASimUI/RequestsModel.h index b83377c..02745be 100644 --- a/src/WASimUI/RequestsModel.h +++ b/src/WASimUI/RequestsModel.h @@ -270,6 +270,8 @@ class RequestsModel : public QStandardItemModel QModelIndex addRequest(const RequestRecord &req) { + static const QString NA = tr("-", "Used for non-applicable column values, like 'N/A'."); // tr("N/A") + int row = findRequestRow(req.requestId); const bool newRow = row < 0; if (newRow) @@ -279,23 +281,23 @@ class RequestsModel : public QStandardItemModel itm->setData(req.metaType, MetaTypeRole); itm->setData(req.properties, PropertiesRole); - itm = setOrCreateItem(row, COL_TYPE, WSEnums::RequestTypeNames[+req.requestType], +req.requestType); + setOrCreateItem(row, COL_TYPE, WSEnums::RequestTypeNames[+req.requestType], +req.requestType); if (req.requestType == WSEnums::RequestType::Calculated) { setOrCreateItem(row, COL_RES_TYPE, WSEnums::CalcResultTypeNames[+req.calcResultType], +req.calcResultType); - setOrCreateItem(row, COL_IDX, tr("N/A"), false); - itm = setOrCreateItem(row, COL_UNIT, tr("N/A"), QString(req.unitName), false); + setOrCreateItem(row, COL_IDX, NA, QString::number(req.simVarIndex), false); + setOrCreateItem(row, COL_UNIT, NA, QString(req.unitName), false); } else { setOrCreateItem(row, COL_RES_TYPE, QString(req.varTypePrefix), req.varTypePrefix); if (Utils::isUnitBasedVariableType(req.varTypePrefix)) setOrCreateItem(row, COL_UNIT, req.unitName, QString(req.unitName)); else - setOrCreateItem(row, COL_UNIT, tr("N/A"), QString(req.unitName), false); + setOrCreateItem(row, COL_UNIT, NA, QString(req.unitName), false); if (req.varTypePrefix == 'A') setOrCreateItem(row, COL_IDX, QString::number(req.simVarIndex)); else - setOrCreateItem(row, COL_IDX, tr("N/A"), false); + setOrCreateItem(row, COL_IDX, NA, QString::number(req.simVarIndex), false); } if (req.metaType == QMetaType::UnknownType) @@ -312,7 +314,7 @@ class RequestsModel : public QStandardItemModel if (req.metaType > QMetaType::UnknownType && req.metaType < QMetaType::User) setOrCreateItem(row, COL_EPSILON, QString::number(req.deltaEpsilon), req.deltaEpsilon); else - setOrCreateItem(row, COL_EPSILON, tr("N/A"), req.deltaEpsilon, false); + setOrCreateItem(row, COL_EPSILON, NA, req.deltaEpsilon, false); if (newRow) { setOrCreateItem(row, COL_VALUE, tr("???")); From c9d6362dc06f53c96d078b60103e7fb3bf21ee31 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sat, 28 Oct 2023 17:10:35 -0400 Subject: [PATCH 46/65] [WASimUI][MultisortTableView] Fix crash due to application's style being re-parented; Add missing project files. --- src/WASimUI/WASimUI.vcxproj | 11 +++++++++++ src/WASimUI/multisort_view/MultisortTableView.cpp | 2 +- src/WASimUI/multisort_view/MultisortTableView.h | 1 - 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/WASimUI/WASimUI.vcxproj b/src/WASimUI/WASimUI.vcxproj index 262104b..9b51b0a 100644 --- a/src/WASimUI/WASimUI.vcxproj +++ b/src/WASimUI/WASimUI.vcxproj @@ -260,10 +260,15 @@ + + + + + @@ -296,7 +301,13 @@ + + + + + + diff --git a/src/WASimUI/multisort_view/MultisortTableView.cpp b/src/WASimUI/multisort_view/MultisortTableView.cpp index 2ae77c8..d868522 100644 --- a/src/WASimUI/multisort_view/MultisortTableView.cpp +++ b/src/WASimUI/multisort_view/MultisortTableView.cpp @@ -42,7 +42,7 @@ MultisortTableView::MultisortTableView ( QWidget *parent ) : // Default icons setSortIcons(QIcon(QStringLiteral("scale=1.5/arrow_drop_up.glyph")), QIcon(QStringLiteral("scale=1.5/arrow_drop_down.glyph"))); horizontalHeader()->setDefaultAlignment( Qt::AlignHCenter | Qt::AlignTop ); - HeaderProxyStyle *proxy = new HeaderProxyStyle(QApplication::style()); + HeaderProxyStyle *proxy = new HeaderProxyStyle("Fusion"); proxy->setParent(horizontalHeader()); horizontalHeader()->setStyle(proxy); horizontalHeader()->setSortIndicatorShown(false); diff --git a/src/WASimUI/multisort_view/MultisortTableView.h b/src/WASimUI/multisort_view/MultisortTableView.h index 64b9fab..eeb2076 100644 --- a/src/WASimUI/multisort_view/MultisortTableView.h +++ b/src/WASimUI/multisort_view/MultisortTableView.h @@ -7,7 +7,6 @@ class AlphanumSortProxyModel; class QIcon; -class HeaderProxyStyle; class MultisortTableView : public QTableView { From 484714f849df442b4cf772dc05fd09f472f512bc Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sat, 28 Oct 2023 17:14:00 -0400 Subject: [PATCH 47/65] [WASimUI] Add `CustomTableView` abstraction class for re-use; Improve header toggle action handling when model changes; Save and restore chosen font size; Improve row resize when font changes; --- src/WASimUI/CustomTableView.h | 228 ++++++++++++++++++++++++++++++++ src/WASimUI/RequestsExport.cpp | 4 +- src/WASimUI/RequestsTableView.h | 126 +----------------- src/WASimUI/WASimUI.cpp | 5 +- src/WASimUI/WASimUI.vcxproj | 1 + 5 files changed, 238 insertions(+), 126 deletions(-) create mode 100644 src/WASimUI/CustomTableView.h diff --git a/src/WASimUI/CustomTableView.h b/src/WASimUI/CustomTableView.h new file mode 100644 index 0000000..6ce1e08 --- /dev/null +++ b/src/WASimUI/CustomTableView.h @@ -0,0 +1,228 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + +#pragma once + +#include +#include +#include +//#include + +#include "multisort_view/MultisortTableView.h" + +namespace WASimUiNS +{ + + class CustomTableView : public MultisortTableView + { + Q_OBJECT + + public: + CustomTableView(QWidget *parent) + : MultisortTableView(parent), + m_defaultFontSize{font().pointSize()}, + m_headerToggleMenu{new QMenu(tr("Toggle table columns"), this)}, + m_fontSizeMenu{new QMenu(tr("Adjust table font size"), this)} + { + setObjectName(QStringLiteral("CustomTableView")); + + setContextMenuPolicy(Qt::ActionsContextMenu); + setEditTriggers(DoubleClicked | SelectedClicked | AnyKeyPressed); + setSelectionMode(ExtendedSelection); + setSelectionBehavior(SelectRows); + setVerticalScrollMode(ScrollPerItem); + setHorizontalScrollMode(ScrollPerPixel); + setGridStyle(Qt::DotLine); + setSortingEnabled(true); + setWordWrap(false); + setCornerButtonEnabled(false); + + verticalHeader()->setVisible(false); + verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); + verticalHeader()->setMinimumSectionSize(10); + adjustRowSize(); + + setupHeader(); + + m_headerToggleMenu->setIcon(QIcon(QStringLiteral("view_column.glyph"))); + m_fontSizeMenu->setIcon(QIcon(QStringLiteral("format_size.glyph"))); + + QAction *plusAct = m_fontSizeMenu->addAction(QIcon("arrow_upward.glyph"), tr("Increase font size"), this, &CustomTableView::fontSizeInc); + plusAct->setShortcuts({ QKeySequence::ZoomIn, QKeySequence(Qt::ControlModifier | Qt::Key_Equal) }); + m_fontSizeMenu->addAction(QIcon("restart_alt.glyph"), tr("Reset font size"), this, &CustomTableView::fontSizeReset, QKeySequence(Qt::ControlModifier | Qt::Key_0)); + m_fontSizeMenu->addAction(QIcon("arrow_downward.glyph"), tr("Decrease font size"), this, &CustomTableView::fontSizeDec, QKeySequence::ZoomOut); + } + + QHeaderView *header() const { return horizontalHeader(); } + QAction *columnToggleMenuAction() const { return m_headerToggleMenu->menuAction(); } + QAction *fontSizeMenuAction() const { return m_fontSizeMenu->menuAction(); } + + QByteArray saveState() const { + QByteArray state; + QDataStream ds(&state, QIODevice::WriteOnly); + ds << header()->saveState(); + ds << font().pointSize(); + return state; + } + + public Q_SLOTS: + void setModel(QAbstractItemModel *model) + { + MultisortTableView::setModel(model); + buildHeaderActions(); + } + + void setHorizontalHeader(QHeaderView *hdr) + { + MultisortTableView::setHorizontalHeader(hdr); + setupHeader(); + } + + void moveColumn(int from, int to) const { horizontalHeader()->moveSection(from, to); } + + void setFontSize(int pointSize) { + QFont f = font(); + if (pointSize > 3 && f.pointSize() != pointSize) { + f.setPointSize(pointSize); + setFont(f); + adjustRowSize(); + } + } + + void fontSizeReset() { + QFont f = font(); + if (f.pointSize() != m_defaultFontSize) + setFontSize(m_defaultFontSize); + } + void fontSizeInc() { + setFontSize(font().pointSize() + 1); + } + void fontSizeDec() { + if (font().pointSize() > 3) + setFontSize(font().pointSize() - 1); + } + + void adjustRowSize() { + verticalHeader()->setDefaultSectionSize(QFontMetrics(font()).lineSpacing() * 1.65); + } + + bool restoreState(const QByteArray &state) + { + if (!model() || state.isEmpty()) + return false; + + QByteArray hdrState; + int fontSize = m_defaultFontSize; + QHeaderView *hdr = horizontalHeader(); + + QDataStream ds(state); + if (!ds.atEnd()) { + ds >> hdrState; + if (!ds.atEnd()) + ds >> fontSize; + } + else { + hdrState = state; + } + + setFontSize(fontSize); + hdr->restoreState(hdrState); + + for (int i = 0; i < model()->columnCount() && i < hdr->actions().length(); ++i) + hdr->actions().at(i)->setChecked(!hdr->isSectionHidden(i)); + hdr->setSortIndicatorShown(false); + return true; + } + + private: + + void setupHeader() + { + QHeaderView *hdr = horizontalHeader(); + hdr->setCascadingSectionResizes(false); + hdr->setMinimumSectionSize(20); + hdr->setDefaultSectionSize(80); + hdr->setHighlightSections(false); + hdr->setSortIndicatorShown(false); + hdr->setStretchLastSection(true); + hdr->setSectionsMovable(true); + hdr->setSectionResizeMode(QHeaderView::Interactive); + hdr->setContextMenuPolicy(Qt::ActionsContextMenu); + hdr->setToolTip(tr( + "

" + "- CTRL-click to sort on multiple columns.
" + "- Right-click for menu to toggle column visibility.
" + "- Click-and-drag headings to re-arrange columns.
" + "- Double-click dividers to adjust column width to fit widest content.
" + "

" + )); + // make sure header has own font size so it doesn't change when grid font is changed + QFont f(font()); + f.setPointSize(m_defaultFontSize); + hdr->setFont(f); + + connect(hdr, &QHeaderView::sectionCountChanged, this, &CustomTableView::onSectionCountChanged, Qt::QueuedConnection); + } + + void onSectionCountChanged(int oldCnt, int newCnt) + { + //qDebug() << oldCnt << newCnt; + if (oldCnt != newCnt && newCnt != horizontalHeader()->actions().length()) + buildHeaderActions(); + } + + void onHeaderToggled(bool on) + { + if (QAction *act = qobject_cast(sender())) { + bool ok; + int id = act->property("col").toInt(&ok); + if (ok && id > -1) + horizontalHeader()->setSectionHidden(id, !on); + } + } + + void buildHeaderActions() + { + for (int i=0, e=m_headerToggleMenu->actions().length(); i < e; ++i) { + if (QAction *act = m_headerToggleMenu->actions().value(0, nullptr)) { + m_headerToggleMenu->removeAction(act); + disconnect(act, nullptr, this, nullptr); + act->deleteLater(); + } + } + + if (!model()) + return; + + QHeaderView *hdr = horizontalHeader(); + for (int i=0; i < model()->columnCount(); ++i) { + QAction *act = m_headerToggleMenu->addAction(model()->headerData(i, Qt::Horizontal).toString(), this, &CustomTableView::onHeaderToggled); + act->setCheckable(true); + act->setChecked(!hdr->isSectionHidden(i)); + act->setProperty("col", i); + } + hdr->addActions(m_headerToggleMenu->actions()); + } + + int m_defaultFontSize; + QMenu *m_headerToggleMenu; + QMenu *m_fontSizeMenu; + }; + +} diff --git a/src/WASimUI/RequestsExport.cpp b/src/WASimUI/RequestsExport.cpp index 616c818..50bd51b 100644 --- a/src/WASimUI/RequestsExport.cpp +++ b/src/WASimUI/RequestsExport.cpp @@ -107,8 +107,8 @@ RequestsExportWidget::RequestsExportWidget(RequestsModel *model, QWidget *parent //ui.pbRegen->setHidden(true); addAction(updateMenu->menuAction()); - addAction(ui.tableView->columnToggleActionsMenu(this)->menuAction()); - addAction(ui.tableView->fontActionsMenu(this)->menuAction()); + addAction(ui.tableView->columnToggleMenuAction()); + addAction(ui.tableView->fontSizeMenuAction()); connect(ui.cbDefaultCategory, &DataComboBox::currentDataChanged, this, [&]() { toggleEditFormBtn(ui); }); connect(ui.leIdPrefix, &QLineEdit::textChanged, this, [&]() { toggleEditFormBtn(ui); }); diff --git a/src/WASimUI/RequestsTableView.h b/src/WASimUI/RequestsTableView.h index 07b5373..dacf822 100644 --- a/src/WASimUI/RequestsTableView.h +++ b/src/WASimUI/RequestsTableView.h @@ -25,7 +25,7 @@ and is also available at . #include #include -#include "multisort_view/MultisortTableView.h" +#include "CustomTableView.h" #include "RequestsModel.h" #include "Widgets.h" @@ -63,87 +63,25 @@ class CategoryDelegate : public QStyledItemDelegate } }; -class RequestsTableView : public MultisortTableView +class RequestsTableView : public CustomTableView { Q_OBJECT public: RequestsTableView(QWidget *parent) - : MultisortTableView(parent), - m_cbCategoryDelegate{new CategoryDelegate(this)}, - m_defaultFontSize{font().pointSize()} + : CustomTableView(parent), + m_cbCategoryDelegate{new CategoryDelegate(this)} { setObjectName(QStringLiteral("RequestsTableView")); - setContextMenuPolicy(Qt::ActionsContextMenu); - setEditTriggers(QAbstractItemView::AllEditTriggers); - setSelectionMode(QAbstractItemView::ExtendedSelection); - setSelectionBehavior(QAbstractItemView::SelectRows); - setIconSize(QSize(16, 16)); - setVerticalScrollMode(QAbstractItemView::ScrollPerItem); - setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel); - setGridStyle(Qt::DotLine); - setSortingEnabled(true); - setWordWrap(false); - setCornerButtonEnabled(false); - verticalHeader()->setVisible(false); - - QHeaderView *hdr = horizontalHeader(); - hdr->setCascadingSectionResizes(false); - hdr->setMinimumSectionSize(20); - hdr->setDefaultSectionSize(80); - hdr->setHighlightSections(false); - hdr->setSortIndicatorShown(false); - hdr->setStretchLastSection(true); - hdr->setSectionsMovable(true); - hdr->setSectionResizeMode(QHeaderView::Interactive); - hdr->setContextMenuPolicy(Qt::ActionsContextMenu); - hdr->setToolTip(tr( - "

" - "- CTRL-click to sort on multiple columns.
" - "- Right-click for menu to toggle column visibility.
" - "- Click-and-drag headings to re-arrange columns.
" - "- Double-click dividers to adjust column width to fit widest content.
" - "

" - )); - - m_fontActions.reserve(3); - m_fontActions.append(new QAction(QIcon("arrow_upward.glyph"), tr("Increase font size"), this)); - m_fontActions.append(new QAction(QIcon("restart_alt.glyph"), tr("Reset font size"), this)); - m_fontActions.append(new QAction(QIcon("arrow_downward.glyph"), tr("Decrease font size"), this)); - m_fontActions[0]->setShortcuts({ QKeySequence::ZoomIn, QKeySequence(Qt::ControlModifier | Qt::Key_Equal) }); - m_fontActions[1]->setShortcut(QKeySequence(Qt::ControlModifier | Qt::Key_0)); - m_fontActions[2]->setShortcut(QKeySequence::ZoomOut); - connect(m_fontActions[0], &QAction::triggered, this, &RequestsTableView::fontSizeInc); - connect(m_fontActions[1], &QAction::triggered, this, &RequestsTableView::fontSizeReset); - connect(m_fontActions[2], &QAction::triggered, this, &RequestsTableView::fontSizeDec); - } - - QHeaderView *header() const { return horizontalHeader(); } - QByteArray saveState() const { return header()->saveState(); } - const QList &fontActions() const { return m_fontActions; } - - QMenu *columnToggleActionsMenu(QWidget *parent) const { - QMenu *menu = new QMenu(tr("Toggle table columns"), parent); - menu->setIcon(QIcon(QStringLiteral("view_column.glyph"))); - menu->addActions(header()->actions()); - return menu; - } - - QMenu *fontActionsMenu(QWidget *parent) const { - QMenu *menu = new QMenu(tr("Adjust font size"), parent); - menu->setIcon(QIcon(QStringLiteral("format_size.glyph"))); - menu->addActions(m_fontActions); - return menu; } public Q_SLOTS: void setExportCategories(const QMap &map) { m_cbCategoryDelegate->textDataMap = map; } - void moveColumn(int from, int to) const { horizontalHeader()->moveSection(from, to); } void setModel(RequestsModel *model) { - MultisortTableView::setModel(model); + CustomTableView::setModel(model); QHeaderView *hdr = horizontalHeader(); hdr->resizeSection(RequestsModel::COL_ID, 40); @@ -166,64 +104,10 @@ class RequestsTableView : public MultisortTableView hdr->resizeSection(RequestsModel::COL_META_FMT, 50); setItemDelegateForColumn(RequestsModel::COL_META_CAT, m_cbCategoryDelegate); - - for (int i=0; i < model->columnCount(); ++i) { - QAction *act = new QAction(model->headerData(i, Qt::Horizontal).toString(), this); - act->setCheckable(true); - act->setChecked(!hdr->isSectionHidden(i)); - act->setProperty("col", i); - hdr->addAction(act); - - connect(act, &QAction::triggered, this, [=](bool chk) { - if (QAction *act = qobject_cast(sender())) { - int id = act->property("col").toInt(); - if (id > -1) - horizontalHeader()->setSectionHidden(id, !chk); - } - }); - } - - } - - void fontSizeReset() { - QFont f = font(); - if (f.pointSize() != m_defaultFontSize) { - f.setPointSize(m_defaultFontSize); - setFont(f); - resizeRowsToContents(); - } - } - void fontSizeInc() { - QFont f = font(); - f.setPointSize(f.pointSize() + 1); - setFont(f); - resizeRowsToContents(); - } - void fontSizeDec() { - if (font().pointSize() > 2) { - QFont f = font(); - f.setPointSize(f.pointSize() - 1); - setFont(f); - resizeRowsToContents(); - } - } - - bool restoreState(const QByteArray &state) - { - if (!model()) - return false; - QHeaderView *hdr = horizontalHeader(); - hdr->restoreState(state); - for (int i = 0; i < model()->columnCount() && i < hdr->actions().length(); ++i) - hdr->actions().at(i)->setChecked(!hdr->isSectionHidden(i)); - hdr->setSortIndicatorShown(false); - return true; } private: CategoryDelegate *m_cbCategoryDelegate; - QList m_fontActions { }; - int m_defaultFontSize; }; } diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index 9188def..fc2dd0a 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -1089,9 +1089,8 @@ WASimUI::WASimUI(QWidget *parent) : }, Qt::QueuedConnection); // Add column toggle and font size actions - ui.wRequests->addAction(ui.requestsView->columnToggleActionsMenu(this)->menuAction()); - ui.wRequests->addAction(ui.requestsView->fontActionsMenu(this)->menuAction()); - + ui.wRequests->addAction(ui.requestsView->columnToggleMenuAction()); + ui.wRequests->addAction(ui.requestsView->fontSizeMenuAction()); // Registered calculator events model view actions diff --git a/src/WASimUI/WASimUI.vcxproj b/src/WASimUI/WASimUI.vcxproj index 9b51b0a..27a7e8e 100644 --- a/src/WASimUI/WASimUI.vcxproj +++ b/src/WASimUI/WASimUI.vcxproj @@ -304,6 +304,7 @@ + From 21d44ac879590c862e03510f1dd107a2ec977e96 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Tue, 31 Oct 2023 23:06:36 -0400 Subject: [PATCH 48/65] [WASimUI] Widgets: - Move files to 'widgets' folder; - Break out DeletableItemsComboBox and add completer options; - Add BuddyLabel, FilterLineEdit, FilterTableHeader; --- src/WASimUI/WASimUI.vcxproj | 22 +- .../{ => widgets}/ActionPushButton.cpp | 1 - src/WASimUI/{ => widgets}/ActionPushButton.h | 0 src/WASimUI/widgets/BuddyLabel.h | 139 +++++++ src/WASimUI/{ => widgets}/CustomTableView.h | 0 src/WASimUI/{ => widgets}/DataComboBox.h | 0 src/WASimUI/widgets/DeletableItemsComboBox.h | 380 ++++++++++++++++++ src/WASimUI/widgets/FilterLineEdit.cpp | 157 ++++++++ src/WASimUI/widgets/FilterLineEdit.h | 59 +++ src/WASimUI/widgets/FilterTableHeader.cpp | 128 ++++++ src/WASimUI/widgets/FilterTableHeader.h | 61 +++ src/WASimUI/{ => widgets}/Widgets.h | 90 +---- 12 files changed, 947 insertions(+), 90 deletions(-) rename src/WASimUI/{ => widgets}/ActionPushButton.cpp (99%) rename src/WASimUI/{ => widgets}/ActionPushButton.h (100%) create mode 100644 src/WASimUI/widgets/BuddyLabel.h rename src/WASimUI/{ => widgets}/CustomTableView.h (100%) rename src/WASimUI/{ => widgets}/DataComboBox.h (100%) create mode 100644 src/WASimUI/widgets/DeletableItemsComboBox.h create mode 100644 src/WASimUI/widgets/FilterLineEdit.cpp create mode 100644 src/WASimUI/widgets/FilterLineEdit.h create mode 100644 src/WASimUI/widgets/FilterTableHeader.cpp create mode 100644 src/WASimUI/widgets/FilterTableHeader.h rename src/WASimUI/{ => widgets}/Widgets.h (84%) diff --git a/src/WASimUI/WASimUI.vcxproj b/src/WASimUI/WASimUI.vcxproj index 27a7e8e..3dea292 100644 --- a/src/WASimUI/WASimUI.vcxproj +++ b/src/WASimUI/WASimUI.vcxproj @@ -104,17 +104,21 @@ true + $(ProjectDir)widgets;$(IncludePath) true + $(ProjectDir)widgets;$(IncludePath) true true + $(ProjectDir)widgets;$(IncludePath) true true + $(ProjectDir)widgets;$(IncludePath) @@ -264,9 +268,9 @@ - - - + + + @@ -304,16 +308,20 @@ - + + + + + - + - - + +
diff --git a/src/WASimUI/ActionPushButton.cpp b/src/WASimUI/widgets/ActionPushButton.cpp similarity index 99% rename from src/WASimUI/ActionPushButton.cpp rename to src/WASimUI/widgets/ActionPushButton.cpp index f40660a..4278b96 100644 --- a/src/WASimUI/ActionPushButton.cpp +++ b/src/WASimUI/widgets/ActionPushButton.cpp @@ -34,7 +34,6 @@ #include #include #include -#include ActionPushButton::ActionPushButton(QAction *defaultAction, QWidget *parent) : QPushButton(parent) diff --git a/src/WASimUI/ActionPushButton.h b/src/WASimUI/widgets/ActionPushButton.h similarity index 100% rename from src/WASimUI/ActionPushButton.h rename to src/WASimUI/widgets/ActionPushButton.h diff --git a/src/WASimUI/widgets/BuddyLabel.h b/src/WASimUI/widgets/BuddyLabel.h new file mode 100644 index 0000000..67763a8 --- /dev/null +++ b/src/WASimUI/widgets/BuddyLabel.h @@ -0,0 +1,139 @@ +/* + BuddyLabel + https://github.com/mpaperno/maxLibQt + + COPYRIGHT: (c)2019 Maxim Paperno; All Right Reserved. + Contact: http://www.WorldDesign.com/contact + + LICENSE: + + Commercial License Usage + Licensees holding valid commercial licenses may use this file in + accordance with the terms contained in a written agreement between + you and the copyright holder. + + GNU General Public License Usage + Alternatively, this file may be used under the terms of the GNU + General Public License as published by the Free Software Foundation, + either version 3 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. + + A copy of the GNU General Public License is available at . +*/ + +#ifndef BUDDYLABEL_H +#define BUDDYLABEL_H + +#include +#include +#include +#include +#include +#include + +/*! + \brief The BuddyLabel class is a QLabel with enhanced "buddy" capabilities. + + It overrides the \c QLabel::setBuddy() method and, besides the usual shortcut handling provided by \c QLabel, + it adds mouse click handling and mirroring of the buddy's tool tip text. + + Mouse clicks are connected to the \c QWidget::setFocus slot. For \c QCheckBox it also connects to the \c click() slot so the box can be (un)checked by clicking on the label. + Mouse double-clicks are connected to \c QLineEdit::selectAll() on widgets which either are or have a \c QLineEdit (like \c QAbstractSpinBox and editable \c QComboBox). + Custom connections could be added by connecting to the \c clicked() and/or \c doubleClicked() signals, or inheriting and overriding the \c connectBuddy() virtual method. +*/ +class BuddyLabel : public QLabel +{ + Q_OBJECT + public: + using QLabel::QLabel; + + public slots: + //! Overrides the \c QLabel::setBuddy() method, which isn't virtual. Calls the base class implementation as well, so the shortcut mechanism still works. + void setBuddy(QWidget *buddy) + { + if (this->buddy()) { + this->buddy()->removeEventFilter(this); + disconnect(this->buddy()); + disconnectBuddy(this->buddy()); + } + + QLabel::setBuddy(buddy); + + if (!buddy) + return; + + setToolTip(buddy->toolTip()); + buddy->installEventFilter(this); + connectBuddy(buddy); + } + + signals: + //! Emitted when label is clicked with left mouse button (or something emulating one). + void clicked(); + //! Emitted when label is double-clicked with left mouse button (or something emulating one). + void doubleClicked(); + + protected: + //! Override this method for custom connections. + virtual void connectBuddy(QWidget *buddy) + { + // Single clicks + connect(this, &BuddyLabel::clicked, buddy, QOverload<>::of(&QWidget::setFocus)); + if (QCheckBox *cb = qobject_cast(buddy)) + connect(this, &BuddyLabel::clicked, cb, &QCheckBox::click); + + // Double clicks + if (QLineEdit *le = qobject_cast(buddy)) + connect(this, &BuddyLabel::doubleClicked, le, &QLineEdit::selectAll); + else if (QAbstractSpinBox *sb = qobject_cast(buddy)) + connect(this, &BuddyLabel::doubleClicked, sb, &QAbstractSpinBox::selectAll); + else if (QComboBox *cb = qobject_cast(buddy)) + if (cb->isEditable() && cb->lineEdit()) + connect(this, &BuddyLabel::doubleClicked, cb->lineEdit(), &QLineEdit::selectAll); + } + + //! Hook for custom disconnections. We already disconnect ourselves from all slots in \a buddy in the main handler. + virtual void disconnectBuddy(QWidget *buddy) { Q_UNUSED(buddy) } + + //! The filter monitors for tool tip changes on the buddy + bool eventFilter(QObject *obj, QEvent *ev) + { + if (ev->type() == QEvent::ToolTipChange && buddy() && obj == buddy()) + setToolTip(buddy()->toolTip()); + return false; + } + + void mousePressEvent(QMouseEvent *ev) + { + if (ev->button() == Qt::LeftButton) { + m_pressed = true; + ev->accept(); + } + QLabel::mousePressEvent(ev); + } + + void mouseReleaseEvent(QMouseEvent *ev) + { + if (m_pressed && rect().contains(ev->pos())) + emit clicked(); + m_pressed = false; + QLabel::mouseReleaseEvent(ev); + } + + void mouseDoubleClickEvent(QMouseEvent *ev) + { + if (ev->button() == Qt::LeftButton && rect().contains(ev->pos())) + emit doubleClicked(); + QLabel::mouseDoubleClickEvent(ev); + } + + private: + bool m_pressed = false; + Q_DISABLE_COPY(BuddyLabel) +}; + +#endif // BUDDYLABEL_H diff --git a/src/WASimUI/CustomTableView.h b/src/WASimUI/widgets/CustomTableView.h similarity index 100% rename from src/WASimUI/CustomTableView.h rename to src/WASimUI/widgets/CustomTableView.h diff --git a/src/WASimUI/DataComboBox.h b/src/WASimUI/widgets/DataComboBox.h similarity index 100% rename from src/WASimUI/DataComboBox.h rename to src/WASimUI/widgets/DataComboBox.h diff --git a/src/WASimUI/widgets/DeletableItemsComboBox.h b/src/WASimUI/widgets/DeletableItemsComboBox.h new file mode 100644 index 0000000..839c424 --- /dev/null +++ b/src/WASimUI/widgets/DeletableItemsComboBox.h @@ -0,0 +1,380 @@ +/* +DeletableItemsComboBox +https://github.com/mpaperno/WASimCommander + +COPYRIGHT: (c)2022 Maxim Paperno; All Right Reserved. +Contact: http://www.WorldDesign.com/contact + +LICENSE: + +Commercial License Usage +Licensees holding valid commercial licenses may use this file in +accordance with the terms contained in a written agreement between +you and the copyright holder. + +GNU General Public License Usage +Alternatively, this file may be used under the terms of the GNU +General Public License as published by the Free Software Foundation, +either version 3 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. + +A copy of the GNU General Public License is available at . +*/ + +#pragma once + +#include +#include +#include +#include +#include + +#include "DataComboBox.h" + +class DeletableItemsComboBox : public DataComboBox +{ + Q_OBJECT + Q_PROPERTY(QString placeholderText READ placeholderText WRITE setPlaceholderText) +public: + DeletableItemsComboBox(QWidget *p = nullptr) : DataComboBox(p) + { + DataComboBox::setEditable(true); + setInsertPolicy(InsertAtTop); + setSizeAdjustPolicy(AdjustToContents); + //setMinimumContentsLength(25); + setMaxVisibleItems(25); + setCurrentIndex(-1); + setToolTip(tr( + "

Press enter after entering text to save it in the list for future selection.

" + "

Saved items can be removed by right-clicking on them while the list is open.

" + )); + + m_defaultCompleter = completer(); + m_defaultCompleter->setParent(this); // Set a parent so QLineEdit doesn't delete it when removing. + m_completerFilter = m_defaultCompleter->filterMode(); + m_completerMode = m_defaultCompleter->completionMode(); + m_completerEnabled = true; + + setContextMenuPolicy(Qt::CustomContextMenu); + connect(this, &DataComboBox::customContextMenuRequested, this, &DeletableItemsComboBox::showContextMenu); + + connect(lineEdit(), &QWidget::destroyed, this, &DeletableItemsComboBox::updateCompleterOption, Qt::UniqueConnection); + + connect(this, &DeletableItemsComboBox::editTextChanged, this, [this](const QString &txt) { + if (txt.isEmpty()) + setCurrentIndex(-1); + }); + connect(view(), &QAbstractItemView::pressed, [this](const QModelIndex &idx) { + if (idx.isValid() && !idx.data(Qt::UserRole).isValid() && (QApplication::mouseButtons() & Qt::RightButton)) + model()->removeRow(idx.row()); + }); + + // Completer options actions/menu + QMenu *compMenu = new QMenu(this); + auto addAct = [=](const QString &t, int role, int d, const QString &i) { + QAction *a = compMenu->addAction(QIcon(i + ".glyph"), t); + a->setCheckable(true); + a->setData(int(d)); + a->setProperty("role", role); + compMenu->addAction(a); + return a; + }; + addAct(tr("Disable Suggestions"), 0, 1, "not_interested"); + compMenu->addSeparator(); + addAct(tr("Match Starts With"), 1, (int)Qt::MatchStartsWith, "start")->setChecked(m_completerFilter == Qt::MatchStartsWith); + addAct(tr("Match Contains"), 1, (int)Qt::MatchContains, "rotate=90/vertical_align_center")->setChecked(m_completerFilter == Qt::MatchContains); + addAct(tr("Match Ends With"), 1, (int)Qt::MatchEndsWith, "rotate=180/start")->setChecked(m_completerFilter == Qt::MatchEndsWith); + compMenu->addSeparator(); + addAct(tr("Show inline suggestions"), 2, (int)QCompleter::InlineCompletion, "Material Icons,9,5,50,1,1,0,0,0/title")->setChecked(m_completerMode == QCompleter::InlineCompletion); + addAct(tr("Show pop-up suggestions"), 2, (int)QCompleter::PopupCompletion, "IcoMoon-Free/option")->setChecked(m_completerMode == QCompleter::PopupCompletion); + addAct(tr("Show unfiltered pop-up suggestions"), 2, (int)QCompleter::UnfilteredPopupCompletion, "filter_list_off")->setChecked(m_completerMode == QCompleter::UnfilteredPopupCompletion); + + connect(compMenu, &QMenu::triggered, this, &DeletableItemsComboBox::onCompleterOptionAction); + + m_completerAction = new QAction(QIcon("manage_search.glyph"), tr("Typing Suggestions"), this); + m_completerAction->setToolTip(tr("Set options for the suggestions while you type are determined and presented.")); + m_completerAction->setMenu(compMenu); + addAction(compMenu->menuAction()); + } + + QAction *completerChoicesMenuAction() const { return m_completerAction->menu()->menuAction(); } + QString placeholderText() const { return lineEdit() ? lineEdit()->placeholderText() : ""; } + + const QStringList editedItems() const + { + QStringList ret; + if (!isEditable()) + return ret; + for (int i = 0, e = count(); i < e; ++i) { + if (!itemData(i).isValid() && !itemText(i).isEmpty()) + ret << itemText(i); + } + return ret; + } + + QByteArray saveState() const + { + QByteArray state; + QDataStream ds(&state, QIODevice::WriteOnly); + const QStringList items = editedItems(); + ds << items.count(); + for (const QString &itm : items) + ds << itm; + ds << (int)m_completerFilter << (int)m_completerMode << (int)m_completerEnabled; + return state; + } + + bool restoreState(const QByteArray &state) + { + if (state.isEmpty()) + return false; + + QDataStream ds(state); + // Restore any saved list items + int itemCount; + ds >> itemCount; + if (itemCount > 0 && !ds.atEnd()) { + QStringList items; + items.reserve(itemCount); + QString item; + for (int i = 0; i < itemCount && !ds.atEnd(); ++i) { + ds >> item; + items << item; + } + insertEditedItems(items); + } + // Restore completer options if there are any + if (!ds.atEnd()) { + int tmp; + ds >> tmp; + if (tmp != (int)m_completerFilter) + setCompleterFilterMode((Qt::MatchFlags)tmp); + ds >> tmp; + if (tmp != (int)m_completerMode) + setCompleterCompletionMode((QCompleter::CompletionMode)tmp); + if (!ds.atEnd()) { + ds >> tmp; + if ((bool)tmp != m_completerEnabled && !tmp) + setCompleterDisabled(); + } + } + + return true; + } + +public Q_SLOTS: + + void setClearButtonEnabled(bool enabled = true) { + if (lineEdit()) lineEdit()->setClearButtonEnabled(enabled); + } + + void setPlaceholderText(const QString &text) { + if (lineEdit()) + lineEdit()->setPlaceholderText(text); + } + + void setMaxLength(int length) { + if (lineEdit()) + lineEdit()->setMaxLength(length); + } + + void setCompleterOptionsButtonEnabled(bool en = true) + { + m_completerButtonEnabled = en; + + if (!lineEdit()) + return; + + if (!en) + lineEdit()->removeAction(m_completerAction); + else if (!lineEdit()->actions().contains(m_completerAction)) + lineEdit()->addAction(m_completerAction, QLineEdit::TrailingPosition); + } + + void setEditable(bool on = true) { + if (on != isEditable()) { + DataComboBox::setEditable(on); + onLineEditChange(); + } + } + + void setLineEdit(QLineEdit *le) { + DataComboBox::setLineEdit(le); + onLineEditChange(); + } + + // Reimplemented to save the last set completer for toggling it on/off. + // There's apparently no way to temporarily suspend a completer. + void setCompleter(QCompleter *c, bool setOptionsFromCompleter = false) + { + if (c == m_defaultCompleter) { + resetCompleter(); + return; + } + // Make sure the line editor doesn't own the completer otherwise it will delete it when removing. + if (c && (!c->parent() || c->parent() == lineEdit())) + c->setParent(this); + m_customCompleter = c; + if (m_completerEnabled || setOptionsFromCompleter) { + m_completerEnabled = true; + DataComboBox::setCompleter(c); + } + if (!c || setOptionsFromCompleter) { + updateCompleterOption(); + return; + } + if ((c = completer())) { + c->setFilterMode(m_completerFilter); + c->setCompletionMode(m_completerMode); + } + } + + // Reset completer back to the QComboBox default one. + void resetCompleter() { + DataComboBox::setCompleter(m_defaultCompleter); + updateCompleterOption(); + } + + // Sets the completer to either a custom one provided previously in `setCompleter()` or sets up the default completer. + void setCompleterEnabled(bool enabled = true) { + if (enabled) { + DataComboBox::setCompleter(!!m_customCompleter ? m_customCompleter : m_defaultCompleter); + updateCompleterOption(); + } + else { + setCompleterDisabled(); + } + } + + void setCompleterDisabled(bool disabled = true) { + if (disabled) { + DataComboBox::setCompleter(nullptr); + updateCompleterOption(); + } + else { + setCompleterEnabled(); + } + } + void setCompleterFilterMode(Qt::MatchFlags flags) + { + if (!completer()) + setCompleterEnabled(); + completer()->setFilterMode(flags); + updateCompleterOption(); + } + + void setCompleterCompletionMode(QCompleter::CompletionMode mode) + { + if (!completer()) + setCompleterEnabled(); + completer()->setCompletionMode(mode); + updateCompleterOption(); + } + + void setCompleterOptions(Qt::MatchFlags flags, QCompleter::CompletionMode mode, bool enableButton = true) { + setCompleterFilterMode(flags); + setCompleterCompletionMode(mode); + setCompleterOptionsButtonEnabled(enableButton); + } + + void insertEditedItems(const QStringList &items, InsertPolicy policy = NoInsert) + { + if (!isEditable() || insertPolicy() == NoInsert || !items.length()) + return; + if (policy == NoInsert) + policy = insertPolicy(); + + const int currIdx = currentIndex(); + if (policy == InsertAlphabetically) { + addItems(items); + model()->sort(0); + } + else { + int index = 0; + switch (policy) { + case InsertAtTop: + break; + case InsertAtBottom: + index = count(); + break; + case InsertAfterCurrent: + index = qMax(0, currentIndex()); + break; + case InsertBeforeCurrent: + index = qMax(0, currentIndex() - 1); + break; + } + QStringList sorted(items); + std::sort(sorted.begin(), sorted.end()); + insertItems(index, sorted); + } + if (currIdx == -1) + setCurrentIndex(currIdx); + } + +private Q_SLOTS: + void onLineEditChange() { + if (lineEdit()) { + setCompleterOptionsButtonEnabled(m_completerButtonEnabled); + connect(lineEdit(), &QWidget::destroyed, this, &DeletableItemsComboBox::updateCompleterOption, Qt::UniqueConnection); + } + updateCompleterOption(); + } + + void onCompleterOptionAction(QAction *act) { + const int role = act->property("role").toInt(); + if (role == 1) + setCompleterFilterMode((Qt::MatchFlags)act->data().toInt()); + else if (role == 2) + setCompleterCompletionMode((QCompleter::CompletionMode)act->data().toInt()); + else + setCompleterDisabled(); + } + + void updateCompleterOption() + { + if (completer()) { + m_completerEnabled = true; + m_completerFilter = completer()->filterMode(); + m_completerMode = completer()->completionMode(); + } + else { + m_completerEnabled = false; + m_completerAction->setEnabled(!!lineEdit()); + } + + for (QAction *a : m_completerAction->menu()->actions()) { + int role = a->property("role").toInt(); + int data = a->data().toInt(); + a->setChecked( + (role == 0 && (!m_completerEnabled || !completer())) || + (role == 1 && data == m_completerFilter && m_completerEnabled) || + (role == 2 && data == m_completerMode && m_completerEnabled) + ); + } + } + + void showContextMenu(const QPoint &pos) + { + if (!lineEdit()) + return; + QMenu* menu = lineEdit()->createStandardContextMenu(); + menu->addSeparator(); + menu->addAction(m_completerAction->menu()->menuAction()); + menu->exec(mapToGlobal(pos)); + } + +private: + QCompleter *m_defaultCompleter = nullptr; + QCompleter *m_customCompleter = nullptr; + QAction *m_completerAction = nullptr; + Qt::MatchFlags m_completerFilter; + QCompleter::CompletionMode m_completerMode; + bool m_completerEnabled; + bool m_completerButtonEnabled = false; +}; + diff --git a/src/WASimUI/widgets/FilterLineEdit.cpp b/src/WASimUI/widgets/FilterLineEdit.cpp new file mode 100644 index 0000000..f6cdae0 --- /dev/null +++ b/src/WASimUI/widgets/FilterLineEdit.cpp @@ -0,0 +1,157 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +Original version from ; Used under GPL v3 license; Modifications applied. +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + +#include "FilterLineEdit.h" + +#include +#include +#include +#include + +FilterLineEdit::FilterLineEdit(QWidget* parent, std::vector* filters, int columnnum) : + QLineEdit(parent), + filterList(filters), + columnNumber(columnnum) +{ + setPlaceholderText(tr("Filter")); + setClearButtonEnabled(true); + setProperty("column", columnnum); // Store the column number for later use + + // Introduce a timer for delaying the signal triggered whenever the user changes the filter value. + // The idea here is that the textChanged() event isn't connected to the update filter slot directly anymore + // but instead there this timer mechanism in between: whenever the user changes the filter the delay timer + // is (re)started. As soon as the user stops typing the timer has a chance to trigger and call the + // delayedSignalTimerTriggered() method which then stops the timer and emits the delayed signal. + delaySignalTimer = new QTimer(this); + delaySignalTimer->setInterval(200); // This is the milliseconds of not-typing we want to wait before triggering + connect(this, &FilterLineEdit::textChanged, delaySignalTimer, QOverload<>::of(&QTimer::start)); + connect(delaySignalTimer, &QTimer::timeout, this, &FilterLineEdit::delayedSignalTimerTriggered); + + const QString help(tr( + "

These input fields allow you to perform quick filters in the currently selected table.

" + "All filters are case-insensitive. By default a search is performed anywhere in the column's value (*text*).
" + "The following operators are also supported:
" + "" + "" + "" + "" + "" + "" + "
*Matches zero or more of any characters.
?Matches any single character.
\\* or \\?Matches a literal asterisk or question mark (\"escapes\" it).
[...]Sets of characters can be represented in square brackets, similar to full regular expressions.
/regexp/Values matching the regular expression between the slashes (/).
" + )); + + setToolTip(help); + setWhatsThis(help); + + // Immediately emit the delayed filter value changed signal if the user presses the enter or the return key or + // the line edit widget loses focus + connect(this, &FilterLineEdit::editingFinished, this, &FilterLineEdit::delayedSignalTimerTriggered); + + // Prepare for adding the What's This information and filter helper actions to the context menu + setContextMenuPolicy(Qt::CustomContextMenu); + connect(this, &FilterLineEdit::customContextMenuRequested, this, &FilterLineEdit::showContextMenu); +} + +void FilterLineEdit::delayedSignalTimerTriggered() +{ + // Stop the timer first to avoid triggering in intervals + delaySignalTimer->stop(); + + // Only emit text changed signal if the text has actually changed in comparison to the last emitted signal. This is necessary + // because this method is also called whenever the line edit loses focus and not only when its text has definitely been changed. + if(text() != lastValue) + { + // Emit the delayed signal using the current value + emit delayedTextChanged(columnNumber, text()); + + // Remember this value for the next time + lastValue = text(); + } +} + +void FilterLineEdit::keyReleaseEvent(QKeyEvent* event) +{ + if (filterList) { + if (event->key() == Qt::Key_Tab) { + if (columnNumber < filterList->size() - 1) + filterList->at((size_t)columnNumber + 1)->setFocus(); + else + filterList->at(0)->setFocus(); + return; + } + else if (event->key() == Qt::Key_Backtab && columnNumber > 0) { + filterList->at((size_t)columnNumber - 1)->setFocus(); + return; + } + } + + QLineEdit::keyReleaseEvent(event); +} + +void FilterLineEdit::focusInEvent(QFocusEvent* event) +{ + QLineEdit::focusInEvent(event); + emit filterFocused(); +} + +void FilterLineEdit::clear() +{ + // When programmatically clearing the line edit's value make sure the effects are applied immediately, i.e. + // bypass the delayed signal timer + QLineEdit::clear(); + delayedSignalTimerTriggered(); +} + +void FilterLineEdit::setText(const QString& text) +{ + // When programmatically setting the line edit's value make sure the effects are applied immediately, i.e. + // bypass the delayed signal timer + QLineEdit::setText(text); + delayedSignalTimerTriggered(); +} + +void FilterLineEdit::setFilterHelper(const QString& filterOperator, const QString& operatorSuffix, bool clearCurrent) +{ + const QString txt(clearCurrent ? "" : text()); + setText(txt + filterOperator + "?" + operatorSuffix); + // Select the value for easy editing of the expression + setSelection(filterOperator.length() + txt.length(), 1); +} + +void FilterLineEdit::showContextMenu(const QPoint &pos) +{ + + // This has to be created here, otherwise the set of enabled options would not update accordingly. + QMenu* editContextMenu = createStandardContextMenu(); + editContextMenu->addSeparator(); + + QMenu* filterMenu = editContextMenu->addMenu(tr("Set Filter Expression")); + + filterMenu->addAction(QIcon("help_outline.glyph"), tr("What's This?"), this, [&]() { + QWhatsThis::showText(pos, whatsThis(), this); + }); + filterMenu->addSeparator(); + filterMenu->addAction(tr("Starts with..."), this, [&]() { setFilterHelper(QString (""), QString("*")); }); + filterMenu->addAction(tr("... ends with"), this, [&]() { setFilterHelper(QString ("*"), QString(""), false); }); + filterMenu->addAction(tr("In range [...]"), this, [&]() { setFilterHelper(QString ("["), QString("]")); }); + filterMenu->addAction(tr("Regular expression..."), this, [&]() { setFilterHelper(QString ("/"), QString ("/")); }); + + editContextMenu->exec(mapToGlobal(pos)); +} diff --git a/src/WASimUI/widgets/FilterLineEdit.h b/src/WASimUI/widgets/FilterLineEdit.h new file mode 100644 index 0000000..098e7bc --- /dev/null +++ b/src/WASimUI/widgets/FilterLineEdit.h @@ -0,0 +1,59 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +Original version from ; Used under GPL v3 license; Modifications applied. +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + +#pragma once + +#include +#include + +class QTimer; +class QKeyEvent; + +class FilterLineEdit : public QLineEdit +{ + Q_OBJECT + +public: + explicit FilterLineEdit(QWidget* parent, std::vector* filters = nullptr, int columnnum = 0); + + // Override methods for programmatically changing the value of the line edit + void clear(); + void setText(const QString& text); + +Q_SIGNALS: + void delayedTextChanged(int column, QString text); + void filterFocused(); + +protected: + void keyReleaseEvent(QKeyEvent* event) override; + void focusInEvent(QFocusEvent* event) override; + +private Q_SLOTS: + void delayedSignalTimerTriggered(); + void setFilterHelper(const QString& filterOperator, const QString& operatorSuffix = QString(), bool clearCurrent = true); + void showContextMenu(const QPoint &pos); + +private: + std::vector* filterList; + int columnNumber; + QTimer* delaySignalTimer; + QString lastValue; + +}; diff --git a/src/WASimUI/widgets/FilterTableHeader.cpp b/src/WASimUI/widgets/FilterTableHeader.cpp new file mode 100644 index 0000000..9db4c3e --- /dev/null +++ b/src/WASimUI/widgets/FilterTableHeader.cpp @@ -0,0 +1,128 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +Original version from ; Used under GPL v3 license; Modifications applied. +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + +#include "FilterTableHeader.h" +#include "FilterLineEdit.h" + +#include +#include +#include + +FilterTableHeader::FilterTableHeader(QTableView* parent) : + QHeaderView(Qt::Horizontal, parent) +{ + // Do some connects: Basically just resize and reposition the input widgets whenever anything changes + connect(this, &FilterTableHeader::sectionResized, this, &FilterTableHeader::adjustPositions); + connect(this, &FilterTableHeader::sectionClicked, this, &FilterTableHeader::adjustPositions); + connect(parent->horizontalScrollBar(), &QScrollBar::valueChanged, this, &FilterTableHeader::adjustPositions); + connect(parent->verticalScrollBar(), &QScrollBar::valueChanged, this, &FilterTableHeader::adjustPositions); +} + +void FilterTableHeader::generateFilters(int number) +{ + // Delete all the current filter widgets + qDeleteAll(filterWidgets); + filterWidgets.clear(); + + // And generate a bunch of new ones + for(int i=0; i < number; ++i) + { + FilterLineEdit* l = new FilterLineEdit(this, &filterWidgets, i); + l->setVisible(true); + // Set as focus proxy the first non-row-id visible filter-line. + if(!i) + setFocusProxy(l); + connect(l, &FilterLineEdit::delayedTextChanged, this, &FilterTableHeader::inputChanged); + connect(l, &FilterLineEdit::filterFocused, this, &FilterTableHeader::filterFocused); + filterWidgets.push_back(l); + } + + // Position them correctly + updateGeometries(); +} + +QSize FilterTableHeader::sizeHint() const +{ + // For the size hint just take the value of the standard implementation and add the height of a input widget to it if necessary + QSize s = QHeaderView::sizeHint(); + if(filterWidgets.size()) + s.setHeight(s.height() + filterWidgets.at(0)->sizeHint().height() + 4); // The 4 adds just adds some extra space + return s; +} + +void FilterTableHeader::updateGeometries() +{ + // If there are any input widgets add a viewport margin to the header to generate some empty space for them which is not affected by scrolling + if(filterWidgets.size()) + setViewportMargins(0, 0, 0, filterWidgets.at(0)->sizeHint().height()); + else + setViewportMargins(0, 0, 0, 0); + + // Now just call the parent implementation and reposition the input widgets + QHeaderView::updateGeometries(); + adjustPositions(); +} + +void FilterTableHeader::adjustPositions() +{ + // The two adds some extra space between the header label and the input widget + const int y = QHeaderView::sizeHint().height() + 2; + // Loop through all widgets + for(int i=0;i < static_cast(filterWidgets.size()); ++i) + { + // Get the current widget, move it and resize it + QWidget* w = filterWidgets.at((size_t)i); + if (QApplication::layoutDirection() == Qt::RightToLeft) + w->move(width() - (sectionPosition(i) + sectionSize(i) - offset()), y); + else + w->move(sectionPosition(i) - offset(), y); + w->resize(sectionSize(i), w->sizeHint().height()); + } +} + +void FilterTableHeader::inputChanged(int col, const QString& new_value) +{ + //adjustPositions(); + // Just get the column number and the new value and send them to anybody interested in filter changes + emit filterChanged(col, new_value); +} + +void FilterTableHeader::clearFilters() +{ + for(FilterLineEdit* filterLineEdit : filterWidgets) + filterLineEdit->clear(); +} + +void FilterTableHeader::setFilter(int column, const QString& value) +{ + if(column < filterWidgets.size()) + filterWidgets.at(column)->setText(value); +} + +QString FilterTableHeader::filterValue(int column) const +{ + return filterWidgets[column]->text(); +} + +void FilterTableHeader::setFocusColumn(int column) +{ + if(column < filterWidgets.size()) + filterWidgets.at(column)->setFocus(Qt::FocusReason::TabFocusReason); +} diff --git a/src/WASimUI/widgets/FilterTableHeader.h b/src/WASimUI/widgets/FilterTableHeader.h new file mode 100644 index 0000000..d9c229a --- /dev/null +++ b/src/WASimUI/widgets/FilterTableHeader.h @@ -0,0 +1,61 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +Original version from ; Used under GPL v3 license; Modifications applied. +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + +#ifndef FILTERTABLEHEADER_H +#define FILTERTABLEHEADER_H + +#include +#include + +class QTableView; +class FilterLineEdit; + +class FilterTableHeader : public QHeaderView +{ + Q_OBJECT + +public: + explicit FilterTableHeader(QTableView* parent = nullptr); + QSize sizeHint() const override; + bool hasFilters() const {return (filterWidgets.size() > 0);} + QString filterValue(int column) const; + +public slots: + void generateFilters(int number); + void adjustPositions(); + void clearFilters(); + void setFilter(int column, const QString& value); + void setFocusColumn(int column); + +signals: + void filterChanged(int column, QString value); + void filterFocused(); + +protected: + void updateGeometries() override; + +private slots: + void inputChanged(int col, const QString& new_value); + +private: + std::vector filterWidgets; +}; + +#endif diff --git a/src/WASimUI/Widgets.h b/src/WASimUI/widgets/Widgets.h similarity index 84% rename from src/WASimUI/Widgets.h rename to src/WASimUI/widgets/Widgets.h index 2993831..4d7fcf4 100644 --- a/src/WASimUI/Widgets.h +++ b/src/WASimUI/widgets/Widgets.h @@ -19,17 +19,14 @@ and is also available at . #pragma once -#include -#include #include -#include #include #include -#include #include #include "client/WASimClient.h" #include "DataComboBox.h" +#include "DeletableItemsComboBox.h" #include "Utils.h" namespace WASimUiNS @@ -289,84 +286,6 @@ class VariableTypeComboBox : public DataComboBox } }; - -class DeletableItemsComboBox : public DataComboBox -{ - Q_OBJECT - Q_PROPERTY(QString placeholderText READ placeholderText WRITE setPlaceholderText) -public: - DeletableItemsComboBox(QWidget *p = nullptr) : DataComboBox(p) - { - setEditable(true); - setInsertPolicy(InsertAtTop); - setSizeAdjustPolicy(AdjustToContents); - //setMinimumContentsLength(25); - setMaxVisibleItems(25); - setCurrentIndex(-1); - setToolTip(tr("Manually added text items (at top of list) can be removed by right-clicking on them while the list is open.")); - - connect(this, &DeletableItemsComboBox::editTextChanged, this, [this](const QString &txt) { - if (txt.isEmpty()) - setCurrentIndex(-1); - }); - connect(view(), &QAbstractItemView::pressed, [this](const QModelIndex &idx) { - if (idx.isValid() && !idx.data(Qt::UserRole).isValid() && (QApplication::mouseButtons() & Qt::RightButton)) - model()->removeRow(idx.row()); - }); - } - - void setClearButtonEnabled(bool enabled = true) { if (lineEdit()) lineEdit()->setClearButtonEnabled(enabled); } - - const QStringList editedItems() const - { - QStringList ret; - if (!isEditable()) - return ret; - for (int i = 0, e = count(); i < e; ++i) { - if (!itemData(i).isValid() && !itemText(i).isEmpty()) - ret << itemText(i); - } - return ret; - } - - void insertEditedItems(const QStringList &items, InsertPolicy policy = NoInsert) - { - if (!isEditable() || insertPolicy() == NoInsert) - return; - if (policy == NoInsert) - policy = insertPolicy(); - int index = 0; - switch (policy) { - case InsertAtTop: - break; - case InsertAtBottom: - index = count(); - break; - case InsertAfterCurrent: - index = qMax(0, currentIndex()); - break; - case InsertBeforeCurrent: - index = qMax(0, currentIndex() - 1); - break; - } - const int currIdx = currentIndex(); - insertItems(index, items); - if (policy == InsertAlphabetically) - model()->sort(0); - if (currIdx == -1) - setCurrentIndex(currIdx); - } - - QString placeholderText() const { return lineEdit() ? lineEdit()->placeholderText() : ""; } - - void setPlaceholderText(const QString &text) - { - if (lineEdit()) - lineEdit()->setPlaceholderText(text); - } - -}; - class ValueSizeComboBox : public DeletableItemsComboBox { Q_OBJECT @@ -403,6 +322,13 @@ class UnitTypeComboBox : public DeletableItemsComboBox public: UnitTypeComboBox(QWidget *p = nullptr) : DeletableItemsComboBox(p) { + setToolTip(tr( + "

Unit Name for the value. For L vars this can be left blank to get the default value of the variable.

" + "

The completion suggestions are looked up from imported SimConnect SDK documentation unit types.

" + "

Unit types may also be saved for quick selection later by pressing Return after selecting one or typing it in.

" + "

Saved items can be removed by right-clicking on them while the list is open.

" + )); + setInsertPolicy(InsertAlphabetically); int i = 0; addItem(QStringLiteral("bar"), i++); From 1bf921df668e26bd715c6c69e631349415f19bb1 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Wed, 1 Nov 2023 13:23:56 -0400 Subject: [PATCH 49/65] [WASimUI][DataComboBox] Add `addItem()` overload with role; convert signal connection to modern C++. --- src/WASimUI/widgets/DataComboBox.h | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/WASimUI/widgets/DataComboBox.h b/src/WASimUI/widgets/DataComboBox.h index 0aa9293..6262cf8 100644 --- a/src/WASimUI/widgets/DataComboBox.h +++ b/src/WASimUI/widgets/DataComboBox.h @@ -53,7 +53,7 @@ class DataComboBox: public QComboBox explicit DataComboBox(QWidget *parent = nullptr): QComboBox(parent) { - connect(this, SIGNAL(currentIndexChanged(int)), this, SLOT(onCurrentIndexChanged(int))); + connect(this, QOverload::of(&QComboBox::currentIndexChanged), this, &DataComboBox::onCurrentIndexChanged); } //! The Role id to use for data operations unless otherwise specified. Default is \c Qt::UserRole. \sa setDefaultRole() @@ -69,16 +69,19 @@ class DataComboBox: public QComboBox return QComboBox::currentData(role); } + //! Adds an item like `QComboBox::addItem(const QString &, const QVariant &)` does but allows specifying the role. + void addItem(const QString &text, const QVariant &data, int role) { + QComboBox::addItem(text); + setItemData(count() - 1, data, role); + } + //! Add items with names from \a texts, with corresponding data from \a datas list, using the specified \a role. If \a role is \c -1 (default) then the \p defaultRole is used. void addItems(const QStringList &texts, const QVariantList &datas, int role = -1) { if (role < 0) role = m_role; - const int c = count(); - for (int i=0; i < texts.count(); ++i) { - addItem(texts.at(i)); - setItemData(i + c, datas.value(i, texts.at(i)), role); - } + for (int i=0; i < texts.count(); ++i) + addItem(texts.at(i), datas.value(i, texts.at(i)), role); } //! Add items with corresponding data from \a items map, using the specified \a role. If \a role is \c -1 (default) then the \p defaultRole is used. @@ -91,11 +94,8 @@ class DataComboBox: public QComboBox { if (role < 0) role = m_role; - const int c = count(); - for (int i=0; i < texts.count(); ++i) { - addItem(texts.at(i)); - setItemData(i + c, datas.value(i, texts.at(i)), role); - } + for (int i=0; i < texts.count(); ++i) + addItem(texts.at(i), datas.value(i, texts.at(i)), role); } //! Add items with corresponding data from \a items map, using the specified \a role. If \a role is \c -1 (default) then the \p defaultRole is used. @@ -103,7 +103,9 @@ class DataComboBox: public QComboBox addItems(items.keys(), items.values(), role); } - using QComboBox::addItems; // bring back the superclass version + // bring back the superclass versions + using QComboBox::addItem; + using QComboBox::addItems; public slots: //! Convenience slot for \c QComboBox::setCurrentIndex(findData(value, role, matchFlags)) From cf1a2045c700e3236cb6889ff77b6dd746f16b40 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Wed, 1 Nov 2023 13:39:04 -0400 Subject: [PATCH 50/65] [WASimUI] Refactor `CustomTableView` to incorporate column filters; Add filters toggle action; Refactor `MultiColumnFilterProxyModel` to `MultiColumnProxyModel` to handle both filtering and sorting; Reformat all imported code with tabs; Remove unused files. --- src/WASimUI/WASimUI.vcxproj | 24 +- .../AlphanumComparer.h | 0 src/WASimUI/model/MultiColumnProxyModel.h | 262 ++++++++++++++++++ .../multisort_view/AlphanumSortProxyModel.h | 81 ------ src/WASimUI/multisort_view/ColumnsSorter.h | 149 ---------- src/WASimUI/multisort_view/LICENSE | 166 ----------- .../multisort_view/MultisortTableView.cpp | 90 ------ .../multisort_view/MultisortTableView.h | 38 --- src/WASimUI/multisort_view/README | 27 -- src/WASimUI/widgets/CustomTableView.h | 82 +++++- src/WASimUI/widgets/FilterTableHeader.cpp | 135 ++++----- src/WASimUI/widgets/FilterTableHeader.h | 43 +-- src/WASimUI/widgets/MultisortTableView.h | 112 ++++++++ 13 files changed, 558 insertions(+), 651 deletions(-) rename src/WASimUI/{multisort_view => model}/AlphanumComparer.h (100%) create mode 100644 src/WASimUI/model/MultiColumnProxyModel.h delete mode 100644 src/WASimUI/multisort_view/AlphanumSortProxyModel.h delete mode 100644 src/WASimUI/multisort_view/ColumnsSorter.h delete mode 100644 src/WASimUI/multisort_view/LICENSE delete mode 100644 src/WASimUI/multisort_view/MultisortTableView.cpp delete mode 100644 src/WASimUI/multisort_view/MultisortTableView.h delete mode 100644 src/WASimUI/multisort_view/README create mode 100644 src/WASimUI/widgets/MultisortTableView.h diff --git a/src/WASimUI/WASimUI.vcxproj b/src/WASimUI/WASimUI.vcxproj index 3dea292..cf95336 100644 --- a/src/WASimUI/WASimUI.vcxproj +++ b/src/WASimUI/WASimUI.vcxproj @@ -31,18 +31,23 @@ Application v142 + Unicode Application v142 + Unicode + PGOptimize Application v142 + Unicode Application v142 + Unicode @@ -53,7 +58,7 @@ core;gui;widgets debug false - true + false 5.12.12_msvc2017_64 @@ -104,21 +109,21 @@ true - $(ProjectDir)widgets;$(IncludePath) + $(ProjectDir)model;$(ProjectDir)widgets;$(IncludePath) true - $(ProjectDir)widgets;$(IncludePath) + $(ProjectDir)model;$(ProjectDir)widgets;$(IncludePath) true true - $(ProjectDir)widgets;$(IncludePath) + $(ProjectDir)model;$(ProjectDir)widgets;$(IncludePath) true true - $(ProjectDir)widgets;$(IncludePath) + $(ProjectDir)model;$(ProjectDir)widgets;$(IncludePath) @@ -164,6 +169,7 @@ true WSMCMND_API_STATIC;%(PreprocessorDefinitions) StdCall + Speed @@ -272,7 +278,6 @@ - @@ -312,11 +317,10 @@ + - - - - + + diff --git a/src/WASimUI/multisort_view/AlphanumComparer.h b/src/WASimUI/model/AlphanumComparer.h similarity index 100% rename from src/WASimUI/multisort_view/AlphanumComparer.h rename to src/WASimUI/model/AlphanumComparer.h diff --git a/src/WASimUI/model/MultiColumnProxyModel.h b/src/WASimUI/model/MultiColumnProxyModel.h new file mode 100644 index 0000000..6a54167 --- /dev/null +++ b/src/WASimUI/model/MultiColumnProxyModel.h @@ -0,0 +1,262 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +Original version of multi-column filtering from ; Public domain; Modifications applied. +Original version multi-column sorting from ; GPL v3 license; Modifications applied. + +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + +#pragma once + +#include +#include +#include +#include "AlphanumComparer.h" + +class MultiColumnProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT + + public: + using QSortFilterProxyModel::QSortFilterProxyModel; + + // Reimplemented to show sort icon and order as part of the heading title. + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole ) const override + { + if (orientation == Qt::Horizontal && role == Qt::DisplayRole && m_sortedColumns.contains(section)) { + const QString header = + sourceModel()->headerData(section, orientation).toString().append(m_sortedColumns.value(section).sortOrder == Qt::AscendingOrder ? " ▲" : " ▼"); + if (m_sortedColumns.count() == 1) + return header; + return header + ' ' + toSuperScript(m_sortedColumnsOrder.indexOf(section) + 1); + } + return QSortFilterProxyModel::headerData(section, orientation, role); + } + + void setUseAlphanumericSortingAlgo(bool use = true) { m_useAlphanumSort = use; } + bool usingAlphanumericSortingAlgo() const { return m_useAlphanumSort; } + + /// Return sort order as `Qt::SortOrder` enum value of the given column index, or -1 if the column isn't sorted. + int columnSortOrder (int column) const { + return m_sortedColumns.contains(column) ? m_sortedColumns.value(column).sortOrder : -1; + } + + /// Return column's position in the list of sorted columns, or -1 if the column isn't sorted. + int columnSortPosition (int column) const { + return m_sortedColumnsOrder.indexOf(column); + } + + /// Return count of sorted columns + int sortedColumnsCount () const { + return m_sortedColumnsOrder.count(); + } + + public Q_SLOTS: + + // Filtering + + virtual void setRegExpFilter(int col, const QRegExp& matcher, int role = Qt::DisplayRole) + { + if (matcher.isEmpty() || !matcher.isValid()) + return removeFilterFromColumn(col, role); + m_filters[(static_cast(col) << 32) | static_cast(role)] = matcher; + //qDebug() << col << matcher.pattern() << role; + invalidateFilter(); + } + + virtual void setStringPatternFilter(int col, QString pattern, int role) + { + if (pattern.isEmpty()) { + removeFilterFromColumn(col); + } + else if (pattern.startsWith('/') && pattern.endsWith('/') /*&& pattern.length() > 2*/) { + setRegExpFilter(col, QRegExp(pattern.mid(1, pattern.length() - 2), Qt::CaseInsensitive, QRegExp::RegExp)); + } + else { + if (!pattern.contains('*') && !pattern.contains('?') && !pattern.contains(']')) + pattern.prepend('*').append('*'); + setRegExpFilter(col, QRegExp(pattern, Qt::CaseInsensitive, QRegExp::WildcardUnix)); + } + } + + virtual void setDisplayRolePatternFilter(int col, QString pattern) { + setStringPatternFilter(col, pattern, Qt::DisplayRole); + } + + virtual void clearFilters() + { + m_filters.clear(); + invalidateFilter(); + } + + virtual void removeFilterFromColumn(int col, int role) + { + m_filters.remove((static_cast(col) << 32) | static_cast(role)); + invalidateFilter(); + } + + virtual void removeFilterFromColumn(int col) + { + for (auto i = m_filters.begin(); i != m_filters.end();) { + if ((i.key() >> 32) == col) + i = m_filters.erase(i); + else + ++i; + } + invalidateFilter(); + } + + // Sorting + + /// Sort by `column` number. If `isModifierPressed` is `true` and the column is not in the current sort list + /// then it is appended to the list using default Ascending order. If already in the list then the order is reversed. + /// If sorted a 3rd time, the column is removed from the list (assuming there is more than one). + /// The modifier is essentially ignored if only one column is being sorted on anyway. + /// If `isModifierPressed` is `false` then any other currently sorted columns are cleared and this column's order is either set to Ascending or is reversed, + /// depending on if it was already in the sort list or not. + /// If `order` is specified as `Qt::AscendingOrder` or `Qt::DescendingOrder`, then the order of sorting is forced instead of being reversed. + virtual void sortColumn(int column, bool isModifierPressed = false, qint8 order = -1) + { + if (isModifierPressed) { + if (m_sortedColumns.contains(column)) { + // remove sorted columns on 3rd click, but not if it's the last one. + if (m_sortedColumns.count() > 1 && m_sortedColumns.value(column).activations >= 2) + removeSortedColumn(column); + else if (order == -1) + changeSortDirection(column, 2); + else + setSortDirection(column, (Qt::SortOrder)order, 2); + } + else { + // append new sorted column + addSortedColumn(column, order == -1 ? Qt::AscendingOrder : (Qt::SortOrder)order); + } + } + else { + if (m_sortedColumns.contains(column) && order == -1) { + if (m_sortedColumns.count() == 1) { + // just change direction + changeSortDirection(column, 1); + } + else { + // need to remove all other columns first; save this column's order so it can be reversed afterwards. + Qt::SortOrder ord = reverseOrder(m_sortedColumns.value(column).sortOrder); + clearSortedColumns(); + addSortedColumn(column, ord); + } + } + else { + // column wasn't currently sorted on so just clear the rest and add the new one with default order + clearSortedColumns(); + addSortedColumn(column, order == -1 ? Qt::AscendingOrder : (Qt::SortOrder)order); + } + } + multisort(); + } + + /// Sorts by a single column in given order. + virtual void sortByColumn(int column, Qt::SortOrder order) { + clearSortedColumns(); + addSortedColumn(column, order); + multisort(); + } + + protected: + bool filterAcceptsRow(int source_row, const QModelIndex & source_parent) const override + { + for (int i = 0; i < sourceModel()->columnCount(source_parent); ++i) { + const QModelIndex currntIndex = sourceModel()->index(source_row, i, source_parent); + for (auto regExpIter = m_filters.constBegin(); regExpIter != m_filters.constEnd(); ++regExpIter) { + if (static_cast(regExpIter.key() >> 32) == i) { + if (regExpIter.value().indexIn(currntIndex.data(static_cast(regExpIter.key() & ((1i64 << 32) - 1))).toString().trimmed()) < 0) + return false; + } + } + } + return true; + } + + bool lessThan(const QModelIndex & left, const QModelIndex & right) const override { + if (m_useAlphanumSort) + return AlphanumComparer::lessThan(sourceModel()->data(left).toString(), sourceModel()->data(right).toString()); + return QSortFilterProxyModel::lessThan(left, right); + } + + virtual void multisort() { + // Perform the actual sort using superclass method in reverse order of currently sorted columns. + for(auto col = m_sortedColumnsOrder.crbegin(); col != m_sortedColumnsOrder.crend(); ++col) + sort(*col, m_sortedColumns.value(*col).sortOrder); + } + + private: + void addSortedColumn(int col, Qt::SortOrder order = Qt::AscendingOrder) { + m_sortedColumns.insert(col, { order, 1 }); + m_sortedColumnsOrder.append(col); + } + + void removeSortedColumn(int col) { + m_sortedColumns.remove(col); + m_sortedColumnsOrder.removeAll(col); + } + + inline void clearSortedColumns() { + m_sortedColumns.clear(); + m_sortedColumnsOrder.clear(); + } + + void changeSortDirection(int col, quint8 activations = 1) { + m_sortedColumns.insert(col, { reverseOrder(m_sortedColumns.value(col).sortOrder), activations }); + } + + void setSortDirection(int col, Qt::SortOrder order, quint8 activations = 1) { + m_sortedColumns.insert(col, { order, activations }); + } + + private: + QHash m_filters {}; + + struct SortTrack { + Qt::SortOrder sortOrder = Qt::AscendingOrder; + quint8 activations = 1; + }; + QHash m_sortedColumns; + QVector m_sortedColumnsOrder; + bool m_useAlphanumSort = true; + + static Qt::SortOrder reverseOrder(Qt::SortOrder ord) { + return ord == Qt::AscendingOrder ? Qt::DescendingOrder : Qt::AscendingOrder; + } + + static QString toSuperScript(int number) { + QString n = QString::number(number); + for (int i=0, e=n.length(); i < e; ++i) { + switch (n[i].toLatin1()) { + case '1': n.replace(i, 1, "¹"); break; + case '2': n.replace(i, 1, "²"); break; + case '3': n.replace(i, 1, "³"); break; + case '4': n.replace(i, 1, "⁴"); break; + case '5': n.replace(i, 1, "⁵"); break; + case '6': n.replace(i, 1, "⁶"); break; + case '7': n.replace(i, 1, "⁷"); break; + case '8': n.replace(i, 1, "⁸"); break; + case '9': n.replace(i, 1, "⁹"); break; + case '0': n.replace(i, 1, "⁰"); break; + } + } + return n; + } +}; diff --git a/src/WASimUI/multisort_view/AlphanumSortProxyModel.h b/src/WASimUI/multisort_view/AlphanumSortProxyModel.h deleted file mode 100644 index bfc0bf7..0000000 --- a/src/WASimUI/multisort_view/AlphanumSortProxyModel.h +++ /dev/null @@ -1,81 +0,0 @@ -// Originally from https://github.com/dimkanovikov/MultisortTableView Licensed under GPL v3 - -#ifndef ALPHANUMSORTPROXYMODEL_H -#define ALPHANUMSORTPROXYMODEL_H - -#include -#include "AlphanumComparer.h" -#include "ColumnsSorter.h" - - -class AlphanumSortProxyModel : public QSortFilterProxyModel -{ - Q_OBJECT -public: - explicit AlphanumSortProxyModel( QObject *parent = 0 ) : - QSortFilterProxyModel( parent ) { } - - // Reimplemented to show order of column in common sorting if needed - // and sorting icon of column - virtual QVariant headerData ( int section, - Qt::Orientation orientation, - int role = Qt::DisplayRole ) const - { - if( orientation == Qt::Horizontal ) { - switch ( role ) - { - //case Qt::DisplayRole: { - // QString header = sourceModel()->headerData( section, orientation ).toString(); - // if ( m_columnSorter.columnsCount() > 1 && - // m_columnSorter.columnOrder( section ) >= 0 ) { - // header.insert(0, QString::number( m_columnSorter.columnOrder( section ) + 1 ) + ": " ); - // } - // return header; - //} - case Qt::DecorationRole: - return m_columnSorter.columnIcon( section ); - default: - return QSortFilterProxyModel::headerData(section, orientation, role); - } - } - // Row number - return section + 1; - } - - // Sort column - void sortColumn ( int column, bool isModifierPressed = false ) - { - m_columnSorter.sortColumn( column, isModifierPressed ); - - // "count-1" becouse indexes are started from zero value - for( int i = m_columnSorter.columnsCount()-1; i >= 0; --i) { - int col = m_columnSorter.columnIndex( i ); - sort( col, m_columnSorter.columnSortOrder( col ) ); - } - } - - // Set icons to decorate sorted table headers - void setSortIcons( QIcon ascIcon, QIcon descIcon ) - { - m_columnSorter.setIcons( ascIcon, descIcon ); - } - - -protected: - // Reimplemented to use alphanum sorting - virtual bool lessThan ( const QModelIndex & left, - const QModelIndex & right ) const - { - QVariant leftData = sourceModel()->data(left), - rightData = sourceModel()->data(right); - - return AlphanumComparer::lessThan( leftData.toString(), - rightData.toString() ); - } - -private: - ColumnsSorter m_columnSorter; - -}; - -#endif // ALPHANUMSORTPROXYMODEL_H diff --git a/src/WASimUI/multisort_view/ColumnsSorter.h b/src/WASimUI/multisort_view/ColumnsSorter.h deleted file mode 100644 index 5b0d591..0000000 --- a/src/WASimUI/multisort_view/ColumnsSorter.h +++ /dev/null @@ -1,149 +0,0 @@ -// Originally from https://github.com/dimkanovikov/MultisortTableView Licensed under GPL v3 - -#ifndef COLUMNSSORTER_H -#define COLUMNSSORTER_H - -#include -#include -#include -#include - -// Class for sorting columns -// Stored dictionary of sorted columns and theirs sort order "m_sortedColumns" -// and list of sorted columns to simple handling order of sorting columns -class ColumnsSorter -{ -public: - ColumnsSorter() { } - - // Set icons to decorate sorted table headers - void setIcons( QIcon ascIcon, QIcon descIcon ) - { - m_ascIcon = ascIcon; - m_descIcon = descIcon; - } - - // Sort column - void sortColumn ( int column, bool isModifierPressed = false ) - { - // If key modifier to multicolumn sorting is pressed - // and column was sorted and nedded to sort only this column, - // i.e. before user click at this column header was sorted only - // this column, than we simple should change sort order of column. - // Else we should clear all sorted columns and sort current - // by defult sort order (ascending) - if ( !isModifierPressed ) { - if ( m_sortedColumns.contains( column ) && - m_sortedColumns.count() == 1) - changeSortDirection( column, 1 ); - else { - clearSortedColumns(); - addSortedColumn( column ); - } - } - // If key modifier to multicolumn sorting isn't pressed, than - // if column was sorted, we change theirs sort order, or if - // column wasn't sorted yet just sort it by defult sort order (ascending) - else { - if ( m_sortedColumns.contains( column ) ) { - // remove sorted columns on 3rd click, but not the last one. - if (m_sortedColumns.count() > 1 && m_sortedColumns.value( column ).activations >= 2) - removeSortedColumn(column); - else - changeSortDirection( column, 2 ); - } else { - addSortedColumn( column ); - } - } - } - - // Return column index - int columnIndex ( int columnOrder ) const - { - return m_sortedColumnsOrder.value( columnOrder ); - } - - // Return column order in list of sorted columns - int columnOrder ( int column ) const - { - return m_sortedColumnsOrder.indexOf( column ); - } - - // Return column sort order - Qt::SortOrder columnSortOrder ( int column ) const - { - return m_sortedColumns.value( column ).order; - } - - // Return column icon, if column wasn't sorted return QIcon() - QIcon columnIcon ( int column ) const - { - QIcon columnIcon; - if ( m_sortedColumns.contains( column ) ) { - if ( m_sortedColumns.value( column ).order == Qt::AscendingOrder ) - columnIcon = m_ascIcon; - else - columnIcon = m_descIcon; - } - return columnIcon; - } - - // Return count of sorted columns - int columnsCount () const - { - return m_sortedColumnsOrder.count(); - } - - -private: - struct SortTrack { - Qt::SortOrder order = Qt::AscendingOrder; - quint8 activations = 1; - }; - - // Dictionary of sorted columns: key - column index, value - sort order - QHash m_sortedColumns; - // List of sorted columns - QList m_sortedColumnsOrder; - // Icons do decorate sorted columns - QIcon m_ascIcon, - m_descIcon; - - - // Add column to list of sorted columns - // Assertions: - // Function don't check that column is already stored - void addSortedColumn ( int column ) - { - m_sortedColumns.insert( column, SortTrack()); - m_sortedColumnsOrder.append( column ); - } - - void removeSortedColumn ( int column ) - { - m_sortedColumns.remove( column ); - m_sortedColumnsOrder.removeAll( column ); - } - - // Change sort order of column - // Assertions: - // Function isn't check that column is already stored - void changeSortDirection ( int column, quint8 activations = 1 ) - { - Qt::SortOrder revertOrder = - m_sortedColumns.value( column ).order != Qt::AscendingOrder ? - Qt::AscendingOrder : - Qt::DescendingOrder; - - m_sortedColumns.insert( column, { revertOrder, activations } ); - } - - // Clear all stored columns - inline void clearSortedColumns() - { - m_sortedColumns.clear(); - m_sortedColumnsOrder.clear(); - } -}; - -#endif // COLUMNSSORTER_H diff --git a/src/WASimUI/multisort_view/LICENSE b/src/WASimUI/multisort_view/LICENSE deleted file mode 100644 index 341c30b..0000000 --- a/src/WASimUI/multisort_view/LICENSE +++ /dev/null @@ -1,166 +0,0 @@ - GNU LESSER GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - - This version of the GNU Lesser General Public License incorporates -the terms and conditions of version 3 of the GNU General Public -License, supplemented by the additional permissions listed below. - - 0. Additional Definitions. - - As used herein, "this License" refers to version 3 of the GNU Lesser -General Public License, and the "GNU GPL" refers to version 3 of the GNU -General Public License. - - "The Library" refers to a covered work governed by this License, -other than an Application or a Combined Work as defined below. - - An "Application" is any work that makes use of an interface provided -by the Library, but which is not otherwise based on the Library. -Defining a subclass of a class defined by the Library is deemed a mode -of using an interface provided by the Library. - - A "Combined Work" is a work produced by combining or linking an -Application with the Library. The particular version of the Library -with which the Combined Work was made is also called the "Linked -Version". - - The "Minimal Corresponding Source" for a Combined Work means the -Corresponding Source for the Combined Work, excluding any source code -for portions of the Combined Work that, considered in isolation, are -based on the Application, and not on the Linked Version. - - The "Corresponding Application Code" for a Combined Work means the -object code and/or source code for the Application, including any data -and utility programs needed for reproducing the Combined Work from the -Application, but excluding the System Libraries of the Combined Work. - - 1. Exception to Section 3 of the GNU GPL. - - You may convey a covered work under sections 3 and 4 of this License -without being bound by section 3 of the GNU GPL. - - 2. Conveying Modified Versions. - - If you modify a copy of the Library, and, in your modifications, a -facility refers to a function or data to be supplied by an Application -that uses the facility (other than as an argument passed when the -facility is invoked), then you may convey a copy of the modified -version: - - a) under this License, provided that you make a good faith effort to - ensure that, in the event an Application does not supply the - function or data, the facility still operates, and performs - whatever part of its purpose remains meaningful, or - - b) under the GNU GPL, with none of the additional permissions of - this License applicable to that copy. - - 3. Object Code Incorporating Material from Library Header Files. - - The object code form of an Application may incorporate material from -a header file that is part of the Library. You may convey such object -code under terms of your choice, provided that, if the incorporated -material is not limited to numerical parameters, data structure -layouts and accessors, or small macros, inline functions and templates -(ten or fewer lines in length), you do both of the following: - - a) Give prominent notice with each copy of the object code that the - Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the object code with a copy of the GNU GPL and this license - document. - - 4. Combined Works. - - You may convey a Combined Work under terms of your choice that, -taken together, effectively do not restrict modification of the -portions of the Library contained in the Combined Work and reverse -engineering for debugging such modifications, if you also do each of -the following: - - a) Give prominent notice with each copy of the Combined Work that - the Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the Combined Work with a copy of the GNU GPL and this license - document. - - c) For a Combined Work that displays copyright notices during - execution, include the copyright notice for the Library among - these notices, as well as a reference directing the user to the - copies of the GNU GPL and this license document. - - d) Do one of the following: - - 0) Convey the Minimal Corresponding Source under the terms of this - License, and the Corresponding Application Code in a form - suitable for, and under terms that permit, the user to - recombine or relink the Application with a modified version of - the Linked Version to produce a modified Combined Work, in the - manner specified by section 6 of the GNU GPL for conveying - Corresponding Source. - - 1) Use a suitable shared library mechanism for linking with the - Library. A suitable mechanism is one that (a) uses at run time - a copy of the Library already present on the user's computer - system, and (b) will operate properly with a modified version - of the Library that is interface-compatible with the Linked - Version. - - e) Provide Installation Information, but only if you would otherwise - be required to provide such information under section 6 of the - GNU GPL, and only to the extent that such information is - necessary to install and execute a modified version of the - Combined Work produced by recombining or relinking the - Application with a modified version of the Linked Version. (If - you use option 4d0, the Installation Information must accompany - the Minimal Corresponding Source and Corresponding Application - Code. If you use option 4d1, you must provide the Installation - Information in the manner specified by section 6 of the GNU GPL - for conveying Corresponding Source.) - - 5. Combined Libraries. - - You may place library facilities that are a work based on the -Library side by side in a single library together with other library -facilities that are not Applications and are not covered by this -License, and convey such a combined library under terms of your -choice, if you do both of the following: - - a) Accompany the combined library with a copy of the same work based - on the Library, uncombined with any other library facilities, - conveyed under the terms of this License. - - b) Give prominent notice with the combined library that part of it - is a work based on the Library, and explaining where to find the - accompanying uncombined form of the same work. - - 6. Revised Versions of the GNU Lesser General Public License. - - The Free Software Foundation may publish revised and/or new versions -of the GNU Lesser General Public License from time to time. Such new -versions will be similar in spirit to the present version, but may -differ in detail to address new problems or concerns. - - Each version is given a distinguishing version number. If the -Library as you received it specifies that a certain numbered version -of the GNU Lesser General Public License "or any later version" -applies to it, you have the option of following the terms and -conditions either of that published version or of any later version -published by the Free Software Foundation. If the Library as you -received it does not specify a version number of the GNU Lesser -General Public License, you may choose any version of the GNU Lesser -General Public License ever published by the Free Software Foundation. - - If the Library as you received it specifies that a proxy can decide -whether future versions of the GNU Lesser General Public License shall -apply, that proxy's public statement of acceptance of any version is -permanent authorization for you to choose that version for the -Library. - diff --git a/src/WASimUI/multisort_view/MultisortTableView.cpp b/src/WASimUI/multisort_view/MultisortTableView.cpp deleted file mode 100644 index d868522..0000000 --- a/src/WASimUI/multisort_view/MultisortTableView.cpp +++ /dev/null @@ -1,90 +0,0 @@ -// Originally from https://github.com/dimkanovikov/MultisortTableView Licensed under GPL v3 - -#include -#include -#include -#include - -#include "MultisortTableView.h" -#include "AlphanumSortProxyModel.h" - - -class HeaderProxyStyle : public QProxyStyle -{ -public: - using QProxyStyle::QProxyStyle; - void HeaderProxyStyle::drawControl(ControlElement el, const QStyleOption *opt, QPainter *p, const QWidget *w) const override - { - // Header label? - if (el == CE_HeaderLabel) { - if (QStyleOptionHeader *header = qstyleoption_cast(const_cast(opt))) { - if (!header->icon.isNull()) - header->direction = header->direction == Qt::RightToLeft ? Qt::LeftToRight : Qt::RightToLeft; - } - } - QProxyStyle::drawControl(el, opt, p, w); - } - - int pixelMetric(PixelMetric metric, const QStyleOption *option = nullptr, const QWidget *widget = nullptr) const { - if (metric == PM_SmallIconSize) - return 14; - return QProxyStyle::pixelMetric(metric, option, widget); - } -}; - - -MultisortTableView::MultisortTableView ( QWidget *parent ) : - QTableView ( parent ), - m_isSortingEnabled(false), - m_proxyModel(new AlphanumSortProxyModel(this)), - m_modifier(Qt::ControlModifier) -{ - // Default icons - setSortIcons(QIcon(QStringLiteral("scale=1.5/arrow_drop_up.glyph")), QIcon(QStringLiteral("scale=1.5/arrow_drop_down.glyph"))); - horizontalHeader()->setDefaultAlignment( Qt::AlignHCenter | Qt::AlignTop ); - HeaderProxyStyle *proxy = new HeaderProxyStyle("Fusion"); - proxy->setParent(horizontalHeader()); - horizontalHeader()->setStyle(proxy); - horizontalHeader()->setSortIndicatorShown(false); - QTableView::setSortingEnabled(false); - - // Handler to sorting table - connect(horizontalHeader(), &QHeaderView::sectionClicked, this, &MultisortTableView::headerClicked); -} - -// Set icons to decorate sorted table headers -void MultisortTableView::setSortIcons ( QIcon ascIcon, QIcon descIcon ) -{ - m_proxyModel->setSortIcons( ascIcon, descIcon ); -} - -// Set key modifier to handle multicolumn sorting -void MultisortTableView::setModifier ( Qt::KeyboardModifier modifier ) -{ - m_modifier = modifier; -} - - -// Reimplemented to self handling of sorting enable state -void MultisortTableView::setSortingEnabled( bool enable ) -{ - m_isSortingEnabled = enable; -} - -// Reimplemented to use AlphanumSortProxyModel -void MultisortTableView::setModel( QAbstractItemModel *model ) -{ - if ( model ) { - m_proxyModel->setSourceModel( model ); - QTableView::setModel( m_proxyModel ); - } -} - -// Handler to sort table -void MultisortTableView::headerClicked ( int column ) -{ - if ( m_isSortingEnabled ) { - bool isModifierPressed = QApplication::keyboardModifiers() & m_modifier; - m_proxyModel->sortColumn( column, isModifierPressed ); - } -} diff --git a/src/WASimUI/multisort_view/MultisortTableView.h b/src/WASimUI/multisort_view/MultisortTableView.h deleted file mode 100644 index eeb2076..0000000 --- a/src/WASimUI/multisort_view/MultisortTableView.h +++ /dev/null @@ -1,38 +0,0 @@ -// Originally from https://github.com/dimkanovikov/MultisortTableView Licensed under GPL v3 - -#ifndef MULTISORTTABLEVIEW_H -#define MULTISORTTABLEVIEW_H - -#include - -class AlphanumSortProxyModel; -class QIcon; - -class MultisortTableView : public QTableView -{ - Q_OBJECT - -public: - explicit MultisortTableView ( QWidget *parent = 0 ); - - // Set icons to decorate sorted table headers - void setSortIcons ( QIcon ascIcon, QIcon descIcon ); - // Set key modifier to handle multicolumn sorting - void setModifier ( Qt::KeyboardModifier modifier ); - - virtual void setSortingEnabled ( bool enable ); - virtual void setModel ( QAbstractItemModel *model ); - -private: - // Sorting enable state - bool m_isSortingEnabled; - // ProxyModel to sorting columns - AlphanumSortProxyModel *m_proxyModel; - // Modifier to handle multicolumn sorting - Qt::KeyboardModifier m_modifier; - -private slots: - void headerClicked ( int column ); -}; - -#endif // MULTISORTTABLEVIEW_H diff --git a/src/WASimUI/multisort_view/README b/src/WASimUI/multisort_view/README deleted file mode 100644 index 5ad1c2e..0000000 --- a/src/WASimUI/multisort_view/README +++ /dev/null @@ -1,27 +0,0 @@ -MultisortTableView is a Qt widget inherits from QTableView, which can sort table by miltiple columns. -To sort data their use AlphanumSortProxyModel inherits from QSortFilterProxyModel and sort data by alphanum algorithm (for more information about algorithm see http://www.davekoelle.com/alphanum.html). - -To use MulrisortTableView in your project next files needed to be include: -AlphanumComparer.h - comparer, which use alphanum alghoritm to compare values; -AlphanumSortProxyModel.h - proxy model, that used to sort data in TableView with alphanum alghoritm; -ColumnsSorter.h - helper, which strore information about sorted columns; -MultisortTableView.h, MultisortTableView.cpp - TableView, which used ColumnsSorter class and AplhanumSortProxyModel to sort data by multiple columns. - -MultisortTableView add two functions to QTableView API: -void setSortIcons( QIcon ascIcon, QIcon descIcon ) - set icons of sorting order (by default used QStyle::SP_ArrowUp and QStyle::SP_ArrowDown icons); -void setModifier ( Qt::KeyboardModifier modifier ) - set modifier to handling multicolumn sorting (by default used ControlModifier). - -How to use: -// Create a table model -QSqlTableModel *users = new QSqlTableModel; -users->setTable( "users" ); -users->select( ); -// Create and customize widget -MultisortTableView tableView; -tableView.setModifier( Qt::ShiftModifier ); -tableView.setSortingEnabled( true ); -tableView.setSortIcons( QIcon(":/icons/bullet_arrow_up.png"), - QIcon(":/icons/bullet_arrow_down.png") ); -tableView.setSelectionBehavior( QAbstractItemView::SelectRows ); -tableView.setModel( users ); -tableView.show(); \ No newline at end of file diff --git a/src/WASimUI/widgets/CustomTableView.h b/src/WASimUI/widgets/CustomTableView.h index 6ce1e08..1d11c7f 100644 --- a/src/WASimUI/widgets/CustomTableView.h +++ b/src/WASimUI/widgets/CustomTableView.h @@ -24,7 +24,8 @@ and is also available at . #include //#include -#include "multisort_view/MultisortTableView.h" +#include "MultisortTableView.h" +#include "FilterTableHeader.h" namespace WASimUiNS { @@ -38,7 +39,8 @@ namespace WASimUiNS : MultisortTableView(parent), m_defaultFontSize{font().pointSize()}, m_headerToggleMenu{new QMenu(tr("Toggle table columns"), this)}, - m_fontSizeMenu{new QMenu(tr("Adjust table font size"), this)} + m_fontSizeMenu{new QMenu(tr("Adjust table font size"), this)}, + m_toggleFilterAction(new QAction(tr("Toggle column filters"), this)) { setObjectName(QStringLiteral("CustomTableView")); @@ -58,11 +60,19 @@ namespace WASimUiNS verticalHeader()->setMinimumSectionSize(10); adjustRowSize(); - setupHeader(); + FilterTableHeader *hdr = new FilterTableHeader(this); + setHorizontalHeader(hdr); m_headerToggleMenu->setIcon(QIcon(QStringLiteral("view_column.glyph"))); m_fontSizeMenu->setIcon(QIcon(QStringLiteral("format_size.glyph"))); + QIcon fltIcon(QStringLiteral("filter_list_off.glyph")); + fltIcon.addFile(QStringLiteral("filter_list.glyph"), QSize(), QIcon::Normal, QIcon::On); + m_toggleFilterAction->setIcon(fltIcon); + m_toggleFilterAction->setCheckable(true); + m_toggleFilterAction->setChecked(hdr->areFiltersVisible()); + connect(m_toggleFilterAction, &QAction::toggled, this, &CustomTableView::setFiltersVisible); + QAction *plusAct = m_fontSizeMenu->addAction(QIcon("arrow_upward.glyph"), tr("Increase font size"), this, &CustomTableView::fontSizeInc); plusAct->setShortcuts({ QKeySequence::ZoomIn, QKeySequence(Qt::ControlModifier | Qt::Key_Equal) }); m_fontSizeMenu->addAction(QIcon("restart_alt.glyph"), tr("Reset font size"), this, &CustomTableView::fontSizeReset, QKeySequence(Qt::ControlModifier | Qt::Key_0)); @@ -72,23 +82,42 @@ namespace WASimUiNS QHeaderView *header() const { return horizontalHeader(); } QAction *columnToggleMenuAction() const { return m_headerToggleMenu->menuAction(); } QAction *fontSizeMenuAction() const { return m_fontSizeMenu->menuAction(); } + QAction *filterToggleAction() const { return m_toggleFilterAction; } + QMenu *actionsMenu(QWidget *parent) + { + QMenu *menu = new QMenu(tr("Table Header Options"), parent); + menu->setIcon(QIcon(QStringLiteral("table_rows.glyph"))); + menu->addAction(filterToggleAction()); + menu->addSeparator(); + menu->addAction(columnToggleMenuAction()); + menu->addSeparator(); + menu->addAction(fontSizeMenuAction()); + return menu; + } + + bool areFiltersVisible() const { + if (FilterTableHeader *fth = qobject_cast(header())) + return fth->areFiltersVisible(); + return false; + } QByteArray saveState() const { QByteArray state; QDataStream ds(&state, QIODevice::WriteOnly); ds << header()->saveState(); ds << font().pointSize(); + ds << areFiltersVisible(); return state; } public Q_SLOTS: - void setModel(QAbstractItemModel *model) + void setModel(QAbstractItemModel *model) override { MultisortTableView::setModel(model); buildHeaderActions(); } - void setHorizontalHeader(QHeaderView *hdr) + void setHorizontalHeader(QHeaderView *hdr) override { MultisortTableView::setHorizontalHeader(hdr); setupHeader(); @@ -96,6 +125,27 @@ namespace WASimUiNS void moveColumn(int from, int to) const { horizontalHeader()->moveSection(from, to); } + void setFiltersVisible(bool en = true) { + if (FilterTableHeader *fth = qobject_cast(header())) + fth->setFiltersVisible(en); + m_toggleFilterAction->setChecked(en); + } + + void setFilterFocus(int column) { + if (FilterTableHeader *fth = qobject_cast(header())) + fth->setFocusColumn(column); + } + + void setFilterText(int column, const QString& value){ + if (FilterTableHeader *fth = qobject_cast(header())) + fth->setFilter(column, value); + } + + void clearFilters() { + if (FilterTableHeader *fth = qobject_cast(header())) + fth->clearFilters(); + } + void setFontSize(int pointSize) { QFont f = font(); if (pointSize > 3 && f.pointSize() != pointSize) { @@ -129,6 +179,7 @@ namespace WASimUiNS QByteArray hdrState; int fontSize = m_defaultFontSize; + bool fltVis = areFiltersVisible(); QHeaderView *hdr = horizontalHeader(); QDataStream ds(state); @@ -136,6 +187,8 @@ namespace WASimUiNS ds >> hdrState; if (!ds.atEnd()) ds >> fontSize; + if (!ds.atEnd()) + ds >> fltVis; } else { hdrState = state; @@ -143,13 +196,18 @@ namespace WASimUiNS setFontSize(fontSize); hdr->restoreState(hdrState); + setFiltersVisible(fltVis); for (int i = 0; i < model()->columnCount() && i < hdr->actions().length(); ++i) hdr->actions().at(i)->setChecked(!hdr->isSectionHidden(i)); - hdr->setSortIndicatorShown(false); + hdr->setSortIndicatorShown(false); // in case it was saved as "shown" for some reason + setSortingEnabled(isSortingEnabled()); // update sort indicator return true; } + Q_SIGNALS: + void filterChanged(int column, QString value); + private: void setupHeader() @@ -159,7 +217,6 @@ namespace WASimUiNS hdr->setMinimumSectionSize(20); hdr->setDefaultSectionSize(80); hdr->setHighlightSections(false); - hdr->setSortIndicatorShown(false); hdr->setStretchLastSection(true); hdr->setSectionsMovable(true); hdr->setSectionResizeMode(QHeaderView::Interactive); @@ -178,6 +235,8 @@ namespace WASimUiNS hdr->setFont(f); connect(hdr, &QHeaderView::sectionCountChanged, this, &CustomTableView::onSectionCountChanged, Qt::QueuedConnection); + if (FilterTableHeader *fth = qobject_cast(hdr)) + connect(fth, &FilterTableHeader::filterChanged, this, &CustomTableView::setDisplayRoleStringFilter); } void onSectionCountChanged(int oldCnt, int newCnt) @@ -211,18 +270,25 @@ namespace WASimUiNS return; QHeaderView *hdr = horizontalHeader(); + hdr->removeAction(m_toggleFilterAction); + for (int i=0; i < model()->columnCount(); ++i) { - QAction *act = m_headerToggleMenu->addAction(model()->headerData(i, Qt::Horizontal).toString(), this, &CustomTableView::onHeaderToggled); + QAction *act = m_headerToggleMenu->addAction(model()->headerData(i, Qt::Horizontal, Qt::EditRole).toString(), this, &CustomTableView::onHeaderToggled); act->setCheckable(true); act->setChecked(!hdr->isSectionHidden(i)); act->setProperty("col", i); } hdr->addActions(m_headerToggleMenu->actions()); + hdr->addAction(m_toggleFilterAction); + + if (FilterTableHeader *fth = qobject_cast(hdr)) + fth->generateFilters(model()->columnCount()); } int m_defaultFontSize; QMenu *m_headerToggleMenu; QMenu *m_fontSizeMenu; + QAction *m_toggleFilterAction; }; } diff --git a/src/WASimUI/widgets/FilterTableHeader.cpp b/src/WASimUI/widgets/FilterTableHeader.cpp index 9db4c3e..e9c067a 100644 --- a/src/WASimUI/widgets/FilterTableHeader.cpp +++ b/src/WASimUI/widgets/FilterTableHeader.cpp @@ -26,103 +26,114 @@ and is also available at . #include FilterTableHeader::FilterTableHeader(QTableView* parent) : - QHeaderView(Qt::Horizontal, parent) + QHeaderView(Qt::Horizontal, parent) { - // Do some connects: Basically just resize and reposition the input widgets whenever anything changes - connect(this, &FilterTableHeader::sectionResized, this, &FilterTableHeader::adjustPositions); - connect(this, &FilterTableHeader::sectionClicked, this, &FilterTableHeader::adjustPositions); - connect(parent->horizontalScrollBar(), &QScrollBar::valueChanged, this, &FilterTableHeader::adjustPositions); - connect(parent->verticalScrollBar(), &QScrollBar::valueChanged, this, &FilterTableHeader::adjustPositions); + // Do some connects: Basically just resize and reposition the input widgets whenever anything changes + connect(this, &FilterTableHeader::sectionResized, this, &FilterTableHeader::adjustPositions); + connect(this, &FilterTableHeader::sectionClicked, this, &FilterTableHeader::adjustPositions); + connect(parent->horizontalScrollBar(), &QScrollBar::valueChanged, this, &FilterTableHeader::adjustPositions); + connect(parent->verticalScrollBar(), &QScrollBar::valueChanged, this, &FilterTableHeader::adjustPositions); } void FilterTableHeader::generateFilters(int number) { - // Delete all the current filter widgets - qDeleteAll(filterWidgets); - filterWidgets.clear(); - - // And generate a bunch of new ones - for(int i=0; i < number; ++i) - { - FilterLineEdit* l = new FilterLineEdit(this, &filterWidgets, i); - l->setVisible(true); - // Set as focus proxy the first non-row-id visible filter-line. - if(!i) - setFocusProxy(l); - connect(l, &FilterLineEdit::delayedTextChanged, this, &FilterTableHeader::inputChanged); - connect(l, &FilterLineEdit::filterFocused, this, &FilterTableHeader::filterFocused); - filterWidgets.push_back(l); - } - - // Position them correctly - updateGeometries(); + // Delete all the current filter widgets + qDeleteAll(filterWidgets); + filterWidgets.clear(); + + // And generate a bunch of new ones + for(int i=0; i < number; ++i) + { + FilterLineEdit* l = new FilterLineEdit(this, &filterWidgets, i); + l->setVisible(m_filtersVisible); + // Set as focus proxy the first non-row-id visible filter-line. + if(!i) + setFocusProxy(l); + connect(l, &FilterLineEdit::delayedTextChanged, this, &FilterTableHeader::inputChanged); + connect(l, &FilterLineEdit::filterFocused, this, &FilterTableHeader::filterFocused); + filterWidgets.push_back(l); + } + + // Position them correctly + updateGeometries(); } QSize FilterTableHeader::sizeHint() const { - // For the size hint just take the value of the standard implementation and add the height of a input widget to it if necessary - QSize s = QHeaderView::sizeHint(); - if(filterWidgets.size()) - s.setHeight(s.height() + filterWidgets.at(0)->sizeHint().height() + 4); // The 4 adds just adds some extra space - return s; + // For the size hint just take the value of the standard implementation and add the height of a input widget to it if necessary + QSize s = QHeaderView::sizeHint(); + if(m_filtersVisible && filterWidgets.size()) + s.setHeight(s.height() + filterWidgets.at(0)->sizeHint().height() + 4); // The 4 adds just adds some extra space + return s; } void FilterTableHeader::updateGeometries() { - // If there are any input widgets add a viewport margin to the header to generate some empty space for them which is not affected by scrolling - if(filterWidgets.size()) - setViewportMargins(0, 0, 0, filterWidgets.at(0)->sizeHint().height()); - else - setViewportMargins(0, 0, 0, 0); - - // Now just call the parent implementation and reposition the input widgets - QHeaderView::updateGeometries(); - adjustPositions(); + // If there are any input widgets add a viewport margin to the header to generate some empty space for them which is not affected by scrolling + if(m_filtersVisible && filterWidgets.size()) + setViewportMargins(0, 0, 0, filterWidgets.at(0)->sizeHint().height()); + else + setViewportMargins(0, 0, 0, 0); + + // Now just call the parent implementation and reposition the input widgets + QHeaderView::updateGeometries(); + adjustPositions(); } void FilterTableHeader::adjustPositions() { - // The two adds some extra space between the header label and the input widget - const int y = QHeaderView::sizeHint().height() + 2; - // Loop through all widgets - for(int i=0;i < static_cast(filterWidgets.size()); ++i) - { - // Get the current widget, move it and resize it - QWidget* w = filterWidgets.at((size_t)i); - if (QApplication::layoutDirection() == Qt::RightToLeft) - w->move(width() - (sectionPosition(i) + sectionSize(i) - offset()), y); - else - w->move(sectionPosition(i) - offset(), y); - w->resize(sectionSize(i), w->sizeHint().height()); - } + // The two adds some extra space between the header label and the input widget + const int y = QHeaderView::sizeHint().height() + 2; + // Loop through all widgets + for(int i=0, e = (int)filterWidgets.size(); i < e; ++i) { + // Get the current widget, move it and resize it + QWidget* w = filterWidgets.at((size_t)i); + if (QApplication::layoutDirection() == Qt::RightToLeft) + w->move(width() - (sectionPosition(i) + sectionSize(i) - offset()), y); + else + w->move(sectionPosition(i) - offset(), y); + w->resize(sectionSize(i), w->sizeHint().height()); + } } void FilterTableHeader::inputChanged(int col, const QString& new_value) { - //adjustPositions(); - // Just get the column number and the new value and send them to anybody interested in filter changes - emit filterChanged(col, new_value); + //adjustPositions(); + // Just get the column number and the new value and send them to anybody interested in filter changes + emit filterChanged(col, new_value); } void FilterTableHeader::clearFilters() { - for(FilterLineEdit* filterLineEdit : filterWidgets) - filterLineEdit->clear(); + for(FilterLineEdit* filterLineEdit : filterWidgets) + filterLineEdit->clear(); } void FilterTableHeader::setFilter(int column, const QString& value) { - if(column < filterWidgets.size()) - filterWidgets.at(column)->setText(value); + if(column < filterWidgets.size()) + filterWidgets.at(column)->setText(value); } QString FilterTableHeader::filterValue(int column) const { - return filterWidgets[column]->text(); + return filterWidgets[column]->text(); } void FilterTableHeader::setFocusColumn(int column) { - if(column < filterWidgets.size()) - filterWidgets.at(column)->setFocus(Qt::FocusReason::TabFocusReason); + if(column < filterWidgets.size()) + filterWidgets.at(column)->setFocus(Qt::FocusReason::TabFocusReason); +} + +void FilterTableHeader::setFiltersVisible(bool visible) +{ + if (m_filtersVisible == visible) + return; + m_filtersVisible = visible; + for(FilterLineEdit* filterLineEdit : filterWidgets) + filterLineEdit->setVisible(visible); + if (!visible) + clearFilters(); + updateGeometries(); } diff --git a/src/WASimUI/widgets/FilterTableHeader.h b/src/WASimUI/widgets/FilterTableHeader.h index d9c229a..34c3014 100644 --- a/src/WASimUI/widgets/FilterTableHeader.h +++ b/src/WASimUI/widgets/FilterTableHeader.h @@ -29,33 +29,36 @@ class FilterLineEdit; class FilterTableHeader : public QHeaderView { - Q_OBJECT + Q_OBJECT public: - explicit FilterTableHeader(QTableView* parent = nullptr); - QSize sizeHint() const override; - bool hasFilters() const {return (filterWidgets.size() > 0);} - QString filterValue(int column) const; - -public slots: - void generateFilters(int number); - void adjustPositions(); - void clearFilters(); - void setFilter(int column, const QString& value); + explicit FilterTableHeader(QTableView* parent = nullptr); + QSize sizeHint() const override; + bool hasFilters() const { return (filterWidgets.size() > 0); } + bool areFiltersVisible() const { return m_filtersVisible; } + QString filterValue(int column) const; + + public Q_SLOTS: + void generateFilters(int number); + void adjustPositions(); + void clearFilters(); + void setFilter(int column, const QString& value); void setFocusColumn(int column); + void setFiltersVisible(bool visible = true); -signals: - void filterChanged(int column, QString value); - void filterFocused(); + Q_SIGNALS: + void filterChanged(int column, QString value); + void filterFocused(); -protected: - void updateGeometries() override; + protected: + void updateGeometries() override; -private slots: - void inputChanged(int col, const QString& new_value); + private Q_SLOTS: + void inputChanged(int col, const QString& new_value); -private: - std::vector filterWidgets; + private: + std::vector filterWidgets {}; + bool m_filtersVisible = false; }; #endif diff --git a/src/WASimUI/widgets/MultisortTableView.h b/src/WASimUI/widgets/MultisortTableView.h new file mode 100644 index 0000000..5771a7a --- /dev/null +++ b/src/WASimUI/widgets/MultisortTableView.h @@ -0,0 +1,112 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +Original version from ; Used under GPL v3 license; Modifications applied. + +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + +#pragma once + +#include +#include +#include +#include + +#include "MultiColumnProxyModel.h" + +class MultisortTableView : public QTableView +{ + Q_OBJECT + public: + explicit MultisortTableView(QWidget *parent = 0) : + QTableView(parent), + m_isSortingEnabled(false), + m_proxyModel(new MultiColumnProxyModel(this)), + m_modifier(Qt::ControlModifier) + { + QTableView::setSortingEnabled(false); // we do our own sorting + setupHeader(); + } + + /// Overrides parent method for internal handling of sorting state. + bool isSortingEnabled() const { return m_isSortingEnabled; } + /// Returns the custom proxy model used for sorting and filtering. + MultiColumnProxyModel *proxyModel() const { return m_proxyModel; } + + public Q_SLOTS: + /// Set key modifier to handle multicolumn sorting. + void setModifier(Qt::KeyboardModifier modifier) { m_modifier = modifier; } + + /// Overrides parent method for internal handling of sorting state. + void setSortingEnabled(bool enable) { + m_isSortingEnabled = enable; + if (enable) + m_proxyModel->sortColumn(horizontalHeader()->sortIndicatorSection(), false, (qint8)horizontalHeader()->sortIndicatorOrder()); + } + + virtual void setStringFilter(int col, QString pattern, int role = Qt::DisplayRole) { + m_proxyModel->setStringPatternFilter(col, pattern, role); + } + + virtual void setDisplayRoleStringFilter(int col, QString pattern) { + m_proxyModel->setStringPatternFilter(col, pattern, Qt::DisplayRole); + } + + virtual void setRegExpFilter(int col, const QRegExp& pattern, int role = Qt::DisplayRole) { + m_proxyModel->setRegExpFilter(col, pattern, Qt::DisplayRole); + } + + /// Overrides parent method for hooking into header's clicked signal and remove default sort indicator. + virtual void setHorizontalHeader(QHeaderView *hdr) { + QTableView::setHorizontalHeader(hdr); + setupHeader(); + } + + /// Overridden to use custom sorting model; + virtual void setModel(QAbstractItemModel *model) { + m_proxyModel->setSourceModel(model); + QTableView::setModel(m_proxyModel); + } + + private Q_SLOTS: + void onHeaderSectionrClicked(int column) { + if (m_isSortingEnabled) + m_proxyModel->sortColumn(column, (QApplication::keyboardModifiers() & m_modifier)); + //qDebug() << column << (Qt::SortOrder)m_proxyModel->columnSortOrder(column); + } + void onSortIndicatorChanged(int column, Qt::SortOrder order) { + //if (m_isSortingEnabled) + //m_proxyModel->sortColumn(column, (QApplication::keyboardModifiers() & m_modifier), (qint8)order); + //qDebug() << column << horizontalHeader()->sortIndicatorSection() << horizontalHeader()->sortIndicatorOrder(); + } + + void setupHeader() { + horizontalHeader()->setSortIndicatorShown(false); // we provide our own indicators + horizontalHeader()->setSectionsClickable(true); // make sure to receive click events so we can sort + connect(horizontalHeader(), &QHeaderView::sectionClicked, this, &MultisortTableView::onHeaderSectionrClicked); + //connect(horizontalHeader(), &QHeaderView::sortIndicatorChanged, this, &MultisortTableView::onSortIndicatorChanged); + } + + private: + // Sorting enable state + bool m_isSortingEnabled; + // ProxyModel to sorting columns + MultiColumnProxyModel *m_proxyModel; + // Modifier to handle multicolumn sorting + Qt::KeyboardModifier m_modifier; + +}; From b85a94c25086f46c5a9a9e776858a16690e04d19 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Wed, 1 Nov 2023 13:51:54 -0400 Subject: [PATCH 51/65] [WASimUI][RequestsExport] Change line edits to editable combo boxes in bulk update form; Add non-regex search/replace option. --- src/WASimUI/RequestsExport.cpp | 54 +++--- src/WASimUI/RequestsExport.ui | 329 ++++++++++++++++++++++----------- 2 files changed, 247 insertions(+), 136 deletions(-) diff --git a/src/WASimUI/RequestsExport.cpp b/src/WASimUI/RequestsExport.cpp index 50bd51b..bf639d0 100644 --- a/src/WASimUI/RequestsExport.cpp +++ b/src/WASimUI/RequestsExport.cpp @@ -31,9 +31,9 @@ using namespace WASimUiNS; static bool editFormEmpty(const Ui::RequestsExport ui) { return ui.cbDefaultCategory->currentText().isEmpty() && - ui.leIdPrefix->text().isEmpty() && - ui.leFormat->text().isEmpty() && - ui.leDefault->text().isEmpty() && + ui.cbIdPrefix->currentText().isEmpty() && + ui.cbFormat->currentText().isEmpty() && + ui.cbDefault->currentText().isEmpty() && (ui.cbReplWhat->currentText().isEmpty() || ui.cbReplCol->currentData().toInt() < 0); } @@ -62,11 +62,17 @@ RequestsExportWidget::RequestsExportWidget(RequestsModel *model, QWidget *parent const auto &cats = RequestsFormat::categoriesList(); ui.cbDefaultCategory->addItems(cats.values(), cats.keys()); + ui.cbIdPrefix->setClearButtonEnabled(); + ui.cbFormat->setClearButtonEnabled(); + ui.cbDefault->setClearButtonEnabled(); + ui.cbReplCol->addItem("", -1); for (int i = RequestsModel::COL_FIRST_META; i <= RequestsModel::COL_LAST_META; ++i) if (i != RequestsModel::COL_META_CAT) ui.cbReplCol->addItem(m_model->columnNames[i], i); + ui.cvReplType->addItem(tr("Replace"), 0); + ui.cvReplType->addItem(tr("Repl. Regex"), 1); ui.cbReplWhat->setPlaceholderText(tr("Search for...")); ui.cbReplWhat->setClearButtonEnabled(); ui.cbReplWith->setPlaceholderText(tr("Replace with...")); @@ -107,18 +113,16 @@ RequestsExportWidget::RequestsExportWidget(RequestsModel *model, QWidget *parent //ui.pbRegen->setHidden(true); addAction(updateMenu->menuAction()); - addAction(ui.tableView->columnToggleMenuAction()); - addAction(ui.tableView->fontSizeMenuAction()); + addAction(ui.tableView->actionsMenu(this)->menuAction()); connect(ui.cbDefaultCategory, &DataComboBox::currentDataChanged, this, [&]() { toggleEditFormBtn(ui); }); - connect(ui.leIdPrefix, &QLineEdit::textChanged, this, [&]() { toggleEditFormBtn(ui); }); - connect(ui.leFormat, &QLineEdit::textChanged, this, [&]() { toggleEditFormBtn(ui); }); - connect(ui.leDefault, &QLineEdit::textChanged, this, [&]() { toggleEditFormBtn(ui); }); + connect(ui.cbIdPrefix, &QComboBox::currentTextChanged, this, [&]() { toggleEditFormBtn(ui); }); + connect(ui.cbFormat, &QComboBox::currentTextChanged, this, [&]() { toggleEditFormBtn(ui); }); + connect(ui.cbDefault, &QComboBox::currentTextChanged, this, [&]() { toggleEditFormBtn(ui); }); connect(ui.cbReplCol, &DataComboBox::currentDataChanged, this, [&]() { toggleEditFormBtn(ui); }); connect(ui.cbReplWhat, &QComboBox::currentTextChanged, this, [&]() { toggleEditFormBtn(ui); }); loadSettings(); - } void RequestsExportWidget::setModel(RequestsModel *model) { @@ -183,9 +187,9 @@ void RequestsExportWidget::updateBulk() if (list.isEmpty() || editFormEmpty(ui)) return; QString cid = ui.cbDefaultCategory->currentData().toString(); - QString idp = ui.leIdPrefix->text(); - QString fmt = ui.leFormat->text(); - QString def = ui.leDefault->text(); + QString idp = ui.cbIdPrefix->currentText(); + QString fmt = ui.cbFormat->currentText(); + QString def = ui.cbDefault->currentText(); for (const QModelIndex &r : list) { if (!cid.isEmpty()) { @@ -222,7 +226,10 @@ void RequestsExportWidget::updateBulk() const QModelIndex col = m_model->index(r.row(), ui.cbReplCol->currentData().toInt()); if (col.isValid()) { QString val = m_model->data(col, Qt::EditRole).toString(); - val.replace(QRegularExpression(ui.cbReplWhat->currentText()), ui.cbReplWith->currentText()); + if (ui.cvReplType->currentData().toInt() == 0) + val.replace(ui.cbReplWhat->currentText(), ui.cbReplWith->currentText()); + else + val.replace(QRegularExpression(ui.cbReplWhat->currentText()), ui.cbReplWith->currentText()); m_model->setData(col, val, Qt::EditRole); m_model->setData(col, val, Qt::ToolTipRole); } @@ -277,9 +284,9 @@ void RequestsExportWidget::ensureDefaultValues() void RequestsExportWidget::clearForm() { ui.cbDefaultCategory->setCurrentIndex(0); - ui.leIdPrefix->clear(); - ui.leDefault->clear(); - ui.leFormat->clear(); + ui.cbIdPrefix->clear(); + ui.cbDefault->clear(); + ui.cbFormat->clear(); ui.cbReplCol->setCurrentData(-1); toggleEditFormBtn(ui); } @@ -290,8 +297,9 @@ void RequestsExportWidget::saveSettings() const s.beginGroup(objectName()); s.setValue(QStringLiteral("windowGeo"), saveGeometry()); s.setValue(QStringLiteral("tableViewState"), ui.tableView->saveState()); - s.setValue(ui.cbReplWhat->objectName(), ui.cbReplWhat->editedItems()); - s.setValue(ui.cbReplWith->objectName(), ui.cbReplWhat->editedItems()); + const QList editable = findChildren(); + for (DeletableItemsComboBox *cb : editable) + s.setValue(cb->objectName(), cb->saveState()); s.endGroup(); } @@ -299,12 +307,10 @@ void RequestsExportWidget::loadSettings() { QSettings s; s.beginGroup(objectName()); - if (s.contains(QStringLiteral("windowGeo"))) - restoreGeometry(s.value(QStringLiteral("windowGeo")).toByteArray()); + restoreGeometry(s.value(QStringLiteral("windowGeo")).toByteArray()); ui.tableView->restoreState(s.value(QStringLiteral("tableViewState")).toByteArray()); - if (s.contains(ui.cbReplWhat->objectName())) - ui.cbReplWhat->insertEditedItems(s.value(ui.cbReplWhat->objectName()).toStringList()); - if (s.contains(ui.cbReplWith->objectName())) - ui.cbReplWith->insertEditedItems(s.value(ui.cbReplWith->objectName()).toStringList()); + const QList editable = findChildren(); + for (DeletableItemsComboBox *cb : editable) + cb->restoreState(s.value(cb->objectName()).toByteArray()); s.endGroup(); } diff --git a/src/WASimUI/RequestsExport.ui b/src/WASimUI/RequestsExport.ui index 62eb141..d7cb32e 100644 --- a/src/WASimUI/RequestsExport.ui +++ b/src/WASimUI/RequestsExport.ui @@ -6,7 +6,7 @@ 0 0 - 1288 + 1290 760 @@ -32,7 +32,7 @@ - + 0 0 @@ -44,24 +44,6 @@ 8 - - - - Format - - - - - - - <p>Set the formatting string on selected item(s).</p> -<p>To set the formatting to an empty value, enter two single or double quotes (<tt>''</tt> or <tt>&quot;&quot;</tt>).</p> - - - true - - - @@ -112,51 +94,21 @@ - - - - Category - - - - - - - ID Prefix - - - - Sets the sorting category on each selected item. - - - - - - - <p>Set the default value on selected item(s).</p> -<p>To clear the default value, enter two single or double quotes (<tt>''</tt> or <tt>&quot;&quot;</tt>).</p> - - - true - - - - - - - <p>Prepends the prefix to the ID of each selected item.</p> -<p>To remove a prefix (any string at the beginning of an ID), start the value here with a exclamation mark (<tt>!</tt>). For example: <tt>!MyPrefix_</tt></p> - - - true + <p>Sets the plugin display category on each selected item.</p> - + + + + 0 + 0 + + Default Value @@ -168,7 +120,7 @@ 0 - + 0 @@ -178,6 +130,9 @@ In + + cbReplCol + @@ -194,20 +149,20 @@ - + - + 0 0 - - replace + + Select the syntax for search/replace, exact match or regular expression. - + 0 @@ -221,7 +176,8 @@ - <p>What to replace. The comparison is case-sensitive. You may use regular expression syntax here.</p><p>Press Enter when done to save your entry for later selection. Saved items can be removed by right-clicking on them while the list is open.</p> + <p>What to replace. The comparison is case-sensitive. Select "Replace Regex" in the preceeding option to use regular expression syntax here and backreferences in the replacement value.</p> +<p>Press Enter when done to save your entry for later selection. Saved items can be removed by right-clicking on them while the list is open.</p> QComboBox::InsertAlphabetically @@ -232,7 +188,7 @@ - + 0 @@ -242,10 +198,13 @@ with + + cbReplWith + - + 0 @@ -259,7 +218,8 @@ - <p>Replacement string. Use backslashes (<tt>&#92;</tt>) for regular expression capture references, for example: <tt">&#92;1</tt> for first capture group.</p><p>Press Enter when done to save your entry for later selection. Saved items can be removed by right-clicking on them while the list is open.</p> + <p>Replacement string. Use backslashes (<tt>&#92;</tt>) for regular expression capture references, for example: <tt">&#92;1</tt> for first capture group.</p> +<p>Press Enter when done to save your entry for later selection. Saved items can be removed by right-clicking on them while the list is open.</p> QComboBox::InsertAlphabetically @@ -271,51 +231,132 @@ + + + + + 0 + 0 + + + + ID Prefix + + + + + + + + 0 + 0 + + + + Format + + + + + + + + 0 + 0 + + + + Category + + + cbDefaultCategory + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + <p>Prepends the prefix to the ID of each selected item.</p> +<p>To remove a prefix (any string at the beginning of an ID), start the value here with a exclamation mark (<tt>!</tt>). For example: <tt>!MyPrefix_</tt></p> +<p>Press Enter when done to save your entry for later selection. Saved items can be removed by right-clicking on them while the list is open.</p> + + + QComboBox::InsertAlphabetically + + + QComboBox::AdjustToContents + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + <p>Set the formatting string on selected item(s).</p> +<p>To set the formatting to an empty value, enter two single or double quotes (<tt>''</tt> or <tt>&quot;&quot;</tt>).</p> +<p>Press Enter when done to save your entry for later selection. Saved items can be removed by right-clicking on them while the list is open.</p> + + + QComboBox::InsertAlphabetically + + + QComboBox::AdjustToContents + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + <p>Set the default value on selected item(s).</p> +<p>To clear the default value, enter two single or double quotes (<tt>''</tt> or <tt>&quot;&quot;</tt>).</p> +<p>Press Enter when done to save your entry for later selection. Saved items can be removed by right-clicking on them while the list is open.</p> + + + QComboBox::InsertAlphabetically + + + QComboBox::AdjustToContents + + + - - - - - 0 - 0 - - - - - Segoe UI - - - - <html><head/><body><p>The produced INI file can be used directly in the plugin as a &quot;Variables Definition&quot; file to provide custom states. See the wiki article <a href="https://github.com/mpaperno/MSFSTouchPortalPlugin/wiki/Using-Custom-States-and-Simulator-Variables">Using Custom States and Simulator Variables</a> for more information.</p><p>The <i>Category</i>, <i>Export ID</i>, <i>Display Name</i>, <i>Default</i>, and <i>Format</i> columns can be edited by clicking on the respective field in the table. Apply changes in bulk to selected table row(s) using the form on the left (hover over form fields for more details). <span style=" font-weight:600;">Note</span>: there is no way to undo bulk edits.</p><p>The records will be exported in the same order as in the table below (CTRL-click headings to sort by multiple columns).</p></body></html> - - - Qt::AutoText - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - true - - - 10 - - - -1 - - - true - - - Qt::LinksAccessibleByMouse - - - - - - @@ -383,6 +424,48 @@ + + + + + + + + 0 + 0 + + + + + Segoe UI + + + + <html><head/><body><p>The produced INI file can be used directly in the plugin as a &quot;Variables Definition&quot; file to provide custom states. See the wiki article <a href="https://github.com/mpaperno/MSFSTouchPortalPlugin/wiki/Using-Custom-States-and-Simulator-Variables">Using Custom States and Simulator Variables</a> for more information.</p><p>The <i>Category</i>, <i>Export ID</i>, <i>Display Name</i>, <i>Default</i>, and <i>Format</i> columns can be edited by clicking on the respective field in the table. Apply changes in bulk to selected table row(s) using the form on the left (hover over form fields for more details). <span style=" font-weight:600;">Note</span>: there is no way to undo bulk edits.</p><p>The records will be exported in the same order as in the table below (CTRL-click headings to sort by multiple columns).</p></body></html> + + + Qt::AutoText + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + 10 + + + -1 + + + true + + + Qt::LinksAccessibleByMouse + + + @@ -393,21 +476,43 @@
ActionPushButton.h
- WASimUiNS::DeletableItemsComboBox + DeletableItemsComboBox QComboBox -
Widgets.h
+
DeletableItemsComboBox.h
WASimUiNS::RequestsTableView QTableView
RequestsTableView.h
+ + BuddyLabel + QLabel +
BuddyLabel.h
+
DataComboBox QComboBox
DataComboBox.h
+ + pbCancel + pbExportSel + pbExportAll + cbDefaultCategory + cbFormat + cbIdPrefix + cbDefault + cbReplCol + cvReplType + cbReplWhat + cbReplWith + pbClearValues + pbRegen + pbSetValues + tableView + From 25d9292272db2f070db4ea350af00eb1bea2b0da Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Wed, 1 Nov 2023 13:53:12 -0400 Subject: [PATCH 52/65] [WASimUI][DocImports] Add initial version of SimConnect SDK docs reference browser. --- src/WASimUI/DocImports.h | 219 ++++++++++++++++ src/WASimUI/DocImportsBrowser.h | 242 ++++++++++++++++++ src/WASimUI/DocImportsBrowser.ui | 132 ++++++++++ src/WASimUI/WASimUI.vcxproj | 20 +- .../resources/MSFS_SDK_Doc_Import.sqlite3 | Bin 0 -> 1019904 bytes 5 files changed, 609 insertions(+), 4 deletions(-) create mode 100644 src/WASimUI/DocImports.h create mode 100644 src/WASimUI/DocImportsBrowser.h create mode 100644 src/WASimUI/DocImportsBrowser.ui create mode 100644 src/WASimUI/resources/MSFS_SDK_Doc_Import.sqlite3 diff --git a/src/WASimUI/DocImports.h b/src/WASimUI/DocImports.h new file mode 100644 index 0000000..6f1a094 --- /dev/null +++ b/src/WASimUI/DocImports.h @@ -0,0 +1,219 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Widgets.h" + +namespace WASimUiNS { + namespace DocImports +{ +Q_NAMESPACE + +const QString DB_NAME = QStringLiteral("MSFS_SDK_Doc_Import"); + +enum RecordType : quint8 { Unknown, SimVars, KeyEvents, SimVarUnits }; +Q_ENUM_NS(RecordType) + +static const QVector RecordTypeNames { + "Unknown", "Simulator Variables", "Key Events", "Variable Units" +}; +static QString recordTypeName(RecordType type) { + return RecordTypeNames.value(type, RecordTypeNames[RecordType::Unknown]); +} + +static bool createConnection() +{ + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", DB_NAME); + db.setDatabaseName(DB_NAME + ".sqlite3"); + db.setConnectOptions("QSQLITE_OPEN_READONLY;QSQLITE_ENABLE_SHARED_CACHE;QSQLITE_ENABLE_REGEXP=30"); + if (db.open()) + return true; + qDebug() << "Failed to open database" << DB_NAME << ".sqlite3 with:" << db.lastError(); + db = QSqlDatabase(); + QSqlDatabase::removeDatabase(DB_NAME); + return false; +} + +static QSqlDatabase getConnection(bool open = true) +{ + if (!QSqlDatabase::contains(DB_NAME)) { + if (!createConnection()) + return QSqlDatabase::database(); + } + return QSqlDatabase::database(DB_NAME, open); +} + + +// ---------------------------------------- +// DocImportsModel +// ---------------------------------------- + +class DocImportsModel : public QSqlTableModel +{ + Q_OBJECT + +public: + DocImportsModel(QObject *parent = nullptr, RecordType type = RecordType::Unknown) + : QSqlTableModel(parent, getConnection()) + { + setEditStrategy(QSqlTableModel::OnManualSubmit); + //qDebug() << "Opened DB" << database().connectionName() << database().databaseName(); + if (type != RecordType::Unknown) + setRecordType(type); + } + + RecordType recordType() const { return m_recordType; } + + QDateTime lastDataUpdate(RecordType type, QString *fromUrl = nullptr) + { + QDateTime lu; + if (type == RecordType::Unknown) + return lu; + + const QString table(QMetaEnum::fromType().valueToKey(type)); + QSqlQuery qry("SELECT LastUpdate, FromURL FROM ImportMeta WHERE TableName = ? ", database()); + qry.addBindValue(table); + qry.exec(); + if (qry.next()) { + lu = qry.value(0).toDateTime(); + lu.setTimeZone(QTimeZone::utc()); + lu = lu.toLocalTime(); + if (fromUrl) + *fromUrl = qry.value(1).toString(); + } + else if (qry.lastError().isValid()) { + qDebug() << "ImportMeta query for table" << table << "failed with:" << qry.lastError(); + } + else { + qDebug() << "ImportMeta query for table" << table << "returned no results."; + } + qry.finish(); + return lu; + } + + void setRecordType(RecordType type) + { + m_recordType = type; + QString table; + switch (type) { + case RecordType::SimVars: + case RecordType::KeyEvents: + case RecordType::SimVarUnits: + table = QString(QMetaEnum::fromType().valueToKey(type)); + break; + + default: + return; + } + + setTable(table); + select(); + + //setQuery("SELECT * FROM " + table, m_db); + if (lastError().isValid()) { + qDebug() << "Query for table" << table << "failed with: " << lastError(); + return; + } + + int col = fieldIndex("Multiplayer"); + if (col > -1) + removeColumn(col); + } + + Qt::ItemFlags flags(const QModelIndex &idx) const override { + if (idx.isValid()) + return Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemNeverHasChildren; + return Qt::NoItemFlags; + } + + QVariant data(const QModelIndex &idx, int role) const override { + if (role == Qt::ToolTipRole) + role = Qt::DisplayRole; + return QSqlTableModel::data(idx, role); + } + + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override { + if (role == Qt::EditRole || role == Qt::ToolTipRole) + return QSqlTableModel::headerData(section, orientation, Qt::DisplayRole); + return QSqlTableModel::headerData(section, orientation, role); + } + +private: + RecordType m_recordType = RecordType::Unknown; + +}; + + +// ---------------------------------------- +// NameCompleter +// ---------------------------------------- + +class NameCompleter : public QCompleter +{ + Q_OBJECT + +public: + NameCompleter(RecordType recordType, QObject *parent = nullptr) : + QCompleter(parent) + { + DocImportsModel *m = new DocImportsModel(this, recordType); + const int modelColumn = m->fieldIndex("Name"); + if (recordType != RecordType::SimVarUnits) + m->setFilter(QStringLiteral("Deprecated = 0")); + m->setSort(modelColumn, Qt::AscendingOrder); + + setModel(m); + setCompletionColumn(modelColumn); + setModelSorting(QCompleter::CaseSensitivelySortedModel); + setCompletionMode(QCompleter::PopupCompletion); + setCaseSensitivity(Qt::CaseInsensitive); + setFilterMode(Qt::MatchContains); + setMaxVisibleItems(12); + } + + DocImportsModel *model() const { return (DocImportsModel *)QCompleter::model(); } + +}; + + +// ---------------------------------------- +// RecordTypeComboBox +// ---------------------------------------- + +class RecordTypeComboBox : public EnumsComboBox +{ +public: + RecordTypeComboBox(QWidget *p = nullptr) : + EnumsComboBox(DocImports::RecordTypeNames, DocImports::RecordType::SimVars, p) { + setToolTip(tr("Select a documentation record type to browse.")); + } +}; + + } // DocImports +} // WASimUiNS diff --git a/src/WASimUI/DocImportsBrowser.h b/src/WASimUI/DocImportsBrowser.h new file mode 100644 index 0000000..de30a1d --- /dev/null +++ b/src/WASimUI/DocImportsBrowser.h @@ -0,0 +1,242 @@ +/* +This file is part of the WASimCommander project. +https://github.com/mpaperno/WASimCommander + +COPYRIGHT: (c) Maxim Paperno; All Rights Reserved. + +This file may be used under the terms of the GNU General Public License (GPL) +as published by the Free Software Foundation, either version 3 of the Licenses, +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. + +A copy of the GNU GPL is included with this project +and is also available at . +*/ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "ui_DocImportsBrowser.h" +#include "DocImports.h" +#include "FilterTableHeader.h" + +namespace WASimUiNS { + namespace DocImports +{ + +class DocImportsBrowser : public QWidget +{ + Q_OBJECT + +public: + enum ViewMode { FullViewMode, PopupViewMode }; + Q_ENUM(ViewMode) + + DocImportsBrowser(QWidget *parent = nullptr, RecordType type = RecordType::Unknown, ViewMode viewMode = ViewMode::FullViewMode) + : QWidget(parent), + m_model(new DocImportsModel(this)) + { + setObjectName(QStringLiteral("DocImportsBrowser")); + ui.setupUi(this); + + setWindowTitle(tr("SimConnect SDK Reference Browser")); + setContextMenuPolicy(Qt::ActionsContextMenu); + + ui.cbRecordType->setCurrentIndex(-1); + connect(ui.cbRecordType, &DataComboBox::currentDataChanged, this, &DocImportsBrowser::setRecordTypeVar); + + ui.tableView->setWordWrap(false); + ui.tableView->setEditTriggers(QTableView::NoEditTriggers); + ui.tableView->horizontalHeader()->setDefaultSectionSize(175); + ui.tableView->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); + ui.tableView->setFiltersVisible(true); + + ui.tableView->setModel(m_model); + + //addAction(ui.tableView->actionsMenu(this)->menuAction()); + addAction(ui.tableView->columnToggleMenuAction()); + addAction(ui.tableView->fontSizeMenuAction()); + addAction(ui.tableView->filterToggleAction()); + + QAction *closeAct = new QAction(QIcon("close.glyph"), tr("Close"), this); + closeAct->setShortcuts({ QKeySequence(Qt::Key_Escape), QKeySequence::Close }); + connect(closeAct, &QAction::triggered, this, &DocImportsBrowser::close); + addAction(closeAct); + + ui.textBrowser->document()->setIndentWidth(2.0); + ui.textBrowser->document()->setDefaultStyleSheet(QStringLiteral( + "dd { margin-bottom: 6px; }" + )); + + if (viewMode == ViewMode::FullViewMode) + connect(ui.tableView->selectionModel(), &QItemSelectionModel::currentRowChanged, this, &DocImportsBrowser::showRecordDetails); + else + connect(ui.tableView->selectionModel(), &QItemSelectionModel::currentRowChanged, this, &DocImportsBrowser::onCurrentRowChanged); + + connect(ui.tableView, &QTableView::doubleClicked, this, [=](const QModelIndex &idx) { + emit itemSelected(ui.tableView->proxyModel()->mapToSource(idx)); + }); + + setRecordType(type); + setViewMode(viewMode); + setInitialSize(); + loadSettings(); + } + + DocImportsModel *model() const { return m_model; } + +public Q_SLOTS: + void setViewMode(ViewMode mode) + { + m_viewMode = mode; + if (mode == ViewMode::FullViewMode) { + ui.cbRecordType->setVisible(true); + ui.toolbarLabel->setText(tr("Select Record Type:")); + ui.lblTitle->setText( + tr("Data is imported from SimConnect SDK web page documentation. Use the filters in each column to search.") + ); + } + else { + ui.cbRecordType->setVisible(false); + ui.toolbarLabel->setText("" + DocImports::recordTypeName(m_model->recordType()) + ""); + ui.lblTitle->setText( + tr("Double-click to select a record and return to the main window. Press Escape key to close. Use the filters to search.") + ); + } + } + + void setRecordType(RecordType type) + { + if (type == RecordType::Unknown || type == m_recordType) + return; + saveTypeSettings(); + m_recordType = type; + m_model->setRecordType(type); + if (ui.textBrowser->property("currentRow").isValid()) + ui.textBrowser->clear(); + loadTypeSettings(); + ui.lblLastUpdate->setText(tr("Data updated on:   %1").arg(m_model->lastDataUpdate(type).toString("d MMMM yyyy"))); + + if (!qobject_cast(sender())) + ui.cbRecordType->setCurrentData((int)type); + } + + void setRecordTypeVar(const QVariant &type) { setRecordType((RecordType)type.toUInt()); } + + void showRecordDetails(const QModelIndex &key) + { + if (m_model->recordType() == RecordType::Unknown || !key.isValid()) + return; + + const QAbstractItemModel *model = ui.tableView->model(); + QString sb(QStringLiteral("
")); + for (int i = 0; i < model->columnCount(); ++i) { + const QVariant &d = model->data(model->index(key.row(), i), Qt::DisplayRole); + if (d.isValid()) + sb.append(QStringLiteral("
%1
%2
").arg(model->headerData(i, Qt::Horizontal).toString(), toHtml(d.toString()))); + } + sb.append(QStringLiteral("
")); + ui.textBrowser->setHtml(sb); + ui.textBrowser->setProperty("currentRow", key.row()); + } + + void saveSettings() const + { + QSettings s; + s.beginGroup(objectName()); + s.beginGroup(QString(QMetaEnum::fromType().valueToKey(m_viewMode))); + s.setValue(QStringLiteral("windowGeo"), saveGeometry()); + s.setValue(QStringLiteral("splitterState"), ui.splitter->saveState()); + s.endGroup(); + s.endGroup(); + } + + void loadSettings() + { + QSettings s; + s.beginGroup(objectName()); + s.beginGroup(QString(QMetaEnum::fromType().valueToKey(m_viewMode))); + restoreGeometry(s.value(QStringLiteral("windowGeo")).toByteArray()); + ui.splitter->restoreState(s.value(QStringLiteral("splitterState")).toByteArray()); + s.endGroup(); + s.endGroup(); + } + + void saveTypeSettings() const + { + if (m_model->recordType() == RecordType::Unknown) + return; + QSettings s; + s.beginGroup(objectName()); + s.beginGroup(QString(QMetaEnum::fromType().valueToKey(m_recordType))); + s.setValue(QStringLiteral("tableViewState"), ui.tableView->saveState()); + s.endGroup(); + s.endGroup(); + } + + void loadTypeSettings() + { + if (m_model->recordType() == RecordType::Unknown) + return; + QSettings s; + s.beginGroup(objectName()); + s.beginGroup(QString(QMetaEnum::fromType().valueToKey(m_recordType))); + ui.tableView->restoreState(s.value(QStringLiteral("tableViewState")).toByteArray()); + s.endGroup(); + s.endGroup(); + } + +Q_SIGNALS: + void itemSelected(const QModelIndex &row); + +protected: + void closeEvent(QCloseEvent *ev) override { + saveSettings(); + saveTypeSettings(); + ev->accept(); + } + + void showEvent(QShowEvent *ev) override { + QWidget::showEvent(ev); + if (m_viewMode == ViewMode::PopupViewMode) + ui.tableView->setFilterFocus(2); + } + +private Q_SLOTS: + void onCurrentRowChanged(const QModelIndex &sel, const QModelIndex &prev) { + if (prev.isValid()) + showRecordDetails(sel); + } + + void setInitialSize() { + if (m_viewMode == ViewMode::PopupViewMode) + resize(width(), 300); + } + +private: + QString toHtml(const QVariant &txt) + { + QString ret(txt.toString().toHtmlEscaped()); + ret.replace('\n', "
"); + return ret; + } + + Ui::DocImportsBrowserClass ui; + DocImportsModel *m_model; + RecordType m_recordType = RecordType::Unknown; + ViewMode m_viewMode = ViewMode::FullViewMode; +}; + + } // DocImports +} // WASimUiNS diff --git a/src/WASimUI/DocImportsBrowser.ui b/src/WASimUI/DocImportsBrowser.ui new file mode 100644 index 0000000..a752754 --- /dev/null +++ b/src/WASimUI/DocImportsBrowser.ui @@ -0,0 +1,132 @@ + + + DocImportsBrowserClass + + + + 0 + 0 + 1170 + 660 + + + + DocImportsBrowser + + + + + + + 0 + + + 0 + + + + + Select Record Type: + + + cbRecordType + + + + + + + + + + + 0 + 0 + + + + + 8 + + + + Data is imported from SimConnect SDK web page documentation. Use the filters in each column to search (hover or r-click on them for details). + + + Qt::AlignCenter + + + + + + + + + + + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + 6 + + + + + QAbstractScrollArea::AdjustToContents + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p align="center" style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:9pt;">Select a record from the table to view details here.</span></p></body></html> + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + true + + + + + + + + + + BuddyLabel + QLabel +
BuddyLabel.h
+
+ + WASimUiNS::CustomTableView + QTableView +
CustomTableView.h
+
+ + WASimUiNS::DocImports::RecordTypeComboBox + QComboBox +
DocImports.h
+
+
+ + cbRecordType + tableView + textBrowser + + + +
diff --git a/src/WASimUI/WASimUI.vcxproj b/src/WASimUI/WASimUI.vcxproj index cf95336..ca46442 100644 --- a/src/WASimUI/WASimUI.vcxproj +++ b/src/WASimUI/WASimUI.vcxproj @@ -55,28 +55,28 @@
5.12.12_msvc2017_64 - core;gui;widgets + core;sql;gui;widgets debug false false 5.12.12_msvc2017_64 - core;gui;widgets + core;sql;gui;widgets release true false 5.12.12_msvc2017_64 - core;gui;widgets + core;sql;gui;widgets release true false 5.12.12_msvc2017_64 - core;gui;widgets + core;sql;gui;widgets debug true false @@ -270,6 +270,7 @@ + @@ -287,6 +288,15 @@ true true + + Document + + + true + true + true + true + true true @@ -314,6 +324,8 @@ + + diff --git a/src/WASimUI/resources/MSFS_SDK_Doc_Import.sqlite3 b/src/WASimUI/resources/MSFS_SDK_Doc_Import.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..bb0deac41a9f7cb05dc47c63b9d6204c53b3fdd5 GIT binary patch literal 1019904 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
  • YVM}%YHqVPW9@vsL_6mk1U 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∓3Pc+$l1GCy_)=O7RW3mf<0GC9!MkbN<>k-> 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 z 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 z z0H@jywYS>q>?QUB`&fG>=?2(gd!Oe1_t~zoonhP95j==57&X%$XOSzi8Iib{LJC+) zNh5E}y0G$!N<<5!v`7+F`_UQPKmpF`V}O#u&7BExO>jMhSlGu96U~|?SvN|O%Ah8= zjsh+4D2B-@bkW6Le|IDn>w`TMiF|+G)(Tw_)9qwKOR99`0h$ zou^W4u#he(>5SI_M)g2R30N>M*iKQ+cL$YqC`F>0kX6$5U|h(;68;LoJdL20SJa2L zOQW0}Bn7jh^YVi!YEwd##ci&*7_&VS!H@v5jy4)3XEDZ|9fH;XNt{Lw=LAWI?CAXb zU|sS$nHXJh+bk~R)bS-#E>Lq2_gcTIM5rf7s#!;qYSxV85suXfC+gvoHhLnTPen~+ zk|MIWI7UY$kunfFkM#6^#(DntD-#u{6SI?ngM0 z<752}#~1YT1({$<;&S#K#9d6MQ?5;edwfwmv(!5TLVldAd>8kNXRZHJwaiy)b|G1t}xpnmOdJ?_j3;k z9zby+H5}GtX)daCyGt5e6;vYLx|(9QIGKx#b)2n&a(fo4s5w~FxhQG9Z}ZbPtFe_- z^KYPWNjobw+9S^m+JX(NMvM-Q?^pPd&$?t{uHO-uL^B^hlqq7bzPYh2;Lpkp?i<|S zTtHV+gDuMOiKzb+O1(k+7$%I!Vn$lXdTB)1-bh+yqmFrr^-%aEMjsFgPRi2wfl`b= z@%=NK|BuuhuE|*9xY=Q~ucTS~Zq`oAlWn1Go^8C%YWB z!Fsv%OzTnBwY1w`Z!NXXuudSW07lDq%|W{5KPEp@`TO8X%)w~ZO^ehgQu!NsEGCoU zzZSX8LAvRmPC!wkmVFI~uKcGHP}G=ZUjw2W|LFu2HDcMffWqeB5~}BQ?MGy%sOK?* zr)Wv_(}+j3Fk|N^!Urc(&e9Yty-`$R5dX=6lq_sT<>_0uM4yXqu)(t6Vk(TRK(H!B zVZ@Yx)B`eAVK~qnITFUkS;7Y~Hb^1o=~Ea#jfja8hU3w{)9QPIlY@&WDHCU^Od?#7 z;*Lp*IN?bQT0Vz}c|w}+#E68izhd6UbSDW}pBt=aSwAzlsP9=Xot{e!J9&hq2~Xl% z{p{V{!8*QfbiBG#m$ZnISBmp)P8X?;qe{`ym0wzN&()A_!luqM(ob znHZ-wwSWFl4Ri%-$&>Lr;fdE%6s+0Fh){X71sC#l*|RcZHOnMPO?_*3PcNxSC>GY9 z>MLq|b!4fiIBrEt9MJ`wrEMG7K+4zJ#Z*f$%XmRYu$savsEu7KOFulRExS7!SG3Xa zt=qY>HGrR!d^L6Myi!+Xtvd^CR~{ESayWTP*7~3q84x>nPd`E~Sjk`+K&R1~3Wda^fzIZh)qXag3qV70F2!@f-x^Og7TGoNod37RbILA?=h$Ec#WVl! zD)(8tESlwiXEgWTWzj7AJEJ*smqoMm?~LY*T^3FE-xA7{?zKjBZ~_ceWGI>+QS{%*X=SWg-O9ygqA@EJ1nPwJ1W zncA(|68lA{F3b2jM# z{GI=le}TV~KZ`$Xd3Zlk**_ZzQsfWS9dTzOvfmiVk3ditUTh|d z4Vl3>>mLy>Ys+ajmzHkpU6u82re2TvhAk%=>|$^MzXGdhqCo;$d|gX67<&UW%%Cwx z7tJ;3KSHt4^pb8Yio;jD)!5zLN^fN4ZVWG?NaSP&b7BTaf@s zf?pU*$HySJTezO0Fg-JVOHL&pq6@o%YCa67=-v<6j1iYL`y3^fDMF0$l=CJ(Tt`J- z5PxGk8ZWm`XbD0Axjkj9OUn=Y`t=TLLiyp^e!at*MSi%ZU+=K0UKCzP?~J#{j>n@# zEm{Y`-Vj@zPiWEYt?PTp@@H;QxH`2b*!W56i8r+;*wsnui6^xu*s@9DNp`r3Jc*yW z%kqS6b~KQ-NZ+h*B|SZsPGIqo^NEU23qq%uY0iMZC$~DhfZm;$8MmkrsZEjGr=tsO zpN2(Qdbz&=LO{=QI^HY0SK=-x#<7Gu^FQ@j?%ktD-=J(6X@^Be> zIX*M)P*!4Is``lTFq(&@sbfdxFx)QI`K2tDHh*JBb=aNSOR`tdFE2|{dr1Z*`sHPD zYA?y+LqEJM4;PV_sftC-$4IfTJgg79$g6@>t%cNE(Ee8y-9n|pi5PiMX*q#*x_w$7 z-Z!7g-x=EmO(El&yvNNJTF%uDD^r{BVA1&xC^Ni)T|(dnOc4 zUp&i8?U_&@sXZ$S=aOfsT1)aQ3%J5LowM;)`6BsEGsRU=6lV<_#V^8rp>1LG~++h@PlEg zA=jYOzoNfdf0({TKY{y|dxpE1>*j{)uG7sVO@I$;TeN#>ULvo*pdh}Zf4|eeKj`1* z^zSqJ_Zt2Ckp8_v|6ZkkFVVl(>ECwx_cQ(5LI0+1YU3wP#3-2EJ@vPdz8imMZy@du zTJJ9Mx@t-qy!GyyO4pJFn^x0H`F1*z9rs+^AM?FSYJC;O3mS@Dl@&#Fg2*kMyEw3u zPMx$it)|&3+rBB;FWCM7B0k`kl4@6l{u zEzY~RKT5sqB#zrtQoN~!o*t7)I_Tpbi2I{d(1ly&VkeZN32GKL3~37`u&J4RnU#5H zGGFLW5;LY-*-%Sa?XHs2l;n+#iXdO8aH?jcvVt#gJ(dc}NvL z%AbY3bnLCFwW+(4ZJ*F)7t<-_tQ88VlWq!#Hs0I)fk0!6KW9@Dc|A6Hah14>>dG2i z&K&2`tW7KE`H_8jepc3|#=btgch;r@`ugn5tW6DleRf9Hrv3jPdv5~YR&ni*Te{NK zE&(Ql5JHrM5St|OmJpIO4T>$tN^Hq^NeCeb#g=0c+cK7&5Fi94!6uNlKv`N^AV6Pv zU7-uKKq+l_-S?H!zHX21r3-X_eU$(AoVoYv-YdzG9rOE?&*#sF7HekiIcLtCnK^Uj zoHOONU0Ho}N4afRR3E*p+_nwXN4J;Tw!Zr4rRBD*t3JA|+_ts3Gmc*3TCxP@BV2VF zB-HSuBhnfVHzm3|aoG-^xyFugGYM5kY9N#B748?9_aj=?mtb#lG#RYw8^QHvPqK&n z_4kmVq5=WN$i~3i21QC+)7zopW?6D8nVxw#g`K z47VdxDU#@H?1)5Soh@;qt1&rD))vE+>Yg-QEeT^V3(YMxlPdLwj%yO^@8d^tLUCw{`hhc|4XeK$r5W&+z7>-N3RW&^XzmoX3KN zWO)n=a*1Vl)6p(KTU;)*cx*CYn~!!*g)|spCxe#r9PPk>uU?cxW*&p|8GeMjo1*cA zNS(hW-rC)Uu~!~&*^(K+7?F{I?3&3j%3mY$=I`MeUcgZ!UaVz*+hxI?f#e{bLcs8k z?uA$cYax@p!Mac`c=Tc-r;_}B3=|lC6d?!t6J(Gz&LrOOE+7Veq`;t%v~ypuHdI?D z+$14YaJ*t4|WXbfPNp8#yr7Np6inJH^s)?AF&Y;^njRj#4>Ll!OhcQG zMgg#<(yWM<1W3txX})wYh~P?Cd*Subi|~xW44}144H_A&bu#97*oRmL)b5avp3-4PzXT#EspV`jUGPm`i($?(;-E z7TSIk&p|9%T8S5QigX!iP64|yGerAPS2)_55TBb(C}LR~O{ND_a%dr|19ZF!F=goH zBWEe->7#fYLTtGwqFs=USjM(QM+7?rix`w>0a~zdG!2b}ms*rUx$jTGonOgK8p?GX z4Z9kaG)8h)otRSsTWdVFG1`R^THTu3dCN}@?Se*8wX|k=uq<)$%&AQw6KiLy+U)3{F}Yde)!_B(N|N zC>q09iTGoL1JoV8hsFzw&><|D^u` z|3QD+zsbMKzrgSF{n_^e-*Ml&eQ)+7^ z`K0Gw&q2>l&qhy`$LD_0{SEhr+;_S6xvy}Cap&*G+kfA7ecJUw*E?J{xvq3=afMxr zTwdhyd*1n!^M2>;&KsRqJ9j%TcV6sla;|co@0{oKI{xl>!SPkc6OMN|ZgK2(Y;;sR zeDYtB!|(I*hvkRld*mbX)pEa_kS~@S<(2Xhd5-L{zheJ`{ipVC**|0dnEk!>d+oQ{ z_t-D9Z?IR{{$+d4_EFmnwzzGf^pf<1bi33qMP1dPW1efa>r|J=Wp}>n{0C_I9efdf z>imK8JI=2;zu5_=@9mj!!$Day;gE#PJ@- zL&#Hjx8pYS@J7c0$392aG3eOkNIJS5aYxkA=ve1i<)}mc!o`mHjyaA>htJ_~SOOoG z|0(}P{)7B$`33oh@^kVxd(M8XeaxP*r|muV9rmsEi|w235&L@kYI}oyxqXR!fqkxhmfdBS zY_Hh(g-$%kzB+U~a9X1fLcBL{5zY+2i& zZI>-++ivT|7)5Q3wsp2u$iBGLw%9h`Hpf9|40;dGr0h|9{{=fTQ_P^-=h5sl1@B6>)|EmA<{^R~n z_#a2k$;aG}xZmS`$bGN-Zuf2OTkuxJ0Z44tJ?P%$PP(_dyWMeQqil4qbFXsOxtF>Z zyXU*-xGUX0x5FtpD25(s@vmRNh6DNQTP`9*RuOODPF9l zF$mRi0 zICM1>z3_v+1}R+xy3XIf@SD^^X`}fY6L)$|OjHRlHmmrJX;gG*O)a0FBdruT^G-%= zKb4Bru$O`Ky&~V#(-=$76{%r=0O`A4I88-1c2P6iEcFLA1>5QB4{YeRN}n{3nlTBs z1==raI8XaUE$1k|m?T?J`$a9!)_zgTGnHRVqHVtRi(1areo@QW$}c9_c8>OoTAro- zqLybkrAngHc3MDMU~S?9C8a^IuQ)|oM8E45xwNlR3hLB?3+Pj{us8Un`BqKgmN(Cm zmW!V{wOQVDhE!uHW!+bfnu}S9m0LuyP#P0mE!d2^S|ir~m@BOY5TzKl+<0PRd<$!- z@~nB%x_liPMLTI$wM6Jq0$XB{6fIHy=O~n0joXQ8V81saI6!dJQT}= zHN`%}y^rHX4c--6BWw}Cz9Z-}RkY$JBHGj%vAnt1XPEC6P7e$bHo()KRks{1_8nx| za*H6+_+29cIAF@M+ccNZfVJ~k?6A1wYU5qw`Uf*bOW)?4l={w)!FS22Pc5-P#B7yFVW z%JLt@2!;obO7H{4KP7_iEk}`;z6|@~&b8!#_wR_*n5z ziQsn@Be+gS@Y{-iO9bChjNm$r;9G^{x52ElVOydjOs3V&t>JdNLx40^z>TlvQoC)o zR0kqTS{&(mM8mi6_37-X8JO%f^lzciMK`)=(OEatO7lRG(V$J-hbd9%2E zvAK|VTV*aJzAjxLMTk_825S>`mgbJg#oduuBg~Ov;O8m$i?^_!buOORD-Hm+#gR*R zWe+MsLNM|YC*aEp7tM8ciSGZ;s^~Vy|9{>cbY1QIx-;hZ0&e_|%j@i)w7YCKNPj_3 zK5nhHJcQf)ZlC1+wf9Tj_j}*!z1q9YyV@J{I*{A{%bwh#+=kaO_JRD2XCU?gWQJc$ zGG{8b&6>Li;fmpz4p(gNh&M)>$b_#2O_CeGdX>{YlIcnH(p}#$ZmmN(Pi{R2P3rTV z%s{|mdw63cu{jpsK!#w9YbUHyL3&XLHp7w3*sgxQomW$0Ds%I7{?{`=ODgc9CI_%#p&;02dBJ z6c*kOw*xLo^&)K(qCNM(XesQ6$!s>$lV%$xz8wqY9Jx1udFv|mPh8Icxf?s;-R&nH zOd7%9F7R_0<7m&VWm5Fn3}%*;(gSHAq%OAxV+iZ*yqO(C;Y)+dCtYN47uls@gQeU_ z$}lRixWOt!Ggk+3g+4Zbn;H?I8%)CxtOhXah8+ocR)cw~EA74gjc$bibnXLml}(ez`t%L1MAzS9$3GE zhe997bCU9?(olY2eFLWP%m&sI4Kp8DU;moYIaA8Vxw*R6l+Kw8tgi)~GaFd18~2$D ztgm@3$(*^s`p|1h=FA1wFMln`oVmdIWv?ZfGZ$FD^tB{&#f+WW&VMb*oT0#a94@P0 zOEPEZ3{2<$GvJDk%zrm|=Htyh58l^X>-?^Bz2jj=m3*H($9@lPY%i6*feW15t#-?S zitqXU&-XLm*L)wxYXLX;_V|*%Hs5L=@;V_4;IF*j@jl~yFY^BH_x5``@Z#Sb&p$o? z4S#@R@CWGiL_AA8F8Bj{4gLUkxc9mf?v1!hJ=687>*ua7yB>GF%XOz~+?94kU5i}~ z=Wm_gc76&s?RVjReZU!W);i}nosK^^o^yQC@gQ!=_c?lSFTM`<;d2~T`8V>nuoc5m}V-pvwi6^SmDXulUvNh z%%GvcNX*m>4a;)pLXc+mY)|yOR%d&AZV}kGZYGaIM6T{^UwSYN2Wm%dArmr#Mg|ih zlMSSSl)BskjN#0l?TMCBXM4ovAYECXnyySe##iB+DV*(tVBd_M?TO?mobBfW|BRmP z3IEj2_HynV5V3Y94OK)$!P%Z;k0w9e&wDLKqwLfD>AAC=-8I!K_m*dzQi723sU5d; z!rc}jMV;)U#IG@or20}wiPxj}X?CXQ=?P)xLHGnB;9zWpDvB&=y42PnL86GYRa|m zP1X-BMn(;DG$5it$I;wuAX{ySx}Qpa1O{bIROWe{B7{|peZpYf%l116k{GevoZK0% z?wW9A&Na=^>fDBFMkQoXEHALzwJUk$JNFIs;38oteGQD^W(tI3Eeo@1*+klLeqCGm z4IfP9W`Vd>D-TbbINZP$IQy`iCy^hCAA`Wd-g{C5-D*IKa4Ob(c7te4cQO>ENY2Nm z&xBVpQe z6xM1Eu_6z6@ybCIk5yfb&<3WrKZS?K5dzhp!QDcBQQtYXi-M#ug7}vhz~O`#NBY}@ zSBcln3?UvFXESMb|9;P1RX(UqRh6mCDZB@eswKXEZfaV%WJVQW?4qr;p zB^>C__gaw_nk}iTv5QgIV_#5*g7Ay(u2{8d8PzPom8&{Zd-)m&@srq$dnn)=WDKMs zxYUz1ZoxtRd`%GFyNBu0Hk;)i!SjPUa^1KRr0Y|qab0a_W$lXNPty5+cEw8-{vY{= z{cfD-YrJudW}uzUcZ8ED^W3_PP3y6L5`d zfy?jwr}NjaOg!y;uk$wN^@tDH=4^&{{aH?nsBK+eg=9)PP*JNRS>a8fD~R! z@@k^Kk|bQO8II;o0X<6%d6L3dM*01=!qnphOP-#~;=rk8QNmq!$%Sk*7nm`$;&`d$ zH<=7A*zbo_;rhWcP5y6e-`c4@VUyR@)JJjXwj7arnBLg0)1&)>dy^wWw9qM0hgI}B z9w^abn{#L6e6;5n-)fo405rF5OTdq4EAC=(YG_pa%}AXiqBKmr{Pkh2#QMIM-jnIX z+B?8mz(H^XdnrAggX`Za0I$2YBQ2%DsR0KA!Ww*h{4xV+8RcJnW4bBkO z$@&76i8MX3sH8hbwh=G@`!w7Ov@-5gZ)!M&v4PZ63nf(uX)eqx?mRzwmRC-wra<9F zkl@;!6Wv5!Qv?Z4YoX3}6Y&XU^pXf3Ob@Y|uXUS54vUuz%tLt>KPw4%m!$FBt z_yh7ga}Fl|;Hf7~eoM4$@|9DHN(bUHQ)_r6^jsFBvJ6S=QKe{h`SVS3H!YXgrC{$+ z25VeJHs7qCz8Ds3@A_(*SOs@K6)kx^J7z29o(xPO7uO= zAK;*%sya6x(rQBtucIrj4Vn&KZ8`92Ls#YO*h3qpZ3rL<)uL)B#$jWawhhI`)&;c& z{jJ%U35Lb4Hkj#6<@V)l?(Uj(xoB?INen?AErtR9`V{@fdQ2BhY|i+{XJk5VAV&$# z+{j9Sr2x;`p#U$x45*oj#anSyw?f}Q4X*7`+J%~smMSweL>@^rl3`5yF4!AJN9k=2 z^`SF9hG&K}66oxlM+JsUV0cZrCVO=s)81_{GF(WX%5IVy@}y*fk~pnl7BItS=d3i! z>yYATdh3uH++;E<5*PAkOFnMsO^bNZhQhG*ZOp1;Zg$Rca->B&I@>9PXNsg@4LpIp zb0}AV_h)K+Wop?Z4Xp5Pfo@0PIGVdQ9m#2ExHf3Aw`TFgvCy_d{Re<=rB9!@QUhA3 zMQc@}`;(y*J=J0Ay&jnAD2YZn;-F#bXh*ohBwa&&P={q;Kd`LEyKz&)qMimO!=xSx zbl48gzs?18m5206#5K|<;h$nnr0^)b^wScK!AY5)l~I*nlJcZBe$B0oJgF2j3eB%z zviHidEVK`0)e%o*VmTpQnZH&Ijo7M^N|pjVEG#Am+Y|Vyahk8RKmlKGDhmZW@Ax~C z;WSEBJp<{%o%|3`3Mul2Myr=$+n@wX^nxJrNbo^HL1_~35S{<$R9shqIO@~<2Yf%l zeZJST)ct1HA6;*Dorl}^HI8TG#}N^JqrKktQ(K?(3}OP8SNy)>I^TzVcjMfj@HP8Z z`l@j5_xmi~KjR(1?<0fYrw|ozhxZ1Y{x9)v@Gi&c|4*Lp;~l^c!w=wg&tcD1o+~^T z4_^yeSWDS5rX+Di$EHXY`Xa+~@D0~M9vUhS4W2ws-Ii8d81iQ%#kQCk61rBnU1%Ue zLU?G+LNW@+U^)!^dkIT!_dc+1t?yuTitN(@VGHK3(9YSipxLUCI&VvIBuKj&2QdcK z&x5V;#?8Sk;SQ2-m>Zez>VCpTO`dtI-oajo6ppMu^@yBhy`f+yjFQ-Z64Wd~>qH`t zc`$>+YkCNq(V)nym|Ji)db_?dx76pFl7MQ2E4j4V!idnJ{Rkox)9l1BmT)#1>O|g> z8FIIer1qqd;u6aQH&JqUAa4vM0zw`)af?D-7{L2XdqHF5o%z`D&#LxH`P%o}rG*yXJg)hJCXYo!h=Jds*0Jx-b z@$>=e)+&P7*LQsuAj5_?P|Mk*@^S`c7tcJ9w@u!3RO%TDpoBjH795JgVD&7N3ypw) zwfoCFl4`USBLF8-R=DY5Nu*0xP5A{cTF@|==pda4#&N0vkxB>zY~NZl9Kgj(zz5~4 zAoehbAf?zer=})L;l_)*VdJ9ZTz$W8Cp2avMw+#dPLi>g>aO(|k<7lHZiF$)>i8Oz0;l`E(B16?^cW{ACCWE;9 zGG7xx3sEmgsX8ZRSZXs<9Rd}%n94@vBFG}CD&qMy9hMjKH7%<$y+a_P&X6&=Oa>s) zE}pq01rNRlnr=r%ENEPO1&H%G?wc}H z7*0JNon}&iAvbq7P^@f2;;pGooc09HFv>iD?;28YHS#>CXV$lL=1+Xan~$vdXp=t~ zVyWhwRJe~OMyH2h9nPI~C8%4~HdXZu4A4=BrLYw*p_fHoo*vT~4CM8tups4$J9K#( zi_R+Fc$p3#`r*-02j3&CHeH%!GC!w=up*O6fLoT*Y{M+X8bNEM`k{$%wqdGu0$2~$ zVSJQDcf>RAf?q~uOkDlr9@_*DAm9o_sq0{DQVgJ&pzI_e^jXsfd6u#wrn|Y^i5qy$ zh>B}i+(K_#&e>Vv|9}X^(S4yHHg)QrV!talZ71=gv3X7j&5S%TM!1Sn&z z8dkv~RiXZV2;Y**&S_7Lf_o|zzuAwff7eN3D!7^|g(-J-AIsy z)RQrJE*OamjrM|~d6i?+Vwkp%s57Nptb2Nh50z!Q_nDTKypI}Nl8NKx;-&Nd+={je zWKf&qf6AZmeIJ?q{NDF@gPsRH=ee(USGam$1(@$R=2##5wVX3hX(p^1&|&>Hq0dUCJwO&KJwGyjhK^us+Vbw1?`Pr|qO9fo1cgqyS5vs$Od%ph-ag<(Np-<+@uN zCz3cO73_f;HXa}iW&e(o#Z<5dmcW!d?J}TR)wpZwj4w29Pk(J7LwJ5=IJjav#9?{j z(Cn#G#jdT$1=Or^mwtzr9vN4TA znHV6AK-1xz=$IK2&+4YBUYV;oNgkG`9ZijY*L6X@mQP1zQ{IW_Wb;vZq!sAexy7Ae z&C+rhPKF?qc9x zI-Qe&HbN(HKFB%Sfxa$24Xs6>*9`W&bDrR&rl_0R9)nX9MW%?Lo@5-9)EM3))D)0% zY@LD&9fUgK)ne`strgQrem_yBQYJ9jJgjUbaU2L;#Yhc zMoWQ*ZNueEF4ipa7GwRGUAh@GEU%o-?!nWWX)q3%hUl_Q)W34WuQX91T|fuhwCZcn zw?_fJu5vnSb{@KEkTl>qM+=~@j8E0sLZe;thbYeiqB%?}_U!zmqVs=c#XS|ir#yE# zuW@|DaiRQW`3n0Z_F1;aZQat#QcgOplIRPS0A;B2T49a{tBs zGxryfZ~X)A``ky}``tr$72sm`diQzmQ(XV#JOEF+{?m0=&bAReYN#yJBd>6LRUYZY zM|BZV*bwCF9C}PfToIEFs#;E!rqw%}X{0JwPj`dTsL%YRry@3a$%%E0{ciQyKQ==Po6?*;@NV`KO3{;zMY$~5@Ju<>S^L^4U} z6XB^3ek{BNxpC>TP{(A7Ng6?1k$zwk$P|fHf9*-3&~c77(V^s?JdPd6{fJBbGSlAa z$4BdfNnZ@@%Zl3O$leJqBj>~%JSs1jHdtIJ>B30nYL6^|c_vi(c(VKw{C z64lFtx@UHhnlqdpVCYI@cf_4anTbUVBPGZ+*^`6~vjna+CyC2sQJM_bJ|td*sr%Y# zh`5+TkUy=r%Y%;T?iRiwY!zc;AlFU!!u!I1QuT5!Ubz?FW&kh zCk4&velifD@j0vL?3cK?=OntJs;5MpeuA+f8*~J@8~ivFS-|tLAj0p~*dh;s!0D{U zwL6X$k%Dw_3>!HwBYBYMw17!dlo>*LjtCqRo{7hg zQB^{NiX})~q9Kzk@ah6~2?KyAhWJq>5{wAOQCbf_VQ3g@KG?L}6 z&o@>c&YPrl$nB=Uc|C_g6fRZroMhVb>_l~*ll^!e6|IA*zCJkigbpvffaXT!#58tk zZHgCL&B)+km=!{5;G?8nyNfX*Ur0J3!?BLy7KCge{wA9yA?B_KR{{>HR+vvl-rfxL zx*sAI3bthSq6UrwJnR6O#Q*APx8n<-qhlSFix78|3? zvti^p#%hF^G&S?UKqfN`iH9R-QiPf$cflQ6oe#tb0TYkLY4I}R&d4OP3hgVXJxyOh z09|G2BH8FkULYsw8iH4h0IgcS8&p3ql2|3^4NdS%xSP_2u?8Gr;?(IZ+*Ju0ls#=! zvEm6?4;|jHl7-|Z%SkMt#kSYin~-qEg((UNu@&RbQE>%w2+al}c48qCBV1c3F(eo~ zr5o~Ns>dikgs3C>jut#EX9TUx5M$bc)$5pDSiCa?h8Fi37a}_UFRQqsB5*8lvHySl z5BXbtfAPJ|x7Pb2PV_O)tDgV#w77qTYyefRZ@8{m;)=sg=hC^=m3jw{2*+WyC3g*_Ml)^U zjj>2q91kyah4}$kY}0DYL|sjNO+(EJ3=}tIiyg6RRQR@L5w(TG$7P2dK(T}#i`I^_ z#ZVY97F*TMP}*^M?6AE&ybW~H zm`S|0^6>I=F*Au*Di1He`Z9BPbN0hlAYQZ09gsZSp z)QZ%)u!X0J-v6u^87st_QQ&}x@iWgBcro{ns#4u?xS}ke4O0So z=s;OO>!$|v`m%uHSV-@9mDl~)q5S~5#9&%0##1v)-B23XnQc9E9RMz_)Cc4h1Gs`7 zvYJ##`EH=~(6wcOYbY1oHD!UTFBjatvcT1q2X5n`s{wA2!Hv2Y?{v^~PAM-#_WzmBTS**7q9rxK=teZAK52@w6~N9&o*uP>NDJG?Ej8KSCs|7 zfgYcl7X0C|;MY$Zex@w=b<>33b!Z6i7gy@D=N99C0eBivWsWI4$5~3{I)-(yEV5Vd zd+r!k_BENxunv?3zhT<&cb5gfe%kP_EDL_!G~st0N&|iw!-`WsNxjDY$%nP|P(PEc z59ll=Eh`cm;$1C(TUxm#?Y#BSuCl;2lnbt}EO7PZf=iVJuC6?AmmcZ`xW#oR;vGX{ z8>@2U5r;YAB%kVFF;$kn{1!KK>7gC~pIdh#Ww01rk)TThRz(&ERvrDXdj27AR+ z!Pb`rHZfJOl=S^X)V3bF9AH-&?xw_yD$ZLxm)m|Y;A)sxcQ86vf8r*o1L)+y23YKNBLSHDgf=cin{j8T zn(BLShcK2N4aJ1?3m5jzQCuS~3o2Q0l!Pjn7*$At1L-9@YE^AD{N3w!tO<5z#wd%= z&ao_jM9DDXK7P#IjCM+7rFu=V#?8~COyD#jVWi1jcF>RRHI&=EXsolVwJYz2_HW<6 z%Mbd{zg6Y-udBNw))ysk#jJCzX(Z;9bKdp2jVy!_jb;AICrQ|}*}OaRbLC9lK+Lo@Jov%Fx{%Z`qWQAoGQx||xx?K(d(eR%FFhF5d#IQ0akwcT z*75JY5CuW3*3Y4XGWv;aQ}2~ix}WfT5ic4m9*qC?eFecapg7o|>#=?6J%0UcGvu_y zJ+9aF*f#YZzkW6zt;cm*k9!YFQ<=RR+h0G5U=YM={Tx1MML(ewmN$FznO|NnYCZdA z2(KT0bb+^B3;K+6Oqnb5mRK_$@qOKNxR0_vh_l$%gB8w(d8>T}Q$1!ZYl>gmADjmF${ zQ-)GV-~QaSfYOjEPaWDC?uaQ}oJ1iiI3DX5t>g_qeoT=Jx6M+Kv#hrU9t}JYcuQb^ zU^uWNusN_a;Pk)b|B3Wx=~vPZq%Zlu?SB%!0SEn;``7xr; z?=0`XkoWH^-Vb~4#=C!4THj;6)jH~pS>9r~&eCV;1o+1smw4BD&-Hpd|MI-#`I+ZA zyae!oXTr1Bv&*y9)8bj$=r7;_Ae^0P|fH&Yw6x=lr1bh;*xzm9|N1rL!f;`YY=f?C05i zwm;jxXM4i-PTN6SuPtI*W;@mS7H7t}#ktTaTZ>|OH5=Sr&!Duj^AAJc!N=4qvm9@l@QRwy4mwh#|I zD41Hq3Z~Y0Y-1khM;A+NlQdBPeZ()#x1yJgWI1e-`vZ77Qc2p_7H*e!5e<<`@Wgm5 z+?wdz*4Y(lQ-oUXBf`yfM0m<2%|b=AOYXHoU*hssdbk)!8c#T5MPt&t=Sk}dKyo!9{Es=(%A&Q>yUv!@ z7Su#L<6wD2dZ-xA#*TPrXEX8#N)PhznP_d~C*7n6h|Q7KE`^o$jb3Exq~Bnlz1btp z;UDpsJ)%~illCV2r4^?%wMEo5*ht%S9Kabs>@zEnGTY5-BF`^qQEc z5@2jr@f*{q=+K&4K2Kt6w!71KNuuTyX;FT~?{1fWQ8)>70`#duQ5Ur|L!K!VORkhY zd4^PD5=&aQo-PEIU$s6_I5D&}>#5mNoe3V*Nlz9|QDrJUQAhzzq>mR;%+7Jr#|kHs zvbuV}Zgz#8l1)sB}`wI~&PDujceT4|sdSd;(g{z@bExofCN@D*z zih;}zz&(Y6ApAdgpL%;Ct3|E!w!%?`3xcw3++7T3el@(SaM0`3)$q<@{UDy+QHT&e z2@1m73lY{O@(AC`8i0BqW%&T8QrqGX=-lFf%RJ_St zmwy4K;wY1>`%d=^OT{f`NvkbIb4SOnn=7p?Ue(-u?QE%`Slu-~X&$tiFb@3P?NXFG z%)h7uEkfm?O7cRHHs*hmE-5wZe&fDgiZCnqb1ZLI`n%&;!w6CnOQ5-ifi@PcqWgbo z18wnwxD`@3|D!>=DF35gTA%+>C%uscL3!;b9%~9?ecsp|>$JU#CplHLcSPH?;)h7j zZKh&Ki)hK}33eQ8UC1WfLifheSR_G>Y$;ZYMFGKybXb>Aua!1!aj3Picn6ZnN4l(w zscZV0=tdm8$wAn9p1G>AgW{R!t8?}6!cZ$a6j8Izr!J`QL=Am#7Ri#ac!m1OB4UTR zjy^i4@S_IxqlJYZu?AtCSNM^taak7>e#DEH_3Xlr)B~h7Sojg!*{pMUGgKIl8Q9z! z-^%YGw8uN6UD)_nNIyJLlLqO(Pt>Gd`oW2s)Jfl8Bt=yoZfK^$iS7Er9aH z0w=BI&KQd69TyO-X2zA)&{l#SUx4B-wI-lbT79atIA2dC7qZB!U+~0VMKg|o(4ujH z`bC1Fp=718q$wt?poN7$;O`rjOFQVNsy3R`JEM|f8Nvy5p`mr8vAc`ralA3o)ZG!W z47$_LS{_TrGR-FWT<4C2dW%B{rAzqVzTUckNH(-N2jeSJ}7Qqd1TM z*}lwvrd_iA-u8Upw}CGO9u6D{3j7m`lofidEQsNKlDD0D24sr9o}``b6{Eck>``1 z`;cwma?cw*=eYmn{*n6`_x@#dvVO_>Ve9?YackCkg*9efYh7p!SpI5x!SZ#>CoS)@+-^C* zc8!btU#h5ZT;3MF1efFy(qtN2EMKBg*NR#r;h5#KG+DJGTD%jTZCEuepEk6m3d<)9 zZD}=E+J4N?mMSbCCUH^V@;YPrx~>tOe?iX|4Mj}PXLLA(pQM80Q--!o&r^oBI(i;A zv}Jleq@#zIFw56;jYx9>Jzp>sF+KmK!y)`C6?&dAv}JmpG_=*x^O&J6)AOi856z+0 z2pu^##4nM5LqA)(n-blzXjiBFYxQSiyfxk-|BAk8jW;DOjmO&(@$N2pnAQ-jBpQ=5 zw1%{XH^5}T-y|Bh;T>;zh)#i1Ez!tUd5|=p&5>=QD841q(Hh<+50Ik0 zDGtj=v~hC+r^D!m4xIkw-GnI`+Y;X#!F#W*?edk>5P#wA?ntDaT?ZrK&WOCxSWb-L z@@7(isC{F+owvW1j(AsBt1uYVl&Zw%a){K@VqZ3Qx3bT!Me1tu-xvi-q+zkdB-)gk&BC%<~bJw(%Z`>H^u=UZEg1|_cI-;-vf}@m`Rr(W2im_aJ*;EFg z(o3c?z?A+-h)BmK6Q(+N50E5jg|JE5jxp+j_dzS(EXq51slrMHHLt4+2EGAWpXer% zmau3{E#A%8P`m~AIBFrUFw_>yMF-59YI&M?tK4RGx2vW7B&1ZTX%K1+OFP%F0ZXA| zmr6-Hy-l$>7T-YUA$xCOG1y^G(eg(y;v4wzWA7ma7%8A|vcxEJt^ziT0fw!;L>&Ox z;&etJy|;xKOSdM9HO;n|EZBur8^hfoaBCDN8Ja4K2spQj6hJtaOi4xROdu8#w_D>I z#VXNV%i>a%=&$8GntsK;Tur^P)RwBg zkgID-Rj=aeP^sz*xO#c1>Xlr*tW-4{ge*%-RSR|IeA2m#b*!P5p+`!6F0_y8QlHlg z#x#|z7o2Gjih#m#i1;d1J|X(f@TN>v@BhVDH*e z>=mC^mHNC+u$NCv#YV4Auy=LI?*)4=ELktuyQ*ZpVDAMb>jisPmaG@-T~V@Lu(zRP zz2HxM$$G)wI$oy2u_k_rM642uHUC^%+FOlPG?f<#)zk1vJZ4!?q&aMnmW8y68^GY) zX<^%ev6OABmiYx>IOxlAPGNZq7B0)Y!g4yLTF#~|Y;(M&6{j3_n2`QqD53rEZ-x@u z4gXHd|0Zm!Z0Lz}NC8@f6^mm6C#(%*OcD%|oTU4h>%bw&PBfV-;xS1s_5s;z9mG=H z{YEc` z;2=Q;#g2Gu#T~REpwgp{^qc*A{VD&&{`LN){xkh{-^)0ge+B*k4XIe6w$j zZ;9^=pXB|S_tV~ot$xd2EI+n<-txHjkhjmbU*L@6tV-};m#p9;AQTpyVgAqr}vj!KXQH9^@Qt~>rJjvSJD-8y}?!O zI>q^l^JmU)I6vtec3$pmcCK)q?Q}c-?)atS8;*}T-sw2#*e#uAebM?k>yy@ZS>Iya zhZB91wc63)xX^K?{4e=O@)zZg$oIjTaizRf4$I5r*|OFCYx~#jkK6CEUuVC}-eRw_ zpKZ6=er5ZP?HSv9ZFkykuwI3);}~r zP|X?=TjH(VZG336f7D!v5wweQEu4V)j0$O$|3DIl%XLxGOcRBUuD_@GZ!E_7^>;KC zs5IUYg${zNen=WYd65<*bA>qE{+6ro%uYNC+g)S0oh%-hsquFCGpvafR>4{;i<3P} z{xH(w3Y%~5NE+p5nKGo}#CaIS&GA;KRPu4^Ep7;uQi1x@YSVBxy-*kFq?sXqiY#ep zipM#-z9ev>vLil0`n;p7#CsSkZ61fMVu1LmOp{nV5P ztg~waYp02pOYQ$jb_heM{avIsb5RFQAo0XjB+-koCVZ@D4FP|rhEaUOm&$LWVb%e{ zpb^SD=xkvuw%3l3M%@V9rHp_INEw0Kl@ZVt+uyA4Q7zLrtc<|TCd^vBeZ1(G`4Ko+^h=`o zdX3Sh5x9nzXx!^X60Pz0W<`;a#|Tmz7l%6&+sO2%s5tGs zb3+6-i}F=Elkfj;egAj0zLTaP4n$wojTh!5-3EN#P{eZgEX^QA?nD*@MesgpXv>oK zgrTiY^ge26%d+=~&~Er%STzZWsb;HT0Tvg-Y89r5EyQ!ySRRkrmKSM`ql9f4tr~5{ z=I|4Z!2}&?+8D7d)ltw04W0BZg@W^SAk`|Opjtn%j2<%7mG9pnu^~t+ru53$#fO(j;WWV_ADUb!->fVgw?8VBhJdN=-YrNmcR4TrobV8 zAIqQgB`mJL*O#zR{#su`ITkEGqm4|-iiRbUogO^8R=jLlWkV|_$!amQBAWhBN4M~* zwEWFb#8ka(C}OgHXDDL2ex<_*`v}wJ)?s9uiPdgsCFrU!v?99xsRJ((>RA42C}O%^ zG88dgFB*!Nu3zdflEz0)A}$?9lwkyIhE`$-Up2HMy8c%OUie*E{$eO%y8dV=V!D27 zC}O&Pp~Fae5Ygq-VdN#zDj8Y{y8dNoMRff`2VSK1vHaOk#B}|^P{efo#!$p`{hSX@ zLQX9&xZPi_xYyEJ;r@vG?e2Z<1oH4Nb)W8f6?f%da(&$OAkNxXyLw!kTo~8DX=mJ%aK6#|C-2X_-}HXkve$Bj`{%y%e5d;?mR9%kzFudY z^IYd_oXKCYe8lo@%i9Bg@@)1rcyIK+-TQu=(!c5WpyO?h1Kz7}i@({?@96TL?Op2i zc~?8u;Kjjz$Ul~k%MZ(Mm2W_Ut$n|o-{-XVJ_6P0zEaUD!+poYm zy~bV%fB&D_zGnM`?cKJUY$LWUwhL{i+bR&n@J;D)>21;|d<$Bn2KbV{YJI`_8S4Yq zH(AH532UQuvDISvo#i{0PX)dccqZ^r;Ko2Q&<5+r{D9>Dx&PDt5BTr&-{9ZrZ}r#u zPxJjA?<5}gz2A4MZzO-zr=dhP`b1Y0Ue`R7j!m?#QW?hv!1U6%8LtjGBBa=8#f~N# zB`jP>9p1e`TpYhjql6Z?W4=(nKL2Btyg&cr0{J@9kZ~EKyl*9B{We;)^@T7r;jm>r z$^_~fx5lj#wEF7Xk|fbxveu{P1yYot>eZn2^GiGrJRujT^s5W{1x>q*DFcZy~ZwyI_rp;K859jX8OSLelvX< zJK1T4hpNX+pVo%xyWC74)haCCW~PtotX*b!2A0RQZ;-THJvmw1#H@krb1ALzt+aBo z1aVP3X5DOTgvHdliSz+(Bw|!HM7qM1=%$-3Zq}$qyslbkY1S6fd(58_Vs^+dR{(u; zu@8udNR`wHNUWb3Fq{h~0+XkG- zVdp|OY&TEdgizfyc@si(gcrSNR~*KvNLahr-p#U>FD8Un^6=!#pxUNQ&A=^NKPk@b zQF^77HNUnfIr9+7_A$~e+apcTCp(}W@^uqMK5X3B8nIkVdSgK)KC`qhkuEN*DU80j ziU-gP9#<}W1TO$hn{|vk0Bx79b=U^qY1=IJ0bLpMyOp8yDsLpbjU`@RK}5Im%+!|_ z5}e`XN#6#^FLO|*W`aR$ouj?>gB8RKkDSYqysBXO>L1_x4eh= z9gmVVH6v#7 z(tu_w%m%h66eE_t!v2`K5L3(kA#))nlbwC%8Lr;I_8x^OIv@zsvz;T(xS`p=_5h7a zK@9=FSHq|?u)UpRRo5I$AS3ggN~d9HVFMnD?YAjrGPO)IpeY8nH<>VN_4Xr0zXVOn zmp2yul4#D+oMG}z2DbgaH5EQoi2DD-e=M?9 z{Cb5dV{ot%KluQ~KQ+zlpS+dw1S2OxET~(e5H6V=frQ3J<@AyKGSP}d9o8y%KEqQT zjzf&}C7MQr6t^w%9~VdwtB5p>H)B1=(nhCazWoqzo}#OZQY|zJo-E!qLC{l3TKuZvfGe_89up5;R zMFA}njtzox(Z&&0e{tPKYI9cF35X-AbVR7|BtR_+fl3K~xDk5)`Ml%vGomHVRt`;1)@b=FZM%NtadXUz10iwhlr{2$cKLkJg zquxE$wZ(^+C@T&qmK`&oa;H?*DWD-2FB8lkNxIH@mNK zUy1wu&F(t)Lig!zyX#M`pSiw+=>Ctp-t9W<+U@FgUF13+Zw|cb{GIa$&M!JY?tI93 zoAZEk(7D~Y$+_BD<(%b|5X1jt$Cn+C;jaG{$F&@-aDijC{7?Cp@>k`@2?ZnKP$9v#L51)cOf zJgkt2O^XCO5QWyoi%KV=O-R?K5mmiHgugpWERFVWXx}r|Dbwo@BwfZD>n}(W^JV16 z%ai}7#v=64o4KPuF}7u1|G-F>#>qTg-!;;uwII5_VWdmP>o4nhjetvq*Po+JEYB$v zTaO#z8+iRRExKK#<82=?2+&5hzl;5r9hNS&oLW)io+>P)f0Y@qF zdgrjeyGZ{wRfG}cC$?|f9ZaV2JF13qX}eP(!f66Q3<&(Dtx#?<6%upgMrKR6F&s1a z5qznznAGgg6&3@E{R^Z?bO|8rZ1EX7S*is%Iy6Di*4)FMCH>V@2~UxjPpn5LCAlj90ie5Y6i(fBPB6e|0gU?D?cr+AGH zKCN-WaQpwo!eXN8vxUWk|MME(Fl{OG!GCFdLxGuZ$2Gp?EA=FOQsWyh0`@O!J;oyg z3g5o2u}73?eEW8>3Zn6wCMZ<)RgG_T2{Ye5rSVOx(-Zj#>O|)@_|hox#7&5BWj{gI z%qs38bg)Onarv&ABDN5l)G|6(c2iH)0?eyUeFK_PUDO-$u)=J>m03O@_uVuQTww^v z{SR6NxL6CweV9~P?Nm&Y>Me#646E%;h7xeb_GSSH7Zzk=p^FRYY(bn-giE#A`T|fZ zouMz-7>P-<1YG`v@2sS`x)Rp(q_cD-^)%_FIl2;-0O?Gc@trumv|{;JxG5dwW@;gQ zcnjlKOW^Q%v!Mx*c*IbGzDqY5O3-&{f|;&@$*)w>IH_718OjGz97mX)m_7^8mEsOe@xQDaUZAfX3$r7I*r^HzU%4{BKH|BwbJ2YbVw7 z9Kx|HfoPwt(*DA7n(q?deCXQK30At6IiCl@ZX^Mk>1kYfKv|Eaxr^YCuGLhCHA#O%w{yyR|-uQiU{*7h+mPvC6cx2_woG zV}BkabsCTi{XpHXa$$yE!@6-8|tHHJs#9=I~fmF&8=pN2qE=SQR?t&+~9A z-^rgN1+5eD<`{b=MZS=E%XhhFh8isG&oM8JrC8v<$LlPtYakk?QYf^xQ8MI%EYfm0 zaub#{quz^4g-6NGGkGK2FudOk3h}@NpdK-UDr^K$?=ypfOJf1351K(0HUg;knnA%2 zr~uRl%%BPz0n~eV6o^-0=!h-kQwIgCsWoEjP-}SeBI6sBT=?5JNvHEj;2Wg|W)Ui` zG!^4WPsOL1it9!3sitB)=u53nVJQUc!Vdi>=^8X*<|=gKVfC}taCfY+1ydfIE(eU- z*xi>t1AJYqz-->5P|U6{`6n7ItT$47V?~376{Et6dds&c|Np{@{S|>*0}FB1zr^<& z-`&1B-nV(@dfww%<^HBS?RL2ya;qId^QLK>}+COm~c(LA>(?91~E&{fGQd&AZVjvzl z=1yyTp(j9;M@E=Ml+BZ$ZKA#fNAXZ|XxGGI^lzPS+cf&e=#em~SSP72h`M}FbLUQ+ zi>|Kp<)(|PBp&s>qR9unmEQf*#+-j?L(ad%i0_&%^>CT0K2PoMNyT*F_?*{Mhc8 z2m;IWW(ij5Vkl6Yho74@F(0ynS4h@PnPGgwU{-1KqF#d>tWsaw6?0BqQ5rM!!udcN zZ?e)PqKU&#Yxm#@PgK!*GnP$NhYn1f1G>V#oHPx(c*Ww3WG9}#^@Nhh5DydPCvbXV z9!rw`<*)MA5A4)*Ge#N6Oas8k#CmF+!c`Mzqq|tS^^ed`30rAQ+te3skUAn}AMc7qG%i%&DyLL{T21L^td>Hym4!%I6?b?}% zQ=KaptV6VGQDuPGV(j9LQv`W#;?DkHZD4_L4@8y^@|g z$KE+~<=9ZqXd2Jn?@f>P2N$Lljfl0;>gy-`Kwjg^&6xsnaR?XL&QF$rYbSglVCfWl z4?l(Ya0(|9?tPFnFYz|s6uaL(;dQQ^x3qGtzV`!sR%y%(jgDjnI)^jq0lc!NnIuK$ zwYTJOC=xC35dBc^Kq|C*!UK?t8#k5@5+_DZGvA)crfH<`+I1~SXx#-37dBM0KpBx} z+Egex;Rd!vm8tTv#bY487*JsB8^i0QsjEj*xLj4Y3TU)0>QUvcDd12aWj3>iy2VK7 ze3*oCr(xMW;bbg(%f|xKGP=SK+wwb4q^&88!b?c|jvotF^=9_s+?5F?gM;az^x)WF zFw=*3#6yD<4#swE`Pg8f0yci|b1*eke02Uly`r}waE|{CzOVYO@V@Ll?AhvmhwBf{ zmz`N>h2v&NrF_W#d)rRyit{KAPUE6@aQ(z?*SeMKD-Wf0 zy^3n@U?oYm!gv;NB#CobzC5etyN68#Ifs-4mauZ_xhsyl$zSIc4dKuiA9La``AB*+{_hrU{{h5K@pcn;=&hFGcR__NhBT(rvV}kWC zDqxZ7%?8t>SfL<(>7kwx!QY+xf(^CHg6Z-g)hUpQ1F8d{36q~{S119#SW{8OfKqZ)HXagpWOr%Lu4 zseysyP%1N)E$KAMOJWyHCM0XAtAkaUVG`ivK(+3jjiEFa>9MR>{6oR^WHyVrj-7Cf zkBq&k(bNcLek!=PKMg4*3k)p>*d9r&NFGwbB>p!zHZYnV9>7OvkqreqQzW0MVA+RM zhO9fOhJvxoD9bp#cA3ty=#SuK*z^cCZ34Yr?YVfGAr#vY%(69(2<%Vp!AweFC(q)! z%&}qcfIm`0M(ZJf(wu*r7s)oD==vdRzE#2~F9+^)PZ`HARrcOxXMjJa)@s zesX9uy&NpYps<3$3drkh5KN4inX$;7ID8xt6?noK?8E+phVtCV%b6O z2g44M6wk-v6~mt0pd=Z$9U@W2bV8Z3 zvxI`tT|-#kp?~%T`-C!u=aFFA84QB1)DY}DJ*nQ%=tPQ7Li@|2hA%%8P2|GJ!dl+} z%MrbMPWZ(Z!iviX76@gU_a|XHqQ{w1!7EePx3ZZbpv_M7GFrpVUvgy3XzTQ7`_dyA z22z(!h;?|P2Uu76u9{Y7al%va(mUe@jI@{(?dWwAJ2BiaBDl)HsgK)LgMPQtL4uYa zh*Dj!s;i?rQcXK2io^;g#(yL^1k>k8a8NPR4JL=ik^`aKzKJC8ZyY-~Rs2fqOg^+& zEJhe-dP!OA2I4v6u(HzcOpWfPwX&84lho!FP;gl^RQ;0lJ`xqCS&%5e3AKG$Fi9K7 za0;S1G@9I%q6w$0+W1dq2(k|;$2`JL!sjcRaRiXMJu(#BF0M`Zm7N`-T-C%C=sb*7 zTc^l;L+8Udxb1=YS|DP+iVh586UmNY-y)8Lg57l5BeUsFs5nI_V#X-XHZ!T}W|Ix& zE}Te!*5$sk28z4Mfm7lj#Hb-4qqrafoA8qKvY?O@kdx`_!+X-pg3aO9PRNeN|6Ur$ zEY71n{ULXmd@e?O=wS84HXfi-BOZ>!H0R!;$?mVYH9KQH{0gp(4yst1D=&}@P|V}eCxc=c}MY9-@83)-M@1mb9cIB*ORV6*8=3qzs30m$L}2PceKlYmOm_K@>o>0NBiq2E$n1Bk>l$Pmh`UzcT|cMu7tXI*9(F$FywCY&=e5W* z(B{0rIoI)jj$b2dz~hd$=PD*-*V=gvmAO;(@;AAeEbr2X^RH?uOww6w7j4B!h|Ti6 ztw!0uQI93Fs;-*1tFa;FH|#Zu2|MgH%ja*N8U?ClZ9@i@JW^Zqcp=|R4tA)z>d>|c z8!#-bG|aIR_c3oxD+Zc3#Ol!Y35lq$H25@>0XuI+D+-)<*XmGu!b;Fro!H_i+|z~E zC9Mm2tD|CcTpp~O+}_wUVR5dVwWw16lxwrGCj+Uy?6_8FC`Aoagc@iCnFYXP|C@qGYSQ@ONU)(Lk?>MV_xAdSYXef(->o58!GxXcK) zDGr!Mwvk;_mV~q}&dQ3G#;VF?>9)vbv0q zd7;sN-PgB%d=#J0uUvlO&*}b4WsEYZ=!$FGcoq<9Dh+-;#d)kc>Y8PVr0JgN!V6iVgSHtW} zZe~Bu5Q6}NRu3sh5sOzJ-H&=}B1fsf{aG8wb#iJw^+=UAv*7v1nJP8yvWseP{ zTGKlV*kzb1dMv%;SMacu%P}FLC3~V5ED7}k6*@seI-S(2w;jJp%rg@JHB0B zS$Z-SeyeCAI+{jw{HJ|zbo^34ZLFLMkG1y)3yw7A5vKE=(fX-a66ySZcEt}X0(S&X z^Y8P|@qNU%37PzE^!(71b^poTjFb5#&S#w49Di}d5mBF(CHp0K!*57>&Kj}YTJgiV z1-JcdxufgREE(y7bSV-P&Sv#NZ=t&MId(xx(U}+5Q$qbAQrf>#7 zevC}rFx>OiWnE2uO+#oSRG7?AcI3wE-Afj%&yEw>)BGdrHDsRzq z=FoUd974$=9Ho0+(n3`WDBJsCOXSNh1QQI6;?_xbg&HH%DXxYU+kQJ4!(((GmK>yu zUt05fQ+;&(O9REwP&AfJg>rQhjUc&UX>Pk-H1qdBI>RXO*FbV}^9gD0>w67|KIEU+ z0HW7!Jv?a~hndft5bvVCJ-~!-Qa!?$;o>SK&X?@Rd+A~gN`s(8bQSWGK~HPA zo1t9gL>Puk+^HILeX|%TPVRjFphebTl@r>C>H=SQ9TQ(712#lD~H14dBPx>4Z128*6z!@)~YF zTIL5nHL(_bTs@88CPrRj+^Nnq>pqq8m32h>1#YUa6lsD+DP$YsU4s1$JtOhq$ zP9qeks6RO~-Rw`^GjZWGrAqN8FC|q5uX2O~trF90>gyaQB`~gZD`sq^J1}tp=$ckI z5JG-A8mByoUtus82lFDIo>++iSU-)mP9>mF>KR$F)vkJC1-d(}Ku!glN%D1=$`k#L zvI9ByPc(qmX~pY^9O`B6LfXif)}mb#^%$7x#Oo;NON>jA)#%)bI&^hf@j3!mu~C`% z^=QXLEjTir*pn3rWeG?{AgfJd`JWz<|TL~{mp|=;^ z?^jG34VYvraF*C~#&yp_A5^vYe$k~DQKRVAQsgB5;ZS1;54j zi0@LL!~3XrtG5DK_PgAVxt?%7?W}ct+HtY`6M4w~zxEsLXCSx!O6ggt-TL3w7Uc9_ zRB?M?L!c&bcEIKTE1-Pc|Fr+T{=5B$kvH&iWDdO0zZeMBV9Pb)9+^%DfB^r& z8o2uRXG(vJ)M!x$cMa?;AoHb z!Obn#;8af0YkNnM!^5~w4-Fr=05}(wgEKKhC^JV^3Y7M;x;e#O7VG5DkrlwWpfY!2 zjOgSHARIi>AQ0+<>r06|=W8r~9E){u;7C32&95{B^B2dLm;rPnN9uqMf~xmOE{?9A zrN$8QrAF^yIXiyLyq=Yo)T@rv0x2Z*#7JihXXZ$az^|N5v=57`n$)O`5 zU@TiyPafgmk>vtmd7?TcgvB~IaAXK1eS812A4C>J(q_>NPw6TFhR8Tl*s3?v7Kv2CkYQ?_Jrf!$@<`E8W%|Wlry3*o0IH~jt${9rZ>GS z4O=>S8{-N_Z0NXq4+Wdq!I&d0^5Kzo%ean&a0J5Pxkj8YOlLPKLvKWAC$^)!VH##}k zEl18Ri(bkRj1@wk-gI)nT}Kv`MK1#`pWgN(3jy}TL&jinj!kUrj*)ZWBtzD5WC1`f zJt1ca5}z8iOWa9-#*YL6baDAWA)b?ynY;YReAnt4MA<}3iEAfrn0DbZ4Q`_?84CAk ziBo>(rbv5hoS(g>1+1THaQ8JlklDvU=vfMFgGS7Eq+&tO_GiWrb)64XLExKuM=G58 z@d3N@FQ@A*1*Q_`g8L6OHlNgT_(;Ruzj!+(QGn8H>^kKj?V5k)^hZ_+y2jrTP#Z~o^N`d^q%SYr{@>)h4NhQa@py*+jG#f%d^R|1X=rE zasL{g`wzHpa9`nG=RU_>;d;UK1=sssZ*gT^m%85Qn(O?#^Lxn8|29j~a=`LV%S)DT zSsu4OZ9Qgx-u^LbxAj_UgVk?;yM3YWCf_{!wU#E|kgvkN!?MKJ<$b}v-uIwyjrX(8 zL()akxy~WUV}05BoLzEm58P#YG4QA}V*9SM)_I2Q31sNM-gbre;lQW8w|lSu|JZvI z__nSpe_Z=6tF5}HZIq@VaTCjyWyf*SG?iu9R$^POWMykwN3mtcjkij6(xj9kSBV`v ztOGyzv1ZsAw!%PJ${HB94%+}TOgofiIs?NtFfhO$X8C>3z3)BUr_E1J0?g+>{V>z| zJ-u_zJ@?%8obx@dao63R9iBBFeccc1zFhZSROmZdcW>Ptb!*(eb3bc}nwm{Fne4{j z8voh&x5f_{Ujekj`;7_Dk3D~5GgyCSeaiYd>tCX3zzeO%c0#c%`4p#S^BtbAB737xZ;mUybglztoRFAl2BDf@d(4Wah`*-Wb>lrgV^~F_DALDxkae;U@1^_z%{Lw(Y-Nck{k@-1Z>7Jv zH2Vl^S9-!Y?qv<(1o>64F-cgq(r;MQAB9#L2ZeU9rz}R&n7Gy$(8mXW+^@p6ejbxE zg3R48^2mE*s0Js^Z`g^O`YXQ0Ue(mc`ku6e3)THYL>%D0(QpXG0F1vcyd>z{co+l6gK| z7;AV5X8{}fvV}jTdMnQw^4q^xy_Kz3e)}!eTbZr=_U{T~5CyLQzy&B`Nw3n_ctZ6H zpxBrE;v>#?>)c9}9qNANj~*%(G)4^<9-&Kktsp8Z(E$a|Dmz(@I#&1v;P(Q0*p<7XmyVLkJ(naa(Fv@_$`i!jpLFrp+u<`5V zd7=!L@o%`8RICIr_x&gx$ZF~7v$8B`Z!|%}okct+J5g34(Q6u!DH8pTI4W1*&bJ>> z|Gm1G3k_!_!=#4)ieX1{g|t}ykMLK}@^ay?X3NWjzXFy&BYRCmV2b-7L|87Fk!7gZ zg~w>RDZ`PZm7fB)onwd>eg-|8SWPI@{D=;q*?VaTYc{c>MXF~3(-fOnrST(`7)%Zf z^d7Lhl;F2zT!`LecXy~eVtI);+%P;@4L-|@`P7Ip57cie7w zYF7WG+z<+|m4Q{m!^uyzOq!l$%Sq8Y>Z|XU`-smcrtcOen*u>;(3_r7y_6>m-eA)| z71lz0DZeCs|5)}jiQ)GO(~R($%<(&HYD61o$}O$w*dlAF%xf-|At?7lnVLo-|G z%5xx$B13?72{h3}dQtzO7(ZL$@^TWS;*VIRAXbt?n6QZXi)ere&!jSAOGR@bs*IhM z8A!zmuy_^MO?!UPbgBG=rpt$lrb~J$O_#^X`~@NCmEnOR$)uWY6YfvwT=H*ewvqok z+(Y%}SUI8>()6HyZa7=SgQjvrqDWvK*pEq=1nyUV!9ryWe^#UgWko2IH9Ddrv3R{6 zK0ER*vb3^M&g%H^=jZ@S3&|?}7@+^b;vZt-vNSV8WM4Be8R9$tkmv*oCN3<)oU{3# zRWIdLSbF(Ss+aPxkzPKldMU5L(#wA=3^;)&q%nVA^+sk$Z@#B`BXgrS-&MVlS<##C zsDzNW7}EaZ8P9&*hC*it>B2m2*o&tg;VGM*netd_0I!75!A2f?+8gWdMn#3rD6N8W z_JAc+?FX<@%U#ue06{Ff*i6S+(5fI(zq7(SBD}N0JEF;tK8$8}4E53(P`J*+)?W4#vbk++>SCXSdy|7e=2tUoV;xiGJ@MO$W8YUgb&_>p0u5@|9Pq28$c2BB$%{Q3PvG`S4@G z?ToAQ=2r57_{Wzm8hN2|1Y3hw&ntKJvKg1nA#YC1jw|-g@s=T$P2dK5<<)G_K9<3! zfHPKVUaRC&rmHrFIf$$I%t?;yLpM+S8jYOIfXBId*`!r2#D)AJSUxF0T{6!nPk5Qj zlE{>f!xN<@`J~4%CTY6MB3;Ut9vwyb7eCbgT&-#k{;%3Eq1qqPs&<6q{jarOGHXAj zRqepX|6jFV8nx%zmQF(LYrXR#?0osX9^blP`KTCSK$D`@2m_xSV6a90uV&@Hf~rV^ z78Yqhw6Q+mM?s1zY4LY0J!sptvCS)HG#3l53PRxLSZW1s&f&XYCn+>%=h6uf+OQfz zxGilA9a%cgz8BH_Gzt=m<9qUcuL$Co8(o^Sqq%$Ly;T-rReJ}b-YI{w|~SCD!Lzk62^YZPr_?2E_XRwdMVm^YDS+Z|SjY zuv}&SHE{m^+WbEA|1r;+51V`Rcj*T`&w4I+Uhg^QdB}6W=YXfj)8=`O=NgZx?w57n zs{3r+V|A}Z4#0R_PhD%>Ep<-!&)r{fzsr5reFB-LUG4_=)vn*WzUR7Nde-!5*V|p^ zfH-)_)#=*ey28B0`D^F5P46(@N0IMnT_8!e$x1MHjry5%*ZT{)ZI|ZV(rS;du(@<}r6&CV6)Fd# zVR)tDgF_wIkne%3#neuhIE5cjh2|FHt)&lx#&x9+n~lEGhXLbS)`nPjH@u#xPYjn< z-1uK~`6cn;IW7FwwhN^;lktdY8(qlC?@L_c`uph0QGOlj8vyh~c#q}H1oxPzn4FMGzFIyjv~3`n=;;WB_n0OGa&#lgy$Dpg z+rUfD^JW)bbw;A$h~c$@N+^g7S~hGic@{LZmpp4WY%6&dFtm|NL24DJXV@xA79GUW zq;ztdNFmfoeQ)_Kc?2X1bh6_y2>N@;%_}|b19~1!`&6)-{>>J2<1KV)ul_|;4rJgz zF|<;=hWaNRD7^va9Qi)UISTiW?q3CgC05d}bUzd9T6!gX_zbxOB=WtnZqz@baJPj1 zkf4;^kwHKsS>8l9ztT_P9qc<$aOzv$D3Iw4^e+uT z5S1@dhk8RDh<{*iU1>bc<=)@RBy+%6USlK!4?D*Xjf{DyjVp|7Rc~m~4O`}3`<>V(xI9YzW4{u43csK> z{a9=he4M1cU!tyYF&5d2s`s$;o;ac%P~^C`)A0Xc3A$Zo z0w+?bu74@>B0UUd$B&tE220%urL;(E;DoaOAS!J3+lhe`id_@>UYih z?^b*l(0@nJm+&1z-1N_gpRvgnb0GrQXcm8kppdReXs}0FDIy8OD=WSnYB9W~^2?y% z)s-%|;JEKCin?1$-eD$?HYdT7cg@PX=8|^->z*$AJfM3@Xs1jcJ5sg{Hk}mOLh<*~E+=8US?Dxnb&u?iLJ?S+ zO%Dl8RlPQy5-E0J5${OG5s=hk*j4^T&~Rt@7tMy9;@_~P;Wx}gWx8cnhM%(0R`LT{?4A`DBetp--Yc$hg@yJz zs$a5o_9+!(Wf3g|hI~`SSor}l{wmX+ER(RvKB?kEFAVQv2MhVP^0NC`l{#sM^C=ZO z)uQ`R@|er3;ZS>%;R)4)ZB2%cs2;R689uCfu(iqXA=QJ{Cc_6+4_cZGkEyI(eB4cF^GrXSHe zb??(1C^`-o{@lBKA6#-fR9M5sw?qh>;r?GCf`&)7(ESkb_Od>_@i7R%#ui_+6Bir) zz03F3L>Rk9At1jh;X^eMmg9FcNPJICgyry`6$$TOJ_y1)y}PR1B9+sz6yH}-7XS&| zzkHx3(z2E{k=|buX<5&jNbjqOw5(|jq;s+5z4l$#?exxXtfFnU168)FMY~bb_w?A< zgj8pb{np1%oODTtTteo&rTb@~3sMzJrA{C7um)I_h?(&TDyNo4g%p%Q!Cl@+hG7}v z+HWvLx76b(tO;~~@~I+yHk%zECg2RIhF{jV2^P^9?M>ACb9XK$(WIbv-m_wpXlIRb z=p9kEtkN`t*07)*!op`wLGpQHvkyPvd9=s{8_T5As9DG!rBfMl_Cns0T;A693y*P! zuoCw&F%5|7BdK9l6tU^TM<^wis&AdP^_Isn?1 z2WkR0RNwH)ebp<8wZ4 zrMLr8p23v@%QBdQF#TURADf&SC+8xc?d9KckLZy!LVL#Hiy{xy_~iIRia{Y$s86gT z;X5`peS}rd3P=11&02h;+VLpzH>9OU`8n|IjDR|yj!7X>yav1D(L^U@K z(D|Jm2b8D*HVY>t4?invD@+V*TA+zkXsg_O`YkXDfK!HO0L%jW>nUUim2(f&`|14O zpnH$b^HANt)x8yXeHQnd-N)SRKmq)w>y54lTsJ#E=RD&0Z^s85_d2eyzZYKiHMV!z z4q1O`eSx*b@|5L4^H0r3%r?_|Ol`&=AjiJV@Dsxa41I>{^q3*YokIUrzt@9_& z?>fKX{Jis{&c~c@GTdmWGZ^&0*8fQVjQ(%*pEX=CJZ|_4!|Mz$HJmY=GVL-2O&d*X zOdgZb_#4BN;fP_cVYlH<qOuE;eg zpW@_`3|=#I3>K8d&SP}c;eXM>hTz?qiPTBoCNj#{*VyV|)OG>PlMSga4Z z<*0Kyh@$8Rr>7?oPlT`{fXt@=mL!wzTRH}cjcySIxp+|*DB>zuB#7z@Mf8G8_k-T% zsxdASJ<8~isHK<2S-Xx^*+xrW;l4EI zR6G`rbRuX%lA_R7j8>oqW~M1B8vjD=aaK8*iahr%r9od)e-4l*d{2L@gOb0LG!se( z$V|_Tfimc2`<6y(r-vzBm6jvWn_C(Nz4dKkwUP?$#{ej7XbAoboh$9pv$U6?Nef27 zc7S|BW2@5?>KC#>w{qUH;J}x-|}Z(=~CSZ0uq_3>i;SJdLg*M*T~4 zhg@IvdRkfz3e8bEf;Xj4lAY=2*b?0wZ}y6Xuqr6NFVdfs6aekNj48pN}gVVRN4(wivgKndi zo(U)831AoS`9mIjwA^rY9$vbeNvL6x2=`(vON21ystvd>hM&As@E=(k0E;$Nd6f!E zP_du^r(#k(+C(mir5KpBy2YAgRhf_vohyZr!NP%?0qQj0IGMyad=C>R?_TN$)dsg% zeyu816-Ch$__YoVzQ(0K&|I%&@5ZUMHZJwn_C-tM(w^GB2sSS5uI-EF#-(U&Uj*8g zdhkV1OUoH*H&W)MF1l~28(*&TrahGhWdxC$C}PMDEb2-0!g;(=POR7o$%V{;1Lxa> z#ke84)CC4>*7mK;K)#z&@(D0NIRl9>j+P4Dt|gr3y*GGwu1uHLD@PRj@KMP>MnZF% z{)V0<9M!Ry@6tesZOsxTl;pPN%u8Wxg7#DgK1RSNcTHCWLL(hB7d2#*+Dvm5N_bAq zj*pV}ZX9oTeg$EM(n~q)-UssjoPMbTjf$*&xXN5xO^$M1L6pnOfJKi);DygdQ4w(p z*_G`4zee|NooC5&b=_O)wz@$5E`Tibb z-tZg4{rbPtcjB|l^3TpQX~)K!H*UN}Ra=mfd2ojUm1(|1!IjM);tnO}Sq?#}_U@Ba zI#Rs0l0f)D3C@v?#c;kMQXtbarurKL{aSKGKg~5GL->|R9!A{^~7gs z{6`rKue+A1aEEphWiY;ScPzJKwFrh(b^MAqL12&nwa6Fo7}FH}j|*GhUbPE{Y?b?P z`0+-%dzaUT0(^tiF-?l=24~tdj*1i5n3VyD<~RC$L`eQ0%=ZBt`R`cXrd9snpQij& z#6y$*6-UUQmj0s6EmJuYO-&Ss$p5S+QUMiBO%zAb|Ewlb%@$2f6bIV> zpeBwl2e6!N_Esx;P&5*SyJQ7?s^AQ6@;3oMpx7UbTf+blwwg5ZQijAu@M_e^OR>$1 z;N{oIOEHpF^E$TNz<5==t}N;=;ljOQe~nC^fHOmkTtJUC!JEBBBR8eTE{fY`jog$z zyC`nAYviW%+C^~7F4u!wL$KO*T<*8TAUyQ6UiTcNnWU(`nzGy#Hgs`|nvtkNq9?I@?Qa*H~X^ z-DWvw{tl1;pKtt<@kYa>{uBBub;Cxz;pGOi{(-Ik=LmU1V`1 zR@+7Kl2p|$e3DtL$|T^5gGB_}{JHuiyH;V<7V*arcGHrL+rrA4RoTKHLDqYh2<%W6CNj2IOU{Hs1fjI76;Hd;wWa^m5+U5S@4BbTWvOgqA_I9Rvb}gK;@c#bzAc!Y zd`GIRo#X?@oOEi*tdTiD3ur4MyGo>O&_yvnxnu(KHVrMm^t^L7ED?5X@SdE$X1!zk zSEk*lJ{;IuQhm5u7hqqK@~J;#_j-N^M*y~(ge&fs%P-vZOL_W9#oF(+z1`X|7)j%y~mvf$d z82sw*sfK?m&rOOxSJ%rb-W7(0^XzjqiX*7|@`>X(`w+z0s6ji0-ne|yn9mZ9D8S3T znvGw9N>{+kl_=1%M=@+=IlMIAt_qOltgt4ljh)Sz&l0kxCPCzGx;%oI&OQi1c4^Uw z)Ipa=4&&JqkORkyY8Zg(VWbFx6OraVVJBtWl$Y-pI{$lhX%8u6*Tc?}jyK!? z#;&(ntRYLMxz%*e_+vo9{k@@H->plVuQL7G^pxq-rpHXLGo3Qs=lLhklb#QI-t3t- ze#MjW+~L{can=2M-7|HcHhu`%1j}_ZbwhQry4-yW`|Pb7Tix@0f)#en0c$gH5;yUa zDxl6HbQFe*BnJwo36?X1;ED;~%-rM*U8YcACi&0OQ)A;(I7bB@g?n|G>H(Ce_$?!Aeq^dp^*`E9zBtwQ&QuZ;Y@0D7Ra`(8(0Q=Y9=*|Us6uFlm*~N4hw$1 zwY4?a>f3}YCPtwi20jIk4gv(TsG;jmFAUmR18wfR%Uc{1d@z%m%8ufwv z5lfTFy~1Ubf6hz~A|TGmpL@CIx8jKnbk2ql0ty$Fn4V+(XemF zJ=if=KTAYuCIw9*kz$sTDadNW-TvIo3vo!==FW+ECrlB16l&24N~e_J0}Jl)+nGpwh~MQFP;DpVFH zg`cy6x&VksKS+0))VTvR6#^ZF`~S0ZGcyz8aBpJfn@J(ITnYmprE$hg7XxX0isjd` znUhG+%ORxQvhgrPzw-Cf*N72A+yR{n*{@i1^bkcJP56gL$9xn==Fgp6h(R4Y-Sgs( zu&O#r2OC#O`EFxDQLro$;a096rN8{XI1&I5A55x9&HC^^3VxrZP#=}{jzS$r(kLp! zT3-mF;K9#DZGsU~T>HwJ@#jJd{r1-NI~sD;8ZQknEE$Od0F3MdZdfnEt$4I1n;UW6 z16YeFb*o|u=ubSGo=HzN4o{#U&InJV=5-^;dmuF%=pg7JG6<=w88KA{LVLX3tWBIS zX*87?OV9dqCl>k~t?OHJMim4?aTiu-1v<01366ULI1MM*>a3(5QbSnmDOPLV9*RDt zE@!FBXeuPa3@&9VvY#tIy=+bITj)jqwKdF(sel?=4nW!d;-<%_l__6#COu4Hpfo7a zGI;>e801ssWouAuVGkPA-k?Ht(bynXNuanzVY0nHYLf;QcB4tV0`t}?Yix;iqLf*8 zH}DOGQ6>e{7mYLQC@Fb{N1Mo5K5Uv@d8kPgs|+@oI*eN?WlsBsdEG*ky6NHhb*pKU zJmyplDnII!F(-v(^8qJI#apNzcyd6qRuf&V4su-!Jv8 z4O3OO4ya+5xi@n?3*FS4ft(o3t-U2(U2M>4XbMc{a<`>@p^MtGIVU>6S8NNxT#7@D zZ%S0_*{HGu5w|ick1--uWq`3g4J;TNPyhUug$P!%VD4CL(;t&w9#jR<#S!K3ss6ce z-iQCm2TIi|dV&8*hs<)X7m=mD4TUz=wP`l&b7b%wf)EVSJO=%udct`Z9AEJJVd_4XPxA3PQw_{eUO;?TdB} z_6*LqoZDy*HtdW&y4h{(Nl%OqPtVXEIU%k1gGjudokbn5@buJdW_qG00-WI305aYQ z#yK`P*c$=vTz{C2_`)>~_6f+1-ozt*w+6Sh)ORRHUToTAAMp6pa3(Fq21$GZWFO58 zb*(Ybylo3@qf$8jJ;=1e!nu4=j-N(ZddN`0ArU(gDo;|_;$yE%n|zrM_F*jKc#0i3 zrzjgAQIpUjBhH1D@ei8Vp_3myF$mdj3UW$#bpGLU&#?s?C?0#2S_6tNFuf@nfTfMA zTMw}a-3DAXTpSnR0k6yjHgMZT+CVeq>oXfrlJ=zo+=@0ZlGbVi(#T)|Vt;rWD99<> z0Q9#0BJ~#d|D)daox2rQy%RaFs~$Um#l_GGW6TpW1e>4`ROR1yZr#OEM)|Swlyf`J z`Ow#q1QkxzFi$d+-ZGP=lK8_GOQfmF@&n6Km@P}+3Nw|E69Lo&F_nT}Md4zhK#EU| z$ejqCTZ;yC@5==*ssZcs z?ZF$`y*WgNlrg37$zd$R;R9hpO*T zR!@XOx3u%?ewJ%Z_bpgAkESP(&yJv5EUchF31uM00KjzJ-DWEUH+P~vUaDZ$jl(v5EhHr5S4-N zh(re1+{7kIR<#f8qj&GC?(KNzN(w`DTHvOf&Gz zbQag;`0tVQaX=$bVmF(Nfg^YVplWH%)8I$5Lxv?NO-i4XMo$-{{2a^b0( zIh#+g>7D>CCgmojhLO%hhZpK*P`+@U6T>C__~a1Cvpu)!89ZTV>MTK>`17}&yB1n( zU$;=+3?(h%7{ZGWRZu0SKWderIl4y2=|QbPni-)0ev&V$l*kSOghoSz)QV}uJ_Oo@ zdhik#_wWc{apHnRKh1@C@?j0KRhV>xCfc2^Oehd zwGyeXeBrL{!XZcwxCNArq+oI{0-tL;#sTtMm>^Qbi z&=ZXgj{kR~?hQK6Ep^|lyVgDJ`n>Z$oz0F9p|0L7wl~=DSV*U^;Ak z%D5X?eb?)Ax*zD?;CjOK8rQ?F`wW{6F8xpSU)I0J61CiJahiW({*w9K<`5Oy4v;ZhD33LEATNkK0~hd(d>iwB57@^#s0c{J8P;#^)PT#t!3pqsj13hR+(_ zYFIRk8+N;PxqL3Y^ZOlVZ?d&F5^P4H{G}>hATdD}&xS+KG!px=J+UYtmw0hAY#ym9 zO77aTYwYcft#0f^S4Acsfm=8{I0(%$jl=R-g+=t+QDO@3GnMYnq~UZLMvmjALVpp) z6LV_Qg}~_e*c=GZ4AKSP88(^Tvo}JrpgY&FI?1SLPJic0f~6mvO;5quu2rtwjc0G* z3TxLaR-zZzGD8E=@E(96#i92QCTRwE4||(ELB6+{@!@00HzOQG8KjDwdwyTfG&Whx z&xbLqIJ?;oF`uB|IGL;rpW)A4arSyh*S40{Gex~Bs|W+jW`+-~n`%%1rt*XRikDU0yMUy1I>&;jIAbbf#w} zrcqi|)^G);r_Or8bcc7kQiJ!wQ=7$t0(XXtXx?%%- zV{xMJ*+_bPn0kWy5y=E;JTV(wbM`74wQFttgedGrR<>-@+}>~Di1k$ zs6g@cB1W2j=IoU;m8b>Go6oWY6NbehoVaK#;o)BGu`Uw$ENKEZa==0>(%4$_97TLoL$ zWv~AfQN@0gT+9+x z{$x{v`UUG&e=dC1X>V^$cvXNVswRa~N3hczKuAq?E;D|L?N$Z=W0^?CqX$ux7`3&f zKT%|rynagUc)n-fWc2k)u zTAQTLs42Ef+LWTa2HH1vvM)~G*7ZU(Hu%sGRF zjhkv}0%5Cj@|ldxl=l?oRmn!jPhkzDZCSa_^5@o{wOnE`1)X0yF?XCb+uPT#_s)xH z1FA^^wW?$XCso?Ure&zB_W*5L6c-}a8R^)cl21VLf;|Q-Y3x~O--6qCxb9eB;TGCm zHBgnI)Ild&0yM@pSPFer$b!NcMYp2UzpFNVH2CMv^M&( z^@9ph@7x%_Z(tMQNIHw_C)6yUA2ZDBQTVB86~tC|czQ~T{bR4?P-iJnPPv`hBn7kL zAa$Jih#Anoa*?%(3TjcXYvDb^|MTi!qw~DZ(^mJjx~Oa3@d?LKApL*Deuwo@^E=E} zm`>n~zTWT^Lqh)=!v*~@-P=}n@n8C1#kOr#L73JarQ%fcn0?qP)5(vAKU0$O=g810d8uh&er81zS`<|dIh&_J;q(J4$k z<0CwYU=Pb7=p@W-I-RC*zd6hD3Hqmfa+U(()*V57*p)#iiJ6<7(l#x6Eyd?9w=p>S~JRcHWp$@dR+g!=akh5EaZk}j`rY%hWJ zjp^VB1z_{tg{&53M6nY^;Q|U_^)OBf8-~v4N=f)uiaC`(^`8pbl501*9~M?-MY5HI z#(pVIMdzVJ3E7EV;$mo1r=e!h+yqExAcU@|05( zZ{g4d$Os;#l)FBFZotYS28e2=+7EdCPCgkR=mYYGdx{TutZitgypNxxf&hGt!^8#) zMW6=mHODi2@H35Ic?k{pb6cArTI4{^zM2M?6HPW05(o9NBaL05-b92JG~k8ehHIn| z#VqJQlugp=1i!Wsf``4T`sRH#+Fzpwi(d+%BQFV6M#R;aA3V>wNd39Afdo<@B>aBIXa95-sX zpUd)3;4o_n)bkj*s2G1Pdk5qIb3}b(0dFasTN%ep8o4)V%46Xd^eLKThH1~h*vUw6 zDVhmz#FqeH*r1mhgDu(?J_|?>uo>cOs`x83*+-dOl9z(F1+zS=V@}RsK7(=7|1iO@ zR{+&u1vTYKhb6XTS-Jy89sNuRnt~q2r-u44UWd>aww=M`Sa`6BuQoWrVD4n~S|kqG z;?SyrmA~Hq=+-Uh*Uh=HytvuYY;%QvWt)vMecH&2e@XpWxjQdKS6<3aW1xM1!X5YA z%~)*K$HJ=|jq+4mZkkoivTU#%r3-StAYn9ZX{sN*9o*NUKEW!MI@pXbUyAhi)>E5+ zQoc&6!el3q9UV<>0^JR(TbLm&RW}#bW`*>lcWuOUy*YJyRV#jB(vXIZkU~>w3R40$ z`GK%7hcTI?2|{vEVtCUDhHYJhSwwM1vRQPf5Gkq5D;h=-o9T*Xxw2E=pZX|zwDURC zh2t01g;)q!7m}(qtwc)97%u`nCq;-rM__rx^v zym6nBfs2(-e(>sGy{5Ct7!G3ThU^5A#Q0n0JH-BKuO)ZjvdJ^9G;ZCWKLfKOgx3D#?spwQu$ zSb|%%N>J$hODe&{@;X>wtIBm;e7z-`0ms!T3xy{+RgS_N2+sgM5TQn43DZ+)A0ZD; zEc-OFQ^VK^7tWN2qMhAZjqGInmy2xOEgIR$xR@8k?%49pjGgH6)zWu@lc89dR6w*s z*Bx8FNh3FP*R7V@8jal4J-1qJH)`aj?zq))%bAyNz_156t$4#uBh19Sa5r;ToG-6)9S|dBf4la(JS0g*6w=a&}RT|kTeR?tMPAp#ucJ;vr zYUw+zt9sQv)rsXRG;$WY@e*Scmt)+Co~2Y(>B2zObNT(Xt? zf#f1|WleI)HuQ&(Ykb)b%Wn0KYo13Gsc_OSx-=}iQGj7_7)8|HuxaE~Vlg%IqVxYc z-K5UbQTLep2d<9irHDTkh37UiM|)r9 z*yUOOL#%vn3tA(WY$YfFq}fyabrW1k?f&SZvld#x>a-lS&}yzu%U%nuKy_NST4*&@ zqt&-)1+9&$yQC_*CuAd#BQPU=z@rX8FU|R~$URR_`D2R~(5v@~-j6Eu7_blsGAyC; z6vK;VQ1q@WA;_?5i3oyYl#p5j;V2+?`h-7cSv1*pw(jtXKrF?A$*}>2=UMnhqp`jS z#UCVM$^Oo=PWt1i%=k1%L}P_L2~mtEHc&ugKnYJ|RaJ9$EgJ1RTiZ6MA}tFnxxJSW zg&aadp=g{%5Mga9ls@cCk3soacG0861EAehZFHK7e`f$8p}Z*gCa^#u0BMAOE$6JE zL_9?U%oQRf@7;!EGD3VV`va~ zrAbs~4I^?#D!>fhVbrch=oV5U3*80oaTLRwWr0@`W4J*i4`pTsP@2<7UO&EYiZQ*nHjCs;yN3XriQE&# zMr5WG>hT~28OzoI&fOG7M=_X4K=wEJ0j6^joHrb(k@KEdPj8|R)#+tV_+AEq1FC2& zk19dV^b(8D+crHuWK=aU5A%S>NqQiLUn_UU-lMQh9+i=wA`2bb2V} z2^J?Vibd&kSi&N3Da)d$-Ad?HJcwJI3b8KPml2POhm{^zv#Rhkc zUEdQ45r)%H_knnfx}SBd?4K%(EwXFhNM;V;`6(VM-eH`X=_b%&CeZTCcgjv9~cp{&CQi#NoS%VM_xq;-r< zI4lj6lD`S#YAiL!a6K>wV=sd_YIGX#upB0h0*BaF2=qe2Iz#lv$5K27pkeVP!+dE+ z2pN$($WQdNjEqjtWfFNu;(is4T?x zv5+$SF?|B{-zU>V>Bux~vT(}DAcTxWr?AcmAhr^s0H)IH9IYSJ+U|h_{Q^Iw&~JLo z@-A7_9hUDHzC^5eA*WrTzJ)TAfkuGeqrJ4C)7&_M6cAiPL>8vdFFW?+L|?Lok<$^1 z)2=uUT$RLNr;_)>44*WGv08wgrR)ecTVpu0U4`<1W^^`Hqh<7&oX|sXz4W)T4Nc8X zPG@J(3LvNQ?dYaJQy}Qa(fH^<3LDb8g-JAIoj13kHUld(1h$npIW-K0%HoRB752fh zj4Ugmx!vF1%rX;>AhzCz%qoOPQiQ%gw|!v(5{24w?X^jyY&0NR0{I4sC`+x97sDuN zmal?rU1U>anf(XfdKC8hv@mH;)M5OdF=xEe z@J7R!VT=CT`j_a#x~FvaIezK*B2WZh;yC8mb@I#s+m0J{uFVRXk;2v`aJ=~32&$LG zlbxLej~9>j4RuBnp^jdPnU-LMDGybi3^vi(h-EcMVaUj6XA#Qv%#1RO8iV!lkDl2N z-s>6cl@CdWXCksGD>KrA9blaIhkZVxwN2-X% zFm48VleBYhKa;?xjWvA=yhrx!u`_XeyLrWqWc(ycT77R$p1GSzDkk@pYa~JT%Vx{Y zbZDh=f=eMu137w_!z2~rOifUbm@!bV0y&X|^VPj)25dX7Yjy7v(k0aVx+ZXu+!sqC zsWg-rI)K$tzMDV<@$eknic_e0&G0Z3H1+$>)lv7C~!vC@zF`Uib@?UOzqM1CKjn0(oz({l*V!`_PWwGq)jk~|Xy z&zs#t6=y`&w65OJKw>C4z^2(zSh)`yml5PTgCk*i0?LA+sVEcB(3u|4UF%-K6gXW> z0p$oY;CTQ<6ryem0DQsvzBAq6u+A-}dzaA`=YUOjT$p&p7Ral`fiqo%gMU!Oeg5Is?JswWevwr)}=p*S&7!^_A_C8+E(+Y zq`~$x9jp45TD1R62;Z)8i-)+1BDa*)Eq3}yEGgCQzB6|*(kl#4ZVAD1q_J(MEx&>r zBlp|>GrP14-CT|`PY5kTdf%BlwF@06M|u=lW|Yk+gWbqNkDu8I%Jps)PMNC;ZF}V%3(|upl?m^{d3m=9xfwiOA%#j2jG>XwZ2~_~|?jN~d?@qct?Aq)6 zrSrwk2FDj1X@}naTKh)Zdu=E6f9_vw1+vpluQ=1Hz!(aeNBTVuU0Dfjt>-{zJd`A);I2@#H_0H* z2?!N_EJ`bVv}lX=v-_B zH}!V6x;6BL7_tE%M*7L^!7VYg=m#0KGovOlSdQ*mYygc$)y}&n8nOP+;+BgO1Bijq z#m%4*TD=&6km--@!?hpK5*BXzq)40WSDk0fyOGdE7KtDZoD`# zn0B878mrK*EQX-niNy_|)#erNo2na3Z*1RCPqe!S*D8|vLNi#RHQnA!F5U)q?X|Lt ztd`%2#r5FVS1UgiJMN3x7wu(%H(Fcz;Nq>|9IKVHx}~dQon2gq$f=tn;#sxGw-f0e z#Q$0fm+Et4+&h3REc1c|o7=W-E1WI>HNwi*T{w>&PWK?dxuu2bi2R{HyXe!#6)9u# zRlfZ3#c*A#jVsc`E+yAnv~ewzN$9xvwx$+uCazx5eNv%ciygoh&eQf$LocQlZ_>si zh}aMUL@VsfYv8d)8;|CT;c=rj9<27#>e_L5@dn02ynn3FA0@tWjoop0@p^46f+9X$ zA$3ecj~`yVP8$nWp$M8PI!9_^ajiBMteDTLSmdr>yvDxMyC#wop&!cjhNg)C?s{pa z!wd(1KKD~|BpBvKm3^vZj_~pwh~!rFcQ0Oz=C7$1$;#*B5_5-lLKMT_v*=}%szljJ zlu8Uvri4m#)FOZG+QqA&Ft0mzOt?xYt1vm}8l-(R-xE3*uxD{I&^67X8t5R(fz^is zTa^E7-r|!U0zGy*qdae6+GLex*|C&?Sk7cnNFhz+5z2if8mT!fydCx;OIRi>{EAv= zZL3bpQwy!O>a^-=p|!O-Eq5)nTC35b`2SmUi@Lfd_t)IFyDqp6Ip66FJAUNoMjgOU z*dMev+kR$yllAr14$BWLhs^(BK5hDf>4eE@{By&{4TtpqrGHGf$Z`6epLV{~?N)+Ckw-$2%P_a7;O3jwZ*I_J6Z~-Tr?2%k9VQ`|R5gSMWRA)3%S=-fDZP?P1$d zTVnpUvj!k7u8%yrR%@AsaCwaMq@LK~&cR{3M_%8D19OF%_uSOSJ$p zX>#qs3C079f?E;1#$&EQl0%lKd@ZfgL&yMNU*Ifbbfq5z6czmenV5_`as#tafeX+h zxB2&+)dSjcy;}^gQYl9Dho~j_c9&n=LK7Hy_ng&%T7y>v6jKpU{h?6fG0fy-Mbv1o z2+YG=siQ#az}1w0Xz>w(;Z%h(s0gb5D5g#YXok3l9R@64(44@e0a$7ln1DHLXz_Wl zno#a;aZXc_RsB(xM@Oi)7sqp6lA9S3l+Q{wQ)m;f^FlM&lvL{lk+9s3`sXH(q%*P6 zIBqBby^6?@;|R=>Q4-4l|FCN5N{|2xokFY_Z_*LW*HpHg^De{(3@<(mCAN8Y);277 z7Y*X}gac6nc7!~-vKnV`-6TPU1AXL}e{%7;;IgCg{Yf=7B4iH%r^z_(P#A!{z=v{s zhT$@S2cBT#u_I+T(8wNn2>f=b;z3j_RDTq88&V^}@eq9Pk^R8b?n2*zV{Y5xDVPGP zSc%R+6&ckZ)o2P0z%n(NLd@RCG{wUs8-`b6_oJdH!U_;}0b@s;AG#5@(}ce&59N9v zf*G;%p!=mEtc>Z|VZYzc277kyNEXH0X`UdmS^wjnd zVe>Pa)6{yTDVj1o=BC(x(X^fQGwo3m*`Ku9@l7!O1O~47va)mIv#i<;nR^zAM@Ws> zB1FJq@71nsy}2al6*!3$wUvqoC}ld%v|;;0c!h47mtB(qjyhDEgy&u#;AfF!pArd zfi%Y8h4YB(MZ_Amq<{(Ki)L=i;{8x^eQr%+wXDj|sx7gqYN& zvKiGMvH=ymjZ#4}-rHs7WT6)XHzco{!Iv>*>saPJbs(*DhZaX6<%Swpx}F&F1K|fl zt#t|jmX1rzB9Ix$tCt;}&P@3q?Oja62HW?Y?ygDSs8WXGLNc1@8R9NcR$Pi{0hth} z|A7UD=Mrq9U_+e9hBM^Mt%e8x%z<8j;%P`T`3@% zK2YaJIUdx+f2o|&_=<~H2orjcaueppvy?r-rNGh$FM1&(L<+eo`dUiCW1skaUFZVk zASc)Jqw?zk-w75XseCPEZyb@zFk`?d{!_oNd4-=htA9SoGfyaC1PgAK>3~#&^M@p! z(t}(Igr7hnIsi8}Gynf)-Jk0`uk~!N`@6bc_ov)jUGH!maQ>I`jm{p&PaO|C8tqTp zp0fUhHDhhH{MPb><$0F7EIRWi&CfUAWj5h9;030;j6XBJz_`Wmq~U(U?fReT->IL{ z*Xw==vKReNtTP|9ZQj_eS|}r^;X9R94L8C=V_{(7Sd5jS<3VW3)Ou&W8D!KhH0@+C zZFb}XAP`=q98yHij(ih{w5~)X7VSmyp4bi+8?r0kh_BW8fMwcH`n95k_Piey)UH)c z6rg~e`38L3Uc2m4+waS7!S|cJVy0jj-f%LY?fc-4r|4jUfKVI`vOUYLsNv}oh^VKy zVagQB+?U^6JA+_N3~sNT0f9wmZ$W+S3_Z;;4{C=3yW$g)QIC{H?Wr9~8e|SNr$P;`hGV zzYi3?-;rO7?^m4B)a_U1qwV=y@O5ydIYs?7b7I3Nx94xJ{bS~fW*^^F`^U_s%syUI z`^U^PjE`IMH{xS;Oi2|(oCc=M+zbU*0+EE*aiOb%6w1A@%6FoixMq-pwRy2==8fZ$ zUpUWPUi{~{8-6%{J@ggMoy^^^I(?a5zD#;d9i5Gmb_NduPCq}dsEUqldnHanTW#=d#u4PI49ctvuc!4xBPFtf_Q zmgX(NEiG`HP^=!ILs5i3cDVeK33i8TS?LmXs(yd|YDm9Xg`rZ09bBnJTM)$qE_Yx6 zS=g%0$Ns#xcG9%@&_epE+DX&4LksCEYbQ+`j+IHr@>hWLidzk}u2qzaG@Q*@^B$1g z*!@WL;Y_^>uFdzq!BVe!_SaG6Lq^!|rbPbKEx9k6d4Iz0dV>*PQEKSEp;U>ni8( zoZokT(fK~-tDN)B`<-!TtMfX??;X#elHeO1^NtZmmt(V|&i)Jg*X$p*pSLgAr|f&} zciNw0ue1Hy_KfYbws+fJV$0eN+mJ|X+hDuGrni2_`c>-%>wB$lvMyRDtzqj%tIhJS zmS-$qvV7F?4$G@7&$rxfiCMN<)>`LPXgLrFa8okEYfG1S9UAk$+{>Aj#>y5$Ur{t0}z2*j_Yp8x6 z2h2wZZ(d#U2`=xD4Q_f>;YDX;pf`3v+EJQbS70r@Al9!Z{ghD-3oEnSR`G2(IcT|6 zr!(}F`Ig0tS=Lwh7MaWVcHND}cD)QRl<37RI}ni~`{}gyFcY*0d&u}J>Jj;aj_Zs8 z1N9#H&#)Jg+GrW~MD&k$B_k9QObP_P5+!9DKSFv`Kj%0K#t&a>Y%F?%NCV@CNQ>$Z z*h*#m;96s->@_ibUunx*xK7?nmRtN?G!g5Cm)iKAay-NFSR&Dd^NR7^YmDtB-%1*NSGjhO zPTyJjf-SkmcU*zer9jkyTM7Ty)n!hJ4=@VNhs6ijjhKgMD9WF7BnJm${pR~z#x?RY z^Sxvt^n?CL2sj3V5=NZ)5cM}b?@M$`&ydSzzK3KhdQ5w4^Ff(W;rHePWQy#^k^ZDi zc)!fBJ04383?WgXg8~Q5d&@kh7VINSAR=A86xTk`3y3wTAxRnl^opnZOHT)75j&%C zm^Kaq(u;)Lq;N8xfW=FXIVUHR2CfbToh@;GQOX=hm5kjm-@3O8tLsEVwy`x zgI~XfjIzvYOey*s>y62B?^pq0nsmtVLI2ign088;#QOC&RA2y;)nCtyj})C8F!3hQ zAN(|Lin@(A8@lNMSeSb1jBEK{11QXH>RxAz@IMQMjeA1L9^ zT*iN8T?dt*ggq^CEVfAZ&8v(X4c*F9aMyi<7Nx?YSikP;>K~ywy1$`GrJJfu4h?j5 z4jqiefNdfVBHfp1>QKK$)l_Z+x-T(nUD*KB%TEPD{GS3nKTG+UN%gWaYO7 z8-0JFW9X$~qmPw+zD@U@vd`Oe?=Jg%tL|N8pSSAXS@wC0?j2>H2X%i@_Ib1J?FEYy zRQV~nvuXhf(V`%_@5CKH1(hRur;1z!n`Uxk zZ)t1&30d3%MfR!jz$TU@b>FX-Nr8%_vqJK@|FiJ6PCl4gO)){zop00VY$P4 zhqcYxY;CY^vaYw@V!gq7wbf&FSk0&z_0h`IYBqo*#O?@A(JM-+I34`J(4Do{xJzkI*2dy zEPCcW&-EPl%y{nijCk_5ox8>!T+_a80U<^`X@CQu5CH;35>kp}cN!>IsR>LJ@kj_D zZ8)~ZhPq>WhdLsW0p>tpagI@bpMSJK;3zpulqd~EZ%o1oN*)ee@^kNogxrWo6b30e zk_PrZBF&j&U}_qmq=>8LmsX9LX#`^WfE$gM5~$`F!8`zIgvG08PEy1rF2^T<+J*4% zDZgV7?ggn*t?~^?p`etcM(&>jUIe>DhfL(em?NhQ@xO4MGajNZhJZYp_JzB;3#G9O zp)-UXP1k3@k@lfd_BbGU;1ZI8KUso>LRd{y{!nBDB7R>;ifcxh?unCImvAD|J3^*I(Ie9 z1eL_#ZmMi1;aD%t5z)PbwH>BlE9ShHdzH5p-h~O~&_Ei3gnELIM+v5di9mfS)Gu!W zcW-Un$zJ9A_KW78+j{OQun!&}eA&v4XYf#zlrV&QGQy#kJZyCN%2E=z6(hqCMpw9S z7YfIh{E9goxk`lFeZt$p7@e7(fMJgCBo69I>Fz(ctpz3kKpr6M>^OHNByT*HLxlAT zl4G5X#HG_58`w;4ki{SYVl4&+A5JnXPz#MW1-6Ne+QKg%L=Osb1M`P|j8Dr;&OovX z1QqeOp1Xn+pUg#86kjksmEzF=!QP70&ZHJzbGTUv!CD?_qUOtsUq+cn&grt*q=1z*TRgXl%B-+3KY0{}H;3PO=ef)w}X+Rvf% zRxsdIB?xPlB-%;Y1bj=Z9-|`9=m@1fvH1cf40|TSmF$=Pf?123p47xNW>v&M6AC)! z+3`t~;6ZE!cH;vv-0H`N7+Xx^N6*_&|i2^^VP=g+M zR#ytU!G_lgTJS$`&Iq0xRDjIdc{Y^-EYLKppb&uDj>Rj#*5}>Z@?XKO zxXu2U?YOnu{9)4$!*N5{aHal>`j_eV>2J_It$Qs#e@lfx{~h_bZS&f4A?twRo=M}7 zlfm`hzz~vt!aXfR?0kS(m@2eDgFEtfYyUb}`t^YJubWH1j%oioQ2h1wd_TV4Q6{jp zOuLIe4(9vtu^MMT!h-D7A5%_uVZrUp_u{LKJH$d=6~B^Ky3%cEV}1|FY*PdsY08QeN>0Cr_G80$YzECrJW+V5b5Wy-If; zfOk+1Aq^-Qg0v=9zmFY4Hd7H1`3WOUIZjv;r%z~3k=PySS->4*cZax0lAe8ZG=*ygww+3$I8AzV6rq#U8gSz^ zTELM;rH{1kV;#`xlk1ZJ)n$BgZz@yopFfrFK_|B7ym_avHm=Z#e1lj^Ty3QwWNJ^T z%A_DI8Ip&Z$-#D+(wSNcS^=idD2#~Ciz6(wnCq?Z1FkT1?s3|-5;1nXrF%EpGXq0J zIScwGrpJ(C&$hNG^K@LgCnE#kC4=PHX4b`W56gui*e+1oL6ZcN)*HddIH*dSsp-$a|~bZV;Jzk-d^Cu~6=O1M;$ zjkwSm*eeqT$2iP>PRLX4d^kvrQwV#O1X>Zy_Q~} z*?=zp{PUah2y$B+KyZEy6I!hQ5{?5Lqg8VUH*2LgxN3{+znn&4(UK{nADF%_gSxin zBg}ZckJ_~xFFYNWP+vvrl20ep^#xPZpsy7ykgT~>i|j^Uwl-;7rp4g=59B-T81>$q zzOvrfE{bN32(FsrEVvAtDY{=OG>m1;DM8Q3TE!B;1rQL;H`Co^u>OI3xJI6Gmd(ZR z?5L3^3v9l~c7|%?Dd+U8zMV(&cVY7sP(eHv&Ee|qr2S9%R>EmiwHpuRcOg1ExKj25 zU!aG8uM>%PplmEG2>j%fA8E%WN6WLji6g)-NoNk_?_3>u%7V~Be&_1QQznEK@^`F` zJe^ZkCf}Fe0rCN_c*nuJS(U|Hs(XRDpzCZrza8|{6(cLt<7t_8= z^X)YgWt~@@=(d`PvM#Gmw5?{MtfQ(C-JRbGqARAdN`16KD(jJaD~NTg%0pM~APF8E z$*fWhK&2+Mlxa7~ar?1XVU@WhM3B4kEnuQX(JI5FLT)_EDJTDblkV+0&to|M>+0TC zH{kxg`*rS+>si-=Yq!hg{4#0)#GM|X`7JoY_W!oO%^tP=2!8b~)~{KotWL|@ED`g+ zn_p{=n0{({xvAawP2&Sbx8d!E34>q%bN&1Dv-*JUr(jU-9~AUXId@&x7|2<%4Jd44 zi!XR+jD$`bFi}QVJY}+*1coEaz40h8jG$6}pAb5&*G_|7Se4Sa>$DCu%Ha_V$Q#(g zjRte^vY-G_j2-z$Ktl7S2)T(F4f#^!dD_2bpuOs^AJ+c0phlVZbG3ghD3E`>J^v8C z)_N&Y)a+FL6h3Y*10PUE?=4L6Lrm$IK?~{ev;mTeb*pzk*-7l4Q~8tHd2rRPg2#i} zdGHQc1&&wv?(N2RU5SF7ctepl+BrK(ISN;fSXx-hF zRmYBe3M4e|?i3pG4(qV?uLWhwUk_>jT2Q0>^?lmE78EG&?(W6cT6cFv%|4hvgpb>` z?IyS#DmxbD(Wd)gwz(^_tvi^%M>}z@(^`lh)J~kMycXgIv=iq&qMdj=zaPZgR7io9 zcLCI4)o3eQw|yY0dFw`0F;yu+3A_(^jG?M0lli^c>GD3*NH?jSF7G~#bO*K5~nxE2=k3=l@N*&*(fK^*rcVS9hUqxX$E$vwOGe2d=zpo%7?)qmKV_yv%XC z{ga5zzs~jz+pO(o>nE+lR*U8BmYDe=)6=FGn_7(DLiInN;VHw*44d>H*M~sx5BSfi z)4*lBxldK%nj7-qQ3MpvPNZ>Xs^sQ355)m|8%Ll%vh|V8L21EIJ;TFUgX>NGIhG&2ueTIOS?f#-H}n|HEgvnRH^xV==XoHv&$ zM{*67Ybj1*(nVp`KYkiGZ#TD>0o16*saUhxc!f^`2JYrYcd%lsl)C{LaG`L*mm8D~ zp9UaYRVvCFBvUC&Lkg7xr-2Q3bHH7SS}3=h$Fxhp56sy@dtRA<+QkA7B*Lp3SWI}Z z{=jKq$7!d{oGRLB16NKvZRR}DP8*nW+G#T2kZ zKUq>Ug0^woTP>E1ICvU>eb`=#NMs2>PVUZ7yiZ!U5a~debfbVqr}&dp2KG26xkO;2{<}^Cq;BOy9P^GB zug5*So=wN;Yd~VpO5TRDB)II5HFX+rbJ$=Ht*Ac5SwECGz+y*vNeDr8C#Qz7QcsPa z;t^(Yu=v#2Y2eObKX{@t5Ao_v?L01f@1}AhD``8y`mxi1q0`E#H~^qVPC%v8%Bk4x zUn3`g)M@2Z?Ch_JQ{ptR>u`5=x0Ze?^6`s|Xn~{Uheu`{J`ET;?H8}eOXX*ES~AJF zM9Rueh0+{1O-%E5^@mRbUQR2E;Lz$=05(o5i{@3b03MuH7J*f<*nb*$aGEb(tLh22 zbgDi$bQ(Bt+Am&vW0-tu=;A}CZQ99G7uQPOs+~M_Z>{7l+R0Pb)<%Btv>D{JUc6SM zUfiXj(o8eZ2TmM`AKkC11xx=#2a+Uc%%|A4M-TZO$+s!XC zKVrV$oJ1vo&E{)?G4K=9-kTjG&pWcnI1JwnGTpbO^v1-O=jcI zjZYgtYkaTqHO2*F#(1x>#~3u;Vze86W%!Qai-r#v&Ks7IrI2#PT-#k6Tvs@MZ`)_< zwS{bm@38r7*Vr7^|F-_h`Y+aJtY5c&-uf}?`>b!ZzQ+0z>ly1q)){Nsde9oPc3Ru5 zexMm#Z*^M@mfrxu;Cq$?@*$pfKIweI`3~o+oy*RLoX4E^IQKYrI5#`jIGw;e{ITO{ zL`pp2c!%TFj%CL~j$@8{9D5u)fPc6KcnZI_|JeSt{Ym>1_IKD{ZC|!OWIty4iY4b- z96`yBU7M$cwLujYr20cHEJAl0IKf5}gjNyi9gGepfrs9g=w&CEqv`RnqW~11RPsvk z2GQoj<5@z^Q>$!tp%NG)cLbQ`7QWMB}m_P!o=~v*5?K0htbZ^bBk&wwuOVA3+ z`B!D@sy}4Iuh>8sBaENf9o$tV?c$*BQbLH|7t4%Las{%ZX8z- z1EPTi(@X0_As+`b#(q%V}1B-jXvE3`y zh)^+6{ZWmha(NE`Y^i1C8}83-UrgHD3CcrEK2$O4o&f$%COZf~UBV-c;NIo#B*0Ul z)i@y+z`25=RnsSYGZU$)G#}RJSKRK%<;Nz;0fP~ZA2m?I))wU9v0;Jotk@B-t4ZHj zW_k`6m2}BU;2o(6WW|jjt8R7{$#J-cMTQa-hj9dx%+3uD182rRvN&iX;l=2sm1GC* zX(Ga8XQh9+?36?#NxKC`$?Lc?gmbbAqk_Jl}MVqmSnJBVsDb`kvB2owky z=t91-q;5)~Gz|?)o0cW;l`kcf0x1a)mh?aOzW3(6Su}TKV*>T(@3ZH=ch5QZ-gCF} z&N;uPO(c<1;hIeaYBe{pk4S!Dr-~E$Y>iMcy5QMvu$I7Zp5U(*{2YP2Rz~gshI4IhWciz z=R0%DU47`~mg+Z@Qjr2O=~mEe?0=pJK5zTj(Gfsa;v9|~U-YIT&_v?GIlnIeIksTT zPtx~D{L28gfgXI4t38E5l4?&Ok_~Od))zouQ?>|hzh^|%kU2Hw*NoTiYi4w5x~Mc^ z%V%VbFwBxdt0}~3iUQF zME}2go9_Q>E8bR7b(Dsb{r1THce(U4bn=Q{-?y{^k zKV;r*`a4r+lmGpPErC13pMJ1c{KMq_w0(K~*H( zyr+p*R3t=|=TDK661g*jypKvtbXf#Cof)w0sjh9-2fr2`GT7?}_pv3LE!8=Bi`X8M z?JMl?(?>9PN44xml-DQ}#1Wl`Uw7SF=R1|@2k%V+ye~QH$;=I4UDK?;4;F2jo5?Uqao+_o*7kOV`8zi_Dm{AMf;l!(Eufx_uevV)p!BAb>0 z@BXe^eYa$K1)0Hx=Hi+42r|oQnQooQZZ9QU)-A{^r)4W)b~>}4uT(D+SgCkF4jc&g zVeuR|IWn72{S+u{9*OA+wT#XRw5#V$P2cIvt3-LLExlyC&v-IXFb}OeTPx6TTpS0G zH%!6BW9txQDL_JlX@=WI=hTF5$N3`!0F8)H`h6T(FPv*69zjD9OEkf+C(3b^24Nca6yy@o1&jmcoF#Ns z?mgLSuWA@MK&vcT0HYO7SDv}*0rXIa~0E{-N?DSB^hdpMd z^tq8E6G*g&e1Rqwh=Y7$>^Tc!iD*NMokQ>T}N=` zu>Lh3*5Buf?UCKF14!)$NCMRsn6rH$S7zS+&yEbVB4vjIDRhwL@EA}4mpeBkS7q8E zS%ZLN_-c4L1zVG8qp5q?kmR9QpLGG_9I0RAZcASJ@s2PW4CTCSTgoLCUGCLt>gNU5JLow<8-(2xPMb+6V$@3=9 z74CPquX6pP>sD8b^XJaLcAjuHJAUf;q+`wzu)koxU|&Rb{;O>NYFn~}<>%!O%ctcI zxmtQkdI*>R`y`k3>(+DDVXMXR3Cm3ukNIKqjiwh(?=?kC*2>RRE>t#Dd=IPy|0GSB z9nSjNa0($!a0qVHZQA_X!Jy*0u6l1X%sp~&!j6C~m;0JCzl%r;flW1a%QzLQG@2L@ zJpsL2R3wEECm}ka5_AylBt!?Z7EpA>|)R0K6@-Fk0?Vyb|q z=vQfVHqvL$30M-kQtg7ISg$Nks!fm->#gNU?Gq%$dT}{YdsAVM+T`9=az~=x)l*`& z7wV(t6f(16RS;1&7Dj|4CJk(J3Tf7a>KQW@H>Z$4O{ktRS8;P{mtZ|(mg1(=PSo2b z&^|+c;@zoMRNLUr&t#UnMZ{bDc^%v0-?TfmLzKYwd;;5vK#M4WZTSSY5rOTZ1h(cA z*h&Ppi4theC(uj;;EF2NiB0(gnutKND1lHufe;aB5+xAKClDk8AyEQ>d;$Ugrk+#~ z1ZoR}isdO1?yOprIDJf`SUdHw2%9x|f^PElqynP!aOo#{?7m-=-l>!y^!8W3uILg% z6JFiNn{oq*G>0A4HANK|-@{AA6rV~pmccr7X<7TqV4WXJZHz4@(`WIR-!Wq(^{G@t z8LaaosV{={t*I?wEgC_dKh~P76!VlKV`LfazBN@}2J`&Uz9{An&kYstue`Cs)9AU< z{k;3@?$5YC;C_qyl>4>rPPgB^%JqWl+pd3hea7`s*WIq;uBfZlWp#cBSpomX`EF<0 zIqe*F?s9H)UgfNG{IIIp@m0qoo);YtI^OKK-7yK@_g6Vu9Ge|>`;Y8jgV*~5_BYz6 z>;u5{TW7c0o`#42L$VP%)y!;*c3FH^}5WMlv$TRYg+zLEFi}Z~2 zAMissFWq1H=E~bDC#!y3_06g;R6SJnHrOp@t46EB(rs0>z$EOH>ZLW3$@&B9lh!X; zAGW^N`X=jX>v8Kr>pnyntg$*Rzp#AQ@+Hf|mJeIrZF!?5VL4=Jv(#CxFh6g8()_6T zy!n3fTan9P+&pC7XZ9niz-{`K>1opwrq3d$;XS6?OjD+VrVdlkwAN&*e9rT%=c&pY zJx_Q(=Q)q~hQ})(uGAqRRGF_e5xjAn#qG*5`aK+t9Kd|V(jkWkEm`@^z@6nF0j6e4 zI9lXTpBx|*|O1ok}>}F+OAvUb6JG!rcFYKdX0A7lrZXfN_2s1QEVM+j>2VE911J>1iWa9ZxDT=6bqp(_U4iu*}E zT^WF%@5^}&Nfs*pgs`SH*dobESkwHcW`&g`>H?)u2Ph;tbT6}VYObA_=wFWYO19h= z!C8{Cg_`|SvJyHtug!9&Nzj690p61;sOywB7^;V4Hk9BU>xr<11jUQ(3Z8j; zB-*Bde!rd2Ve=k>zU6HMJev0;)@!+^U=?t(yp{BXs=&1qK`7mdDt?Gjf?h{@BK@6_ z-nN5rhKO!?12tiIAPPikf<-4V#EL&98pF`n?iiWm+kvf0di8oz)M4NR#`Xdwf;hRj!%Dz{Ld+bc!@^;C;7!h?T3c?RPud=J_}Q| z{#I0H+JsT!!xY`xhYV@c?_G-Nd z9FGIx{`NS!>l#92X0+5oZL6u#wjjeJ`D(3AYPrgAoi-%;C-PcNGNo9)N|OvrMX$U@ z2#xU}&H8?P3qZ?d{UH2#T3C zxGjw3h(?#j5dL7{{z3W5EJwrB7P-2}lkM`$i#*vTzpTiUt@0H`o;1rUto+3{| zvb)HWpzJF0Bp^GBJn_p8Z89oHf%a%ym>^o)4gz&tdXlzA)`38cj{BQsyRo22wiydT zvTQ5}%961lAhQiPOVlr0G?fT<9|#{Di1&qiNng#H*t&A2L?(6O2jgr;(VVDNrx2`^ z(SE|(<#68eE7g|+*_W?VUxsng@bYTAc@=B1T*+T)kHj!x+hUkP$_n-8>;aBU=qiLS z^}(|zekjr(lRm-PVSE6|q>mRUr<1_>0_9jPq>nM34);f6JrQK%Cx4}0mAl14Yst_6 z0n*}G|4`qc^g&kC%W@M*} z$m}ZdM$qgm@kYSx@S59}lf!JM6=aZ(4b<0`v!qb;vkOrGI4D`ps0COV!U)5%Bx#|e z2#0t$+Rq1w4HZs0=E9FK92QgIM;H-HB`tP&K`fS`++r3VVi_d89gM{goZA;4LT-0; z&yhaBE9jq)f;>oyz_lxmO;J7iRo+C4zrMT!X-X`wC;d}P!=xbnL+!xCwA{JQ95z1K zha~89A_9cgaKv&4Er&|QVV!!htn-!%*8CWYLKzOX4Gs0gI}nUA)E}|5vN7b3pj5qd zR#Vmq9xG*j5np!|y~00(m>2u{!}}u|g&iBs`&HJ(Y$s+*ftQqfFKGa~x=^krxt|oC z{e)vxyaPHe-$2J8wMg!xU5Ncc1SWglt6hGgy>gGbA_U{?C0aiAsAYj{*=}tCQjaPz zcs0a&2jyMVBWw-J9)FCx)=qYar7apOS*vyc7(Cd=4*)x~ZIO!P7V3RQ5Gbd>ccUec z50$)Kdo<7+24)^RlkmIbHf@{FK9aX;%(U&jIeQ0%YtEqDMBo2wD_bk7{-SEU>T1uI zJ!#Ld=Q>ydKJT7)uXlafb%$%S^I7Moao6v2Jc~?#TkZd5f0zA`eXZ>qwm-AI)^>&b zDfuSZhP!!_^;6cJmPbsFgWQV#Bx^E*&iXZV$siZqE-!91+v4T8ZGf{v zQn~2`K&c{e7uDBL@s7-m0Fa!y>r(PUpF;!SnrGo!h!RpPaMKG@H&dt`d|Gr-w3``} z8JCVkJUU7AyZ~n(oj3}lGgT0DCre2)q5#QdvjzfMJKED1>&Gc=aEIa=%Yf9-;WN{a zdt`QYa$=Ohb%O!P-aaySd|)0JziK2MYe51|HZruNcB57+!jq~sZy17sg%Y1n~&$r-*&FkV|&Z;$ZCC@ob3$u!>7+Jxeq%v%Q- zG*VL}mbmN#emadjDzMVuoUe7djhU_g z0I7WhXBp0TDo&^}&!jb!0w#+PU1lFDgD%iogy=H6P#JV@7o;ncepBm*{i)kPcayuP z_bWxuk<(QDv|#nz$)Qv~ zC0IRIzghLx)Jas22(&%-`yI)?D|G_3qbsP*4~wr!Eimq-$C2Zw7(t3OPiXAP%J(}h zVI~2zye2g-NWD*j!-BCm=mE|dSsx16p%~cZn&rPx_~Qm4zNQ0kZAsf04ovlywhnZ zd~EUws*8Ip1&k6QiaO0KK@qSdLKJn{Sb`#uNQ5ZrG;mpp4Jm++z!BJ8a&&kLWJJuebA5sB#6-ayoH^qBY0oPUsBVqf*q2Grba<2AmA3uUA@U;OTUY$ zdPEr*qbY!a2+`v1-pkVhDu@s*?()4nEntKQ(c*63%h96y|0^rLS>gGD`x~wwyWZ&> zas0;qMcY%h2W*GsU&{B(tEG2K)z&v#*IPbi37J1;mP|KR-h&GFtmukgy3$l;%28j} z($Hf-1mq@wzLB~42|#c6(cM1L2itu#+^rY`!bkx^8Spws7AWwG(uo{R`j__DL$z(y zNgNlu#&Fe~nZ@PFJD^-)`)4S7PutA&{M^iBe~irCNZ{B>bfUd5T@Cg}d-S;lvN69L zp#oV_;leV%aD-B3*Lks1g53NZ0F>dAM7LQb)rN9qraa)hT)RtMIEPF2^n9Eu#bM=` z^(8x(c0=ZtND@Kif-;lsPZd|a&h|~r11Fnf&~L79rUx5g! zdJe*3*Vr*K36tCAz!tAxub748fl|&)H!=|dTl{stV@oZNAw;>g%kBo|nx~rxMftH5 zUK2~(skPN3E8)a^vjkZ2{94qt+c3Q0>LkMI#M;%?Jv7+XB{0IHlMA#WC??P4##dh2 ziWaxFAaUACWDp%jTbtxwb3IE@_)V478lm3h=CMy2c+~3PFTA${kmZ2wb~Q((;cSgGzn? zzBL)x`r=DLY9eKdD~mqYX^jC?w@Cl`!b<^g*h9xsF%BG#gEF7--5A}ubIA`HEC8ky z4OW|1)J7vYw$x}3HMI7eK}L)M1XQb$dJ5=@bVuRbLLbxA&r@XRU>0KSU7p7(n~0rl ztGQmua4c34GxXacrHG(R4zy3ga;(lnKi^=thPF3RW{C;RR5b|@R%P6|8H`AQty!t) zI}=#)QA1;t0b_*?#fHlos%^PS#x8wJ_nk%-VVuk?y`I3m5G{ATDTwJ+H51BWg8<{{E8tXJ9h z0tk?mQc0xYyJ@Mx7HZf<5NIpxG78VcLPJ*wW7EO!D894*pMj_UJ6(Oqm;ab^(b?&=IQH4!XE)j2VXKmp@*3%*QoHrj z*4J8oV|kNhz4@5wMCGj&k9pqexx+JN`HE+c$Ls!;`@8PHcR%F*WB2RbD>{5A@r2+BexfRsU)GsqJyw2QAg+7tK$b z|H=Fj^WElTUAoEMx^7c-QlCn{gF68z*Zc@zB`Ild0v>?F$_<|)N3^w-U*5))*R<<= z0}=@O{mn=r*i|U|CflG!N3hkf(-1QPU<=4DZAU4oH%_*czjJ9Z1Rw zw+h;`Sz@ah+(HeYV7jzMe0v5tWT2X*FZ%cxKRE$U0-k9XNw27%nI|EM?`8R|e95VF z5N&VlITI?~c6O}OsHlgrzL~@XeQ`!gAZB|Ak+{qVk-bW4t(wig%uinGd1CoPglhIc z?`e6Q%4je|y>L!x@V0aS4GtVIq>wB!*jj_Rda5l3pw^UE*OJoBW?HD$AlnTcI?HYP z(X`*zTElMInnQ(A@bCoE)6YyN+K~Pa=Vlz=HCHs+gOMTy4hw8rz$!;?vQydI!VyYv zu8NoN&88c{s4=QfK2Vqu>mbG^*2{uiutRzKlri=e*9Voo%F}l^?E}j?L$d55EU8h% zjk!{xS=t#{G4?P=BZUEvNxX30_nLGA=o`UfO3){_>U{E(V@Gk4QTmzeM*&bzTGCrU zzB#H-OS~+3O`*snTxO>+Wx}m*+&gv(*`aZ6SHMJk$I|r>qaoZzEhDMbhK5a+g ze3FjzW_xQ5PzH)`0|mQJ@o`oCfuS=zJ-uO~u|YdBksuc<+(u^^jDULQEM(g=rEAgb zJ?mg`DW}<*FzRAO?sAc7=GjH|J@SsKqf@XV3-aV|Q5>oC@*?jf4f8GO>mbAik(Gc? zOXZ5bqBS6SO?ndsptjnOLw$Lz5>hB-mR>kl#@?YVYK()rTV9pk2nlhl84w;70XJZAgBXd9G{9ch*1Fqt8fBoO@udJB8ss8Fdqz&L zg+J(~b!YVl%|fJDSRuRV($Yot9MTe{(FW%~I%s0))kYt^9mtN5!M=r`(UY&*9d8lY6h*D}NDnJ}$pszE8dzmV*U(LOv|tAS1Rx zZji5)SIKti*V0d3RURfefqzf8-i-9dz|bAClf9-796JJEVj(E*+No zU9GM<*J@-Q_?h$jK;V1K`8nsuobPeI#d)W5#u>kJF^oK}#_SEb2_f4l?2mGk^T*@wewD#<1wA3!u6>`t z8bqK&`tQJVi@VEW3Rj((%O>kltkgW*3n< zch*7d3+D{Fr+x*FFK#c3gDSi@hZ`5Sfx`yFWv@URAYD)i#=@M@tI>sX9B%~I8vt0r z$!Esf8y;3leK#&{6(k>63Hjm0W{|(uV8tjPy>g00BeD~o_qci?gvglrG{@_79v>aVS>eT0m&K{!(g4Sb1?)i)dq*$ z0^*~lN>kNvT8rth!>khUL0VW0f>LL--ujzMsV&ySHv;^p2bt7>Fuxqk8{Q(9Kn515 z!o1~usb5$Ol*bpD*e(U%WOUJw9K+%2Gx}(!Wm}Aa4FRVEp`eq>ptrS`xp8rPg;E6d)go4kO$e*E>zY)WZb~hGU3VRQ4p@KFjY$@JA$Nm;-deOu{#_xCKl@HB;p= z%m+VtWktO@m@!{YN(J&6x Date: Wed, 1 Nov 2023 14:27:47 -0400 Subject: [PATCH 53/65] [WASimUI] Events view now uses `CustomTableView` and all its features; Link up buddy labels; Refactor for `DeletableItemsComboBox` changes; Cosmetics. --- src/WASimUI/WASimUI.cpp | 162 +++++++++---------- src/WASimUI/WASimUI.ui | 334 ++++++++++++++++++++++++++++------------ 2 files changed, 318 insertions(+), 178 deletions(-) diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index fc2dd0a..21f07ea 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -101,6 +101,13 @@ class WASimUIPrivate client->setCommandResultCallback(&WASimUI::commandResultReady, q); client->setDataCallback(&WASimUI::dataResultReady, q); client->setListResultsCallback(&WASimUI::listResults, q); + + // Connect our own signals for client callback handling in a thread-safe manner, marshaling back to GUI thread as needed. + // The "signal" methods are "emitted" by the Client as callbacks, registered in Private::setupClient(). + QObject::connect(q, &WASimUI::clientEvent, q, &WASimUI::onClientEvent, Qt::QueuedConnection); + QObject::connect(q, &WASimUI::listResults, q, &WASimUI::onListResults, Qt::QueuedConnection); + // Data updates can go right to the requests model. + QObject::connect(q, &WASimUI::dataResultReady, reqModel, &RequestsModel::setRequestValue, Qt::QueuedConnection); } bool isConnected() const { return client->isConnected(); } @@ -514,7 +521,13 @@ class WASimUIPrivate return; if (reqExportWidget) { - reqExportWidget->raise(); + if (reqExportWidget->isMinimized()) { + reqExportWidget->showNormal(); + } + else { + reqExportWidget->raise(); + reqExportWidget->activateWindow(); + } return; } @@ -701,7 +714,7 @@ class WASimUIPrivate QSettings set; set.setValue(QStringLiteral("mainWindowGeo"), q->saveGeometry()); set.setValue(QStringLiteral("mainWindowState"), q->saveState()); - set.setValue(QStringLiteral("eventsViewHeaderState"), ui->eventsView->horizontalHeader()->saveState()); + set.setValue(QStringLiteral("eventsViewState"), ui->eventsView->saveState()); set.setValue(QStringLiteral("requestsViewState"), ui->requestsView->saveState()); ui->wLogWindow->saveSettings(); @@ -718,7 +731,7 @@ class WASimUIPrivate set.beginGroup(QStringLiteral("EditableSelectors")); const QList editable = q->findChildren(); for (DeletableItemsComboBox *cb : editable) - set.setValue(cb->objectName(), cb->editedItems()); + set.setValue(cb->objectName(), cb->saveState()); set.endGroup(); // Variables form @@ -738,7 +751,7 @@ class WASimUIPrivate QSettings set; q->restoreGeometry(set.value(QStringLiteral("mainWindowGeo")).toByteArray()); q->restoreState(set.value(QStringLiteral("mainWindowState")).toByteArray()); - ui->eventsView->horizontalHeader()->restoreState(set.value(QStringLiteral("eventsViewHeaderState")).toByteArray()); + ui->eventsView->restoreState(set.value(QStringLiteral("eventsViewState")).toByteArray()); ui->requestsView->restoreState(set.value(QStringLiteral("requestsViewState")).toByteArray()); ui->wLogWindow->loadSettings(); @@ -758,14 +771,11 @@ class WASimUIPrivate set.beginGroup(QStringLiteral("EditableSelectors")); const QList editable = q->findChildren(); for (DeletableItemsComboBox *cb : editable) { - if (set.contains(cb->objectName())) { - QStringList val = set.value(cb->objectName()).toStringList(); - if (val.isEmpty()) - continue; - if (cb->insertPolicy() != QComboBox::InsertAlphabetically) - std::sort(val.begin(), val.end()); - cb->insertEditedItems(val); - } + // check for old version settings format + if (set.value(cb->objectName()).canConvert()) + cb->insertEditedItems(set.value(cb->objectName()).toStringList()); + else + cb->restoreState(set.value(cb->objectName()).toByteArray()); } set.endGroup(); @@ -792,22 +802,22 @@ class WASimUIPrivate #define GLYPH_STR(ICN) QStringLiteral(##ICN ".glyph") #define GLYPH_ICON(ICN) QIcon(GLYPH_STR(ICN)) -#define MAKE_ACTION_NC(ACT, TTL, ICN, BTN, W, TT) \ - QAction *ACT = new QAction(GLYPH_ICON(ICN), TTL, this); \ - ACT->setAutoRepeat(false); ACT->setToolTip(TT); ui.##BTN->setDefaultAction(ACT); ui.##W->addAction(ACT) - -#define MAKE_ACTION_CONN(ACT, M) connect(ACT, &QAction::triggered, this, [this](bool chk) { d->##M; }) -#define MAKE_ACTION_SCUT(ACT, KS) ACT->setShortcut(KS); ACT->setShortcutContext(Qt::WidgetWithChildrenShortcut) -#define MAKE_ACTION_ITXT(ACT, T) ACT->setIconText(" " + T) - -#define MAKE_ACTION(ACT, TTL, ICN, BTN, W, M, TT) MAKE_ACTION_NC(ACT, TTL, ICN, BTN, W, TT); MAKE_ACTION_CONN(ACT, M) - -#define MAKE_ACTION_D(ACT, TTL, ICN, BTN, W, M, TT) MAKE_ACTION(ACT, TTL, ICN, BTN, W, M, TT); ACT->setDisabled(true) -#define MAKE_ACTION_SC(ACT, TTL, ICN, BTN, W, M, TT, KS) MAKE_ACTION(ACT, TTL, ICN, BTN, W, M, TT); MAKE_ACTION_SCUT(ACT, KS) -#define MAKE_ACTION_SC_D(ACT, TTL, ICN, BTN, W, M, TT, KS) MAKE_ACTION_SC(ACT, TTL, ICN, BTN, W, M, TT, KS); ACT->setDisabled(true) -#define MAKE_ACTION_PB(ACT, TTL, IT, ICN, BTN, W, M, TT, KS) MAKE_ACTION_SC(ACT, TTL, ICN, BTN, W, M, TT, KS); MAKE_ACTION_ITXT(ACT, IT) -#define MAKE_ACTION_PB_D(ACT, TTL, IT, ICN, BTN, W, M, TT, KS) MAKE_ACTION_SC_D(ACT, TTL, ICN, BTN, W, M, TT, KS); MAKE_ACTION_ITXT(ACT, IT) -#define MAKE_ACTION_PB_NC(ACT, TTL, IT, ICN, BTN, W, TT) MAKE_ACTION_NC(ACT, TTL, ICN, BTN, W, TT); MAKE_ACTION_ITXT(ACT, IT) +#define MAKE_ACTION_NW(ACT, TTL, ICN, TT) QAction *ACT = new QAction(GLYPH_ICON(ICN), TTL, this); ACT->setAutoRepeat(false); ACT->setToolTip(TT) +#define MAKE_ACTION_NB(ACT, TTL, ICN, W, TT) MAKE_ACTION_NW(ACT, TTL, ICN, TT); ui.##W->addAction(ACT) +#define MAKE_ACTION_NC(ACT, TTL, ICN, BTN, W, TT) MAKE_ACTION_NB(ACT, TTL, ICN, W, TT); ui.##BTN->setDefaultAction(ACT) + +#define MAKE_ACTION_CONN(ACT, M) connect(ACT, &QAction::triggered, this, [this](bool chk) { d->##M; }) +#define MAKE_ACTION_SCUT(ACT, KS) ACT->setShortcut(KS); ACT->setShortcutContext(Qt::WidgetWithChildrenShortcut) +#define MAKE_ACTION_ITXT(ACT, T) ACT->setIconText(" " + T) + +#define MAKE_ACTION(ACT, TTL, ICN, BTN, W, M, TT) MAKE_ACTION_NC(ACT, TTL, ICN, BTN, W, TT); MAKE_ACTION_CONN(ACT, M) +#define MAKE_ACTION_D(ACT, TTL, ICN, BTN, W, M, TT) MAKE_ACTION(ACT, TTL, ICN, BTN, W, M, TT); ACT->setDisabled(true) +#define MAKE_ACTION_SC(ACT, TTL, ICN, BTN, W, M, TT, KS) MAKE_ACTION(ACT, TTL, ICN, BTN, W, M, TT); MAKE_ACTION_SCUT(ACT, KS) +#define MAKE_ACTION_SC_D(ACT, TTL, ICN, BTN, W, M, TT, KS) MAKE_ACTION_SC(ACT, TTL, ICN, BTN, W, M, TT, KS); ACT->setDisabled(true) +#define MAKE_ACTION_PB(ACT, TTL, IT, ICN, BTN, W, M, TT, KS) MAKE_ACTION_SC(ACT, TTL, ICN, BTN, W, M, TT, KS); MAKE_ACTION_ITXT(ACT, IT) +#define MAKE_ACTION_PB_D(ACT, TTL, IT, ICN, BTN, W, M, TT, KS) MAKE_ACTION_SC_D(ACT, TTL, ICN, BTN, W, M, TT, KS); MAKE_ACTION_ITXT(ACT, IT) +#define MAKE_ACTION_PB_NC(ACT, TTL, IT, ICN, BTN, W, TT) MAKE_ACTION_NC(ACT, TTL, ICN, BTN, W, TT); MAKE_ACTION_ITXT(ACT, IT) +#define MAKE_ACTION_NW_SC(ACT, TTL, IT, ICN, M, TT, KS) MAKE_ACTION_NW(ACT, TTL, ICN, TT); MAKE_ACTION_CONN(ACT, M); MAKE_ACTION_ITXT(ACT, IT); MAKE_ACTION_SCUT(ACT, KS) // --------------------------------- WASimUI::WASimUI(QWidget *parent) : @@ -863,12 +873,18 @@ WASimUI::WASimUI(QWidget *parent) : ui.dsbDeltaEpsilon->setMaximum(FLT_MAX); // maximum lengths for text edit boxes ui.leCmdSData->setMaxLength(STRSZ_CMD); - ui.cbCalculatorCode->lineEdit()->setMaxLength(STRSZ_CMD); - ui.cbVariableName->lineEdit()->setMaxLength(STRSZ_CMD); + ui.cbCalculatorCode->setMaxLength(STRSZ_CMD); + ui.cbVariableName->setMaxLength(STRSZ_CMD); ui.cbLvars->lineEdit()->setMaxLength(STRSZ_CMD); - ui.cbLookupName->lineEdit()->setMaxLength(STRSZ_CMD); + ui.cbLookupName->setMaxLength(STRSZ_CMD); ui.leEventName->setMaxLength(STRSZ_ENAME); - ui.cbNameOrCode->lineEdit()->setMaxLength(STRSZ_REQ); + ui.cbNameOrCode->setMaxLength(STRSZ_REQ); + + // Init the calculator editor form + ui.wCalcForm->setProperty("eventId", -1); + ui.btnUpdateEvent->setVisible(false); + // Connect variable selector to enable/disable relevant actions + connect(ui.cbCalculatorCode, &QComboBox::currentTextChanged, this, [=](const QString &txt) { d->updateCalcCodeFormState(txt); }); // Set up the Requests table view ui.requestsView->setExportCategories(RequestsFormat::categoriesList()); @@ -879,6 +895,8 @@ WASimUI::WASimUI(QWidget *parent) : hdr->hideSection(i); // connect double click action to populate the request editor form connect(ui.requestsView, &QTableView::doubleClicked, this, [this](const QModelIndex &idx) { d->populateRequestForm(idx); }); + // Connect to table view selection model to en/disable the remove/update actions when selection changes. + connect(ui.requestsView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=]() { d->toggleRequestButtonsState(); }); // Set up the Events table view ui.eventsView->setModel(d->eventsModel); @@ -888,21 +906,29 @@ WASimUI::WASimUI(QWidget *parent) : ui.eventsView->horizontalHeader()->resizeSection(EventsModel::COL_CODE, 140); // connect double click action to populate the event editor form connect(ui.eventsView, &QTableView::doubleClicked, this, [this](const QModelIndex &idx) { d->populateEventForm(idx); }); + // Connect to table view selection model to en/disable the remove/update actions when selection changes. + connect(ui.eventsView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=]() { d->toggleEventButtonsState(); }); // Set up the Log table view ui.wLogWindow->setClient(d->client); // Set initial state of Variables form, Local var type is default. - ui.wOtherVarsForm->setVisible(false); - ui.wGetSetSimVarIndex->setVisible(false); + d->toggleSetGetVariableType(); - // Init the request and calc editor forms - ui.wRequestForm->setProperty("requestId", -1); - ui.wCalcForm->setProperty("eventId", -1); + // Connect variable selector to enable/disable relevant actions + connect(ui.cbLvars, &QComboBox::currentTextChanged, this, [=]() { d->updateLocalVarsFormState(); }); + connect(ui.cbVariableName, &QComboBox::currentTextChanged, this, [=]() { d->updateLocalVarsFormState(); }); + // connect to variable type combo box to switch between views for local vars vs. everything else + connect(ui.cbGetSetVarType, &DataComboBox::currentDataChanged, this, [=](const QVariant &) { + d->toggleSetGetVariableType(); + d->updateLocalVarsFormState(); + }); + + // Init the request editor form + ui.wRequestForm->setProperty("requestId", -1); // Update the Data Request form UI based on default types. d->toggleRequestType(); - d->toggleRequestVariableType(); // Connect the request type radio buttons to toggle the UI. connect(ui.bgrpRequestType, QOverload::of(&QButtonGroup::buttonToggled), this, [this](int,bool) { d->toggleRequestType(); }); @@ -926,14 +952,6 @@ WASimUI::WASimUI(QWidget *parent) : ui.sbInterval->setEnabled(data.toUInt() >= +UpdatePeriod::Tick); }); - // connect the Data Request save/add buttons - MAKE_ACTION_PB(addReqAct, tr("Add Request"), tr("Add"), "add", btnAddRequest, wRequestForm, handleRequestForm(false), - tr("Add new request record from current form entries."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); - MAKE_ACTION_PB_D(saveReqAct, tr("Save Edited Request"), tr("Save"), "edit", btnUpdateRequest, wRequestForm, handleRequestForm(true), - tr("Update the existing request record from current form entries."), QKeySequence(Qt::ControlModifier | Qt::ShiftModifier | Qt::Key_Return)); - MAKE_ACTION_PB(updReqAct, tr("Clear Form"), tr("Clear"), "scale=.9/backspace", btnClearRequest, wRequestForm, clearRequestForm(), - tr("Reset the editor form to default values."), QKeySequence(Qt::ControlModifier | Qt::Key_Backspace)); - // connect to requests model row removed to check if the current editor needs to be reset, otherwise the "Save" button stays active and re-adds a deleted request. connect(d->reqModel, &RequestsModel::rowsAboutToBeRemoved, this, [this](const QModelIndex &, int first, int last) { const int current = d->reqModel->findRequestRow(ui.wRequestForm->property("requestId").toInt()); @@ -941,13 +959,6 @@ WASimUI::WASimUI(QWidget *parent) : d->setRequestFormId(-1); }); - // Connect our own signals for client callback handling in a thread-safe manner, marshaling back to GUI thread as needed. - // The "signal" methods are "emitted" by the Client as callbacks, registered in Private::setupClient(). - connect(this, &WASimUI::clientEvent, this, &WASimUI::onClientEvent, Qt::QueuedConnection); - connect(this, &WASimUI::listResults, this, &WASimUI::onListResults, Qt::QueuedConnection); - // Data updates can go right to the requests model. - connect(this, &WASimUI::dataResultReady, d->reqModel, &RequestsModel::setRequestValue, Qt::QueuedConnection); - // Set up actions for triggering various events. Actions are typically mapped to UI elements like buttons and menu items and can be reused in multiple places. // Network connection actions @@ -981,6 +992,7 @@ WASimUI::WASimUI(QWidget *parent) : pingAct->setShortcut(QKeySequence(Qt::Key_F7)); connect(pingAct, &QAction::triggered, this, [this]() { d->pingServer(); }); + // Sub-menu for individual connection actions. QMenu *connectMenu = new QMenu(tr("Connection actions"), this); connectMenu->setIcon(GLYPH_ICON("settings_remote")); connectMenu->addActions({ d->initAct, pingAct, d->connectAct }); @@ -998,14 +1010,8 @@ WASimUI::WASimUI(QWidget *parent) : MAKE_ACTION_SC_D(copyCalcAct, tr("Copy to Data Request"), "move_to_inbox", btnCopyCalcToRequest, wCalcForm, copyCalcCodeToRequest(), tr("Copy Calculator Code to new Data Request."), QKeySequence(Qt::ControlModifier | Qt::Key_Down)); - ui.btnUpdateEvent->setVisible(false); - // Connect variable selector to enable/disable relevant actions - connect(ui.cbCalculatorCode, &QComboBox::currentTextChanged, this, [=](const QString &txt) { d->updateCalcCodeFormState(txt); }); - // Variables section actions - d->toggleSetGetVariableType(); - // Request Local Vars list MAKE_ACTION(reloadLVarsAct, tr("Reload L.Vars"), "autorenew", btnList, wVariables, refreshLVars(), tr("Reload Local Variables.")); // Get local variable value @@ -1022,26 +1028,23 @@ WASimUI::WASimUI(QWidget *parent) : MAKE_ACTION_SC_D(copyVarAct, tr("Copy to Data Request"), "move_to_inbox", btnCopyLVarToRequest, wVariables, copyLocalVarToRequest(), tr("Copy Variable to new Data Request."), QKeySequence(Qt::ControlModifier | Qt::Key_Down)); - // Connect variable selector to enable/disable relevant actions - connect(ui.cbLvars, &QComboBox::currentTextChanged, this, [=]() { d->updateLocalVarsFormState(); }); - connect(ui.cbVariableName, &QComboBox::currentTextChanged, this, [=]() { d->updateLocalVarsFormState(); }); - - // connect to variable type combo box to switch between views for local vars vs. everything else - connect(ui.cbGetSetVarType, &DataComboBox::currentDataChanged, this, [=](const QVariant &) { - d->toggleSetGetVariableType(); - d->updateLocalVarsFormState(); - }); - - // Other forms - // Lookup action MAKE_ACTION_SC(lookupItemAct, tr("Lookup"), "search", btnVarLookup, wDataLookup, lookupItem(), tr("Query server for ID of named item (Lookup command)."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); - // Send Key Event action + + // Send Key Event Form MAKE_ACTION_SC(sendKeyEventAct, tr("Send Key Event"), "send", btnKeyEventSend, wKeyEvent, sendKeyEventForm(), tr("Send the specified Key Event to the server."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); // Send Command action MAKE_ACTION_SC(sendCmdAct, tr("Send Command"), "keyboard_command_key", btnCmdSend, wCommand, sendCommandForm(), tr("Send the selected Command to the server."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); - // Requests model view actions + // Requests editor form and model view actions + + // connect the Data Request save/add buttons + MAKE_ACTION_PB(addReqAct, tr("Add Request"), tr("Add"), "add", btnAddRequest, wRequestForm, handleRequestForm(false), + tr("Add new request record from current form entries."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); + MAKE_ACTION_PB_D(saveReqAct, tr("Save Edited Request"), tr("Save"), "edit", btnUpdateRequest, wRequestForm, handleRequestForm(true), + tr("Update the existing request record from current form entries."), QKeySequence(Qt::ControlModifier | Qt::ShiftModifier | Qt::Key_Return)); + MAKE_ACTION_PB(updReqAct, tr("Clear Form"), tr("Clear"), "scale=.9/backspace", btnClearRequest, wRequestForm, clearRequestForm(), + tr("Reset the editor form to default values."), QKeySequence(Qt::ControlModifier | Qt::Key_Backspace)); // Remove selected Data Request(s) from item model/view MAKE_ACTION_PB_D(removeRequestsAct, tr("Remove Selected Data Request(s)"), tr("Remove"), "fg=#c2d32e2e/delete_forever", btnReqestsRemove, wRequests, removeSelectedRequests(), @@ -1050,9 +1053,6 @@ WASimUI::WASimUI(QWidget *parent) : MAKE_ACTION_PB_D(updateRequestsAct, tr("Update Selected Data Request(s)"), tr("Update"), "refresh", btnReqestsUpdate, wRequests, updateSelectedRequests(), tr("Request data update on selected Data Request(s)."), QKeySequence(Qt::ControlModifier | Qt::Key_R, QKeySequence::Refresh)); - // Connect to table view selection model to en/disable the remove/update actions when selection changes. - connect(ui.requestsView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=]() { d->toggleRequestButtonsState(); }); - // Pause/resume data updates of requests MAKE_ACTION_PB_D(pauseRequestsAct, tr("Toggle Updates"), tr("Suspend"), "pause", btnReqestsPause, wRequests, pauseRequests(chk), tr("Temporarily pause all data value updates on Server side."), QKeySequence(Qt::ControlModifier | Qt::Key_U)); @@ -1088,9 +1088,9 @@ WASimUI::WASimUI(QWidget *parent) : d->toggleRequestButtonsState(); }, Qt::QueuedConnection); - // Add column toggle and font size actions - ui.wRequests->addAction(ui.requestsView->columnToggleMenuAction()); - ui.wRequests->addAction(ui.requestsView->fontSizeMenuAction()); + // Add column toggle and font size actions to the parent widgets + ui.wRequestForm->addAction(ui.requestsView->actionsMenu(ui.wRequests)->menuAction()); + ui.wRequests->addAction(ui.requestsView->actionsMenu(ui.wRequests)->menuAction()); // Registered calculator events model view actions @@ -1100,8 +1100,6 @@ WASimUI::WASimUI(QWidget *parent) : // Update data of selected Data Request(s) in item model/view MAKE_ACTION_PB_D(updateEventsAct, tr("Transmit Selected Event(s)"), tr("Transmit"), "play_for_work", btnEventsTransmit, wEventsList, transmitSelectedEvents(), tr("Trigger the selected Event(s)."), QKeySequence(Qt::ControlModifier | Qt::Key_T)); - // Connect to table view selection model to en/disable the remove/update actions when selection changes. - connect(ui.eventsView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [=]() { d->toggleEventButtonsState(); }); // Save current Events to a file MAKE_ACTION_PB_D(saveEventsAct, tr("Save Events"), tr("Save"), "save", btnEventsSave, wEventsList, saveEvents(), tr("Save current Events list to file."), QKeySequence::Save); @@ -1123,6 +1121,8 @@ WASimUI::WASimUI(QWidget *parent) : loadEventsAct->setMenu(nullptr); saveEventsAct->setEnabled(rows > 0); }, Qt::QueuedConnection); + // Add column toggle and font size actions to the parent widget + ui.wEventsList->addAction(ui.eventsView->actionsMenu(ui.wRequests)->menuAction()); // Other UI-related actions diff --git a/src/WASimUI/WASimUI.ui b/src/WASimUI/WASimUI.ui index 0ed8389..4051ca9 100644 --- a/src/WASimUI/WASimUI.ui +++ b/src/WASimUI/WASimUI.ui @@ -263,7 +263,7 @@ - 6 + 4 0 @@ -278,7 +278,7 @@ 0 - + 0 @@ -294,15 +294,18 @@ List + + cbLvars + - + Qt::ClickFocus - Local Variables (press Refresh button to (re)load) + <p>List of Local variables currently defined on the simulator (press Refresh button to (re)load)</p> true @@ -335,7 +338,7 @@ - 6 + 4 0 @@ -350,7 +353,7 @@ 0 - + 0 @@ -366,15 +369,21 @@ Name + + cbVariableName + - + Qt::ClickFocus - Variable name + <p>Enter the Variable name to use for the Get/Set command.</p> +<p>The Simulator Variable (SimVar) completion suggestions are loaded from imported SimConnect SDK documentation.</p> +<p>Press enter after entering text to save it in the list for future selection.</p> +<p>Saved items can be removed by right-clicking on them while the list is open.</p> true @@ -414,9 +423,6 @@ Qt::StrongFocus - - Optional Unit Name for the value. Leave blank to use the default units of the variable.<p>(Save items by pressing Enter, Remove by right-clicking on them while the list is open.)</p> - true @@ -447,7 +453,7 @@ 0 - + 0 @@ -457,6 +463,9 @@ Idx: + + sbGetSetSimVarIndex + @@ -478,7 +487,7 @@ - + 0 @@ -488,10 +497,13 @@ Value: + + dsbSetVarValue + - + 0 @@ -501,6 +513,9 @@ Unit: + + cbSetVarUnitName + @@ -566,8 +581,8 @@ 5 - - + + 0 @@ -577,10 +592,13 @@ Values: + + sbKeyEvent_v1 + - - + + 0 @@ -588,7 +606,7 @@ - Integer data for third event value. + Integer data for fourth event value. -999999999 @@ -598,18 +616,8 @@ - - - - Send - - - Qt::ToolButtonIconOnly - - - - - + + 0 @@ -617,7 +625,7 @@ - Integer data for fifth event value. + Integer data for first event value. -999999999 @@ -627,30 +635,8 @@ - - - - Qt::ClickFocus - - - Event ID or Name or send. Names are listed in MSFS Event IDs reference, IDs can be found using the Lookup command. - - - true - - - 25 - - - QComboBox::InsertAtTop - - - Name or ID - - - - - + + 0 @@ -658,7 +644,7 @@ - Integer data for second event value. + Integer data for fifth event value. -999999999 @@ -669,7 +655,7 @@ - + 0 @@ -677,7 +663,7 @@ - Integer data for fourth event value. + Integer data for second event value. -999999999 @@ -687,8 +673,8 @@ - - + + 0 @@ -696,18 +682,15 @@ - Integer data for first event value. - - - -999999999 + Send a Key Event to the simulator with up to 5 optional values. - - 999999999 + + Key Event Name/ID: - - + + 0 @@ -715,13 +698,57 @@ - Send a Key Event to the simulator with up to 5 optional values. + Integer data for third event value. + + + -999999999 + + 999999999 + + + + + - Key Event Name/ID: + Send + + + Qt::ToolButtonIconOnly + + + + 3 + + + + + Qt::ClickFocus + + + <p>SimConnect Key Event name or ID to send..</p> +<p>The completion suggestions are loaded from imported SimConnect SDK documentation.</p> +<p>IDs can be found using the Lookup command.</p> +<p>Press enter after entering text to save it in the list for future selection.</p> +<p>Saved items can be removed by right-clicking on them while the list is open.</p> + + + true + + + 25 + + + QComboBox::InsertAtTop + + + Name or ID + + + @@ -753,7 +780,7 @@ 4 - + 0 @@ -766,6 +793,9 @@ Lookup Type: + + cbLookupItemType + @@ -779,7 +809,7 @@ - + Qt::ClickFocus @@ -902,7 +932,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c 4 - + 0 @@ -912,6 +942,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c Update On: + + cbPeriod + @@ -922,7 +955,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + 0 @@ -932,6 +965,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c Interval: + + sbInterval + @@ -948,7 +984,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + 0 @@ -958,6 +994,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c ΔΕ: + + dsbDeltaEpsilon + @@ -1199,7 +1238,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + 6 @@ -1219,15 +1258,18 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + - + 0 0 - Variable Name or Calculator Code<p>(Save items by pressing Enter, Remove by right-clicking on them while the list is open.)</p> + <p>Variable Name or Calculator Code for the Request.</p> +<p>The Simulator Variable completion suggestions are loaded from imported SimConnect SDK documentation.</p> +<p>Press enter after entering text to save it in the list for future selection.</p> +<p>Saved items can be removed by right-clicking on them while the list is open.</p> true @@ -1252,7 +1294,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c 0 - + 0 @@ -1262,6 +1304,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c Idx: + + sbSimVarIndex + @@ -1278,7 +1323,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + 0 @@ -1288,6 +1333,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c Unit: + + cbUnitName + @@ -1298,9 +1346,6 @@ Submitted requests will appear in the "Data Requests" window. Double-c 0 - - Optional Unit Name for Named Variables<p>(Save items by pressing Enter, Remove by right-clicking on them while the list is open.)</p> - true @@ -1317,7 +1362,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c 0 - + 0 @@ -1327,6 +1372,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c Result: + + cbRequestCalcResultType + @@ -1339,7 +1387,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + 0 @@ -1349,6 +1397,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c Size: + + cbValueSize + @@ -1507,6 +1558,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c Event + + leEventName + @@ -1541,7 +1595,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + 0 @@ -1551,6 +1605,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c Code: + + cbCalculatorCode + @@ -1571,7 +1628,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + 0 @@ -1587,7 +1644,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + 0 @@ -1597,6 +1654,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c Result: + + cbCalcResultType + @@ -1630,7 +1690,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c 3 - + 0 @@ -1640,10 +1700,13 @@ Submitted requests will appear in the "Data Requests" window. Double-c uData: + + sbCmdUData + - + 0 @@ -1653,6 +1716,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c sData: + + leCmdSData + @@ -1711,7 +1777,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c - + 0 @@ -1721,10 +1787,13 @@ Submitted requests will appear in the "Data Requests" window. Double-c fData: + + sbCmdFData + - + 0 @@ -1737,6 +1806,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c Command + + cbCommandId + @@ -1995,7 +2067,7 @@ Submitted requests will appear in the "Data Requests" window. Double-c 5 - + Registered Events (double click to edit) @@ -2152,9 +2224,9 @@ Submitted requests will appear in the "Data Requests" window. Double-c
    ActionPushButton.h
    - WASimUiNS::DeletableItemsComboBox + DeletableItemsComboBox QComboBox -
    Widgets.h
    +
    DeletableItemsComboBox.h
    WASimUiNS::CalculationTypeComboBox @@ -2202,7 +2274,75 @@ Submitted requests will appear in the "Data Requests" window. Double-c QTableView
    RequestsTableView.h
    + + BuddyLabel + QLabel +
    BuddyLabel.h
    +
    + + WASimUiNS::CustomTableView + QTableView +
    CustomTableView.h
    +
    + + cbCalculatorCode + cbCalcResultType + btnCalc + btnCopyCalcToRequest + leEventName + btnUpdateEvent + btnAddEvent + cbGetSetVarType + btnList + btnFindSimVar + cbSetVarUnitName + sbGetSetSimVarIndex + btnGetVar + btnGetCreate + btnCopyLVarToRequest + dsbSetVarValue + btnSetVar + btnSetCreate + cbLookupItemType + btnVarLookup + sbKeyEvent_v1 + sbKeyEvent_v2 + sbKeyEvent_v3 + sbKeyEvent_v4 + sbKeyEvent_v5 + btnKeyEventSend + cbCommandId + sbCmdUData + sbCmdFData + leCmdSData + btnCmdSend + rbRequestType_Named + rbRequestType_Calculated + cbVariableType + cbNameOrCode + sbSimVarIndex + cbUnitName + cbRequestCalcResultType + cbValueSize + cbPeriod + sbInterval + dsbDeltaEpsilon + btnClearRequest + btnUpdateRequest + btnAddRequest + requestsView + btnReqestsRemove + btnReqestsUpdate + btnReqestsPause + btnReqestsLoad + btnReqestsSave + eventsView + btnEventsRemove + btnEventsTransmit + btnEventsLoad + btnEventsSave + From 5a5bf3a0ab962eac87cbb85a9bbec06084c30d5f Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Wed, 1 Nov 2023 14:34:46 -0400 Subject: [PATCH 54/65] [WASimUI] Add data browser, search, and inline suggestions using imported SimConnect SDK reference documentation features. --- src/WASimUI/WASimUI.cpp | 121 ++++++++++++++++++++++++++++++++++++---- src/WASimUI/WASimUI.ui | 41 ++++++++++++++ 2 files changed, 152 insertions(+), 10 deletions(-) diff --git a/src/WASimUI/WASimUI.cpp b/src/WASimUI/WASimUI.cpp index 21f07ea..0270dd6 100644 --- a/src/WASimUI/WASimUI.cpp +++ b/src/WASimUI/WASimUI.cpp @@ -20,6 +20,7 @@ and is also available at . #include #include +#include #include #include #include @@ -37,6 +38,8 @@ and is also available at . #include "WASimUI.h" +#include "DocImports.h" +#include "DocImportsBrowser.h" #include "EventsModel.h" #include "LogConsole.h" #include "RequestsExport.h" @@ -46,6 +49,7 @@ and is also available at . #include "Widgets.h" using namespace WASimUiNS; +using namespace DocImports; using namespace WASimCommander; using namespace WASimCommander::Client; using namespace WASimCommander::Enums; @@ -73,6 +77,7 @@ class WASimUIPrivate RequestsModel *reqModel; EventsModel *eventsModel; RequestsExportWidget *reqExportWidget = nullptr; + DocImportsBrowser *docBrowserWidget = nullptr; QAction *toggleConnAct = nullptr; QAction *initAct = nullptr; QAction *connectAct = nullptr; @@ -266,17 +271,25 @@ class WASimUIPrivate void toggleSetGetVariableType() { const QChar vtype = ui->cbGetSetVarType->currentData().toChar(); - bool isLocal = vtype == 'L'; + bool isLocal = vtype == 'L', isSimVar = vtype == 'A'; ui->wLocalVarsForm->setVisible(isLocal); ui->wOtherVarsForm->setVisible(!isLocal); - ui->wGetSetSimVarIndex->setVisible(!isLocal && vtype == 'A'); // sim var index box visible only for... simvars! + ui->wGetSetSimVarIndex->setVisible(isSimVar); ui->btnSetCreate->setVisible(isLocal); ui->btnGetCreate->setVisible(isLocal); bool hasUnit = ui->cbGetSetVarType->currentText().contains('*'); ui->cbSetVarUnitName->setVisible(hasUnit); ui->lblSetVarUnit->setVisible(hasUnit); - if (isLocal) - ui->cbSetVarUnitName->setCurrentText(""); + if (isSimVar) { + ui->cbVariableName->setCompleter(new DocImports::NameCompleter(DocImports::RecordType::SimVars, ui->cbNameOrCode)); + ui->cbVariableName->lineEdit()->addAction(ui->btnFindSimVar->defaultAction(), QLineEdit::TrailingPosition); + } + else { + ui->cbVariableName->lineEdit()->removeAction(ui->btnFindSimVar->defaultAction()); + ui->cbVariableName->resetCompleter(); + if (isLocal) + ui->cbSetVarUnitName->setCurrentText(""); + } } void copyLocalVarToRequest() @@ -340,8 +353,6 @@ class WASimUIPrivate ui->cbRequestCalcResultType->setVisible(isCalc); ui->lblRequestCalcResultType->setVisible(isCalc); ui->cbVariableType->setVisible(!isCalc); - //ui->lblUnit->setVisible(!isCalc); - //ui->cbUnitName->setVisible(!isCalc); toggleRequestVariableType(); if (isCalc) ui->cbValueSize->setCurrentData(Utils::calcResultTypeToMetaType(CalcResultType(ui->cbRequestCalcResultType->currentData().toUInt()))); @@ -359,8 +370,18 @@ class WASimUIPrivate bool hasUnit = !isCalc && ui->cbVariableType->currentText().contains('*'); ui->lblUnit->setVisible(hasUnit); ui->cbUnitName->setVisible(hasUnit); - if (type == 'L') - ui->cbUnitName->setCurrentText(""); + + if (needIdx) { + ui->cbNameOrCode->setCompleter(new DocImports::NameCompleter(DocImports::RecordType::SimVars, ui->cbNameOrCode)); + ui->cbNameOrCode->lineEdit()->addAction(ui->btnReqFindSimVar->defaultAction(), QLineEdit::TrailingPosition); + } + else { + // restore default completer + ui->cbNameOrCode->resetCompleter(); + ui->cbNameOrCode->lineEdit()->removeAction(ui->btnReqFindSimVar->defaultAction()); + if (type == 'L') + ui->cbUnitName->setCurrentText(""); + } } void setRequestFormId(uint32_t id) @@ -706,6 +727,46 @@ class WASimUIPrivate emit q->commandResultReady(Command(level == LogLevel::Error ? CommandId::Nak : CommandId::Ack, +cmd, qPrintable(msg))); } + void openDocsLookup(DocImports::RecordType type, QComboBox *cb) + { + DocImportsBrowser *browser = new DocImportsBrowser(q, type, DocImportsBrowser::ViewMode::PopupViewMode); + browser->setAttribute(Qt::WA_DeleteOnClose); + browser->setWindowFlag(Qt::Dialog); + browser->setWindowModality(Qt::ApplicationModal); + browser->show(); + const QPoint qPos = q->mapToGlobal(QPoint(0,0)); + const QPoint cbPos = cb->mapToGlobal(QPoint(0, cb->height())); + const QRect rect(qPos, q->size()); + QPoint pos = QStyle::alignedRect(Qt::LeftToRight, Qt::AlignCenter, browser->size(), rect).topLeft(); + pos.setY(rect.y() + cbPos.y() - qPos.y()); + browser->move(pos); + QObject::connect(browser, &DocImportsBrowser::itemSelected, q, [=](const QModelIndex &row) { + if (row.isValid()) + cb->setCurrentText(browser->model()->record(row.row()).field("Name").value().toString()); + browser->close(); + }); + } + + void openDocsLookupWindow() + { + if (docBrowserWidget) { + if (docBrowserWidget->isMinimized()) { + docBrowserWidget->showNormal(); + } + else { + docBrowserWidget->raise(); + docBrowserWidget->activateWindow(); + } + return; + } + + docBrowserWidget = new DocImportsBrowser(q); + docBrowserWidget->setAttribute(Qt::WA_DeleteOnClose); + docBrowserWidget->setWindowFlag(Qt::Dialog); + docBrowserWidget->show(); + QObject::connect(docBrowserWidget, &QObject::destroyed, q, [=]() { docBrowserWidget = nullptr; }); + } + // Save/Load UI settings ------------------------------------------------- @@ -880,12 +941,32 @@ WASimUI::WASimUI(QWidget *parent) : ui.leEventName->setMaxLength(STRSZ_ENAME); ui.cbNameOrCode->setMaxLength(STRSZ_REQ); + // Unit name suggestions completer from imported docs. + ui.cbUnitName->setCompleter(new DocImports::NameCompleter(DocImports::RecordType::SimVarUnits, ui.cbUnitName), true); + ui.cbSetVarUnitName->setCompleter(new DocImports::NameCompleter(DocImports::RecordType::SimVarUnits, ui.cbSetVarUnitName), true); + ui.cbSetVarUnitName->setCompleterOptionsButtonEnabled(); + // Enable clear and suggestion options buttons on the data lookup combos. + ui.cbCalculatorCode->setCompleterOptions(Qt::MatchContains, QCompleter::PopupCompletion); + ui.cbCalculatorCode->setClearButtonEnabled(); + ui.cbLvars->setCompleterOptions(Qt::MatchStartsWith, QCompleter::PopupCompletion); + ui.cbLvars->setClearButtonEnabled(); + ui.cbVariableName->setCompleterOptions(Qt::MatchContains, QCompleter::PopupCompletion); + ui.cbVariableName->setClearButtonEnabled(); + ui.cbKeyEvent->setCompleterOptions(Qt::MatchContains, QCompleter::PopupCompletion); + ui.cbKeyEvent->setClearButtonEnabled(); + ui.cbNameOrCode->setCompleterOptions(Qt::MatchContains, QCompleter::PopupCompletion); + ui.cbNameOrCode->setClearButtonEnabled(); + ui.cbUnitName->setCompleterOptions(Qt::MatchStartsWith, QCompleter::PopupCompletion); + // Init the calculator editor form ui.wCalcForm->setProperty("eventId", -1); ui.btnUpdateEvent->setVisible(false); // Connect variable selector to enable/disable relevant actions connect(ui.cbCalculatorCode, &QComboBox::currentTextChanged, this, [=](const QString &txt) { d->updateCalcCodeFormState(txt); }); + // Key event name completer + ui.cbKeyEvent->setCompleter(new DocImports::NameCompleter(DocImports::RecordType::KeyEvents, ui.cbKeyEvent)); + // Set up the Requests table view ui.requestsView->setExportCategories(RequestsFormat::categoriesList()); ui.requestsView->setModel(d->reqModel); @@ -1027,12 +1108,22 @@ WASimUI::WASimUI(QWidget *parent) : // Copy LVar as new Data Request MAKE_ACTION_SC_D(copyVarAct, tr("Copy to Data Request"), "move_to_inbox", btnCopyLVarToRequest, wVariables, copyLocalVarToRequest(), tr("Copy Variable to new Data Request."), QKeySequence(Qt::ControlModifier | Qt::Key_Down)); + // Open docs import browser for Sim Vars + MAKE_ACTION_SC(findSimVarAct, tr("Sim Var Lookup"), "search", btnFindSimVar, wVariables, openDocsLookup(DocImports::RecordType::SimVars, ui.cbVariableName), + tr("Open a new window to search and select Simulator Variables from imported MSFS SDK documentation."), QKeySequence::Find); + ui.btnFindSimVar->setVisible(false); // hide the button, we put the action into the combo box for now // Lookup action MAKE_ACTION_SC(lookupItemAct, tr("Lookup"), "search", btnVarLookup, wDataLookup, lookupItem(), tr("Query server for ID of named item (Lookup command)."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); // Send Key Event Form MAKE_ACTION_SC(sendKeyEventAct, tr("Send Key Event"), "send", btnKeyEventSend, wKeyEvent, sendKeyEventForm(), tr("Send the specified Key Event to the server."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); + // Open docs import browser for Key Events + MAKE_ACTION_SC(findKeyEventAct, tr("Key Event Lookup"), "search", btnFindEvent, wKeyEvent, openDocsLookup(DocImports::RecordType::KeyEvents, ui.cbKeyEvent), + tr("Open a new window to search and select Key Events from imported MSFS SDK documentation."), QKeySequence::Find); + ui.cbKeyEvent->lineEdit()->addAction(findKeyEventAct, QLineEdit::TrailingPosition); + ui.btnFindEvent->setHidden(true); // hide the button, we put the action into the combo box for now + // Send Command action MAKE_ACTION_SC(sendCmdAct, tr("Send Command"), "keyboard_command_key", btnCmdSend, wCommand, sendCommandForm(), tr("Send the selected Command to the server."), QKeySequence(Qt::ControlModifier | Qt::Key_Return)); @@ -1046,6 +1137,11 @@ WASimUI::WASimUI(QWidget *parent) : MAKE_ACTION_PB(updReqAct, tr("Clear Form"), tr("Clear"), "scale=.9/backspace", btnClearRequest, wRequestForm, clearRequestForm(), tr("Reset the editor form to default values."), QKeySequence(Qt::ControlModifier | Qt::Key_Backspace)); + // Open docs import browser for Sim Vars + MAKE_ACTION_SC(findReqSimVarAct, tr("Sim Var Lookup"), "search", btnReqFindSimVar, wRequestForm, openDocsLookup(DocImports::RecordType::SimVars, ui.cbNameOrCode), + tr("Open a new window to search and select Simulator Variables from imported MSFS SDK documentation."), QKeySequence::Find); + ui.btnReqFindSimVar->setVisible(false); // hide the button, we put the action into the combo box for now + // Remove selected Data Request(s) from item model/view MAKE_ACTION_PB_D(removeRequestsAct, tr("Remove Selected Data Request(s)"), tr("Remove"), "fg=#c2d32e2e/delete_forever", btnReqestsRemove, wRequests, removeSelectedRequests(), tr("Delete the selected Data Request(s)."), QKeySequence(Qt::ControlModifier | Qt::Key_D)); @@ -1130,6 +1226,11 @@ WASimUI::WASimUI(QWidget *parent) : viewMenu->setIcon(GLYPH_ICON("grid_view")); //viewMenu->menuAction()->setShortcut(QKeySequence(Qt::AltModifier | Qt::Key_M)); + MAKE_ACTION_NW_SC(docBrowserAct, tr("SimConnect SDK Docs Reference Browser"), tr("Reference"), "search", openDocsLookupWindow(), + tr("

    Opens a window which allow searching through Simulator Variables, Key Events, and Unit types imported from online SimConnect SDK documentation.

    "), + QKeySequence(Qt::AltModifier | Qt::Key_R)); + viewMenu->addAction(docBrowserAct); + #define WIDGET_VIEW_TOGGLE_ACTION(T, W, V, K) {\ QAction *act = new QAction(tr("Show %1 Form").arg(T), this); \ act->setAutoRepeat(false); act->setCheckable(true); act->setChecked(V); \ @@ -1164,7 +1265,7 @@ WASimUI::WASimUI(QWidget *parent) : // add all actions to this widget, for context menu and shortcut handling addActions({ d->toggleConnAct, connectMenu->menuAction(), - Utils::separatorAction(this), viewMenu->menuAction(), styleAct, aboutAct, projectLinkAct + Utils::separatorAction(this), docBrowserAct, viewMenu->menuAction(), styleAct, aboutAct, projectLinkAct }); @@ -1180,7 +1281,7 @@ WASimUI::WASimUI(QWidget *parent) : toolbar->addWidget(Utils::spacerWidget(Qt::Horizontal, 6)); toolbar->addActions({ d->toggleConnAct }); toolbar->addSeparator(); - toolbar->addActions({ viewMenu->menuAction(), styleAct, aboutAct /*, projectLinkAct*/ }); + toolbar->addActions({ viewMenu->menuAction(), docBrowserAct, styleAct, aboutAct /*, projectLinkAct*/ }); // default toolbutton menu mode is lame if (QToolButton *tb = qobject_cast(toolbar->widgetForAction(d->toggleConnAct))) { tb->setMenu(connectMenu); diff --git a/src/WASimUI/WASimUI.ui b/src/WASimUI/WASimUI.ui index 4051ca9..f3d0994 100644 --- a/src/WASimUI/WASimUI.ui +++ b/src/WASimUI/WASimUI.ui @@ -390,6 +390,19 @@
    + + + + Lookup + + + Qt::ToolButtonIconOnly + + + false + + + @@ -749,6 +762,21 @@ + + + + Lookup + + + Qt::ToolButtonIconOnly + + + false + + + + + @@ -1293,6 +1321,19 @@ Submitted requests will appear in the "Data Requests" window. Double-c 0 + + + + Lookup + + + Qt::ToolButtonIconOnly + + + false + + + From 62c3e426abf7155b9b0a565aad60558f370ca992 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Wed, 1 Nov 2023 14:48:16 -0400 Subject: [PATCH 55/65] [build] Update versioning build scripts; Automate module xml version update; Get rid of module's ReleaseNotes.xml; Add "since" macro to Doxyfile. --- build/Make-Version.ps1 | 2 ++ build/Merge-Tokens.ps1 | 15 ++++----- docs/Doxyfile | 3 +- .../wasimcommander-module.xml.in | 31 +++++++++++++++++++ .../wasimcommander-module/ReleaseNotes.xml | 16 ---------- 5 files changed, 41 insertions(+), 26 deletions(-) create mode 100644 src/WASimModule/WASimModuleProject/WASimCommander-Module/PackageDefinitions/wasimcommander-module.xml.in delete mode 100644 src/WASimModule/WASimModuleProject/WASimCommander-Module/PackageDefinitions/wasimcommander-module/ReleaseNotes.xml diff --git a/build/Make-Version.ps1 b/build/Make-Version.ps1 index 03bab61..07ca9a3 100644 --- a/build/Make-Version.ps1 +++ b/build/Make-Version.ps1 @@ -40,5 +40,7 @@ function Make-Version { Merge-Tokens -InputFile $SrcPath\include\wasim_version.in -OutputFile $SrcPath\include\wasim_version.h -Tokens $Tokens -NoWarning Merge-Tokens -InputFile $SrcPath\WASimClient_CLI\AssemblyInfo.cpp.in -OutputFile $SrcPath\WASimClient_CLI\AssemblyInfo.cpp -Tokens $Tokens -NoWarning Merge-Tokens -InputFile $DocPath\version.Doxyfile.in -OutputFile $DocPath\version.Doxyfile -Tokens $Tokens -NoWarning + $path = "${SrcPath}\WASimModule\WASimModuleProject\WASimCommander-Module\PackageDefinitions\wasimcommander-module.xml" + Merge-Tokens -InputFile "${path}.in" -OutputFile $path -Tokens $Tokens -NoWarning } diff --git a/build/Merge-Tokens.ps1 b/build/Merge-Tokens.ps1 index 7833f4e..096d67d 100644 --- a/build/Merge-Tokens.ps1 +++ b/build/Merge-Tokens.ps1 @@ -83,11 +83,12 @@ function Merge-Tokens { Exit 1 } + $TmpFile = [System.IO.Path]::GetTempFileName() + # If the OutputFile is null, we will write to a temporary file if ([string]::IsNullOrWhiteSpace($OutputFile)) { Write-Verbose "OutputFile was omitted. Replacing InputFile." - $OutputFile = [System.IO.Path]::GetTempFileName() - $ReplaceInputFile = $true + $OutputFile = $InputFile } # Empty OutputFile if it already exists @@ -115,8 +116,6 @@ function Merge-Tokens { $usedTokens = New-Object -TypeName "System.Collections.ArrayList" #$sw = [System.IO.File]::AppendText($OutputFile) - # hack to force no-BOM UTF8 on PS v5.x - " " | Out-File -Encoding ASCII -NoNewline -FilePath $OutputFile (Get-Content $InputFile) | ForEach-Object { $line = $_ $totalTokens += GetTokenCount($line) @@ -134,13 +133,11 @@ function Merge-Tokens { } $missedTokens += GetTokenCount($line) #$sw.WriteLine($line) - $line | Out-File -Append -Encoding UTF8 -FilePath $OutputFile + $line | Out-File -Append -Encoding UTF8 -FilePath $TmpFile } - # If no OutputFile was given, we will replace the InputFile with the temporary file - if ($ReplaceInputFile) { - Get-Content -Path $OutputFile | Out-File -FilePath $InputFile -Encoding UTF8 - } + # Remove UTF8 BOM + Get-Content -Path $TmpFile | Out-File -FilePath $OutputFile # Write warning if there were tokens given in the Token parameter which were not replaced if (!$NoWarning -and $usedTokens.Count -ne $Tokens.Count) { diff --git a/docs/Doxyfile b/docs/Doxyfile index 743407e..0ebb4fa 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -28,7 +28,8 @@ ALIASES = \ "pacc=\par Access functions:^^" \ "psig=\par Notifier signal:^^" \ "intern=\parInternal use only." \ - "qflags{2}=

    The \ref \1 type is a typedef for `QFlags<\2>`. It stores an OR combination of \ref \2 values.

    " + "qflags{2}=

    The \ref \1 type is a typedef for `QFlags<\2>`. It stores an OR combination of \ref \2 values.

    " \ + "since{1}=\par **Since \1**" TOC_INCLUDE_HEADINGS = 5 AUTOLINK_SUPPORT = YES diff --git a/src/WASimModule/WASimModuleProject/WASimCommander-Module/PackageDefinitions/wasimcommander-module.xml.in b/src/WASimModule/WASimModuleProject/WASimCommander-Module/PackageDefinitions/wasimcommander-module.xml.in new file mode 100644 index 0000000..1492c1a --- /dev/null +++ b/src/WASimModule/WASimModuleProject/WASimCommander-Module/PackageDefinitions/wasimcommander-module.xml.in @@ -0,0 +1,31 @@ + + + + MISC + @PROJECT_NAME@ WASM Module + + @PROJECT_COPY@ + + + false + false + + + + ContentInfo + + false + + PackageDefinitions\wasimcommander-module\ContentInfo\ + ContentInfo\wasimcommander-module\ + + + Copy + + false + + PackageSources\@SERVER_NAME@\ + modules\ + + + diff --git a/src/WASimModule/WASimModuleProject/WASimCommander-Module/PackageDefinitions/wasimcommander-module/ReleaseNotes.xml b/src/WASimModule/WASimModuleProject/WASimCommander-Module/PackageDefinitions/wasimcommander-module/ReleaseNotes.xml deleted file mode 100644 index a82e22f..0000000 --- a/src/WASimModule/WASimModuleProject/WASimCommander-Module/PackageDefinitions/wasimcommander-module/ReleaseNotes.xml +++ /dev/null @@ -1,16 +0,0 @@ -Initial release! v1.0.0.5-beta1 -v1.0.0.6-beta2 -* Fixed that Formatted type calculation -results cannot be pre-compiled. -* Event loop is paused when last client -disconnects. -WASimCommander v1.1.0.0 -* Minor updates for SU10: Updated lookup lists of Token vars -and Key events; Use new trigger_key_event_EX1(). -* Rebuilt using /O1 optimization flag. -WASimCommander v1.1.1.0 -* Updated lookup lists of Key events for SU11/SDK0.20.5.0. -* Rebuilt using /O3 optimization flag as per new -documentation recommendations and since fixes in SU11. -WASimCommander v1.1.2.0 -* Fixed KEY event alias for "AUTORUDDER_TOGGLE" -> KEY_AUTOCOORD_TOGGLE. From 6196f1db2c27c438214fdd478126229271270a25 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Wed, 1 Nov 2023 14:49:32 -0400 Subject: [PATCH 56/65] [CS_BasicConsole] Add async request example; Add timestamps to logging output. --- src/Testing/CS_BasicConsole/Program.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Testing/CS_BasicConsole/Program.cs b/src/Testing/CS_BasicConsole/Program.cs index cca9973..08c8050 100644 --- a/src/Testing/CS_BasicConsole/Program.cs +++ b/src/Testing/CS_BasicConsole/Program.cs @@ -129,7 +129,8 @@ static void Main(string[] _) // Test subscribing to a string type value. We'll use the Sim var "TITLE" (airplane name), which can only be retrieved using calculator code. // We allocate 32 Bytes here to hold the result and we request this one with an update period of Once, which will return a result right away // but will not be scheduled for regular updates. If we wanted to update this value later, we could call the client's `updateDataRequest(requestId)` method. - hr = client.saveDataRequest(new DataRequest( + // Also we can use the "async" version which doesn't wait for the server to respond before returning. We're going to wait for a result anyway after submitting the request. + hr = client.saveDataRequestAsync(new DataRequest( requestId: (uint)Requests.REQUEST_ID_2_STR, resultType: CalcResultType.String, calculatorCode: "(A:TITLE, String)", @@ -198,7 +199,8 @@ static void ClientStatusHandler(ClientEvent ev) // Event handler for showing listing results (eg. local vars list) static void ListResultsHandler(ListResult lr) { - Log(lr.ToString()); // just use the ToString() override + Log($"Got {lr.list.Count} results for list type {lr.listType}. (Uncomment next line in ListResultsHandler() to print them.)"); + //Log(lr.ToString()); // To print all the items just use the ToString() override. // signal completion dataUpdateEvent.Set(); } @@ -206,7 +208,7 @@ static void ListResultsHandler(ListResult lr) // Event handler to process data value subscription updates. static void DataSubscriptionHandler(DataRequestRecord dr) { - Console.Write($"<< Got Data for request {(Requests)dr.requestId} \"{dr.nameOrCode}\" with Value: "); + Console.Write($"[{DateTime.Now.ToString("mm:ss.fff")}] << Got Data for request {(Requests)dr.requestId} \"{dr.nameOrCode}\" with Value: "); // Convert the received data into a value using DataRequestRecord's tryConvert() methods. // This could be more efficient in a "real" application, but it's good enough for our tests with only 2 value types. if (dr.tryConvert(out float fVal)) @@ -215,14 +217,14 @@ static void DataSubscriptionHandler(DataRequestRecord dr) Console.WriteLine($"(string) \"{sVal}\""); } else - Console.WriteLine("Could not convert result data to value!"); + Log("Could not convert result data to value!", "!!"); // signal completion dataUpdateEvent.Set(); } static void Log(string msg, string prfx = "=:") { - Console.WriteLine(prfx + ' ' + msg); + Console.WriteLine("[{0}] {1} {2}", DateTime.Now.ToString("mm:ss.fff"), prfx, msg); } } From 2c293f9084f945998612bf0bcc2195070bb93c7f Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Wed, 1 Nov 2023 14:51:19 -0400 Subject: [PATCH 57/65] [build] Bump version to 1.2.0.0 --- build/version.ps1 | 4 ++-- docs/version.Doxyfile | 4 ++-- src/WASimClient_CLI/AssemblyInfo.cpp | 8 ++++---- .../PackageDefinitions/wasimcommander-module.xml | 5 ++--- src/include/wasim_version.h | 16 ++++++++-------- 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/build/version.ps1 b/build/version.ps1 index 412ce4a..ad58de9 100644 --- a/build/version.ps1 +++ b/build/version.ps1 @@ -1,7 +1,7 @@ $VER_MAJOR = 1 -$VER_MINOR = 1 -$VER_PATCH = 2 +$VER_MINOR = 2 +$VER_PATCH = 0 $VER_BUILD = 0 $VER_COMIT = 0 $VER_NAME = "" diff --git a/docs/version.Doxyfile b/docs/version.Doxyfile index e2cf38f..26c8061 100644 --- a/docs/version.Doxyfile +++ b/docs/version.Doxyfile @@ -1,7 +1,7 @@ - + # Doxyfile 1.8.17 # THIS FILE IS GENERATED BY A SCRIPT, CHANGES WILL NOT PERSIST. EDIT THE CORRESPONDING .in TEMPLATE FILE INSTEAD. PROJECT_NAME = "WASimCommander" -PROJECT_NUMBER = v1.1.2.0 +PROJECT_NUMBER = v1.2.0.0 PROJECT_BRIEF = "Remote access to the Microsoft Flight Simulator 2020 Gauge API." diff --git a/src/WASimClient_CLI/AssemblyInfo.cpp b/src/WASimClient_CLI/AssemblyInfo.cpp index 7e6462d..c4989cc 100644 --- a/src/WASimClient_CLI/AssemblyInfo.cpp +++ b/src/WASimClient_CLI/AssemblyInfo.cpp @@ -1,4 +1,4 @@ - + // THIS FILE IS GENERATED BY A SCRIPT, CHANGES WILL NOT PERSIST. EDIT THE CORRESPONDING .in TEMPLATE FILE INSTEAD. using namespace System; @@ -22,8 +22,8 @@ using namespace System::Security::Permissions; [assembly:AssemblyTrademarkAttribute(L"")]; [assembly:AssemblyCultureAttribute(L"")]; -[assembly:AssemblyVersionAttribute(L"1.1.2.0")]; -[assembly:AssemblyFileVersionAttribute("1.1.2.0")]; -[assembly:AssemblyInformationalVersionAttribute("1.1.2.0")]; +[assembly:AssemblyVersionAttribute(L"1.2.0.0")]; +[assembly:AssemblyFileVersionAttribute("1.2.0.0")]; +[assembly:AssemblyInformationalVersionAttribute("1.2.0.0")]; [assembly:ComVisible(false)]; diff --git a/src/WASimModule/WASimModuleProject/WASimCommander-Module/PackageDefinitions/wasimcommander-module.xml b/src/WASimModule/WASimModuleProject/WASimCommander-Module/PackageDefinitions/wasimcommander-module.xml index fda8511..3b530ce 100644 --- a/src/WASimModule/WASimModuleProject/WASimCommander-Module/PackageDefinitions/wasimcommander-module.xml +++ b/src/WASimModule/WASimModuleProject/WASimCommander-Module/PackageDefinitions/wasimcommander-module.xml @@ -1,10 +1,10 @@ - + MISC WASimCommander WASM Module - Maxim Paperno + Copyright Maxim Paperno; All rights reserved. false @@ -29,4 +29,3 @@ - diff --git a/src/include/wasim_version.h b/src/include/wasim_version.h index 9b87025..a115965 100644 --- a/src/include/wasim_version.h +++ b/src/include/wasim_version.h @@ -1,4 +1,4 @@ - /* +/* This file is part of the WASimCommander project. https://github.com/mpaperno/WASimCommander @@ -27,21 +27,21 @@ and are available at . #define WSMCMND_GUI_NAME "WASimUI" #define WSMCMND_VER_MAJOR 1 -#define WSMCMND_VER_MINOR 1 -#define WSMCMND_VER_PATCH 2 +#define WSMCMND_VER_MINOR 2 +#define WSMCMND_VER_PATCH 0 #define WSMCMND_VER_BUILD 0 // Git commit hash (top 8 bytes) -#define WSMCMND_VER_COMIT 0x0C321F25UL +#define WSMCMND_VER_COMIT 0x4A699DF9UL /// Version number in 32 bit "binary coded decimal", eg. 0x01230400 = 1.23.4.0 -#define WSMCMND_VERSION 0x01010200UL +#define WSMCMND_VERSION 0x01020000UL /// Possible version suffix, eg "-beta1" (can be blank for release versions) #define WSMCMND_VER_NAME "" /// Dotted version string Maj.Min.Pat.Bld, eg. "1.23.4.0" -#define WSMCMND_VERSION_STR "1.1.2.0" +#define WSMCMND_VERSION_STR "1.2.0.0" /// Dotted version string with possible suffix, eg. "1.23.4.0-beta1" -#define WSMCMND_VERSION_INFO "1.1.2.0" +#define WSMCMND_VERSION_INFO "1.2.0.0" /// Build date & time in ISO-8601 "Zulu Time" format, UTC -#define WSMCMND_BUILD_DATE "2023-02-23T09:43:21Z" +#define WSMCMND_BUILD_DATE "2023-11-01T18:50:07Z" #define WSMCMND_PROJECT_URL "https://github.com/mpaperno/WASimCommander" From 0f9826a84cf7c02c8892e756c7d055ef1c97ecb5 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Wed, 1 Nov 2023 16:36:09 -0400 Subject: [PATCH 58/65] Update CHANGELOG for v1.2.0. --- CHANGELOG.md | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e12279..e1b8733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,87 @@ # WASimCommander - Change Log +## 1.2.0.0 (next) + +### WASimModule +* Fix binary data representation in results for named variable requests with 1-4 byte integer value sizes (`int8` - `int32` types) -- the result data would be encoded as a float type instead. ([8c7724e6]) +* Restore ability to use Unit type specifiers when setting and getting Local vars. ([e16049ac]) +* Added ability to specify/set a default 'L' var value and unit type in `GetCreate` command to use if the variable needs to be created. ([61a52674]) +* `GetCreate` and `SetCreate` commands for non-L types now silently fall back to `Get` and `Set` respectively. ([61a52674]) +* Fixed that command response for `GetCreate` was always sent as if responding to a `Get` command. ([61a52674]) +* Added `requestId` to error logging and response output for data requests and add more info for `Get` command errors. ([17791eef]) +* Added ability to return string type results for Get commands and Named data requests by converting them to calculator expressions automatically on the server. ([983e7ab6]) +* Improved automatic conversion to calc code for other variable types by including the unit type, if given, and narrowing numeric results to integer types if needed. ([983e7ab6]) +* Prevent possible simulator hang on exit when quitting with active client(s) connections. ([70e0ef31]) +* Event loop processing is now paused/restarted also based on whether any connected client(s) have active data requests and if they are paused or not (previously it was only based on if any clients were connected at all). ([90242ed4]) +* Fixes a logged SimConnect error when trying to unsubscribe from the "Frame" event (cause unknown). ([90242ed4]) +* Data requests with `Once` type update period are now queued if data updates are paused when the request is submitted. These requests will be sent when/if updates are resumed again by the client. Fixes that data would be sent anyway when the request is initially submitted, even if updates are paused. ([fe99bbb2]) +* Update reference list of KEY events and aliases as of MSFS SDK v0.22.3.0. ([f045e150]) + +[8c7724e6]: https://github.com/mpaperno/WASimCommander/commit/8c7724e60ed94e622d5ee2669cf7e000031c2c18 +[e16049ac]: https://github.com/mpaperno/WASimCommander/commit/e16049ac69ff15cdcdd9084c7fdab6920a1ffba1 +[61a52674]: https://github.com/mpaperno/WASimCommander/commit/61a52674e0dff7e1f3e63ed73a0bed711bb2c479 +[17791eef]: https://github.com/mpaperno/WASimCommander/commit/17791eefecc86454c031636a5da9c19d56e21139 +[983e7ab6]: https://github.com/mpaperno/WASimCommander/commit/983e7ab609e81af81525ff84431b1c4557447d87 +[70e0ef31]: https://github.com/mpaperno/WASimCommander/commit/70e0ef31b01a1a772d9e49102e0a77ec6f3e928b +[90242ed4]: https://github.com/mpaperno/WASimCommander/commit/90242ed494069aba5bcdad839914b9fcfc6521e2 +[fe99bbb2]: https://github.com/mpaperno/WASimCommander/commit/fe99bbb25c5dd907e8a4d513769759c4b430580f +[f045e150]: https://github.com/mpaperno/WASimCommander/commit/f045e15007abd6b7b05b97c004a7a55488a33a9b + +### WASimClient and WASimClient_CLI (C#) +* Fix incoming data size check for variable requests which are less than 4 bytes in size. ([c8e74dfa]) +* Restored ability to specify Unit type for L vars and support for GetCreate with default value/unit and added extra features: ([3090d534], [0a30646d]) + * Added unit name parameter to `setLocalVariable()` and `setOrCreateLocalVariable()`. + * Added `getOrCreateLocalVariable()`. + * Added `VariableRequest::createLVar` property. + * Add optional `create` flag and unit name to `VariableRequest()` c'tor overloads. +* Added async option to `saveDataRequest()` which doesn't wait for server response (`saveDataRequestAsync()` for the C# version). ([82ea4252], [0a30646d]) +* Add ability to return a string value with `getVariable()` to make use of new WASimModule feature. ([8e75eb8c], [0e54794b]) +* The request updates paused state (set with `setDataRequestsPaused()`) is now saved locally even if not connected to server and will be sent to server upon connection and before sending any queued data requests. + This allows connecting and sending queued requests but suspending any actual value checks until needed. ([bea8bccb]) +* Removed logged version mismatch warning on Ping response. +* Documentation updates. + +[c8e74dfa]: https://github.com/mpaperno/WASimCommander/commit/c8e74dfa706647cf785c7e6c811731d8945e49c6 +[3090d534]: https://github.com/mpaperno/WASimCommander/commit/3090d5344c3a34c62e81f61237fe1fd91f6b11c5 +[0a30646d]: https://github.com/mpaperno/WASimCommander/commit/0a30646d0ae985580d67ed40c8a441a0f5a0ba17 +[82ea4252]: https://github.com/mpaperno/WASimCommander/commit/82ea4252bd25423bbeab354799d6be41f053880e +[8e75eb8c]: https://github.com/mpaperno/WASimCommander/commit/8e75eb8c087f5a39fee93c2b7d073500e4f14664 +[0e54794b]: https://github.com/mpaperno/WASimCommander/commit/0e54794b2ec8411f42d34a7696426724ffc5e932 +[bea8bccb]: https://github.com/mpaperno/WASimCommander/commit/bea8bccba38fae987690d5af259f6f8b22fbc781 + +### WASimClient_CLI (C#) +* Fixed possible exception when assembling list lookup results dictionary in the off-case of duplicate keys. ([ea2c6347]) + +[ea2c6347]: https://github.com/mpaperno/WASimCommander/commit/ea2c6347750999d090ac28dc50216c2fd151eb27 + +### WASimUI +* Added database of Sim Vars, Key Events, and Unit types imported SimConnect SDK online documentation. + This is used for typing suggestions in the related form fields, can be used as a popup search window from each related field, or be opened as a standalone window for browsing all data. +* Added ability to import and export Data Requests in _MSFS/SimConnect Touch Portal Plugin_ format with a new editor window available to adjust plugin-specific data before export (category, format, etc.) +* Many improvements in table views (all options are saved to user settings and persist between sessions): + * All column widths are now re-sizable. + * Columns can be toggled on/off in the views. + * Can now be sorted by multiple columns (CTRL-click). + * Option to show filtering (searching) text fields for each column. Filters support wildcards and optional regular expressions. + * Font size can be adjusted (using context menu or CTRL key with `+`, `-`, or `0` to reset. + * Tooltips shown with data values when hovered over table cells (readable even if text is too long to fit in the column). +* Added ability to toggle visibility of each main form area of the UI from the View menu (eg. Variables or Key Events groups). Choices are preserved between sessions. +* String type variables can now be used in the "Variables" section for `Get` commands. +* Unit type specifier is now shown for 'L' variables as well (unit is optional). +* Added "Get or Create" action/button for 'L' vars. +* Numerous shortcuts and context menus added throughout, each relevant to the respective forms/tables. +* Typing suggestions can be configured independently for each text/combo box which has any and the choices are saved between sessions. +* Simplified the connection/disconnection procedure by providing one action/button for both sim and server connections (independent actions still available via extension menu). +* Last selected variable types and data request type are saved between sessions. +* Fixed that the state of current item selections in tables wasn't always properly detected and buttons didn't get enabled/disabled when needed (eg. "Remove Requests" button). +* The list of 'L' variables loaded from simulator is now sorted alphabetically. +* Most actions/buttons which require a server connection to work are now disabled when not connected. +* When loading data requests from a file while connected to the server, the requests are now sent asynchronously, improving UI responsiveness. +* More minor quality-of-life improvements! + +**Full log:** [v1.2.0-alpha3...HEAD](https://github.com/mpaperno/WASimCommander/compare/1.1.2.0...HEAD) + +--- ## 1.1.2.0 (23-Feb-2023) ### WASimModule From cf46967b499a9bb19a77a14a47bd2ac29b4d0989 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Thu, 2 Nov 2023 02:19:44 -0400 Subject: [PATCH 59/65] [CLI][WASimClient] Better fix for possible exception when assembling list lookup results dictionary in the off-case of duplicate keys (supersedes ea2c6347); Suppress bogus Intellisense errors in Structs.h. --- src/WASimClient_CLI/Structs.h | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/WASimClient_CLI/Structs.h b/src/WASimClient_CLI/Structs.h index b83aae0..16e5e5a 100644 --- a/src/WASimClient_CLI/Structs.h +++ b/src/WASimClient_CLI/Structs.h @@ -34,6 +34,9 @@ using namespace System::Collections::Generic; using namespace System::Runtime::InteropServices; using namespace msclr::interop; +// We need this to ignore the errors about "unknown" pragmas which actually work to suppress bogus Intellisense errors. Yeah. +#pragma warning(disable:4068) + /// WASimCommander::CLI::Structs namespace. /// CLI/.NET versions of WASimCommander API and Client data structures. namespace WASimCommander::CLI::Structs @@ -148,9 +151,11 @@ namespace WASimCommander::CLI::Structs explicit Command(CommandId id) : commandId(id) { } explicit Command(CommandId id, uint32_t uData) : uData(uData), commandId(id) { } explicit Command(CommandId id, uint32_t uData, double fData) : uData(uData), fData(fData), commandId(id) { } +#pragma diag_suppress 144 // a value of type "System::String ^" cannot be used to initialize an entity of type "unsigned char" (sData is not a uchar... someone's confused) explicit Command(CommandId id, uint32_t uData, String ^sData) : uData(uData), fData(0.0), commandId(id), sData{sData} { } explicit Command(CommandId id, uint32_t uData, String ^sData, double fData) : uData(uData), fData(fData), commandId(id), sData{sData} { } explicit Command(CommandId id, uint32_t uData, String ^sData, double fData, int32_t token) : token(token), uData(uData), fData(fData), commandId(id), sData{sData} { } +#pragma diag_restore 144 void setStringData(String ^sData) { @@ -455,8 +460,10 @@ namespace WASimCommander::CLI::Structs explicit ListResult(const WASimCommander::Client::ListResult &r) : listType{(LookupItemType)r.listType}, result(r.result), list{gcnew ListCollectionType((int)r.list.size()) } { +#pragma diag_suppress 2242 // for list[] operator: expression must have pointer-to-object or handle-to-C++/CLI-array type but it has type "ListCollectionType ^" (um... isn't `list` a pointer?) for (const auto &pr : r.list) - list->TryAdd(pr.first, gcnew String(pr.second.c_str())); + list[pr.first] = gcnew String(pr.second.c_str()); +#pragma diag_default 2242 } }; From c491ab34e3fde818df9343f700835b9da1ee3e9a Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Thu, 2 Nov 2023 02:25:27 -0400 Subject: [PATCH 60/65] [CLI] Add .NET7 build configuration and include in releases. --- build/build.ps1 | 5 +- .../CS_BasicConsole/CS_BasicConsole.csproj | 13 ++++- src/WASimClient_CLI/WASimClient_CLI.vcxproj | 53 ++++++++++++++++++- src/WASimCommander.sln | 26 ++++++++- 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/build/build.ps1 b/build/build.ps1 index 1654baf..e9e3d85 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -8,7 +8,7 @@ Param( [string[]]$Targets = "all", [string]$RootPath = "..", - [string[]]$Configuration = @("Debug", "Debug-DLL", "Release-DLL", "Release-net6", "Release-netfw", "Release"), + [string[]]$Configuration = @("Debug", "Debug-DLL", "Release-DLL", "Release-net6", "Release-net7", "Release-netfw", "Release"), [string]$Platform = "x64", [string[]]$Projects = "all", [string]$BuildType = "Clean,Rebuild", @@ -154,6 +154,9 @@ if ($LastExitCode -ge 8) { Write-Output($LastExitCode); Exit 1 } # .NET 6 robocopy "$BuildPath\${CLIENT_NAME}_CLI\Release-net6-$Platform" "${csLibPath}\net6" *.dll *.pdb *.xml *.ini $copyOptions if ($LastExitCode -ge 8) { Write-Output($LastExitCode); Exit 1 } +# .NET 7 +robocopy "$BuildPath\${CLIENT_NAME}_CLI\Release-net7-$Platform" "${csLibPath}\net7" *.dll *.pdb *.xml *.ini $copyOptions +if ($LastExitCode -ge 8) { Write-Output($LastExitCode); Exit 1 } # .NET Framework robocopy "$BuildPath\${CLIENT_NAME}_CLI\Release-netfw-$Platform" "${csLibPath}\net46" *.dll *.pdb *.xml *.ini $copyOptions if ($LastExitCode -ge 8) { Write-Output($LastExitCode); Exit 1 } diff --git a/src/Testing/CS_BasicConsole/CS_BasicConsole.csproj b/src/Testing/CS_BasicConsole/CS_BasicConsole.csproj index 4ced449..6407f30 100644 --- a/src/Testing/CS_BasicConsole/CS_BasicConsole.csproj +++ b/src/Testing/CS_BasicConsole/CS_BasicConsole.csproj @@ -2,7 +2,6 @@ Exe - net6.0-windows disable enable CS_BasicConsole.Program @@ -11,14 +10,26 @@ False none x64 + Debug;Release;Release-net7 + 1.1.0.0 + 1.1.0.0 + net6.0-windows embedded + net6.0-windows + embedded + True + + + + net7.0-windows embedded + True diff --git a/src/WASimClient_CLI/WASimClient_CLI.vcxproj b/src/WASimClient_CLI/WASimClient_CLI.vcxproj index 280e67b..b50c9aa 100644 --- a/src/WASimClient_CLI/WASimClient_CLI.vcxproj +++ b/src/WASimClient_CLI/WASimClient_CLI.vcxproj @@ -9,6 +9,10 @@ Release-net6 x64 + + Release-net7 + x64 + Release-netfw x64 @@ -56,6 +60,16 @@ x64 true + + false + v143 + NetCore + net7.0 + DynamicLibrary + Unicode + x64 + true + false v143 @@ -83,6 +97,10 @@ + + + + @@ -108,6 +126,13 @@ $(MSFS_SDK)\SimConnect SDK\include;$(IncludePath) WASimCommander.WASimClient + + true + true + false + $(MSFS_SDK)\SimConnect SDK\include;$(IncludePath) + WASimCommander.WASimClient + true false @@ -192,7 +217,7 @@ $(ProjectDir)deps.manifest %(AdditionalManifestFiles) - + NotUsing Level3 @@ -218,6 +243,32 @@ $(ProjectDir)deps.manifest %(AdditionalManifestFiles) + + + NotUsing + Level3 + NETFRAMEWORK;WSMCMND_API_STATIC;_CRT_SECURE_NO_DEPRECATE;_CRT_NONSTDC_NO_DEPRECATE_DEBUG;_WINDOWS;NDEBUG;%(PreprocessorDefinitions) + stdcpp17 + stdc17 + /Zc:__cplusplus /Zc:twoPhase- + true + Speed + true + true + StdCall + MultiThreadedDLL + true + 4635;%(DisableSpecificWarnings) + true + + + /ignore:4099 + Default + + + $(ProjectDir)deps.manifest %(AdditionalManifestFiles) + + diff --git a/src/WASimCommander.sln b/src/WASimCommander.sln index f3a2cb8..2a1577c 100644 --- a/src/WASimCommander.sln +++ b/src/WASimCommander.sln @@ -76,6 +76,8 @@ Global Release-DLL|x64 = Release-DLL|x64 Release-net6|MSFS = Release-net6|MSFS Release-net6|x64 = Release-net6|x64 + Release-net7|MSFS = Release-net7|MSFS + Release-net7|x64 = Release-net7|x64 Release-netfw|MSFS = Release-netfw|MSFS Release-netfw|x64 = Release-netfw|x64 EndGlobalSection @@ -96,6 +98,9 @@ Global {A5468B35-BBBD-4C55-97ED-81BFE343B0E4}.Release-DLL|x64.ActiveCfg = Release|MSFS {A5468B35-BBBD-4C55-97ED-81BFE343B0E4}.Release-net6|MSFS.ActiveCfg = Release|MSFS {A5468B35-BBBD-4C55-97ED-81BFE343B0E4}.Release-net6|x64.ActiveCfg = Release|MSFS + {A5468B35-BBBD-4C55-97ED-81BFE343B0E4}.Release-net7|MSFS.ActiveCfg = Release|MSFS + {A5468B35-BBBD-4C55-97ED-81BFE343B0E4}.Release-net7|MSFS.Build.0 = Release|MSFS + {A5468B35-BBBD-4C55-97ED-81BFE343B0E4}.Release-net7|x64.ActiveCfg = Release|MSFS {A5468B35-BBBD-4C55-97ED-81BFE343B0E4}.Release-netfw|MSFS.ActiveCfg = Release|MSFS {A5468B35-BBBD-4C55-97ED-81BFE343B0E4}.Release-netfw|MSFS.Build.0 = Release|MSFS {A5468B35-BBBD-4C55-97ED-81BFE343B0E4}.Release-netfw|x64.ActiveCfg = Release|MSFS @@ -115,6 +120,10 @@ Global {639093FF-FD94-4E89-92AC-C6FABE5DF664}.Release-net6|MSFS.ActiveCfg = Release|x64 {639093FF-FD94-4E89-92AC-C6FABE5DF664}.Release-net6|x64.ActiveCfg = Release|x64 {639093FF-FD94-4E89-92AC-C6FABE5DF664}.Release-net6|x64.Build.0 = Release|x64 + {639093FF-FD94-4E89-92AC-C6FABE5DF664}.Release-net7|MSFS.ActiveCfg = Release-DLL|x64 + {639093FF-FD94-4E89-92AC-C6FABE5DF664}.Release-net7|MSFS.Build.0 = Release-DLL|x64 + {639093FF-FD94-4E89-92AC-C6FABE5DF664}.Release-net7|x64.ActiveCfg = Release|x64 + {639093FF-FD94-4E89-92AC-C6FABE5DF664}.Release-net7|x64.Build.0 = Release|x64 {639093FF-FD94-4E89-92AC-C6FABE5DF664}.Release-netfw|MSFS.ActiveCfg = Release|x64 {639093FF-FD94-4E89-92AC-C6FABE5DF664}.Release-netfw|MSFS.Build.0 = Release|x64 {639093FF-FD94-4E89-92AC-C6FABE5DF664}.Release-netfw|x64.ActiveCfg = Release|x64 @@ -132,6 +141,9 @@ Global {E9AD8656-009B-4A6A-832B-0D19DE288BFC}.Release-DLL|x64.ActiveCfg = Release-DLL|x64 {E9AD8656-009B-4A6A-832B-0D19DE288BFC}.Release-net6|MSFS.ActiveCfg = Release-DLL|x64 {E9AD8656-009B-4A6A-832B-0D19DE288BFC}.Release-net6|x64.ActiveCfg = Release|x64 + {E9AD8656-009B-4A6A-832B-0D19DE288BFC}.Release-net7|MSFS.ActiveCfg = Release-DLL|x64 + {E9AD8656-009B-4A6A-832B-0D19DE288BFC}.Release-net7|MSFS.Build.0 = Release-DLL|x64 + {E9AD8656-009B-4A6A-832B-0D19DE288BFC}.Release-net7|x64.ActiveCfg = Release|x64 {E9AD8656-009B-4A6A-832B-0D19DE288BFC}.Release-netfw|MSFS.ActiveCfg = Release|x64 {E9AD8656-009B-4A6A-832B-0D19DE288BFC}.Release-netfw|MSFS.Build.0 = Release|x64 {E9AD8656-009B-4A6A-832B-0D19DE288BFC}.Release-netfw|x64.ActiveCfg = Release|x64 @@ -152,6 +164,10 @@ Global {DAF5B792-C4E6-4E54-9CBF-0A0335E80306}.Release-net6|MSFS.Build.0 = Release-net6|x64 {DAF5B792-C4E6-4E54-9CBF-0A0335E80306}.Release-net6|x64.ActiveCfg = Release-net6|x64 {DAF5B792-C4E6-4E54-9CBF-0A0335E80306}.Release-net6|x64.Build.0 = Release-net6|x64 + {DAF5B792-C4E6-4E54-9CBF-0A0335E80306}.Release-net7|MSFS.ActiveCfg = Release-net7|x64 + {DAF5B792-C4E6-4E54-9CBF-0A0335E80306}.Release-net7|MSFS.Build.0 = Release-net7|x64 + {DAF5B792-C4E6-4E54-9CBF-0A0335E80306}.Release-net7|x64.ActiveCfg = Release-net7|x64 + {DAF5B792-C4E6-4E54-9CBF-0A0335E80306}.Release-net7|x64.Build.0 = Release-net7|x64 {DAF5B792-C4E6-4E54-9CBF-0A0335E80306}.Release-netfw|MSFS.ActiveCfg = Release|x64 {DAF5B792-C4E6-4E54-9CBF-0A0335E80306}.Release-netfw|MSFS.Build.0 = Release|x64 {DAF5B792-C4E6-4E54-9CBF-0A0335E80306}.Release-netfw|x64.ActiveCfg = Release-netfw|x64 @@ -164,11 +180,14 @@ Global {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release|MSFS.ActiveCfg = Release|x64 {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release|MSFS.Build.0 = Release|x64 {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release|x64.ActiveCfg = Release|x64 - {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release|x64.Build.0 = Release|x64 {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release-DLL|MSFS.ActiveCfg = Release|x64 {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release-DLL|x64.ActiveCfg = Release|x64 {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release-net6|MSFS.ActiveCfg = Release|x64 {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release-net6|x64.ActiveCfg = Release|x64 + {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release-net6|x64.Build.0 = Release|x64 + {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release-net7|MSFS.ActiveCfg = Release|x64 + {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release-net7|MSFS.Build.0 = Release|x64 + {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release-net7|x64.ActiveCfg = Release-net7|x64 {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release-netfw|MSFS.ActiveCfg = Release|x64 {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release-netfw|MSFS.Build.0 = Release|x64 {5B7D7234-D6C8-4D1F-B135-C5297D6476D8}.Release-netfw|x64.ActiveCfg = Release|x64 @@ -187,6 +206,9 @@ Global {523ABD54-4C1A-4F21-8977-5EFA5821F71D}.Release-DLL|x64.ActiveCfg = Release-DLL|x64 {523ABD54-4C1A-4F21-8977-5EFA5821F71D}.Release-net6|MSFS.ActiveCfg = Release-DLL|x64 {523ABD54-4C1A-4F21-8977-5EFA5821F71D}.Release-net6|x64.ActiveCfg = Release|x64 + {523ABD54-4C1A-4F21-8977-5EFA5821F71D}.Release-net7|MSFS.ActiveCfg = Release-DLL|x64 + {523ABD54-4C1A-4F21-8977-5EFA5821F71D}.Release-net7|MSFS.Build.0 = Release-DLL|x64 + {523ABD54-4C1A-4F21-8977-5EFA5821F71D}.Release-net7|x64.ActiveCfg = Release|x64 {523ABD54-4C1A-4F21-8977-5EFA5821F71D}.Release-netfw|MSFS.ActiveCfg = Release|x64 {523ABD54-4C1A-4F21-8977-5EFA5821F71D}.Release-netfw|MSFS.Build.0 = Release|x64 {523ABD54-4C1A-4F21-8977-5EFA5821F71D}.Release-netfw|x64.ActiveCfg = Release|x64 @@ -200,6 +222,8 @@ Global {C6D4303F-E717-4257-990B-2CAE894898A0}.Release-DLL|x64.ActiveCfg = Release|Any CPU {C6D4303F-E717-4257-990B-2CAE894898A0}.Release-net6|MSFS.ActiveCfg = Release|Any CPU {C6D4303F-E717-4257-990B-2CAE894898A0}.Release-net6|x64.ActiveCfg = Release|Any CPU + {C6D4303F-E717-4257-990B-2CAE894898A0}.Release-net7|MSFS.ActiveCfg = Release|Any CPU + {C6D4303F-E717-4257-990B-2CAE894898A0}.Release-net7|x64.ActiveCfg = Release|Any CPU {C6D4303F-E717-4257-990B-2CAE894898A0}.Release-netfw|MSFS.ActiveCfg = Release|Any CPU {C6D4303F-E717-4257-990B-2CAE894898A0}.Release-netfw|x64.ActiveCfg = Release|Any CPU EndGlobalSection From bed0111ff2cdf1c8e6fd5955a2422ad87dfeec2e Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Thu, 2 Nov 2023 22:42:04 -0400 Subject: [PATCH 61/65] Update README. --- README.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ceca2f6..e2a48b9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/mpaperno/WASimCommander?include_prereleases)](https://github.com/mpaperno/WASimCommander/releases) [![GPLv3 License](https://img.shields.io/badge/license-GPLv3-blue.svg)](LICENSE.GPL.txt) [![LGPGv3 License](https://img.shields.io/badge/license-LGPLv3-blue.svg)](LICENSE.LGPL.txt) +[![API Documentation](https://img.shields.io/badge/API-Documentation-07A7EC?labelColor=black)](https://wasimcommander.max.paperno.us/) [![Discord](https://img.shields.io/static/v1?style=flat&color=7289DA&&labelColor=7289DA&message=Discord%20Chat&label=&logo=discord&logoColor=white)](https://discord.gg/meWyE4dcAt) @@ -15,7 +16,7 @@ **A WASM module-based Server and a full Client API combination.** This project is geared towards other MSFS developers/coders who need a convenient way to remotely access parts of the Simulator which are normally -inaccessible via _SimConnect_, such as locally-defined aircraft variables or custom events. +inaccessible via _SimConnect_, such as some variable types and 'H' events, and running RPN "calculator code" directly on the sim. The Client API can be utilized natively from C++, or via .NET managed assembly from C#, Python, or other languages. @@ -90,7 +91,9 @@ On a more practical note, I am using it with the [MSFS Touch Portal Plugin](http #### Desktop GUI - Includes a full-featured desktop application which demonstrates/tests all available features of the API. -- Fully usable as a standalone application which saves preferences, imports/exports lists of data subscriptions/registered events, and other usabililty features. +- Fully usable as a standalone application which saves preferences, imports/exports lists of data subscriptions/registered events, and other friendly features. +- Very useful for "exploring" the simulator in general, like checking variable values, testing effects of key events and RPN calculator code. +- Can be used with the [MSFS/SimConnect Touch Portal Plugin](https://github.com/mpaperno/MSFSTouchPortalPlugin) for import/export of custom variable request definitions.

     

    @@ -117,8 +120,8 @@ Update announcements are also posted on my Discord server's [WASimCommander rele There are three basic console-style tests/examples included for `C++`, `C#`, and `Python` in the [src/Testing](https://github.com/mpaperno/WASimCommander/tree/main/src/Testing) folder. If you like reading code, this is the place to start. -API docuemntation generated from source comments is published here: https://mpaperno.github.io/WASimCommander/
    -A good place to start with the docs is probably the [`WASimClient`](https://mpaperno.github.io/WASimCommander/class_w_a_sim_commander_1_1_client_1_1_w_a_sim_client.html) page. +API docuemntation generated from source comments is published here: https://wasimcommander.max.paperno.us/
    +A good place to start with the docs is probably the [`WASimClient`](https://wasimcommander.max.paperno.us/class_w_a_sim_commander_1_1_client_1_1_w_a_sim_client.html) page. The GUI is written in C++ (using Qt library for UI), and while not the simplest example, _is_ a full implementation of almost all the available API features. The main `WASimClient` interactions all happen in the `MainWindow::Private` class at the top of the @@ -185,12 +188,17 @@ Uses and includes [_IniPP_ by Matthias C. M. Troffaes](https://github.com/mcmtro Uses the _Microsoft SimConnect SDK_ under the terms of the _MS Flight Simulator SDK EULA (11/2019)_ document. -The GUI component uses portions of the [_Qt Library_](http://qt.io) under the terms of the GPL v3 license. +WASimUI (GUI): +- Uses portions of the [_Qt Library_](http://qt.io) under the terms of the GPL v3 license. +- Uses and includes the following symbol fonts for icons, under the terms of their respective licenses: + - [IcoMoon Free](https://icomoon.io/#icons-icomoon) - IcoMoon.io, GPL v3. + - [Material Icons](https://material.io/) - Google, Apache License v2.0. +- Uses modified versions of `FilterTableHeader` and `FilterLineEdit` components from [DB Browser for SQLite](https://github.com/sqlitebrowser/sqlitebrowser) under GPL v3 license. +- Uses modified version of `MultisortTableView` from under GPL v3 license. +- Uses Natural (alpha-numeric) sorting algorithm implementation for _Qt_ by Litkevich Yuriy (public domain). -The GUI component uses and includes the following symbol fonts for icons, under the terms of their respective licenses: -- [IcoMoon Free](https://icomoon.io/#icons-icomoon) - IcoMoon.io, GPL v3. -- [Material Icons](https://material.io/) - Google, Apache License v2.0. +Documentation generated with [Doxygen](https://www.doxygen.nl/) and styled with the most excellent [Doxygen Awesome](https://jothepro.github.io/doxygen-awesome-css). ------------- ### Copyright, License, and Disclaimer From a05a28c3d1af56444be3fbe54f619e62548736a0 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sat, 4 Nov 2023 06:12:36 -0400 Subject: [PATCH 62/65] [WASimClient] Fix early timeout issue on long-running `List` requests. --- src/WASimClient/WASimClient.cpp | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/WASimClient/WASimClient.cpp b/src/WASimClient/WASimClient.cpp index c292199..ac9fd57 100644 --- a/src/WASimClient/WASimClient.cpp +++ b/src/WASimClient/WASimClient.cpp @@ -787,7 +787,7 @@ class WASimClient::Private return &reponses.try_emplace(token, token, cv).first->second; } - // Blocks and waits for a response to a specific command token. + // Blocks and waits for a response to a specific command token. `timeout` can be -1 to use `extraPredicate` only (which is then required). HRESULT waitCommandResponse(uint32_t token, Command *response, uint32_t timeout = 0, std::function extraPredicate = nullptr) { TrackedResponse *tr = findTrackedResponse(token); @@ -798,8 +798,10 @@ class WASimClient::Private if (!timeout) timeout = settings.networkTimeout; - auto stop_waiting = [=]() { - return !serverConnected || (tr->response.commandId != CommandId::None && tr->response.token == token && (!extraPredicate || extraPredicate())); + bool stopped = false; + auto stop_waiting = [=, &stopped]() { + return stopped = + !serverConnected || (tr->response.commandId != CommandId::None && tr->response.token == token && (!extraPredicate || extraPredicate())); }; HRESULT hr = E_TIMEOUT; @@ -808,10 +810,20 @@ class WASimClient::Private } else { unique_lock lock(tr->mutex); - if (cv->wait_for(lock, chrono::milliseconds(timeout), stop_waiting)) - hr = S_OK; + if (timeout > 0) { + if (cv->wait_for(lock, chrono::milliseconds(timeout), stop_waiting)) + hr = S_OK; + } + else if (!!extraPredicate) { + cv->wait(lock, stop_waiting); + hr = stopped ? ERROR_ABANDONED_WAIT_0 : S_OK; + } + else { + hr = E_INVALIDARG; + LOG_DBG << "waitCommandResponse() requires a predicate condition when timeout parameter is < 0."; + } } - if (SUCCEEDED(hr) && response) { + if (SUCCEEDED(hr) && !!response) { unique_lock lock(tr->mutex); *response = move(tr->response); } @@ -839,14 +851,17 @@ class WASimClient::Private void waitListRequestEnd() { auto stop_waiting = [this]() { - //shared_lock lock(listResult.mutex); return listResult.nextTimeout.load() >= Clock::now(); }; Command response; - HRESULT hr = waitCommandResponse(listResult.token, &response, 0, stop_waiting); - if (hr == E_TIMEOUT) { + HRESULT hr = waitCommandResponse(listResult.token, &response, -1, stop_waiting); + if (hr == ERROR_ABANDONED_WAIT_0) { LOG_ERR << "List request timed out."; + hr = E_TIMEOUT; + } + else if (hr != S_OK) { + LOG_ERR << "List request failed with result: " << LOG_HR(hr); } else if (response.commandId != CommandId::Ack) { LOG_WRN << "Server returned Nak for list request of " << Utilities::getEnumName(listResult.listType.load(), LookupItemTypeNames); From 94823483fbc3003e21524c58bbde1adfb481bdf8 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Sun, 5 Nov 2023 20:46:55 -0500 Subject: [PATCH 63/65] [WASimUI] Fix automatic data request value size selection for "bool"/"boolean" unit types. --- src/WASimUI/Utils.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WASimUI/Utils.h b/src/WASimUI/Utils.h index fbd467e..aa2845e 100644 --- a/src/WASimUI/Utils.h +++ b/src/WASimUI/Utils.h @@ -222,7 +222,7 @@ class Utils if (unit == "string") return QMetaType::User + 256; if (boolUnits.contains(unit)) - return QMetaType::Bool; + return QMetaType::UChar; if (integralUnits.contains(unit)) return QMetaType::Int; return QMetaType::Double; From 576914a235c81b73ba0ea85655d913b61cbc5015 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Mon, 6 Nov 2023 00:40:05 -0500 Subject: [PATCH 64/65] [WASimClient] Validate that variable type is settable before sending command to server. --- src/WASimClient/WASimClient.cpp | 12 ++++++++---- src/shared/utilities.h | 5 +++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/WASimClient/WASimClient.cpp b/src/WASimClient/WASimClient.cpp index ac9fd57..4365817 100644 --- a/src/WASimClient/WASimClient.cpp +++ b/src/WASimClient/WASimClient.cpp @@ -952,10 +952,14 @@ class WASimClient::Private HRESULT setVariable(const VariableRequest &v, const double value) { - const string sValue = buildVariableCommandString(v, true); - if (sValue.empty() || sValue.length() >= STRSZ_CMD) - return E_INVALIDARG; - return sendServerCommand(Command(v.createLVar && v.variableType == 'L' ? CommandId::SetCreate : CommandId::Set, v.variableType, sValue.c_str(), value)); + if (Utilities::isSettableVariableType(v.variableType)) { + const string sValue = buildVariableCommandString(v, true); + if (sValue.empty() || sValue.length() >= STRSZ_CMD) + return E_INVALIDARG; + return sendServerCommand(Command(v.createLVar && v.variableType == 'L' ? CommandId::SetCreate : CommandId::Set, v.variableType, sValue.c_str(), value)); + } + LOG_WRN << "Cannot Set a variable of type '" << v.variableType << "'."; + return E_INVALIDARG; } #pragma endregion diff --git a/src/shared/utilities.h b/src/shared/utilities.h index e388b43..625bbd6 100644 --- a/src/shared/utilities.h +++ b/src/shared/utilities.h @@ -89,6 +89,11 @@ namespace WASimCommander { return find(VAR_TYPES_UNIT_BASED.cbegin(), VAR_TYPES_UNIT_BASED.cend(), type) != VAR_TYPES_UNIT_BASED.cend(); } + static bool isSettableVariableType(const char type) { + static const std::vector VAR_TYPES_SETTABLE = { 'A', 'C', 'H', 'K', 'L', 'Z' }; + return find(VAR_TYPES_SETTABLE.cbegin(), VAR_TYPES_SETTABLE.cend(), type) != VAR_TYPES_SETTABLE.cend(); + } + // returns actual byte size from given size which may be one of the SimConnect_AddToClientDataDefinition() dwSizeOrType constants static constexpr uint32_t getActualValueSize(DWORD dwSizeOrType) { From b084c4c6e54711b641b7cebdb1982207fd658836 Mon Sep 17 00:00:00 2001 From: Max Paperno Date: Tue, 7 Nov 2023 14:45:12 -0500 Subject: [PATCH 65/65] Update CHANGELOG and README. --- CHANGELOG.md | 47 +++++++++++++++++++++++++++-------------------- README.md | 18 ++++++++++-------- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1b8733..cf556a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,59 +27,66 @@ [fe99bbb2]: https://github.com/mpaperno/WASimCommander/commit/fe99bbb25c5dd907e8a4d513769759c4b430580f [f045e150]: https://github.com/mpaperno/WASimCommander/commit/f045e15007abd6b7b05b97c004a7a55488a33a9b -### WASimClient and WASimClient_CLI (C#) -* Fix incoming data size check for variable requests which are less than 4 bytes in size. ([c8e74dfa]) +### WASimClient and WASimClient_CLI (managed .NET) +* Fixed incoming data size check for variable requests which are less than 4 bytes in size. ([c8e74dfa]) +* Fixed early timeout being reported on long-running `list()` requests (eg.thousands of L vars). ([a05a28c3]) * Restored ability to specify Unit type for L vars and support for GetCreate with default value/unit and added extra features: ([3090d534], [0a30646d]) * Added unit name parameter to `setLocalVariable()` and `setOrCreateLocalVariable()`. * Added `getOrCreateLocalVariable()`. * Added `VariableRequest::createLVar` property. * Add optional `create` flag and unit name to `VariableRequest()` c'tor overloads. * Added async option to `saveDataRequest()` which doesn't wait for server response (`saveDataRequestAsync()` for the C# version). ([82ea4252], [0a30646d]) -* Add ability to return a string value with `getVariable()` to make use of new WASimModule feature. ([8e75eb8c], [0e54794b]) +* Added ability to return a string value with `getVariable()` to make use of new WASimModule feature. ([8e75eb8c], [0e54794b]) * The request updates paused state (set with `setDataRequestsPaused()`) is now saved locally even if not connected to server and will be sent to server upon connection and before sending any queued data requests. This allows connecting and sending queued requests but suspending any actual value checks until needed. ([bea8bccb]) +* The `setVariable()` method now verifies that the specified variable type is settable before sending the command to the server. ([576914a2]) * Removed logged version mismatch warning on Ping response. * Documentation updates. [c8e74dfa]: https://github.com/mpaperno/WASimCommander/commit/c8e74dfa706647cf785c7e6c811731d8945e49c6 +[a05a28c3]: https://github.com/mpaperno/WASimCommander/commit/a05a28c3d1af56444be3fbe54f619e62548736a0 [3090d534]: https://github.com/mpaperno/WASimCommander/commit/3090d5344c3a34c62e81f61237fe1fd91f6b11c5 [0a30646d]: https://github.com/mpaperno/WASimCommander/commit/0a30646d0ae985580d67ed40c8a441a0f5a0ba17 [82ea4252]: https://github.com/mpaperno/WASimCommander/commit/82ea4252bd25423bbeab354799d6be41f053880e [8e75eb8c]: https://github.com/mpaperno/WASimCommander/commit/8e75eb8c087f5a39fee93c2b7d073500e4f14664 [0e54794b]: https://github.com/mpaperno/WASimCommander/commit/0e54794b2ec8411f42d34a7696426724ffc5e932 [bea8bccb]: https://github.com/mpaperno/WASimCommander/commit/bea8bccba38fae987690d5af259f6f8b22fbc781 +[576914a2]: https://github.com/mpaperno/WASimCommander/commit/576914a235c81b73ba0ea85655d913b61cbc5015 -### WASimClient_CLI (C#) -* Fixed possible exception when assembling list lookup results dictionary in the off-case of duplicate keys. ([ea2c6347]) +### WASimClient_CLI (managed .NET) +* Fixed possible exception when assembling list lookup results dictionary in the off-case of duplicate keys. ([cf46967b]) -[ea2c6347]: https://github.com/mpaperno/WASimCommander/commit/ea2c6347750999d090ac28dc50216c2fd151eb27 +[cf46967b]: https://github.com/mpaperno/WASimCommander/commit/cf46967b499a9bb19a77a14a47bd2ac29b4d0989 ### WASimUI -* Added database of Sim Vars, Key Events, and Unit types imported SimConnect SDK online documentation. - This is used for typing suggestions in the related form fields, can be used as a popup search window from each related field, or be opened as a standalone window for browsing all data. +* Added database of Simulator Variables, Key Events, and Unit types imported from SimConnect SDK online documentation. This is used for: + * Typing suggestions in the related form fields when entering names of 'A' vars, Key Events, or Unit types. + * Available as a popup search window from each related form (Variables, Key Events, Data Requests) via button/menu/CTRL-F shortcut. + * Can be opened as a standalone window for browsing and searching all imported data by type. * Added ability to import and export Data Requests in _MSFS/SimConnect Touch Portal Plugin_ format with a new editor window available to adjust plugin-specific data before export (category, format, etc.) +* Fixed that the state of current item selections in tables wasn't always properly detected and buttons didn't get enabled/disabled when needed (eg. "Remove Requests" button). +* Added ability to toggle visibility of each main form area of the UI from the View menu (eg. Variables or Key Events groups). Choices are preserved between sessions. +* Simplified the connection/disconnection procedure by providing one action/button for both Sim and Server connections (independent actions still available via extension menu). +* Typing suggestions in combo boxes now use a drop-down menu style selection list by default, and the behavior can be configured independently for each one. +* String type variables can now be used in the "Variables" section for `Get` commands. +* Unit type specifier is now shown and used for 'L' variables as well (unit is optional). +* Added "Get or Create" action/button for 'L' vars. +* The list of 'L' variables loaded from simulator is now sorted alphabetically. +* The Size field in Data Request form is automatically populated with a likely match when a new Unit type is selected. * Many improvements in table views (all options are saved to user settings and persist between sessions): - * All column widths are now re-sizable. - * Columns can be toggled on/off in the views. + * All column widths are now re-sizable in all tables. + * Columns can be toggled on/off in the views (r-click for context menu). * Can now be sorted by multiple columns (CTRL-click). * Option to show filtering (searching) text fields for each column. Filters support wildcards and optional regular expressions. * Font size can be adjusted (using context menu or CTRL key with `+`, `-`, or `0` to reset. * Tooltips shown with data values when hovered over table cells (readable even if text is too long to fit in the column). -* Added ability to toggle visibility of each main form area of the UI from the View menu (eg. Variables or Key Events groups). Choices are preserved between sessions. -* String type variables can now be used in the "Variables" section for `Get` commands. -* Unit type specifier is now shown for 'L' variables as well (unit is optional). -* Added "Get or Create" action/button for 'L' vars. -* Numerous shortcuts and context menus added throughout, each relevant to the respective forms/tables. -* Typing suggestions can be configured independently for each text/combo box which has any and the choices are saved between sessions. -* Simplified the connection/disconnection procedure by providing one action/button for both sim and server connections (independent actions still available via extension menu). +* Numerous shortcuts and context menus added throughout, each relevant to the respective forms/tables currently being used or clicked. * Last selected variable types and data request type are saved between sessions. -* Fixed that the state of current item selections in tables wasn't always properly detected and buttons didn't get enabled/disabled when needed (eg. "Remove Requests" button). -* The list of 'L' variables loaded from simulator is now sorted alphabetically. * Most actions/buttons which require a server connection to work are now disabled when not connected. * When loading data requests from a file while connected to the server, the requests are now sent asynchronously, improving UI responsiveness. * More minor quality-of-life improvements! -**Full log:** [v1.2.0-alpha3...HEAD](https://github.com/mpaperno/WASimCommander/compare/1.1.2.0...HEAD) +**Full log:** [v1.1.2.0...HEAD](https://github.com/mpaperno/WASimCommander/compare/1.1.2.0...next) --- ## 1.1.2.0 (23-Feb-2023) diff --git a/README.md b/README.md index e2a48b9..71eb586 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ to _SimConnect_ for basic functionality like reading/setting Simulation Variable One of the motivations for this project was seeing multiple MSFS tool authors and casual hackers creating their own WASM modules and protocols just to support their own product or need. There is nothing wrong with this, of course, but for the Sim user it can be a disadvantage on several levels. They may end up running -multiple versions of modules which all do eseentially the same thing, and it may be confusing which WASM module they need to support which tool, +multiple versions of modules which all do essentially the same thing, and it may be confusing which WASM module they need to support which tool, just to name two obvious issues. For the developer, programming the WASM modules comes with its own quirks, too, not to mention the time involved. And regardless of the supposed isolated environment a WASM module is supposed to run in, it's still very easy to take down the whole Simulator with some errant code... ;-) @@ -61,7 +61,7 @@ On a more practical note, I am using it with the [MSFS Touch Portal Plugin](http - Any calculator code saved in subscriptions is **pre-compiled to a more efficient byte code** representation before being passed to the respective calculator functions. This significantly improves performance for recurring calculations. - **Register Named Events**: - - Save recurring "set events," like activiating controls using calculator code, for more efficient and simpler re-use. + - Save recurring "set events," like activating controls using calculator code, for more efficient and simpler re-use. Saved calculator code is pre-compiled to a more efficient byte code representation before being passed to the calculator function. This significantly improves performance for recurring events. - Registered events can be executed "natively" via _WASim API_ by simply sending a short command with the saved event ID. @@ -69,7 +69,7 @@ On a more practical note, I am using it with the [MSFS Touch Portal Plugin](http - Event names can be completely custom (including a `.` (period) as per SimConnect convention), or derive from the connected Client's name (to ensure uniqueness). - **Send Simulator "Key Events"** directly by ID or name (instead of going through the SimConnect mapping process or executing calculator code). Much more efficient than the other methods. - **New in v1.1.0:** Send Key Events with up to 5 values (like the new `SimConnect_TransmitClientEvent_EX1()`). -- **Remote Logging**: Log messages (errors, warnings, debug, etc) can optionally be sent to the Client, with specific minimum level (eg. only warnings and errros). +- **Remote Logging**: Log messages (errors, warnings, debug, etc) can optionally be sent to the Client, with specific minimum level (eg. only warnings and errors). - **Ping** the Server to check that the WASM module is installed and running before trying to connect or use its features. #### Core Components @@ -78,9 +78,9 @@ On a more practical note, I am using it with the [MSFS Touch Portal Plugin](http - Well-defined message API for communication between Server module and any client implementation. - Uses standard SimConnect messages for the base network "transport" layer. - All data allocations are on client side, so SimConnect limits in WASM module are bypassed (can in theory support unlimited clients). -- No wasted data allocations, each data/variable subscription is stored independently avoiding complications with offets or data overflows. +- No wasted data allocations, each data/variable subscription is stored independently avoiding complications with offsets or data overflows. - Minimum possible impact on MSFS in terms of memory and CPU usage; practically zero effect for Sim user when no clients are connected (Server is idle). -- Server periodically checks that a client is still connected by sending "hearbeat" ping requests and enforcing a timeout if no response is received. +- Server periodically checks that a client is still connected by sending "heartbeat" ping requests and enforcing a timeout if no response is received. - Extensive logging at configurable levels (debug/info/warning/etc) to multiple destinations (file/console/remote) for both Server and Client. - Uses an efficient **lazy logging** implementation which doesn't evaluate any arguments if the log message will be discarded anyway (eg. a DEBUG level message when minimum logging level is INFO). @@ -103,7 +103,7 @@ On a more practical note, I am using it with the [MSFS Touch Portal Plugin](http ------------- -### Downloads +### Downloads and Updates Over in the [Releases](https://github.com/mpaperno/WASimCommander/releases) there are 3 packages provided. (The actual file names have version numbers appended.) - `WASimCommander_SDK` - All header files, pre-built static and dynamic libs, packaged WASM module, pre-build GUI, reference documentation, and other tools/examples. @@ -114,13 +114,15 @@ _Watch_ -> _Custom_ -> _Releases_ this repo (button at top) or subscribe to the Update announcements are also posted on my Discord server's [WASimCommander release announcement channel](https://discord.gg/StbmZ2ZgsF). +The SDK and updates are [published on Flightsim.to](https://flightsim.to/file/36474/wasimcommander) where one could "subscribe" to release notifications (account required). + ------------- ### Documentation & Examples There are three basic console-style tests/examples included for `C++`, `C#`, and `Python` in the [src/Testing](https://github.com/mpaperno/WASimCommander/tree/main/src/Testing) folder. If you like reading code, this is the place to start. -API docuemntation generated from source comments is published here: https://wasimcommander.max.paperno.us/
    +API documentation generated from source comments is published here: https://wasimcommander.max.paperno.us/
    A good place to start with the docs is probably the [`WASimClient`](https://wasimcommander.max.paperno.us/class_w_a_sim_commander_1_1_client_1_1_w_a_sim_client.html) page. The GUI is written in C++ (using Qt library for UI), and while not the simplest example, _is_ a full implementation of almost all the available @@ -144,7 +146,7 @@ The module also logs to a file, though it's a bit tricky to find. On my edition `D:\WpSystem\S-1-5-21-611220451-769921231-644967174-1000\AppData\Local\Packages\Microsoft.FlightSimulator_8wekyb3d8bbwe\LocalState\packages\wasimcommander-module\work` To enable more verbose logging on the module at startup, edit the `server_conf.ini` file which is found in the module's install folder -(`Comunity\wasimcommander-module\modules`). There are comments in there indicating the options. +(`Community\wasimcommander-module\modules`). There are comments in there indicating the options. Keep in mind that the server logging level can also be changed remotely at runtime, but of course that only works if you can establish a connection to the module in the first place.