diff --git a/components/ffmsep/presist/presist.cc b/components/ffmsep/presist/presist.cc index 770f636..1534910 100644 --- a/components/ffmsep/presist/presist.cc +++ b/components/ffmsep/presist/presist.cc @@ -6,6 +6,7 @@ #include "components/ffmsep/cpstream_core.hh" +#include #include #include #include @@ -19,6 +20,73 @@ namespace { using nlohmann::json; +bool is_simple_array(const json& value) { + if (!value.is_array()) { + return false; + } + return std::all_of(value.begin(), value.end(), [](const json& item) { + return item.is_primitive(); + }); +} + +void dump_compact_json(std::ostream& out, + const json& value, + int indent = 0, + int indent_step = 2) { + const auto indent_str = std::string(static_cast(indent), ' '); + const auto child_indent = indent + indent_step; + const auto child_indent_str = std::string(static_cast(child_indent), ' '); + + if (value.is_object()) { + out << "{\n"; + bool first = true; + for (auto it = value.begin(); it != value.end(); ++it) { + if (!first) { + out << ",\n"; + } + first = false; + out << child_indent_str << json(it.key()).dump() << ": "; + dump_compact_json(out, it.value(), child_indent, indent_step); + } + out << '\n' << indent_str << '}'; + return; + } + + if (value.is_array()) { + if (value.empty()) { + out << "[]"; + return; + } + + if (is_simple_array(value)) { + out << '['; + for (std::size_t idx = 0; idx < value.size(); ++idx) { + if (idx != 0U) { + out << ", "; + } + out << value[static_cast(idx)].dump(); + } + out << ']'; + return; + } + + out << "[\n"; + bool first = true; + for (const auto& item : value) { + if (!first) { + out << ",\n"; + } + first = false; + out << child_indent_str; + dump_compact_json(out, item, child_indent, indent_step); + } + out << '\n' << indent_str << ']'; + return; + } + + out << value.dump(); +} + json serialize_tactile_frame(const DecodedFrame& frame) { json result = { {"pts", frame.pts}, @@ -165,7 +233,8 @@ WriteResult JsonWritter::write_once(const std::string& path, return {false, "failed to open export file", path}; } - stream << std::setw(2) << root; + dump_compact_json(stream, root); + stream << '\n'; stream.flush(); if (!stream.good()) { return {false, "failed to write export file", path}; diff --git a/components/view.cc b/components/view.cc index e121d19..eef9cfc 100644 --- a/components/view.cc +++ b/components/view.cc @@ -6,13 +6,22 @@ #include #include #include +#include +#include #include #include +#include #include #include #include #include #include +#include +#include +#include +#include +#include +#include #include #include #include @@ -42,6 +51,7 @@ #include #include #include +#include "components/ffmsep/presist/presist.hh" #include "components/ffmsep/tactile/tacdec.hh" #define DEBUG 0 @@ -146,7 +156,7 @@ public: cfg.packet_queue_capacity = 128; cfg.frame_queue_capacity = 32; cfg.slave_request_command.assign(kSlaveRequestCommand.begin(), kSlaveRequestCommand.end()); - cfg.slave_request_interval = std::chrono::milliseconds{200}; + cfg.slave_request_interval = 3ms; reset_core(); core_ = std::make_unique(); @@ -175,14 +185,29 @@ public: return true; } - void stop() { - reset_core(); - active_port_.clear(); - if (heatmap_data_ && matrix_context_) { - heatmap_data_->set(make_flat_points(matrix_context_->get())); - } - connected_ = false; - } + void stop() { + if (!core_) { + active_port_.clear(); + if (heatmap_data_ && matrix_context_) { + heatmap_data_->set(make_flat_points(matrix_context_->get())); + } + connected_ = false; + return; + } + + core_->set_frame_callback({}); + if (core_->is_running()) { + core_->stop(); + } + + core_->clear_frames(); + + connected_ = false; + active_port_.clear(); + if (heatmap_data_ && matrix_context_) { + heatmap_data_->set(make_flat_points(matrix_context_->get())); + } + } [[nodiscard]] bool is_running() const noexcept { return core_ && core_->is_running(); @@ -200,6 +225,21 @@ public: return last_error_; } + std::future export_frames(const QString& path, + bool clear_after_export) { + if (path.isEmpty()) { + return make_failed_future(path, "export path is empty"); + } + if (!core_) { + return make_failed_future(path, "stream is not active"); + } + if (core_->recorded_frame_count() == 0U) { + return make_failed_future(path, "no tactile frames recorded"); + } + const auto normalized = QDir::toNativeSeparators(path); + return core_->export_recorded_frames(normalized.toStdString(), clear_after_export); + } + private: void reset_core() { connected_ = false; @@ -380,6 +420,20 @@ private: QString active_port_; QString last_error_; bool connected_ = false; + + static std::future make_failed_future( + const QString& path, + std::string message) { + std::promise promise; + auto future = promise.get_future(); + ffmsep::persist::WriteResult result{ + false, + std::move(message), + path.toStdString() + }; + promise.set_value(std::move(result)); + return future; + } }; struct SensorUiState { @@ -482,7 +536,7 @@ static auto ComConfigComponent(ThemeManager& manager) { ibpro::ThemeManager {manager}, ibpro::FixedSize {40, 40}, ibpro::Color { IconButton::Color::TONAL }, - ibpro::Font { material::kRoundSmallFont }, + ibpro::Font { material::kRegularExtraSmallFont }, MutableForward { icon_button::pro::FontIcon {}, link_icon_context, @@ -513,18 +567,15 @@ static auto ComConfigComponent(ThemeManager& manager) { ibpro::ThemeManager { manager }, ibpro::FixedSize { 40, 40 }, ibpro::Color { IconButton::Color::TONAL }, - ibpro::Font { material::kRoundSmallFont }, + ibpro::Font { material::kRegularExtraSmallFont }, ibpro::FontIcon { material::icon::kRefresh }, ibpro::Clickable {[&sensor] { - // 刷新串口列表 QStringList ports_list; const auto ports = ffmsep::CPStreamCore::list_available_ports(); ports_list.reserve(static_cast(ports.size())); for (const auto& info : ports) { ports_list.emplace_back(QString::fromStdString(info.port)); } - - // 保持原选择(若仍然存在) if (!sensor.selected_port.isEmpty()) { const bool exists = ports_list.contains(sensor.selected_port); if (!exists) { @@ -539,15 +590,74 @@ static auto ComConfigComponent(ThemeManager& manager) { }, lnpro::Item { fbpro::ThemeManager {manager}, - fbpro::FixedSize {40, 40}, - fbpro::Color {FilledButton::Color::TONAL}, - fbpro::Font {material::kRoundSmallFont}, - fbpro::FontIcon {material::icon::kRefresh}, - fbpro::Clickable { - [] { - qDebug() << "export"; + fbpro::FixedSize {40, 40}, + fbpro::Radius {8.0}, + // fbpro::Color { IconButton::Color::TONAL }, + fbpro::Font { material::kRegularExtraSmallFont }, + fbpro::Text { "drive_file_move" }, + fbpro::Clickable {[&sensor] { + auto* controller = sensor.controller.get(); + if (!controller) { + QMessageBox::warning(nullptr, + QStringLiteral("导出失败"), + QStringLiteral("当前串流尚未初始化。")); + return; } - } + + const auto documents = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + const auto timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_HHmmss")); + QString suggested_name = QStringLiteral("touchsensor_%1.json").arg(timestamp); + QString initial_path = documents.isEmpty() + ? suggested_name + : QDir(documents).filePath(suggested_name); + + const QString chosen_path = QFileDialog::getSaveFileName( + nullptr, + QStringLiteral("导出触觉帧"), + initial_path, + QStringLiteral("JSON 文件 (*.json)")); + if (chosen_path.isEmpty()) { + return; + } + + auto future = controller->export_frames(chosen_path, false); + std::thread([future = std::move(future)]() mutable { + ffmsep::persist::WriteResult result{}; + try { + result = future.get(); + } catch (const std::exception& ex) { + result.ok = false; + result.error = ex.what(); + } catch (...) { + result.ok = false; + result.error = "unknown export failure"; + } + + if (auto* app = QCoreApplication::instance()) { + QMetaObject::invokeMethod(app, + [res = std::move(result)]() { + if (res.ok) { + QMessageBox::information( + nullptr, + QStringLiteral("导出成功"), + QStringLiteral("触觉帧已导出至:\n%1") + .arg(QString::fromStdString(res.path))); + } else { + const auto error = QString::fromStdString( + res.error.empty() ? std::string{"unknown error"} : res.error); + const auto target = QString::fromStdString(res.path); + const auto message = target.isEmpty() + ? QStringLiteral("原因:%1").arg(error) + : QStringLiteral("原因:%1\n目标:%2").arg(error, target); + QMessageBox::warning(nullptr, + QStringLiteral("导出失败"), + message); + } + }, + Qt::QueuedConnection); + } + }).detach(); + }} } }; return new Widget { diff --git a/modern-qt/utility/material-icon.hh b/modern-qt/utility/material-icon.hh index 72c0826..6cbdc06 100644 --- a/modern-qt/utility/material-icon.hh +++ b/modern-qt/utility/material-icon.hh @@ -96,6 +96,13 @@ namespace material { inline const auto kOutlinedLargeFont = outlined::font_3; inline const auto kOutlinedExtraLargeFont = outlined::font_4; + constexpr auto kRegularFontName = regular::font; + inline const auto kRegularExtraSmallFont = regular::font_0; + inline const auto kRegularSmallFont = regular::font_1; + inline const auto kRegularMediumFont = regular::font_2; + inline const auto kRegularLargeFont = regular::font_3; + inline const auto kRegularExtraLargeFont = regular::font_4; + namespace icon { // Function @@ -140,6 +147,7 @@ namespace material { constexpr auto kLogout = "logout"; constexpr auto kRoutine = "routine"; constexpr auto kDarkMode = "dark_mode"; + constexpr auto kFileExport = "file_export"; // File constexpr auto kFolder = "folder";