diff --git a/docs/cargo-wix-guide.md b/docs/cargo-wix-guide.md new file mode 100644 index 0000000..6b815fb --- /dev/null +++ b/docs/cargo-wix-guide.md @@ -0,0 +1,1060 @@ +# cargo-wix 打包与 WiX 常用修改指南 + +本文档说明本项目如何使用 `cargo-wix` 生成 Windows MSI 安装包,以及常见 WiX 配置的修改方法。当前项目的安装配置文件是 `wix/main.wxs`,Rust 包信息来自 `Cargo.toml`。 + +## 1. 当前项目打包方式 + +本项目使用: + +- Rust/Cargo 构建 release 可执行文件 +- `cargo-wix` 读取 `Cargo.toml` 和 `wix/main.wxs` +- WiX Toolset v3 编译并链接生成 `.msi` + +当前可执行文件配置在 `Cargo.toml`: + +```toml +[[bin]] +name = "ESkinPlayer" +path = "src/main.rs" +``` + +当前 WiX 元信息: + +```toml +[package.metadata.wix] +eula = false +``` + +当前 MSI 默认生成到: + +```text +target\wix\eskin-model-player--x86_64.msi +``` + +例如: + +```text +target\wix\eskin-model-player-5.0.0-x86_64.msi +``` + +## 2. 环境准备 + +需要安装: + +1. Rust toolchain +2. WiX Toolset v3 +3. cargo-wix + +检查 Rust: + +```powershell +rustc --version +cargo --version +``` + +安装 `cargo-wix`: + +```powershell +cargo install cargo-wix +``` + +检查 `cargo-wix`: + +```powershell +cargo wix --help +``` + +本机常见 WiX v3 路径: + +```text +C:\Program Files (x86)\WiX Toolset v3.14\bin +``` + +检查 WiX 工具是否存在: + +```powershell +Test-Path "C:\Program Files (x86)\WiX Toolset v3.14\bin\candle.exe" +Test-Path "C:\Program Files (x86)\WiX Toolset v3.14\bin\light.exe" +``` + +## 3. 常用打包命令 + +进入项目目录: + +```powershell +cd D:\eskin-player +``` + +清理旧的 WiX 输出: + +```powershell +cargo wix clean +``` + +正常打包: + +```powershell +cargo wix --bin-path "C:\Program Files (x86)\WiX Toolset v3.14\bin" --nocapture +``` + +说明: + +- `--bin-path` 指定 WiX Toolset 的 `bin` 目录。 +- `--nocapture` 显示 `candle.exe` 和 `light.exe` 的详细输出,排查错误时建议加上。 +- 默认会先执行 `cargo build --release`。 +- 默认输出到 `target\wix`。 + +查看生成的 MSI: + +```powershell +Get-ChildItem target\wix\*.msi +``` + +指定输出目录: + +```powershell +cargo wix --bin-path "C:\Program Files (x86)\WiX Toolset v3.14\bin" --output dist\ +``` + +指定输出文件名: + +```powershell +cargo wix --bin-path "C:\Program Files (x86)\WiX Toolset v3.14\bin" --output dist\ESkinPlayer.msi +``` + +只打包,不重新构建 Rust: + +```powershell +cargo wix --bin-path "C:\Program Files (x86)\WiX Toolset v3.14\bin" --no-build --target-bin-dir target\release +``` + +打包后立即安装: + +```powershell +cargo wix --bin-path "C:\Program Files (x86)\WiX Toolset v3.14\bin" --install +``` + +## 4. 重要文件说明 + +### Cargo.toml + +`Cargo.toml` 决定包名、版本、描述、作者、二进制名称等。 + +常见字段: + +```toml +[package] +name = "eskin-model-player" +version = "5.0.0" +authors = ["JOYSONQUIN"] +description = "Desktop pressure sensor visualization and playback application." +``` + +这些字段会影响: + +- MSI 文件名 +- 控制面板里的产品版本 +- WiX 模板变量 + +修改版本号后重新打包: + +```toml +version = "5.0.1" +``` + +Windows Installer 的升级判断依赖版本号。发布新的安装包时建议递增版本号。 + +### wix/main.wxs + +`wix/main.wxs` 是 WiX 主配置文件。它决定: + +- 安装包名称 +- 安装范围 +- 安装目录 +- 包含哪些文件 +- 是否写 PATH +- 卸载行为 +- UI 样式 +- 升级规则 +- 图标、EULA、快捷方式等 + +## 5. Product 和 Package + +典型结构: + +```xml + + + + +``` + +关键字段: + +- `Product/@Id='*'`:每次构建生成新的 ProductCode。 +- `UpgradeCode`:同一个产品线必须保持稳定,不要随意修改。 +- `Version='$(var.Version)'`:使用 `Cargo.toml` 的版本。 +- `InstallScope`:安装范围,常见值是 `perUser` 或 `perMachine`。 +- `InstallPrivileges`:权限要求,`limited` 表示不要求管理员权限,`elevated` 表示需要提升权限。 + +## 6. per-user 和 per-machine 的区别 + +### 当前用户安装:perUser + +适合: + +- 不希望安装时弹管理员权限 +- 应用只给当前用户使用 +- 安装到用户目录,例如 `%LOCALAPPDATA%` +- 写用户 PATH 或 HKCU 注册表 + +推荐配置: + +```xml + +``` + +目录使用: + +```xml + + + + ... + + + +``` + +环境变量使用用户级 PATH: + +```xml + +``` + +注意: + +- 安装到用户目录的组件需要使用 `HKCU` 注册表项作为 `KeyPath`。 +- 用户目录下的安装目录建议添加 `RemoveFolder`,否则 ICE64 会报错。 + +### 所有用户安装:perMachine + +适合: + +- 应用需要给所有用户使用 +- 安装到 `Program Files` +- 写系统 PATH +- 写 HKLM 注册表 +- 公司 IT 管理员部署 + +推荐配置: + +```xml + +``` + +目录使用: + +```xml + + + + ... + + + +``` + +64 位安装包通常使用: + +```xml + + + + + +``` + +然后: + +```xml + +``` + +系统 PATH: + +```xml + +``` + +注意: + +- `perMachine` 通常需要管理员权限。 +- 如果普通用户安装,会出现权限不足提示。 +- 写 `Program Files` 和系统 PATH 都需要提升权限。 + +## 7. 当前项目的 per-user 配置要点 + +当前项目已经改为当前用户安装: + +```xml +InstallScope='perUser' +InstallPrivileges='limited' +``` + +安装目录: + +```xml + + + + + ... + + + + +``` + +PATH 组件使用 HKCU registry key 作为 KeyPath: + +```xml + + + + + + +``` + +程序文件组件也使用 HKCU registry key 作为 KeyPath: + +```xml + + + + +``` + +## 8. Component、File、KeyPath 的基本规则 + +WiX 的核心概念: + +- `Directory`:安装目录。 +- `Component`:安装和卸载的最小管理单元。 +- `File`:要安装的文件。 +- `RegistryValue`:要写入的注册表项。 +- `KeyPath`:Windows Installer 用来判断组件是否已安装的关键路径。 + +常见规则: + +- 一个组件通常只放一个主要文件。 +- 组件 GUID 一旦发布后不要随意修改。 +- `perUser` 安装到用户目录时,建议用 `HKCU` registry key 作为 `KeyPath`。 +- `perMachine` 安装到 Program Files 时,文件通常可以作为 `KeyPath`。 +- 如果组件同时包含文件和注册表 KeyPath,WiX v3 通常不能用 `Guid='*'` 自动生成 GUID,需要写固定 GUID。 + +生成 GUID: + +```powershell +[guid]::NewGuid().ToString().ToUpper() +``` + +示例: + +```xml + + ... + +``` + +## 9. 添加文件 + +假设要安装一个 `README.txt` 到应用目录。 + +目录结构: + +```text +wix\ + main.wxs + README.txt +``` + +添加组件: + +```xml + + + + +``` + +然后在 `Feature` 里引用: + +```xml + +``` + +如果是 `perMachine` 安装,也可以简化为: + +```xml + + + +``` + +## 10. 添加资源目录 + +如果程序运行时需要模型、图片、配置文件等资源,建议明确安装到应用目录下。 + +示例: + +```xml + + + + + + +``` + +引用: + +```xml + +``` + +如果资源很多,不建议长期手写大量 ``。可以考虑: + +- 用 WiX Heat 自动收集目录 +- 在构建脚本里生成 `.wxs` 片段 +- 只把稳定的少量资源手写到 `main.wxs` + +## 11. 修改安装目录 + +### 改为当前用户目录 + +```xml + + + + ... + + + +``` + +实际路径通常类似: + +```text +C:\Users\\AppData\Local\eskin-model-player +``` + +### 改为 Program Files + +```xml + + + + ... + + + +``` + +64 位项目推荐: + +```xml + + + + ... + + + +``` + +## 12. 是否添加 PATH + +当前项目有一个可选 Feature:把安装目录下的 `bin` 加入 PATH。 + +Feature: + +```xml + + + +``` + +如果不希望修改 PATH,可以删除或注释: + +```xml + +``` + +也可以把整个 `Environment` Feature 注释掉。 + +用户级 PATH: + +```xml +System='no' +``` + +系统级 PATH: + +```xml +System='yes' +``` + +注意: + +- 用户级 PATH 不需要管理员权限。 +- 系统级 PATH 需要管理员权限。 +- 安装后新开的终端才能看到新的 PATH。 +- 已打开的 PowerShell/CMD 通常不会自动刷新环境变量。 + +## 13. 添加开始菜单快捷方式 + +常见做法是在 `ProgramMenuFolder` 下创建快捷方式。 + +目录: + +```xml + + + +``` + +在可执行文件组件里添加: + +```xml + + + +``` + +如果是 per-user 安装,快捷方式组件也应该使用 HKCU registry key 作为 `KeyPath`。 + +## 14. 添加桌面快捷方式 + +目录: + +```xml + +``` + +快捷方式: + +```xml + +``` + +桌面快捷方式不一定适合默认创建。更推荐放到可选 Feature,或者只创建开始菜单快捷方式。 + +## 15. 修改控制面板图标 + +准备 `.ico` 文件,例如: + +```text +wix\Product.ico +``` + +启用: + +```xml + + +``` + +注意: + +- `.ico` 最好包含多个尺寸,例如 16、32、48、256。 +- 路径相对执行打包时的项目根目录。 + +## 16. 添加 EULA + +如果需要安装时显示许可协议: + +1. 准备 RTF 文件,例如 `wix\Eula.rtf` +2. 修改 `Cargo.toml` + +```toml +[package.metadata.wix] +eula = true +``` + +3. 在 `wix/main.wxs` 中启用: + +```xml + +``` + +如果当前使用 `WixUI_FeatureTree` 并跳过 EULA,需要检查 `` 里的 `Publish` 逻辑,避免安装流程跳转冲突。 + +## 17. 修改安装 UI + +当前项目使用: + +```xml + + + ... + +``` + +常见 UI: + +- `WixUI_Minimal`:最简单。 +- `WixUI_InstallDir`:允许选择安装目录。 +- `WixUI_FeatureTree`:允许选择 Feature。 +- `WixUI_Advanced`:更完整的高级安装流程。 + +如果要允许用户改安装路径,常用: + +```xml + + +``` + +## 18. 升级策略 + +当前项目使用: + +```xml + +``` + +含义: + +- 安装新版本时自动卸载旧版本。 +- 不允许安装更旧版本覆盖新版本。 +- `UpgradeCode` 必须保持不变。 +- `Version` 必须递增。 + +Windows Installer 版本规则: + +- 通常只比较前三段版本号:`Major.Minor.Build` +- `5.0.0` 到 `5.0.1` 是升级 +- 只重打同一个 `5.0.0` 可能不会触发正常升级 + +发布新版时建议: + +```toml +version = "5.0.1" +``` + +## 19. 签名 MSI + +未签名 MSI 在 Windows 上可能触发安全提示。正式发布建议签名。 + +`cargo-wix` 支持 `sign` 子命令,也可以在生成 MSI 后用 `signtool.exe`。 + +示例: + +```powershell +signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /a target\wix\eskin-model-player-5.0.0-x86_64.msi +``` + +实际签名命令取决于证书来源: + +- 本机证书 +- PFX 文件 +- 硬件 token +- 公司代码签名服务 + +## 20. 常见错误与处理 + +### 权限不足 + +错误示例: + +```text +You do not have sufficient privileges to complete this installation for all users of the machine. +``` + +常见原因: + +- `InstallScope='perMachine'` +- 安装到 `ProgramFilesFolder` +- 写系统 PATH:`System='yes'` +- 写 HKLM 注册表 + +解决: + +- 以管理员身份运行 MSI +- 或改成 `perUser` +- 或安装到 `LocalAppDataFolder` +- 或把 PATH 改为 `System='no'` + +### ICE38 + +错误示例: + +```text +ICE38: Component ... installs to user profile. It must use a registry key under HKCU as its KeyPath, not a file. +``` + +原因: + +- 组件安装到用户目录 +- 但组件使用文件作为 `KeyPath` + +解决: + +```xml + +``` + +并移除 `File` 上的: + +```xml +KeyPath='yes' +``` + +### ICE64 + +错误示例: + +```text +ICE64: The directory Bin is in the user profile but is not listed in the RemoveFile table. +``` + +原因: + +- 用户目录下创建了目录 +- 卸载时没有明确清理 + +解决: + +```xml + + +``` + +### CNDL0230 + +错误示例: + +```text +The Component/@Guid attribute's value '*' is not valid for this component because it does not meet the criteria for having an automatically generated guid. +``` + +原因: + +- 组件里有文件 +- 但 KeyPath 是 registry value +- WiX v3 不能自动生成这种组件 GUID + +解决: + +生成固定 GUID: + +```powershell +[guid]::NewGuid().ToString().ToUpper() +``` + +然后写入: + +```xml + +``` + +### ICE91 + +警告示例: + +```text +ICE91: The file 'exe0' will be installed to the per user directory ... +``` + +原因: + +- 文件安装到用户目录 +- ICE 提醒如果该包被当作 per-machine 安装,文件不会复制到每个用户的 profile + +处理: + +- 如果安装包明确是 `perUser`,通常可以忽略。 +- 如果目标是 `perMachine`,应改用 `ProgramFilesFolder`,不要安装到用户 profile。 + +### 新 MSI 没有覆盖旧 MSI + +常见原因: + +- `Cargo.toml` 版本号没变 +- `UpgradeCode` 改了 +- 旧版本是 per-machine,新版本是 per-user + +解决: + +- 发布时递增版本号 +- 保持 `UpgradeCode` 不变 +- 如果旧版本是 per-machine,先用管理员权限卸载旧版本 + +## 21. 调试 MSI 安装 + +生成详细安装日志: + +```powershell +msiexec /i target\wix\eskin-model-player-5.0.0-x86_64.msi /l*v install.log +``` + +静默安装: + +```powershell +msiexec /i target\wix\eskin-model-player-5.0.0-x86_64.msi /qn /l*v install.log +``` + +卸载: + +```powershell +msiexec /x target\wix\eskin-model-player-5.0.0-x86_64.msi /l*v uninstall.log +``` + +如果安装失败,优先查看日志里: + +```text +Return value 3 +``` + +它附近通常是真正失败原因。 + +## 22. 推荐发布流程 + +1. 更新 `Cargo.toml` 版本号。 +2. 确认 `wix/main.wxs` 的安装范围符合发布目标。 +3. 执行 release 构建和 MSI 打包。 +4. 在干净机器或干净用户环境中测试安装。 +5. 测试启动程序。 +6. 测试卸载后文件、快捷方式、PATH 是否清理干净。 +7. 正式发布前对 MSI 签名。 + +命令: + +```powershell +cd D:\eskin-player +cargo wix clean +cargo wix --bin-path "C:\Program Files (x86)\WiX Toolset v3.14\bin" --nocapture +``` + +验证: + +```powershell +Get-ChildItem target\wix\*.msi +msiexec /i target\wix\eskin-model-player-5.0.0-x86_64.msi /l*v install.log +``` + +## 23. 本项目常用修改清单 + +### 改包名 + +修改 `Product/@Name`: + +```xml +Name='eskin-model-player' +``` + +以及 `Cargo.toml`: + +```toml +name = "eskin-model-player" +``` + +### 改显示厂商 + +修改: + +```xml +Manufacturer='JOYSONQUIN' +``` + +以及: + +```toml +authors = ["JOYSONQUIN"] +``` + +### 改安装目录名 + +修改: + +```xml + +``` + +### 改可执行文件名 + +修改 `Cargo.toml`: + +```toml +[[bin]] +name = "ESkinPlayer" +``` + +同步修改 WiX: + +```xml + +``` + +### 关闭 PATH 写入 + +删除或注释: + +```xml + +``` + +也可以删除整个 `Environment` Feature。 + +### 从 per-user 改回 per-machine + +修改: + +```xml +InstallScope='perMachine' +InstallPrivileges='elevated' +``` + +安装目录改为: + +```xml + +``` + +PATH 改为: + +```xml +System='yes' +``` + +注意: + +- 这会重新要求管理员权限。 +- 如果已经发布过 per-user 版本,切换安装范围前需要测试升级和卸载路径。 + +## 24. 维护注意事项 + +- `UpgradeCode` 是产品线标识,正常发布不要改。 +- 发布新版本时递增 `Cargo.toml` 的 `version`。 +- 已发布组件的 `Guid` 不要随意变更。 +- 用户目录安装使用 `HKCU` registry key 作为 `KeyPath`。 +- 系统目录安装通常需要管理员权限。 +- 修改 PATH 后要测试安装和卸载。 +- 加文件后必须在 `Feature` 中添加 `ComponentRef`,否则不会被安装。 +- WiX 报错时优先看 `candle` 还是 `light` 阶段: + - `candle`:XML 结构、语法、GUID、路径等编译错误。 + - `light`:链接、ICE 校验、MSI 表规则问题。 + diff --git a/src/app.rs b/src/app.rs index dfce2d7..ba0511e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -364,11 +364,11 @@ impl EskinDesktopApp { fn draw_floating_panels(&mut self, ctx: &egui::Context) { // draw_scene_panel(ctx, &mut self.scene_panel); - if self.config_state.mode == SerialMode::Hand && self.stats_panel.visible { - self.config_panel.visible = false; - self.export_panel.visible = false; - self.matrix_config_panel.visible = false; - } + // if self.config_state.mode == SerialMode::Hand && self.stats_panel.visible { + // self.config_panel.visible = false; + // self.export_panel.visible = false; + // self.matrix_config_panel.visible = false; + // } if let Some(next_mode) = draw_config_panel( ctx, diff --git a/src/ui.rs b/src/ui.rs index 5c1f667..eb38e24 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -138,8 +138,60 @@ impl Default for ConnectPanelState { } } +const PANEL_VIEWPORT_MARGIN: f32 = 12.0; + +fn viewport_panel_rect(ctx: &egui::Context) -> egui::Rect { + ctx.content_rect().shrink(PANEL_VIEWPORT_MARGIN) +} + +fn responsive_panel_width(ctx: &egui::Context, preferred: f32, min_width: f32) -> f32 { + let screen = ctx.content_rect(); + let available = (screen.width() - PANEL_VIEWPORT_MARGIN * 2.0).max(240.0); + let side_cap = (screen.width() * 0.44).max(min_width.min(available)); + + preferred + .min(side_cap) + .min(available) + .max(min_width.min(available)) +} + +fn responsive_panel_height(ctx: &egui::Context, preferred: f32, min_height: f32) -> f32 { + let screen = ctx.content_rect(); + let available = (screen.height() - layout::TITLE_BAR_HEIGHT - PANEL_VIEWPORT_MARGIN * 2.0) + .max(min_height.min(screen.height())); + let height_cap = (screen.height() * 0.72).max(min_height.min(available)); + + preferred + .min(height_cap) + .min(available) + .max(min_height.min(available)) +} + +fn responsive_panel_pos( + ctx: &egui::Context, + preferred: egui::Pos2, + panel_width: f32, +) -> egui::Pos2 { + let rect = viewport_panel_rect(ctx); + let min_y = rect.top() + layout::TITLE_BAR_HEIGHT + PANEL_VIEWPORT_MARGIN; + let max_x = (rect.right() - panel_width).max(rect.left()); + let preferred_right_side = preferred.x > rect.center().x; + let x = if preferred_right_side { + max_x + } else { + preferred.x.clamp(rect.left(), max_x) + }; + let y = preferred.y.clamp(min_y, rect.bottom()); + + egui::pos2(x, y) +} + +fn narrow_panel(ui: &egui::Ui) -> bool { + ui.available_width() < 420.0 +} + pub fn draw_scene_panel(ctx: &egui::Context, panel: &mut FloatingPanelState) { - draw_floating_panel(ctx, panel, "场景", "scene_panel", |ui| { + draw_floating_panel(ctx, panel, "场景", "scene_panel", 320.0, 260.0, |ui| { ui.horizontal(|ui| { ui.colored_label(dim_text(), "视图"); let _ = ui.selectable_label(true, "簇"); @@ -223,7 +275,7 @@ pub fn draw_connect_panel( // } fn draw_connect_port_row(ui: &mut egui::Ui, config: &mut ConnectPanelState) { - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { ui.label(style::field_label("串口")); egui::ComboBox::from_id_salt("connect_ports") .width(150.0) @@ -264,7 +316,7 @@ fn draw_connect_port_row(ui: &mut egui::Ui, config: &mut ConnectPanelState) { } fn draw_connect_matrix_row(ui: &mut egui::Ui, config: &mut ConnectPanelState) { - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { ui.checkbox(&mut config.manual, "手动矩阵"); ui.add_enabled_ui(config.manual, |ui| { @@ -291,7 +343,7 @@ fn draw_connect_action_row( connection: &ConnectionManager, recorder: &Recorder, ) { - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { let status_text = match conn_state { ConnectionState::Disconnected => "未连接", ConnectionState::Connecting => "连接中...", @@ -343,13 +395,15 @@ pub fn draw_config_panel( let mut changed_mode = None; let conn_state = connection.state(); let stats = connection.stats(); + let panel_width = responsive_panel_width(ctx, 560.0, 340.0); config.connected = matches!( conn_state, ConnectionState::Connected | ConnectionState::Streaming ); - draw_floating_panel(ctx, panel, "配置", "config_panel", |ui| { - ui.set_min_width(560.0); + draw_floating_panel(ctx, panel, "配置", "config_panel", 560.0, 340.0, |ui| { + ui.set_min_width(panel_width); + ui.set_max_width(panel_width); if let Some(mode) = draw_mode_row(ui, config) { changed_mode = Some(mode); @@ -369,7 +423,7 @@ pub fn draw_config_panel( fn draw_mode_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option { let mut changed_to = None; - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { ui.colored_label(dim_text(), "模式"); ui.add_space(12.0); @@ -398,50 +452,42 @@ fn draw_connection_row( recorder: &Recorder, conn_state: ConnectionState, ) { - ui.horizontal(|ui| { - ui.add( - egui::Image::new(egui::include_image!("../static/cpu.png")) - .fit_to_exact_size(egui::vec2(72.0, 72.0)), - ); + let is_narrow = narrow_panel(ui); + let draw_port_status = |ui: &mut egui::Ui, config: &mut ConfigPanelState| { + ui.horizontal_wrapped(|ui| { + ui.label(style::field_label("端口")); + egui::ComboBox::from_id_salt("config_ports") + .width(ui.available_width().min(150.0).max(104.0)) + .selected_text(if config.port.is_empty() { + "无可用串口".to_owned() + } else { + config.port.clone() + }) + .show_ui(ui, |ui| { + for port in &config.available_ports { + ui.selectable_value(&mut config.port, port.clone(), port.as_str()); + } + }); - ui.add_space(METRICS.item_gap); - - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label(style::field_label("端口")); - egui::ComboBox::from_id_salt("config_ports") - .width(126.0) - .selected_text(if config.port.is_empty() { - "无可用串口".to_owned() - } else { - config.port.clone() - }) - .show_ui(ui, |ui| { - for port in &config.available_ports { - ui.selectable_value(&mut config.port, port.clone(), port.as_str()); - } - }); - - if ui - .add(style::icon_button( - egui::RichText::new("⟳").color(ONE_DARK_PRO.text).size(16.0), - METRICS.icon_button, - )) - .on_hover_text("刷新串口") - .clicked() - { - refresh_config_ports(config); - } - }); - ui.label(style::value_text(format!("波特率 {}", config.baud_rate))); - ui.colored_label( - connection_status_color(conn_state), - connection_status_text(conn_state), - ); + if ui + .add(style::icon_button( + egui::RichText::new("⟳").color(ONE_DARK_PRO.text).size(16.0), + METRICS.icon_button, + )) + .on_hover_text("刷新串口") + .clicked() + { + refresh_config_ports(config); + } }); + ui.label(style::value_text(format!("波特率 {}", config.baud_rate))); + ui.colored_label( + connection_status_color(conn_state), + connection_status_text(conn_state), + ); + }; - ui.add_space(22.0); - + let draw_connect_button = |ui: &mut egui::Ui, config: &ConfigPanelState| { let is_connected = matches!( conn_state, ConnectionState::Connected | ConnectionState::Streaming @@ -468,22 +514,46 @@ fn draw_connection_row( ); } } + }; - // Legacy reconnect/link-protection indicator: - // ui.add_space(18.0); - // ui.colored_label( - // if config.auto_reconnect { - // style::status_color_ok() - // } else { - // ONE_DARK_PRO.text_dim - // }, - // if config.auto_reconnect { - // "链路保护 开" - // } else { - // "链路保护 关" - // }, - // ); - }); + if is_narrow { + ui.vertical(|ui| { + draw_port_status(ui, config); + ui.add_space(METRICS.item_gap); + draw_connect_button(ui, config); + }); + } else { + ui.horizontal(|ui| { + ui.add( + egui::Image::new(egui::include_image!("../static/cpu.png")) + .fit_to_exact_size(egui::vec2(72.0, 72.0)), + ); + + ui.add_space(METRICS.item_gap); + + ui.vertical(|ui| { + draw_port_status(ui, config); + }); + + ui.add_space(22.0); + draw_connect_button(ui, config); + }); + } + + // Legacy reconnect/link-protection indicator: + // ui.add_space(18.0); + // ui.colored_label( + // if config.auto_reconnect { + // style::status_color_ok() + // } else { + // ONE_DARK_PRO.text_dim + // }, + // if config.auto_reconnect { + // "链路保护 开" + // } else { + // "链路保护 关" + // }, + // ); } fn draw_serial_grid(ui: &mut egui::Ui, config: &mut ConfigPanelState) { @@ -584,7 +654,7 @@ fn draw_mode_body( } fn draw_status_bytes_row(ui: &mut egui::Ui, conn_state: ConnectionState, stats: SerialIoStats) { - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { ui.colored_label(dim_text(), "状态"); ui.label(connection_status_text(conn_state)); ui.colored_label(dim_text(), "接收"); @@ -704,8 +774,8 @@ pub fn draw_stats_panel( force_history: &[f32], _spatial_force: Option, ) { - const PANEL_WIDTH: f32 = 380.0; - const PANEL_HEIGHT: f32 = 340.0; + const PREFERRED_PANEL_WIDTH: f32 = 380.0; + const PREFERRED_PANEL_HEIGHT: f32 = 340.0; const PANEL_OUTSIDE_GAP: f32 = 18.0; let force_active = has_recent_resultant_force(force_history); @@ -723,24 +793,34 @@ pub fn draw_stats_panel( let eased = ease_in_out(anim); let screen = ctx.content_rect(); - let target_x = panel.default_pos.x; + let panel_width = responsive_panel_width(ctx, PREFERRED_PANEL_WIDTH, 260.0); + let panel_height = responsive_panel_height(ctx, PREFERRED_PANEL_HEIGHT, 220.0); + let viewport = viewport_panel_rect(ctx); + let target_pos = egui::pos2( + viewport.left(), + (viewport.bottom() - panel_height - PANEL_VIEWPORT_MARGIN * 2.0) + .max(viewport.top() + layout::TITLE_BAR_HEIGHT + PANEL_VIEWPORT_MARGIN), + ); + let target_x = target_pos.x; let hidden_x = if target_x < screen.center().x { - screen.left() - PANEL_WIDTH - PANEL_OUTSIDE_GAP + screen.left() - panel_width - PANEL_OUTSIDE_GAP } else { screen.right() + PANEL_OUTSIDE_GAP }; let x = egui::lerp(hidden_x..=target_x, eased); - let y = panel.default_pos.y; + let y = target_pos.y; egui::Area::new(egui::Id::new("stats_panel")) .fixed_pos(egui::pos2(x, y)) + .constrain_to(viewport_panel_rect(ctx)) .order(egui::Order::Foreground) .show(ctx, |ui| { panel_frame(ctx).show(ui, |ui| { ui.spacing_mut().item_spacing = egui::vec2(METRICS.item_gap, 6.0); - ui.set_min_width(PANEL_WIDTH); - ui.set_min_height(PANEL_HEIGHT); - draw_force_chart_panel_contents(ui, "RF", "合力", force_history, PANEL_HEIGHT); + ui.set_min_width(panel_width); + ui.set_max_width(panel_width); + ui.set_min_height(panel_height); + draw_force_chart_panel_contents(ui, "RF", "合力", force_history, panel_height); }); }); } @@ -749,9 +829,9 @@ const FORCE_CHART_MAX_N: f32 = 25.6; const FORCE_CHART_SAMPLE_SECONDS: f32 = 0.1; const FORCE_PANEL_ACTIVE_TAIL: usize = 8; -const HAND_FORCE_PANEL_MIN_WIDTH: f32 = 280.0; +const HAND_FORCE_PANEL_MIN_WIDTH: f32 = 220.0; const HAND_FORCE_PANEL_MAX_WIDTH: f32 = 500.0; -const HAND_FORCE_PANEL_MIN_HEIGHT: f32 = 140.0; +const HAND_FORCE_PANEL_MIN_HEIGHT: f32 = 104.0; const HAND_FORCE_PANEL_MAX_HEIGHT: f32 = 190.0; const HAND_FORCE_PANEL_GAP: f32 = 12.0; const HAND_FORCE_PANEL_SIDE_MARGIN: f32 = 24.0; @@ -780,41 +860,41 @@ pub fn draw_hand_force_panels(ctx: &egui::Context, visible: bool, histories: &[V .any(|history| has_recent_resultant_force(history)); let target_visible = visible && hand_active; let screen = ctx.content_rect(); - let side_width = ((screen.width() - HAND_FORCE_PANEL_SIDE_MARGIN * 4.0) * 0.25).max(0.0); + let side_margin = HAND_FORCE_PANEL_SIDE_MARGIN.min((screen.width() * 0.025).max(10.0)); + let vertical_margin = HAND_FORCE_PANEL_VERTICAL_MARGIN.min((screen.height() * 0.04).max(10.0)); + let gap = HAND_FORCE_PANEL_GAP.min((screen.height() * 0.018).max(6.0)); + let side_width = ((screen.width() - side_margin * 4.0) * 0.25).max(0.0); let panel_width = side_width .min(HAND_FORCE_PANEL_MAX_WIDTH) .max(HAND_FORCE_PANEL_MIN_WIDTH.min(side_width)); - let left_x = screen.left() + HAND_FORCE_PANEL_SIDE_MARGIN; - let right_x = screen.right() - HAND_FORCE_PANEL_SIDE_MARGIN - panel_width; + let left_x = screen.left() + side_margin; + let right_x = screen.right() - side_margin - panel_width; let left_count = 4usize; let right_count = HAND_FORCE_PANEL_TITLES.len() - left_count; - let available_height = (screen.height() - HAND_FORCE_PANEL_VERTICAL_MARGIN * 2.0).max(0.0); - let panel_height = ((available_height - (left_count - 1) as f32 * HAND_FORCE_PANEL_GAP) - / left_count as f32) + let available_height = + (screen.height() - layout::TITLE_BAR_HEIGHT - vertical_margin * 2.0).max(0.0); + let panel_height = ((available_height - (left_count - 1) as f32 * gap) / left_count as f32) .clamp( HAND_FORCE_PANEL_MIN_HEIGHT.min(available_height), HAND_FORCE_PANEL_MAX_HEIGHT, ); - let left_height = - left_count as f32 * panel_height + (left_count - 1) as f32 * HAND_FORCE_PANEL_GAP; - let right_height = right_count as f32 * panel_height - + (right_count.saturating_sub(1)) as f32 * HAND_FORCE_PANEL_GAP; - let left_top = screen.center().y - left_height * 0.5; - let right_top = screen.center().y - right_height * 0.5; + let left_height = left_count as f32 * panel_height + (left_count - 1) as f32 * gap; + let right_height = + right_count as f32 * panel_height + (right_count.saturating_sub(1)) as f32 * gap; + let min_top = screen.top() + layout::TITLE_BAR_HEIGHT + vertical_margin; + let left_top = (screen.center().y - left_height * 0.5).max(min_top); + let right_top = (screen.center().y - right_height * 0.5).max(min_top); for index in 0..HAND_FORCE_PANEL_TITLES.len() { let history = histories.get(index).map(Vec::as_slice).unwrap_or(&[]); let (code, title) = HAND_FORCE_PANEL_TITLES[index]; let (target_x, target_y) = if index < left_count { - ( - left_x, - left_top + index as f32 * (panel_height + HAND_FORCE_PANEL_GAP), - ) + (left_x, left_top + index as f32 * (panel_height + gap)) } else { let right_index = index - left_count; ( right_x, - right_top + right_index as f32 * (panel_height + HAND_FORCE_PANEL_GAP), + right_top + right_index as f32 * (panel_height + gap), ) }; @@ -858,11 +938,13 @@ fn draw_force_chart_area( egui::Area::new(id) .fixed_pos(egui::pos2(target_pos.x, y)) + .constrain_to(viewport_panel_rect(ctx)) .order(egui::Order::Foreground) .show(ctx, |ui| { panel_frame(ctx).show(ui, |ui| { ui.spacing_mut().item_spacing = egui::vec2(METRICS.item_gap, 6.0); ui.set_min_width(size.x); + ui.set_max_width(size.x); ui.set_min_height(size.y); draw_force_chart_panel_contents(ui, code, title, values, size.y); }); @@ -895,7 +977,7 @@ fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32], chart_height: const CHART_POINTS: usize = 42; const CHART_WINDOW_SECONDS: f32 = CHART_POINTS as f32 * FORCE_CHART_SAMPLE_SECONDS; - let width = ui.available_width().max(280.0); + let width = ui.available_width().max(180.0); let (rect, _) = ui.allocate_exact_size(egui::vec2(width, chart_height), egui::Sense::hover()); let painter = ui.painter_at(rect); let radius = egui::CornerRadius::same(5); @@ -949,7 +1031,6 @@ fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32], chart_height: } let plot_painter = painter.with_clip_rect(chart_rect.expand2(egui::vec2(1.0, 1.0))); - paint_force_sweep(&plot_painter, chart_rect, time); paint_force_area(&plot_painter, &points, plot_rect); if points.len() >= 2 { @@ -1036,28 +1117,6 @@ fn paint_force_grid( } } -fn paint_force_sweep(painter: &egui::Painter, chart_rect: egui::Rect, time: f32) { - let sweep = (time * 0.55).fract(); - let x = chart_rect.left() + sweep * chart_rect.width(); - let sweep_rect = egui::Rect::from_min_max( - egui::pos2(x - 8.0, chart_rect.top()), - egui::pos2(x + 8.0, chart_rect.bottom()), - ); - - painter.rect_filled( - sweep_rect, - egui::CornerRadius::ZERO, - color_alpha(ONE_DARK_PRO.accent, 20), - ); - painter.line_segment( - [ - egui::pos2(x, chart_rect.top()), - egui::pos2(x, chart_rect.bottom()), - ], - egui::Stroke::new(1.0, color_alpha(ACCENT_GREEN, 95)), - ); -} - fn paint_force_area(painter: &egui::Painter, points: &[egui::Pos2], plot_rect: egui::Rect) { if points.len() < 2 { return; @@ -1090,22 +1149,32 @@ fn draw_floating_panel( panel: &mut FloatingPanelState, title: &'static str, id: &'static str, + preferred_width: f32, + min_width: f32, add_contents: impl FnOnce(&mut egui::Ui), ) { if panel.visible { let mut open = true; let mut hide_requested = false; let mut window_rect = None; + let panel_width = responsive_panel_width(ctx, preferred_width, min_width); + let max_height = responsive_panel_height(ctx, ctx.content_rect().height(), 180.0); + let default_pos = responsive_panel_pos(ctx, panel.default_pos, panel_width); let window_response = egui::Window::new(title) .id(egui::Id::new(id)) .open(&mut open) - .default_pos(panel.default_pos) + .default_pos(default_pos) + .max_width(panel_width) + .max_height(max_height) + .constrain_to(viewport_panel_rect(ctx)) .title_bar(false) .resizable(true) .frame(panel_frame(ctx)) .show(ctx, |ui| { ui.spacing_mut().item_spacing = egui::vec2(METRICS.item_gap, 6.0); + ui.set_min_width(panel_width); + ui.set_max_width(panel_width); ui.horizontal(|ui| { if ui .add(rich_tag_button("隐藏", style::ONE_DARK_PRO.text_dim)) @@ -1117,7 +1186,15 @@ fn draw_floating_panel( ui.label(style::panel_title(title)); }); ui.separator(); - add_contents(ui); + let content_max_height = (max_height - 60.0).max(120.0); + egui::ScrollArea::vertical() + .max_height(content_max_height) + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.set_min_width(panel_width); + ui.set_max_width(panel_width); + add_contents(ui); + }); }); if let Some(response) = window_response { @@ -1172,9 +1249,10 @@ fn draw_center_floating_panel( collapsed_label: &'static str, add_contents: impl FnOnce(&mut egui::Ui), ) { - const PANEL_WIDTH: f32 = 520.0; + const PREFERRED_PANEL_WIDTH: f32 = 520.0; const SLIDE_DISTANCE: f32 = 18.0; + let panel_width = responsive_panel_width(ctx, PREFERRED_PANEL_WIDTH, 320.0); let anim = advance_center_panel_anim(ctx, panel); let eased = ease_in_out(anim); @@ -1206,8 +1284,8 @@ fn draw_center_floating_panel( ) .order(egui::Order::Tooltip) .show(ctx, |ui| { - let response = center_panel_shell(ui, PANEL_WIDTH, |ui| { - ui.set_width(PANEL_WIDTH); + let response = center_panel_shell(ui, panel_width, |ui| { + ui.set_width(panel_width); add_contents(ui); ui.add_space(6.0); let handle_rect = allocate_center_panel_handle(ui); @@ -1540,7 +1618,7 @@ pub fn draw_recording_toolbar(ui: &mut egui::Ui, recorder: &Recorder, export_pat let duration_ms = recorder.duration_ms(); ui.separator(); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { ui.colored_label(ACCENT_RED, "● REC"); ui.colored_label( ONE_DARK_PRO.text, @@ -1548,7 +1626,7 @@ pub fn draw_recording_toolbar(ui: &mut egui::Ui, recorder: &Recorder, export_pat ); }); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { // Full recording let rec_btn = if is_recording { tag_button("⏹ 停止") @@ -1634,16 +1712,26 @@ pub fn draw_recording_toolbar(ui: &mut egui::Ui, recorder: &Recorder, export_pat }); // Export path - ui.horizontal(|ui| { - ui.colored_label(dim_text(), "导出路径"); - ui.add_sized( - egui::vec2( - (ui.available_width() - 80.0).max(160.0), - METRICS.field_height, - ), - egui::TextEdit::singleline(export_path).hint_text("eskin_export_*.csv"), - ); - }); + if narrow_panel(ui) { + ui.vertical(|ui| { + ui.colored_label(dim_text(), "导出路径"); + ui.add_sized( + egui::vec2(ui.available_width().max(180.0), METRICS.field_height), + egui::TextEdit::singleline(export_path).hint_text("eskin_export_*.csv"), + ); + }); + } else { + ui.horizontal(|ui| { + ui.colored_label(dim_text(), "导出路径"); + ui.add_sized( + egui::vec2( + (ui.available_width() - 80.0).max(160.0), + METRICS.field_height, + ), + egui::TextEdit::singleline(export_path).hint_text("eskin_export_*.csv"), + ); + }); + } } // ── Export Panel (floating) ──────────────────────────────────────────── @@ -1655,10 +1743,20 @@ pub fn draw_export_panel( recorder: &Recorder, export_path: &mut String, ) { - draw_floating_panel(ctx, panel, "录制导出", "export_panel", |ui| { - ui.set_min_width(300.0); - draw_recording_toolbar(ui, recorder, export_path); - }); + let panel_width = responsive_panel_width(ctx, 340.0, 280.0); + draw_floating_panel( + ctx, + panel, + "录制导出", + "export_panel", + 340.0, + 280.0, + |ui| { + ui.set_min_width(panel_width); + ui.set_max_width(panel_width); + draw_recording_toolbar(ui, recorder, export_path); + }, + ); } // ── Matrix Config Panel ──────────────────────────────────────────────── @@ -1687,75 +1785,85 @@ pub fn draw_matrix_config_panel( panel: &mut FloatingPanelState, config: &mut MatrixConfigState, ) { - draw_floating_panel(ctx, panel, "矩阵配置", "matrix_config_panel", |ui| { - ui.set_min_width(280.0); + let panel_width = responsive_panel_width(ctx, 380.0, 280.0); + draw_floating_panel( + ctx, + panel, + "矩阵配置", + "matrix_config_panel", + 380.0, + 280.0, + |ui| { + ui.set_min_width(panel_width); + ui.set_max_width(panel_width); + + ui.horizontal_wrapped(|ui| { + ui.label(style::field_label("矩阵尺寸")); + ui.add_space(METRICS.item_gap); + ui.label(style::value_text("行")); + ui.add_sized( + egui::vec2(56.0, METRICS.field_height), + egui::DragValue::new(&mut config.rows).range(1..=128), + ); + ui.label(style::value_text("列")); + ui.add_sized( + egui::vec2(56.0, METRICS.field_height), + egui::DragValue::new(&mut config.cols).range(1..=128), + ); + }); - ui.horizontal(|ui| { - ui.label(style::field_label("矩阵尺寸")); ui.add_space(METRICS.item_gap); - ui.label(style::value_text("行")); - ui.add_sized( - egui::vec2(56.0, METRICS.field_height), - egui::DragValue::new(&mut config.rows).range(1..=128), - ); - ui.label(style::value_text("列")); - ui.add_sized( - egui::vec2(56.0, METRICS.field_height), - egui::DragValue::new(&mut config.cols).range(1..=128), - ); - }); - ui.add_space(METRICS.item_gap); - - ui.horizontal(|ui| { - ui.label(style::field_label("预设")); - for (r, c, name) in &[ - (12, 7, "12×7"), - (24, 12, "24×12"), - (32, 16, "32×16"), - (48, 24, "48×24"), - (64, 32, "64×32"), - ] { - if ui - .add(rich_tag_button(*name, style::ONE_DARK_PRO.text_dim)) - .clicked() - { - config.rows = *r; - config.cols = *c; + ui.horizontal_wrapped(|ui| { + ui.label(style::field_label("预设")); + for (r, c, name) in &[ + (12, 7, "12×7"), + (24, 12, "24×12"), + (32, 16, "32×16"), + (48, 24, "48×24"), + (64, 32, "64×32"), + ] { + if ui + .add(rich_tag_button(*name, style::ONE_DARK_PRO.text_dim)) + .clicked() + { + config.rows = *r; + config.cols = *c; + } } - } - }); + }); - ui.add_space(METRICS.item_gap); - - ui.horizontal(|ui| { - ui.label(style::field_label("色域范围")); ui.add_space(METRICS.item_gap); - ui.label(style::value_text("最小")); - ui.add_sized( - egui::vec2(72.0, METRICS.field_height), - egui::DragValue::new(&mut config.color_min) - .range(0.0..=10000.0) - .speed(100.0), - ); - ui.label(style::value_text("最大")); - ui.add_sized( - egui::vec2(72.0, METRICS.field_height), - egui::DragValue::new(&mut config.color_max) - .range(1.0..=10000.0) - .speed(100.0), - ); - }); - ui.add_space(METRICS.row_gap); + ui.horizontal_wrapped(|ui| { + ui.label(style::field_label("色域范围")); + ui.add_space(METRICS.item_gap); + ui.label(style::value_text("最小")); + ui.add_sized( + egui::vec2(72.0, METRICS.field_height), + egui::DragValue::new(&mut config.color_min) + .range(0.0..=10000.0) + .speed(100.0), + ); + ui.label(style::value_text("最大")); + ui.add_sized( + egui::vec2(72.0, METRICS.field_height), + egui::DragValue::new(&mut config.color_max) + .range(1.0..=10000.0) + .speed(100.0), + ); + }); - if ui - .add(rich_tag_button("重置默认", style::ONE_DARK_PRO.text_dim)) - .clicked() - { - *config = MatrixConfigState::default(); - } - }); + ui.add_space(METRICS.row_gap); + + if ui + .add(rich_tag_button("重置默认", style::ONE_DARK_PRO.text_dim)) + .clicked() + { + *config = MatrixConfigState::default(); + } + }, + ); } pub fn panel_restore_item(ui: &mut egui::Ui, title: &'static str, panel: &mut FloatingPanelState) { diff --git a/wix/main.wxs b/wix/main.wxs index c6bd3de..9d1d100 100644 --- a/wix/main.wxs +++ b/wix/main.wxs @@ -74,7 +74,8 @@ InstallerVersion='450' Languages='1033' Compressed='yes' - InstallScope='perMachine' + InstallScope='perUser' + InstallPrivileges='limited' SummaryCodepage='1252' /> @@ -86,7 +87,7 @@ - + - + + + + + System='no'/> - + + + Source='$(var.CargoTargetBinDir)\ESkinPlayer.exe'/>