diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a9e9d4..56bb414 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,15 @@ jobs: yes | "$sdkmanager" --licenses >/dev/null || true "$sdkmanager" "platforms;android-36" "build-tools;36.0.0" + - name: Validate native packaging scripts + shell: bash + run: | + python3 native/verify-engine-apk.py --self-test + python3 native/verify-octave-runtime.py --lock-only + python3 native/verify-octave-apk.py --self-test + python3 native/verify-elf-page-size.py --help >/dev/null + bash -n native/*.sh + - name: Run unit tests shell: bash run: | @@ -57,10 +66,9 @@ jobs: shell: bash run: ./gradlew --no-daemon -Pmaxmath.buildNative=false :app:assembleDebug - # 暂不阻断:lint 从未在本仓库跑过,先让历史问题可见,基线清理干净后 - # 去掉 continue-on-error 变成硬门禁。 + # Lint errors are release blockers. Dependency update notices remain + # visible as warnings without weakening the gate. - name: Android Lint - continue-on-error: true shell: bash run: ./gradlew --no-daemon -Pmaxmath.buildNative=false :app:lintDebug diff --git a/.gitignore b/.gitignore index a0815ea..5a31ad2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ .externalNativeBuild/ captures/ *.iml +__pycache__/ +*.py[cod] # Machine-local SDK and signing configuration local.properties @@ -20,6 +22,7 @@ keystore/ # Native toolchain workspace and generated runtime payloads .build/ app/src/main/assets/engine/ +app/src/main/assets/octave/ app/src/main/jniLibs/ # Packaged outputs diff --git a/README.md b/README.md index 9b250a0..dccb809 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# MaxMath (Higher Algebra Calculator) +# MaxMath (Higher Algebra Calculator / MATLAB on the phone) [![CI](https://github.com/ParuhParhat/MaxMath/actions/workflows/ci.yml/badge.svg)](https://github.com/ParuhParhat/MaxMath/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/ParuhParhat/MaxMath?label=release)](https://github.com/ParuhParhat/MaxMath/releases/latest) @@ -9,20 +9,25 @@ 简体中文

-[Download MaxMath 1.1.1 APK](https://github.com/ParuhParhat/MaxMath/releases/download/v1.1.1/MaxMath-v1.1.1-arm64-v8a.apk) -· [Release notes](https://github.com/ParuhParhat/MaxMath/releases/tag/v1.1.1) +[Download MaxMath 2.0.1 APK](https://github.com/ParuhParhat/MaxMath/releases/download/v2.0.1/MaxMath-v2.0.1-arm64-v8a.apk) +· [Release notes](https://github.com/ParuhParhat/MaxMath/releases/tag/v2.0.1) -MaxMath is an offline Android app for higher-algebra computation and interactive -plotting, powered by GNU Maxima. Its interface is built with Kotlin and Jetpack -Compose, mathematical input is handled by a pure Kotlin parser, and complex -symbolic computations run in a separate engine process. +MaxMath is an offline Android app for higher-algebra computation, numeric +computation and interactive plotting, powered by GNU Maxima and GNU Octave. Its +interface is built with Kotlin and Jetpack Compose, mathematical input is +handled by a pure Kotlin parser, and complex computations run in separate +engine processes. -> Current release: 1.1.1. Minimum supported version: Android 8.0 (API 26). +> Current release: 2.0.1. Minimum supported version: Android 8.0 (API 26). > The native computation engine currently provides an `arm64-v8a` build > workflow only. ## Features +- Octave console: MATLAB-style command line, workspace browser, .m script + editing/import/export, interactive plot/plot3/surf/contour/stem/imagesc/ + subplot/colormap/colorbar rendering, and bundled Signal 1.4.8 tools such as + `hilbert`, `butter`, `filtfilt` and `findpeaks` (fully offline) - Matrices: determinant, inverse, transpose, rank, trace, eigenvalues, and eigenvectors - Systems of equations: natural syntax such as `x+y=1; 2x-y=3`, plus raw Maxima input - Polynomials: factorization, greatest common divisor, root finding, expansion, and simplification @@ -42,7 +47,7 @@ symbolic computations run in a separate engine process. | Path | Responsibility | | --- | --- | | `parser/` | Lexing, AST construction, expression evaluation, and Maxima/NumPy code generation | -| `engine/` | Typed computation tasks, Maxima scripts, JNI subprocesses, the isolated process service, and 2D plotting | +| `engine/` | Typed computation tasks, Maxima scripts, JNI subprocesses, isolated process services, 2D plotting, and the Octave interactive subprocess (:octave) | | `app/` | Compose screens, state management, LaTeX output, and interactive OpenGL 3D/contour rendering | | `native/` | Android cross-compilation and runtime packaging scripts for ECL and Maxima | | `docs/` | Product specifications and implementation constraints | @@ -52,6 +57,8 @@ through Android Messenger to the isolated `:engine` process. Cancellation or a timeout terminates the corresponding Maxima subprocess. Maxima assists with 2D plot analysis before Matplotlib renders the image; 3D surfaces and contour plots are sampled locally from the same expression AST and rendered with OpenGL. +The Octave console runs in its own `:octave` process; its plot commands are +exported as plot_spec.json by the bridge layer and rendered by Compose/OpenGL. ## Getting the Source @@ -111,6 +118,13 @@ cd .. ./gradlew :app:assembleDebug ~~~ +`package-engine.sh` writes one `assets/engine/runtime.zip` plus a content-addressed +manifest. Installation uses full hash verification, staging, and an atomic runtime +switch while keeping `user/` and `work/` separate. After assembling, run +`python3 native/verify-engine-apk.py app/build/outputs/apk/debug/app-debug.apk` to +verify the nested archive file set, `linearalgebra`, ECL data, and Maxima/ECL JNI +hashes at the final APK boundary. + The current scripts target a Linux x86_64 host and the NDK's `linux-x86_64` toolchain. See [native/README.md](native/README.md) for complete dependency, environment-variable, and troubleshooting information. @@ -119,8 +133,42 @@ Generated directories that must not be committed include: - `.build/` - `app/src/main/assets/engine/` +- `app/src/main/assets/octave/` - `app/src/main/jniLibs/` +## Octave console engine + +The console uses the official Termux GNU Octave 11.3.0 runtime and supports +`arm64-v8a` only. Every Termux package version, filename and SHA-256 is pinned in +`native/octave-termux.lock`; fetching never resolves a mutable latest package. +Recreate and package the runtime with: + +~~~bash +./native/download-octave-termux.sh arm64-v8a +./native/build-octave-16k-overrides.sh +./native/download-octave-forge.sh +./native/build-octave-forge-android.sh arm64-v8a +./native/package-octave-engine.sh arm64-v8a +~~~ + +The download step rebuilds a clean stage from only the locked archives. The +Forge steps verify and cross-compile the pinned GNU Octave Signal 1.4.8 package +and its Control 4.2.3 dependency. Signal is registered and loaded at engine +startup, so its common functions are available without a network install. The +packager follows `DT_NEEDED` from the CLI entry and arm64 `.oct` modules, keeps +the matching Termux `libc++_shared.so`, removes stale/non-arm64 Octave assets, +verifies the complete payload before transactionally switching it, and writes a +deterministic `assets/octave/runtime-manifest.json`. The complex-math and WebP +packages which still ship 4 KiB-aligned ELF segments are rebuilt from pinned +official Android/WebM sources for 16 KiB Android page compatibility. It finishes by +running `native/verify-octave-runtime.py`, which checks architecture, dependency +and strong C++ symbol closure, locked libc++ identity, file hashes and the +manifest `runtimeId`. After assembling the APK, run +`python3 native/verify-octave-apk.py app/build/outputs/apk/debug/app-debug.apk` to +confirm that AAPT retained every manifest-owned file, including `.oct-config`, +and that every packaged native ELF has 16 KiB-compatible LOAD alignment. +See [native/README.md](native/README.md) for prerequisites. + ## Project Documentation - [Product and implementation specification](docs/SPEC.md) @@ -133,7 +181,7 @@ Generated directories that must not be committed include: ## Known Limitations -- The native computation engine currently targets `arm64-v8a` only; the `x86_64` build scripts still need further verification. +- The packaged Maxima and Octave computation runtimes support `arm64-v8a` only. - A fresh source checkout does not include a prebuilt Maxima/ECL runtime. Generate it as described above before running full computations. - Results come from computer algebra and numerical algorithms and should not be treated as formal proofs. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0a8b894..a77dd55 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,4 +1,4 @@ -# MaxMath(高代计算器) +# MaxMath(高代计算器 / 手机端 MATLAB) [![CI](https://github.com/ParuhParhat/MaxMath/actions/workflows/ci.yml/badge.svg)](https://github.com/ParuhParhat/MaxMath/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/ParuhParhat/MaxMath?label=release)](https://github.com/ParuhParhat/MaxMath/releases/latest) @@ -9,18 +9,22 @@ 简体中文

-[下载 MaxMath 1.1.1 APK](https://github.com/ParuhParhat/MaxMath/releases/download/v1.1.1/MaxMath-v1.1.1-arm64-v8a.apk) -· [发布说明](https://github.com/ParuhParhat/MaxMath/releases/tag/v1.1.1) +[下载 MaxMath 2.0.1 APK](https://github.com/ParuhParhat/MaxMath/releases/download/v2.0.1/MaxMath-v2.0.1-arm64-v8a.apk) +· [发布说明](https://github.com/ParuhParhat/MaxMath/releases/tag/v2.0.1) -基于 GNU Maxima 的离线 Android 高等代数计算与交互式绘图应用。界面使用 -Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理,复杂符号计算在独立 -引擎进程中执行。 +基于 GNU Maxima 与 GNU Octave 的离线 Android 高等代数计算、数值计算与交互式 +绘图应用。界面使用 Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理, +复杂符号计算与 Octave 控制台在独立引擎进程中执行。 -> 当前版本 1.1.1;最低系统 Android 8.0(API 26);原生计算引擎目前仅提供 +> 当前版本 2.0.1;最低系统 Android 8.0(API 26);原生计算引擎目前仅提供 > `arm64-v8a` 构建流程。 ## 功能 +- Octave 控制台:MATLAB 风格命令行、工作区变量列表、.m 脚本编辑/导入/导出, + 支持 plot/plot3/surf/contour/stem/imagesc/subplot/colormap/colorbar 交互绘图, + 并内置 Signal 1.4.8 的 `hilbert`、`butter`、`filtfilt`、`findpeaks` 等工具 + (数值引擎为 GNU Octave 11.3.0,完全离线) - 矩阵:行列式、逆、转置、秩、迹、特征值与特征向量 - 方程组:支持 `x+y=1; 2x-y=3` 一类自然写法,也支持原始 Maxima 输入 - 多项式:因式分解、最大公因式、求根、展开与化简 @@ -39,7 +43,7 @@ Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理,复杂 | 路径 | 职责 | | --- | --- | | `parser/` | 词法分析、AST、表达式求值,以及 Maxima/NumPy 代码生成 | -| `engine/` | 类型化计算任务、Maxima 脚本、JNI 子进程、独立进程服务和 2D 绘图 | +| `engine/` | 类型化计算任务、Maxima 脚本、JNI 子进程、独立进程服务、2D 绘图,以及 Octave 交互子进程与 :octave 服务 | | `app/` | Compose 页面、状态管理、LaTeX 输出及 OpenGL 3D/等高线交互 | | `native/` | ECL 与 Maxima 的 Android 交叉编译和运行时打包脚本 | | `docs/` | 产品规格与实现约束 | @@ -47,6 +51,8 @@ Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理,复杂 轻量操作在 UI 进程执行;耗时操作通过 Android Messenger 转发到 `:engine` 独立 进程。取消或超时会终止对应的 Maxima 子进程。2D 图像由 Maxima 辅助分析并交给 Matplotlib 渲染;3D 曲面和等高线由同一表达式 AST 在本地采样并交给 OpenGL 绘制。 +Octave 控制台由独立的 `:octave` 进程承载,绘图命令经桥接层导出为 plot_spec.json +后交给 Compose/OpenGL 渲染,与 Maxima 引擎互不干扰。 ## 获取源码 @@ -104,6 +110,11 @@ cd .. ./gradlew :app:assembleDebug ~~~ +`package-engine.sh` 会生成单文件 `assets/engine/runtime.zip` 和内容寻址清单;安装器用 +暂存目录、完整哈希校验和原子切换升级,保留独立的 `user/`、`work/`。构建后运行 +`python3 native/verify-engine-apk.py app/build/outputs/apk/debug/app-debug.apk`,确认最终 +APK 中 `linearalgebra`、ECL 数据、归档文件集合和 Maxima/ECL JNI 哈希全部一致。 + 当前脚本以 Linux x86_64 主机和 NDK 的 `linux-x86_64` 工具链为目标。完整依赖、 环境变量与故障说明见 [native/README.md](native/README.md)。 @@ -111,8 +122,37 @@ cd .. - `.build/` - `app/src/main/assets/engine/` +- `app/src/main/assets/octave/` - `app/src/main/jniLibs/` +## Octave 控制台引擎 + +控制台使用 Termux 官方 GNU Octave 11.3.0 运行时,仅支持 `arm64-v8a`。 +`native/octave-termux.lock` 严格锁定每个 Termux 包的版本、文件名与 SHA-256, +下载过程不会在构建时解析可变的“最新版”。重新生成与打包命令为: + +~~~bash +./native/download-octave-termux.sh arm64-v8a +./native/build-octave-16k-overrides.sh +./native/download-octave-forge.sh +./native/build-octave-forge-android.sh arm64-v8a +./native/package-octave-engine.sh arm64-v8a +~~~ + +Forge 两个步骤会校验并交叉编译锁定的 GNU Octave Signal 1.4.8 及其 Control 4.2.3 +依赖;引擎启动时自动注册和加载,常用信号处理函数无需联网安装。 +下载脚本只使用锁定归档重建干净 stage。打包脚本从 CLI 入口与 arm64 `.oct` +模块沿 `DT_NEEDED` 收集实际闭包,保留匹配的 Termux `libc++_shared.so`,清理旧版 +及非 arm64 Octave 资产;新载荷在临时目录通过验证后才事务切换,并生成确定性的 +`assets/octave/runtime-manifest.json`。官方 Termux 包中仍为 4 KiB 对齐的 complex-math +与 WebP 库会从锁定的 Android/WebM 官方源码重建为 16 KiB 兼容版本。 +最后由 `native/verify-octave-runtime.py` 校验架构、依赖与强 C++ 符号闭包、锁定 +libc++、逐文件哈希和清单 `runtimeId`。APK 构建后再运行 +`python3 native/verify-octave-apk.py app/build/outputs/apk/debug/app-debug.apk`,从最终 +制品确认 AAPT 没有过滤 `.oct-config` 等清单文件,并扫描每个 native ELF 的 +16 KiB LOAD 对齐。主机依赖见 +[native/README.md](native/README.md)。 + ## 项目文档 - [产品与实现规格](docs/SPEC.md) @@ -125,7 +165,7 @@ cd .. ## 已知限制 -- 原生计算引擎当前只面向 `arm64-v8a`;`x86_64` 构建脚本仍需进一步验证。 +- Maxima 与 Octave 原生计算运行时目前均只支持 `arm64-v8a`。 - 新检出的源码仓库不含预编译 Maxima/ECL 运行时,必须按上文生成后才能执行完整计算。 - 计算结果来自计算机代数与数值算法,不应被视为形式化证明。 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 65fa93c..c7b8d75 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,58 @@ +# MaxMath 2.0.1 + +手机端 MATLAB 修复版(2026-08-19):加固 Maxima 矩阵计算、Octave 三维交互与界面图标体系。 + +## 本版改进 + +- 修复矩阵“迹”调用 `mat_trace` 时触发未预编译 `linearalgebra` 自动加载、长时间 + 停在“计算中”;现直接遍历主对角线求和,并保留非方阵的明确错误。 +- Octave 三维曲面取消仰角 0–180°硬限位,水平和垂直方向均可连续环绕;同时 + 修正上下拖动方向,使模型跟随手指旋转。 +- 将占满绘图区高度且方向错误的彩色色标改成紧凑水平渐变图例,最小值、最大值 + 与颜色方向保持一致,不再遮挡坐标与曲面。 +- 内置 GNU Octave Signal 1.4.8(含 ARM64 原生模块)与 Control 4.2.3 依赖, + 启动时自动注册加载,离线提供 `hilbert`、`butter`、`filtfilt`、`findpeaks` 等工具。 +- 绘图桥新增 `stem`、`imagesc`、`caxis`、`axis xy/ij`,热图支持颜色范围、colorbar + 和 `hold on` 标记叠加;图表内纵向拖动会优先滚动页面,可到达第四及后续子图。 +- 重绘 33 枚 Claude 风格 SVG/Compose 图标及暖色 `M²` 启动图标,运行、停止、复制、 + 保存与展开操作具有对应状态形态;图标源文件、RTL 返回和无障碍标签均有测试覆盖。 + +# MaxMath 2.0.0 + +手机端 MATLAB 升级版(2026-08-12):新增基于 GNU Octave 11.3.0 的控制台模式。 + +## 本版改进 + +- 修复 Maxima 交叉编译时误读取外层 MaxMath 仓库 Git 标签,导致二进制搜索 + `share/maxima/v1.1.0_...` 而打包目录实际为 `5.49.0`、所有计算均报启动失败; + 构建、运行时清单和最终 APK 现在都会校验编译版本与资源目录版本一致。 +- 修复 Octave `surf(X,Y,Z)` 把完整 `meshgrid` 的 Y 矩阵当一维向量读取、使各行 + Y 坐标相同并把三维曲面压成平面;曲面与等高线渲染同时兼容完整网格和坐标向量。 +- Maxima 运行时改为确定性的单文件归档与内容清单;同版本缺失或损坏 + `linearalgebra` 会自动重装,升级使用暂存、完整哈希校验和原子回滚,并保留 + 独立的 `user/`、`work/`。 +- 所有 Maxima 计算统一进入 `:engine` 服务,新增请求 ID、Preparing/Running 进度、 + 90 秒准备上限、130 秒客户端执行上限、请求级取消及断连终态,避免永久“计算中”。 +- Octave 变量改用独立详情页;图表从控制台大画布改为轻量结果卡片,点按后进入 + 纵向滚动的独立多子图页面;命令输入框不再显示可见提示词。 +- 新增 Octave 控制台模式:MATLAB 风格命令行、命令历史、实时流式输出、取消与 + 120 秒超时;单个数值结果可在 MATLAB 文本与 LaTeX 渲染之间切换。 +- 新增工作区面板:列出变量名称、类型、维数与大小,点按预览、单删或清空。 +- 新增 .m 脚本编辑器:新建/导入/导出(系统文件选择器)、运行/停止、错误定位。 +- 新增绘图桥:控制台里的 plot/plot3/surf/contour/subplot/hold/axis/grid/ + legend/colormap/colorbar 等高层命令导出为 plot_spec.json,由 Compose/OpenGL + 实时渲染,2D 可平移缩放、3D/等高线可旋转缩放,支持多子图布局。 +- 引擎架构:Octave 运行在独立 `:octave` 进程,与 Maxima `:engine` 互不干扰; + 常驻会话、哨兵协议、RSS 1.5GB 内存保护与空闲回收。 +- 原生运行时:严格锁定 Termux GNU Octave 11.3.0 及全部 arm64-v8a 包的版本与 + SHA-256;从 CLI 与 `.oct` 根节点收集实际 ELF 闭包,使用匹配的 Termux libc++, + 生成稳定运行时清单并通过依赖、架构、C++ 符号与文件哈希静态门禁。 +- 16 KiB 页面:从锁定的 Android/WebM 官方源码重建 complex-math 与 WebP,ECL、 + Maxima 和应用 JNI 统一使用 16 KiB 链接参数,最终 APK 逐 ELF 检查 LOAD 对齐。 +- 运行链路加固:运行时事务安装与崩溃回滚、请求 ID 全链路校验、即时取消、 + 异常退出自动恢复、1 MiB 输出上限,以及 typed preview/绘图制品的有界传输。 +- 符号计算仍由 Maxima 模式承担,控制台首版聚焦数值计算。 + # MaxMath 1.1.1 3D/等高线绘图性能、交互与坐标可读性更新(2026-08-08)。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ea9e206..a099604 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -14,6 +14,9 @@ MIT License 不会覆盖或替代这些条款。 | Chaquopy | 17.0.0 | Android 内嵌 CPython | MIT;https://github.com/chaquo/chaquopy | | Matplotlib | 3.6.0 | 2D 图像渲染 | Matplotlib License;https://matplotlib.org/3.6.0/users/project/license.html | | NumPy | 1.23.3 | 数值数组与表达式求值 | BSD-3-Clause;https://github.com/numpy/numpy | +| GNU Octave | 11.3.0 | Octave 控制台数值引擎(Termux 预编译 CLI) | GNU GPL-3.0-or-later;https://octave.org/ | +| GNU Octave Signal | 1.4.8 | 离线信号处理函数与 ARM64 `.oct` 模块 | GPL-3.0-or-later / public domain;https://github.com/gnu-octave/octave-signal | +| GNU Octave Control | 4.2.3 | Signal 包依赖的控制系统函数 | GPL-3.0-or-later,部分 SLICOT 文件为 BSD-3-Clause;https://github.com/gnu-octave/pkg-control | | ContourPy | 1.0.5 | 等高线计算 | BSD-3-Clause;https://github.com/contourpy/contourpy | | kiwisolver | 1.4.5 | Matplotlib 约束求解 | BSD-3-Clause;https://github.com/nucleic/kiwi | | Pillow | 9.2.0 | Python 图像支持 | HPND;https://github.com/python-pillow/Pillow/blob/9.2.0/LICENSE | @@ -29,6 +32,7 @@ Gradle 的测试依赖不会打入正式 APK,其准确版本记录在 gradle/l - .build/ - app/src/main/assets/engine/ +- app/src/main/assets/octave/ - app/src/main/jniLibs/ 本地工作区可能仍保留这些目录。发布 APK 或重新分发其中的 Maxima、ECL、Python @@ -36,8 +40,51 @@ Gradle 的测试依赖不会打入正式 APK,其准确版本记录在 gradle/l 源代码提供、署名及其他再分发义务。 尤其需要注意:应用直接依赖 GPL-2.0 的 jlatexmath-android,并可包含 GPL 的 -Maxima。分发完整 APK 时必须同时满足这些 GPL 组件的条款;将 MaxMath 原创源码置于 -MIT License 下并不会消除该义务。 +Maxima 与 GPL-3.0 的 GNU Octave。分发完整 APK 时必须同时满足这些 GPL 组件的 +条款;将 MaxMath 原创源码置于 MIT License 下并不会消除该义务。GNU Octave 的 +对应源码可从 https://ftpmirror.gnu.org/octave/ 取得(11.3.0 版本); +Termux 的构建配方见 https://github.com/termux/termux-packages (packages/octave)。 + +## Octave 运行时依赖(Termux 预编译包) + +Octave 引擎及其共享库来自 Termux 官方仓库(packages.termux.dev,当前仅使用 +arm64-v8a 对应的 aarch64 包)。主要组件与许可证: + +| 组件 | 许可证 | +| --- | --- | +| GNU Octave 11.3.0 | GPL-3.0-or-later | +| OpenBLAS(含 LAPACK) | BSD-3-Clause | +| FFTW | GPL-2.0-or-later(可选,已随包分发) | +| ARPACK-ng | BSD-3-Clause | +| SuiteSparse(CHOLMOD/UMFPACK/SPQR 等) | BSD-2-Clause | +| Sundials | BSD-3-Clause | +| PCRE2 | BSD-3-Clause | +| Qhull | Qhull License(BSD 风格) | +| readline | GPL-3.0-or-later | +| ncurses | MIT | +| zlib | zlib License | +| bzip2 | BSD-4-Clause | +| libcurl / libssh2 / nghttp2 | curl License / BSD-3-Clause / MIT | +| OpenSSL | Apache-2.0 | +| HDF5 | BSD-3-Clause | +| GraphicsMagick | MIT | +| freetype | FTL / BSD | +| glib / gdk-pixbuf | LGPL-2.1-or-later | +| libxml2 / libexpat | MIT | +| libpng / libjpeg-turbo / libtiff / libwebp / giflib | 各自 BSD/MIT 风格许可 | +| libsndfile / FLAC / Ogg / Vorbis / Opus | LGPL-2.1-or-later / BSD | +| libiconv | LGPL-2.1-or-later | +| libicu | Unicode-3.0 | +| GMP / MPFR | LGPL-3.0-or-later / LGPL-3.0-or-later | +| Android Bionic/NetBSD complex math | BSD-2-Clause;锁定 Android 官方源码见 `native/octave-16k-overrides.lock` | +| GLib 相关 Termux 兼容库(其他 libandroid-* 等) | 以 Termux 包元数据为准 | +| libc++ | Apache-2.0 with LLVM exception | + +以上清单以 Octave 的 ELF DT_NEEDED 闭包为准(native/package-octave-engine.sh +自动收集)。每个包的精确版本、来源与校验和记录在 `native/octave-termux.lock`、 +`native/octave-16k-overrides.lock`、`native/octave-forge.lock` 及打包生成的 +`assets/octave/runtime-manifest.json` 中; +完整逐包许可证以 Termux 仓库的 .deb 元数据(usr/share/doc/*/copyright)为准。 如果在本地加入 QEPCAD 或其他可选组件,请在分发前单独确认其来源和许可证。本仓库 默认不会发布此类本地生成或手动加入的资产。 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 983e070..4ab8aa0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,6 @@ import java.io.FileInputStream import java.util.Properties +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) @@ -23,8 +24,8 @@ android { applicationId = "com.paruh.maxmath" minSdk = 26 targetSdk = 36 - versionCode = 17 - versionName = "1.1.1" + versionCode = 21 + versionName = "2.0.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -41,8 +42,7 @@ android { } debug { ndk { - // 引擎当前只打包了 arm64 的 Maxima 5.49;x86_64 需要 - // 运行 build-maxima-android.sh x86_64 后再放开。 + // Maxima/ECL 与锁定的 Termux Octave 运行时均只打包 arm64。 abiFilters += listOf("arm64-v8a") } } @@ -67,12 +67,29 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } buildFeatures { compose = true } + androidResources { + // Octave uses hidden .oct-config files as runtime metadata. AGP's default + // asset ignore list contains ".*", which silently removes them from the APK. + // Keep the other standard VCS/editor filters, but package required dotfiles. + ignoreAssetsPattern = + "!.svn:!.git:!.ds_store:!*.scc:!.directory:_*:!CVS:!thumbs.db:!picasa.ini:!*~" + } + bundle { + // The app switches between bundled Chinese and English resources itself. + // Language splits would remove the non-device locale from an installed AAB. + language { + enableSplit = false + } + } + lint { + // Octave/Maxima ship an arm64-only native runtime by product decision. + disable += "ChromeOsAbiSupport" + // AAPT only exposes this adaptive icon from its v26-qualified directory. + disable += "ObsoleteSdkInt" + } testOptions { unitTests { isIncludeAndroidResources = true @@ -84,10 +101,21 @@ android { // nativeLibraryDir 才能被 execve(Android 10+ 禁止从 filesDir 执行)。 jniLibs { useLegacyPackaging = true + // OctaveInstaller verifies the packaged native payload against the + // runtime manifest. Stripping prebuilt Termux ELF files would change + // their size/hash after the manifest was generated and reject every + // install as corrupt. + keepDebugSymbols += "**/*.so" } } } +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + dependencies { implementation(project(":parser")) implementation(project(":engine")) @@ -99,14 +127,13 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.jlatexmath.android) implementation(libs.jlatexmath.android.font.greek) + implementation(libs.org.json) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) implementation(libs.compose.ui.graphics) implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) - implementation(libs.compose.material.icons.core) - debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.test.manifest) testImplementation(libs.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f953a8f..57a4484 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,7 +9,8 @@ + android:exported="true" + android:windowSoftInputMode="adjustResize"> diff --git a/app/src/main/java/com/paruh/maxmath/ui/AppLocale.kt b/app/src/main/java/com/paruh/maxmath/ui/AppLocale.kt index d63afb0..74aefc5 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/AppLocale.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/AppLocale.kt @@ -4,11 +4,12 @@ import android.content.Context import android.content.res.Configuration import android.os.LocaleList import androidx.annotation.StringRes +import androidx.core.content.edit import com.paruh.maxmath.R import java.util.Locale /** 应用内可选语言。 */ -enum class AppLanguage(@StringRes val labelRes: Int, val code: String) { +enum class AppLanguage(@param:StringRes val labelRes: Int, val code: String) { SYSTEM(R.string.language_system, "system"), ZH(R.string.language_zh, "zh"), EN(R.string.language_en, "en"), @@ -30,10 +31,9 @@ object AppLocale { ?: AppLanguage.SYSTEM fun set(context: Context, value: AppLanguage) { - context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .edit() - .putString(KEY_LOCALE, value.code) - .apply() + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit { + putString(KEY_LOCALE, value.code) + } } /** 返回应用了所选语言的 base context;跟随系统时原样返回。 */ diff --git a/app/src/main/java/com/paruh/maxmath/ui/CalcViewModel.kt b/app/src/main/java/com/paruh/maxmath/ui/CalcViewModel.kt index 094da50..cf3408b 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/CalcViewModel.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/CalcViewModel.kt @@ -5,11 +5,14 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.paruh.maxmath.engine.CalcRequest import com.paruh.maxmath.engine.CalcResponse +import com.paruh.maxmath.engine.CalcEvent +import com.paruh.maxmath.engine.CalcFailure import com.paruh.maxmath.engine.EngineClient import com.paruh.maxmath.engine.MathTask import com.paruh.maxmath.engine.MaximaEngine import com.paruh.maxmath.R import kotlinx.coroutines.Job +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -25,11 +28,22 @@ import java.util.UUID * 「结果已过期」不单独存字段——它就是 `loading && response != null`, * 再加一个标志位只会多一份可能对不上的真相。 */ +sealed interface CalcActivity { + val requestId: String? + data object Idle : CalcActivity { override val requestId: String? = null } + data class Preparing(override val requestId: String) : CalcActivity + data class Running(override val requestId: String) : CalcActivity + data class Cancelling(override val requestId: String) : CalcActivity +} + data class CalcUiState( - val loading: Boolean = false, + val activity: CalcActivity = CalcActivity.Idle, val response: CalcResponse? = null, val error: String? = null, -) + val failure: CalcFailure? = null, +) { + val loading: Boolean get() = activity !is CalcActivity.Idle +} class CalcViewModel : AndroidViewModel { @@ -47,36 +61,86 @@ class CalcViewModel : AndroidViewModel { val state: StateFlow = _state.asStateFlow() private var job: Job? = null + private var cancelJob: Job? = null fun compute(task: MathTask) { - job?.cancel() - MaximaEngine.cancel() + if (_state.value.loading) return // 保留上一次结果:重算时不清空,界面不会塌陷再展开。 // 错误必须清掉,否则旧报错会和新的进度指示并排显示。 - _state.update { it.copy(loading = true, error = null) } + val request = CalcRequest(UUID.randomUUID().toString(), task) + _state.update { + it.copy( + activity = CalcActivity.Preparing(request.id), + error = null, + failure = null, + ) + } job = viewModelScope.launch { - val request = CalcRequest(UUID.randomUUID().toString(), task) - val response = client.compute(request) + val response = try { + client.compute(request) event@{ event -> + if (event.requestId != request.id) return@event + when (event) { + is CalcEvent.Preparing -> _state.update { current -> + if (current.activity.requestId == request.id) { + current.copy(activity = CalcActivity.Preparing(request.id)) + } else { + current + } + } + is CalcEvent.Running -> _state.update { current -> + if (current.activity is CalcActivity.Preparing && + current.activity.requestId == request.id + ) { + current.copy(activity = CalcActivity.Running(request.id)) + } else { + current + } + } + is CalcEvent.Done, + is CalcEvent.Failure, + -> Unit + } + } + } catch (cancelled: CancellationException) { + return@launch + } + if (_state.value.activity.requestId != request.id) return@launch + if (_state.value.activity is CalcActivity.Cancelling) return@launch // 这里整体赋值:一次计算结束后,旧结果就该被新结果或新错误取代。 // 若协程已被 cancel(),withContext 恢复时会先抛 CancellationException, // 走不到这一行,因此被取消的计算不会覆盖主线程刚写好的状态。 _state.value = CalcUiState( - loading = false, + activity = CalcActivity.Idle, response = response.takeIf { it.ok }, - error = response.error, + error = response.failure?.message ?: response.error, + failure = response.failure, ) } } /** 取消的是这次请求,不是整个界面:结果停留在取消前的样子。 */ fun cancel() { - job?.cancel() - MaximaEngine.cancel() - _state.update { it.copy(loading = false) } + val requestId = _state.value.activity.requestId ?: return + if (_state.value.activity is CalcActivity.Cancelling) return + _state.update { it.copy(activity = CalcActivity.Cancelling(requestId)) } + cancelJob?.cancel() + cancelJob = viewModelScope.launch { + val terminal = runCatching { client.cancel(requestId) }.getOrNull() + if (_state.value.activity.requestId != requestId) return@launch + if (terminal is CalcEvent.Done) { + job?.cancel() + job = null + _state.update { it.copy(activity = CalcActivity.Idle) } + } else { + _state.update { it.copy(activity = CalcActivity.Running(requestId)) } + } + } } fun showError(message: String?) { val text = message ?: AppLocale.wrap(getApplication()).getString(R.string.error_input_invalid) - _state.update { it.copy(loading = false, error = text) } + _state.update { + it.copy(activity = CalcActivity.Idle, error = text, failure = null) + } } } diff --git a/app/src/main/java/com/paruh/maxmath/ui/MaxMathApp.kt b/app/src/main/java/com/paruh/maxmath/ui/MaxMathApp.kt index 65480e8..beeff09 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/MaxMathApp.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/MaxMathApp.kt @@ -5,10 +5,24 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.navigation.NavBackStackEntry import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController import com.paruh.maxmath.ui.screens.CalculusScreen +import com.paruh.maxmath.ui.console.ConsoleScreen +import com.paruh.maxmath.ui.console.ConsoleViewModel +import com.paruh.maxmath.ui.console.OctavePlotScreen +import com.paruh.maxmath.ui.console.VariableDetailScreen +import com.paruh.maxmath.engine.OctaveClient import com.paruh.maxmath.ui.screens.HomeScreen import com.paruh.maxmath.ui.screens.MatrixScreen import com.paruh.maxmath.ui.screens.PlotScreen @@ -27,6 +41,10 @@ object Routes { const val QUADRATIC = "quadratic" const val CALCULUS = "calculus" const val PLOT = "plot" + const val CONSOLE = "console" + const val CONSOLE_HOME = "console/home" + const val CONSOLE_VARIABLE = "console/variable" + const val CONSOLE_PLOT = "console/plot" } private const val NAV_DURATION_MS = 280 @@ -59,6 +77,34 @@ fun MaxMathApp() { composable(Routes.HOME) { HomeScreen(onNavigate = { route -> nav.navigate(route) }) } + navigation(startDestination = Routes.CONSOLE_HOME, route = Routes.CONSOLE) { + composable(Routes.CONSOLE_HOME) { entry -> + val parent = remember(entry) { nav.getBackStackEntry(Routes.CONSOLE) } + val vm = sharedConsoleViewModel(parent) + ConsoleScreen( + onBack = { nav.popBackStack() }, + onOpenVariable = { + nav.navigate(Routes.CONSOLE_VARIABLE) { launchSingleTop = true } + }, + onOpenPlot = { + nav.navigate(Routes.CONSOLE_PLOT) { launchSingleTop = true } + }, + viewModelOverride = vm, + ) + } + composable(Routes.CONSOLE_VARIABLE) { entry -> + val parent = remember(entry) { nav.getBackStackEntry(Routes.CONSOLE) } + val vm = sharedConsoleViewModel(parent) + val state by vm.state.collectAsState() + VariableDetailScreen(state, vm, onBack = { nav.popBackStack() }) + } + composable(Routes.CONSOLE_PLOT) { entry -> + val parent = remember(entry) { nav.getBackStackEntry(Routes.CONSOLE) } + val vm = sharedConsoleViewModel(parent) + val state by vm.state.collectAsState() + OctavePlotScreen(state, onBack = { nav.popBackStack() }) + } + } composable(Routes.MATRIX) { MatrixScreen(onBack = { nav.popBackStack() }) } @@ -82,3 +128,14 @@ fun MaxMathApp() { } } } + +@Composable +private fun sharedConsoleViewModel(owner: NavBackStackEntry): ConsoleViewModel { + val context = LocalContext.current.applicationContext + return viewModel( + viewModelStoreOwner = owner, + factory = viewModelFactory { + initializer { ConsoleViewModel(OctaveClient(context)) } + }, + ) +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/ModeIcons.kt b/app/src/main/java/com/paruh/maxmath/ui/ModeIcons.kt deleted file mode 100644 index dd79c57..0000000 --- a/app/src/main/java/com/paruh/maxmath/ui/ModeIcons.kt +++ /dev/null @@ -1,183 +0,0 @@ -package com.paruh.maxmath.ui - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.PathParser -import androidx.compose.ui.unit.dp - -/** - * 首页模式图标:统一 24dp 实心几何风格。 - * 花括号、上标 2、积分号取自 DejaVu Sans Bold 字形轮廓, - * 其余为自绘几何路径;颜色统一用黑色,由 Icon 的 tint 着色。 - */ -object ModeIcons { - - val Matrix: ImageVector = icon( - "ModeMatrix", - paths = listOf( - // 左/右方括号 - "M4,4 H7 V6 H6 V18 H7 V20 H4 Z", - "M20,4 H17 V6 H18 V18 H17 V20 H20 Z", - // 2x2 圆点 - "M11.1,9 C11.1,10.16 10.16,11.1 9,11.1 C7.84,11.1 6.9,10.16 6.9,9 " + - "C6.9,7.84 7.84,6.9 9,6.9 C10.16,6.9 11.1,7.84 11.1,9 Z", - "M16.1,9 C16.1,10.16 15.16,11.1 14,11.1 C12.84,11.1 11.9,10.16 11.9,9 " + - "C11.9,7.84 12.84,6.9 14,6.9 C15.16,6.9 16.1,7.84 16.1,9 Z", - "M11.1,15 C11.1,16.16 10.16,17.1 9,17.1 C7.84,17.1 6.9,16.16 6.9,15 " + - "C6.9,13.84 7.84,12.9 9,12.9 C10.16,12.9 11.1,13.84 11.1,15 Z", - "M16.1,15 C16.1,16.16 15.16,17.1 14,17.1 C12.84,17.1 11.9,16.16 11.9,15 " + - "C11.9,13.84 12.84,12.9 14,12.9 C15.16,12.9 16.1,13.84 16.1,15 Z", - ), - ) - - val Equations: ImageVector = icon( - "ModeEquations", - paths = listOf( - BRACE, - "M12.5,10.5 H21 V12.5 H12.5 Z", - "M12.5,13.5 H21 V15.5 H12.5 Z", - ), - ) - - val Polynomial: ImageVector = icon( - "ModePolynomial", - paths = listOf("M3,15 Q12,1.5 21,15 Q12,10 3,15 Z"), - ) - - val Vector: ImageVector = icon( - "ModeVector", - paths = listOf( - "M5.15,17.15 L14.15,8.15 L15.85,9.85 L6.85,18.85 Z", - "M13.24,7.24 L21,4 L17.76,11.76 Z", - ), - ) - - val Quadratic: ImageVector = icon( - "ModeQuadratic", - paths = listOf( - "M3,16 Q12,3.5 21,16 Q12,10.5 3,16 Z", - TWO, - ), - ) - - val Calculus: ImageVector = icon("ModeCalculus", paths = listOf(INTEGRAL)) - - val Plot: ImageVector = icon( - "ModePlot", - paths = listOf( - "M3,12 C6,4 9,4 12,12 C15,20 18,20 21,12 " + - "C18,16.5 15,16.5 12,12 C9,7.5 6,7.5 3,12 Z", - ), - ) - - /** 语言切换按钮图标:描边地球。 */ - val LanguageGlobe: ImageVector = icon( - "LanguageGlobe", - paths = emptyList(), - strokes = listOf( - "M12,3.5 A8.5,8.5 0 1,1 11.99,3.5 Z", - "M12,3.5 C8.5,3.5 7.5,7.5 7.5,12 C7.5,16.5 8.5,20.5 12,20.5 " + - "C15.5,20.5 16.5,16.5 16.5,12 C16.5,7.5 15.5,3.5 12,3.5 Z", - "M3.5,12 H20.5", - ), - ) - - /** - * 步进器的减号。material-icons-core 只有 Add 没有 Remove(Remove 在 - * material-icons-extended 里,那个包会给 APK 加几 MB),所以自绘一条。 - * 尺寸刻意对齐 Material 的 Add 字形:横向 5→19,厚度 11→13。 - * - * **不要放进 [all]** —— ModeIconsTest 断言 all.size == 7,那是首页模块图标集, - * 减号不属于它。 - */ - val Minus: ImageVector = icon("Minus", paths = listOf("M5,11 H19 V13 H5 Z")) - - val all: List = listOf(Matrix, Equations, Polynomial, Vector, Quadratic, Calculus, Plot) - - private fun icon( - name: String, - paths: List, - strokes: List = emptyList(), - ): ImageVector { - val builder = ImageVector.Builder( - name = name, - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ) - paths.forEach { d -> - builder.addPath( - pathData = PathParser().parsePathString(d).toNodes(), - fill = SolidColor(Color.Black), - ) - } - strokes.forEach { d -> - builder.addPath( - pathData = PathParser().parsePathString(d).toNodes(), - stroke = SolidColor(Color.Black), - strokeLineWidth = 2f, - strokeLineCap = StrokeCap.Round, - strokeLineJoin = StrokeJoin.Round, - ) - } - return builder.build() - } - - private const val BRACE = - "M11.0 18.08879492600423V19.991543340380552H9.16490486257928" + - "Q7.321353065539112 19.991543340380552 6.47568710359408 19.247357293868923" + - "Q5.630021141649048 18.503171247357294 5.630021141649048 16.871035940803385" + - "V15.247357293868923Q5.630021141649048 13.978858350951375 5.17336152219873 13.484143763213531" + - "Q4.716701902748413 12.989429175475689 3.515856236786469 12.989429175475689" + - "H2.9999999999999996V11.103594080338267H3.515856236786469" + - "Q4.716701902748413 11.103594080338267 5.17336152219873 10.613107822410148" + - "Q5.630021141649048 10.12262156448203 5.630021141649048 8.854122621564482" + - "V7.120507399577168Q5.630021141649048 5.488372093023257 6.47568710359408 4.748414376321354" + - "Q7.321353065539112 4.008456659619451 9.16490486257928 4.008456659619451" + - "H11.0V5.911205073995772H10.416490486257928" + - "Q9.224101479915433 5.911205073995772 8.864693446088795 6.279069767441861" + - "Q8.505285412262158 6.64693446088795 8.505285412262158 7.847780126849894" + - "V9.251585623678647Q8.505285412262158 10.579281183932348 8.124735729386892 11.17970401691332" + - "Q7.7441860465116275 11.780126849894291 6.813953488372093 11.99154334038055" + - "Q7.752642706131078 12.219873150105709 8.128964059196617 12.82029598308668" + - "Q8.505285412262158 13.420718816067653 8.505285412262158 14.739957716701904" + - "V16.143763213530654Q8.505285412262158 17.353065539112052 8.864693446088795 17.72093023255814" + - "Q9.224101479915433 18.08879492600423 10.416490486257928 18.08879492600423Z" - - private const val TWO = - "M17.222697368421052 7.69671052631579H20.248355263157894V9.0H15.251644736842106" + - "V7.69671052631579L17.761513157894736 5.481578947368421" + - "Q18.097697368421052 5.177631578947368 18.258881578947367 4.8875" + - "Q18.420065789473686 4.597368421052632 18.420065789473686 4.28421052631579" + - "Q18.420065789473686 3.800657894736843 18.095394736842106 3.50592105263158" + - "Q17.770723684210527 3.2111842105263166 17.231907894736842 3.2111842105263166" + - "Q16.817434210526315 3.2111842105263166 16.32467105263158 3.3884868421052636" + - "Q15.831907894736842 3.5657894736842106 15.270065789473684 3.915789473684211" + - "V2.405263157894738Q15.86875 2.2072368421052637 16.45361842105263 2.1036184210526323" + - "Q17.038486842105264 2.000000000000001 17.60032894736842 2.000000000000001" + - "Q18.83453947368421 2.000000000000001 19.518421052631577 2.5434210526315795" + - "Q20.202302631578945 3.086842105263158 20.202302631578945 4.058552631578948" + - "Q20.202302631578945 4.620394736842106 19.912171052631578 5.106250000000001" + - "Q19.62203947368421 5.592105263157896 18.691776315789475 6.407236842105263Z" - - private const val INTEGRAL = - "M12.709308113489298 18.710054753608762" + - "Q12.605276256844201 20.16650074664012 11.57441513190642 20.989298158287706" + - "Q10.931309109009458 21.5 9.730214036834246 21.5" + - "Q8.226480836236934 21.5 7.517172722747636 20.76231956197113" + - "Q7.006470881035342 20.232702837232456 6.83623693379791 18.899203583872573" + - "L8.935788949726232 18.69113987058238Q9.106022896963664 19.230214036834248 9.673469387755102 19.230214036834248" + - "Q10.288203086112494 19.230214036834248 10.354405176704828 18.36012941762071" + - "Q10.354405176704828 18.36012941762071 11.290691886510702 5.289945246391241" + - "Q11.394723743155799 3.833499253359882 12.42558486809358 3.0107018417122955" + - "Q13.068690890990542 2.5000000000000018 14.269785963165754 2.5000000000000018" + - "Q15.773519163763066 2.5000000000000018 16.482827277252365 3.237680438028871" + - "Q16.99352911896466 3.767297162767548 17.16376306620209 5.100796416127428" + - "L15.06421105027377 5.3088601294176225Q14.893977103036338 4.769785963165756 14.326530612244898 4.769785963165756" + - "Q13.711796913887508 4.769785963165756 13.645594823295172 5.639870582379295" + - "Q13.645594823295172 5.639870582379295 12.709308113489298 18.710054753608762Z" -} diff --git a/app/src/main/java/com/paruh/maxmath/ui/components/Components.kt b/app/src/main/java/com/paruh/maxmath/ui/components/Components.kt index 9a309fc..14560e9 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/components/Components.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/components/Components.kt @@ -10,7 +10,9 @@ import android.content.ClipboardManager import android.content.Context import android.os.Build import android.widget.Toast +import androidx.compose.animation.Crossfade import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.ScrollState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -27,12 +29,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -53,6 +49,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -61,6 +58,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -77,7 +75,8 @@ import kotlinx.coroutines.delay import com.paruh.maxmath.R import com.paruh.maxmath.engine.CalcResponse import com.paruh.maxmath.ui.CalcUiState -import com.paruh.maxmath.ui.ModeIcons +import com.paruh.maxmath.ui.CalcActivity +import com.paruh.maxmath.ui.icons.AppIcons import com.paruh.maxmath.ui.theme.MathMonoStyle import com.paruh.maxmath.ui.theme.Sizing import com.paruh.maxmath.ui.theme.Spacing @@ -90,6 +89,9 @@ import com.paruh.maxmath.ui.theme.Spacing */ val LocalModuleScrollState = compositionLocalOf { null } +internal const val FEEDBACK_ICON_IDLE_TAG = "feedback_icon_idle" +internal const val FEEDBACK_ICON_SUCCESS_TAG = "feedback_icon_success" + @Composable fun ModuleScaffold( title: String, @@ -109,7 +111,7 @@ fun ModuleScaffold( navigationIcon = { IconButton(onClick = onBack) { Icon( - Icons.AutoMirrored.Filled.ArrowBack, + AppIcons.Navigation.Back, contentDescription = stringResource(R.string.back), ) } @@ -142,8 +144,8 @@ fun ExpressionField( label: String, value: String, onValueChange: (String) -> Unit, - hint: String? = null, modifier: Modifier = Modifier, + hint: String? = null, ) { OutlinedTextField( value = value, @@ -216,7 +218,7 @@ fun Stepper( onClick = { onChange((value - 1).coerceAtLeast(min)) }, enabled = value > min, ) { - Icon(ModeIcons.Minus, contentDescription = stringResource(R.string.decrease_value, label)) + Icon(AppIcons.Action.Remove, contentDescription = stringResource(R.string.decrease_value, label)) } Text( value.toString(), @@ -228,7 +230,7 @@ fun Stepper( onClick = { onChange((value + 1).coerceAtMost(max)) }, enabled = value < max, ) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.increase_value, label)) + Icon(AppIcons.Action.Add, contentDescription = stringResource(R.string.increase_value, label)) } } } @@ -299,6 +301,60 @@ fun AdvancedSwitch( } } +/** + * Text action with short-lived visual confirmation. The label remains stable so + * accessibility and layout do not jump; only the leading icon changes shape. + */ +@Composable +fun FeedbackTextButton( + label: String, + onClick: () -> Unit, + icon: ImageVector, + modifier: Modifier = Modifier, + feedbackIcon: ImageVector = AppIcons.Status.Success, +) { + var feedbackToken by remember { mutableIntStateOf(0) } + var showingFeedback by remember { mutableStateOf(false) } + LaunchedEffect(feedbackToken) { + if (feedbackToken == 0) return@LaunchedEffect + delay(1_400) + showingFeedback = false + } + TextButton( + onClick = { + onClick() + showingFeedback = true + feedbackToken++ + }, + modifier = modifier, + ) { + FeedbackActionIcon(showingFeedback, icon, feedbackIcon) + Spacer(Modifier.width(Spacing.xs)) + Text(label) + } +} + +@Composable +internal fun FeedbackActionIcon( + showingFeedback: Boolean, + icon: ImageVector, + feedbackIcon: ImageVector = AppIcons.Status.Success, +) { + Crossfade( + targetState = showingFeedback, + animationSpec = tween(durationMillis = 120), + label = "feedbackActionIcon", + ) { confirmed -> + Icon( + if (confirmed) feedbackIcon else icon, + contentDescription = null, + modifier = Modifier + .size(Sizing.iconSmall) + .testTag(if (confirmed) FEEDBACK_ICON_SUCCESS_TAG else FEEDBACK_ICON_IDLE_TAG), + ) + } +} + /** * 结果区。三个分支是**叠加**而不是互斥的:重算期间上一次的结果继续显示 * (淡出到 45%),下面同时挂一张进度卡片,界面不会塌陷再展开。 @@ -316,7 +372,7 @@ fun ResultCard( ) Column(verticalArrangement = Arrangement.spacedBy(Spacing.s)) { if (state.loading) { - LoadingCard() + LoadingCard(state.activity) } state.error?.let { ErrorCard(it) } state.response?.let { @@ -326,16 +382,7 @@ fun ResultCard( } @Composable -private fun LoadingCard() { - // 空闲超过 60 秒(EngineService.IDLE_TIMEOUT_MS / MaximaEngine.IDLE_STOP_MS) - // 之后的第一次计算要重新加载 ECL 镜像,实测 1–3 秒;超过 2.5 秒基本可以 - // 断定是冷启动。整棵子树在 loading 结束时销毁,计时器每次计算自然重置, - // 热计算永远看不到这句提示。 - var slow by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - delay(2_500) - slow = true - } +private fun LoadingCard(activity: CalcActivity) { Card(Modifier.fillMaxWidth()) { Row( modifier = Modifier.padding(Spacing.l), @@ -344,14 +391,15 @@ private fun LoadingCard() { ) { CircularProgressIndicator(modifier = Modifier.size(Sizing.progress)) Column { - Text(stringResource(R.string.computing)) - if (slow) { - Text( - stringResource(R.string.engine_warming_up), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Text( + stringResource( + when (activity) { + is CalcActivity.Preparing -> R.string.engine_preparing + is CalcActivity.Cancelling -> R.string.engine_cancelling + else -> R.string.computing + }, + ), + ) } } } @@ -386,7 +434,7 @@ private fun ErrorCard(message: String) { verticalAlignment = Alignment.CenterVertically, ) { Icon( - Icons.Default.Warning, + AppIcons.Status.Warning, contentDescription = null, modifier = Modifier.size(Sizing.iconSmall), ) @@ -400,12 +448,18 @@ private fun ErrorCard(message: String) { onClick = { expanded = !expanded }, modifier = Modifier.testTag("error_expand_toggle"), ) { - Icon( - if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = stringResource( - if (expanded) R.string.collapse_details else R.string.expand_details, - ), - ) + Crossfade( + targetState = expanded, + animationSpec = tween(durationMillis = 120), + label = "errorDetailIcon", + ) { isExpanded -> + Icon( + if (isExpanded) AppIcons.Status.Collapse else AppIcons.Status.Expand, + contentDescription = stringResource( + if (isExpanded) R.string.collapse_details else R.string.expand_details, + ), + ) + } } } if (expanded) { @@ -418,9 +472,11 @@ private fun ErrorCard(message: String) { .heightIn(max = Sizing.errorBodyMax) .verticalScroll(rememberScrollState()), ) - TextButton(onClick = { copyToClipboard(context, "error", message) }) { - Text(stringResource(R.string.copy_error)) - } + FeedbackTextButton( + label = stringResource(R.string.copy_error), + onClick = { copyToClipboard(context, "error", message) }, + icon = AppIcons.Action.Copy, + ) } else { Text( message.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(), @@ -463,10 +519,18 @@ private fun ResultContent( HorizontalDivider() Row(horizontalArrangement = Arrangement.spacedBy(Spacing.s)) { tex?.let { - TextButton(onClick = { onCopyTex(tex) }) { Text(stringResource(R.string.copy_tex)) } + FeedbackTextButton( + label = stringResource(R.string.copy_tex), + onClick = { onCopyTex(tex) }, + icon = AppIcons.Action.Copy, + ) } plain?.let { - TextButton(onClick = { onCopyPlain(plain) }) { Text(stringResource(R.string.copy_plain)) } + FeedbackTextButton( + label = stringResource(R.string.copy_plain), + onClick = { onCopyPlain(plain) }, + icon = AppIcons.Action.Copy, + ) } } } diff --git a/app/src/main/java/com/paruh/maxmath/ui/components/ComputeSection.kt b/app/src/main/java/com/paruh/maxmath/ui/components/ComputeSection.kt index 9fb159e..6e8992a 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/components/ComputeSection.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/components/ComputeSection.kt @@ -7,8 +7,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedButton @@ -22,11 +20,13 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import com.paruh.maxmath.R import com.paruh.maxmath.ui.CalcUiState +import com.paruh.maxmath.ui.icons.AppIcons import com.paruh.maxmath.ui.theme.Sizing import com.paruh.maxmath.ui.theme.Spacing @@ -47,6 +47,7 @@ fun ComputeActionRow( onCancel: () -> Unit, modifier: Modifier = Modifier, computeLabel: String = stringResource(R.string.compute), + computeIcon: ImageVector = AppIcons.Action.Compute, ) { Row( modifier = modifier, @@ -54,6 +55,12 @@ fun ComputeActionRow( verticalAlignment = Alignment.CenterVertically, ) { Button(onClick = onCompute, enabled = !loading) { + Icon( + computeIcon, + contentDescription = null, + modifier = Modifier.size(Sizing.iconSmall), + ) + Spacer(Modifier.width(Spacing.xs)) Text(computeLabel) } AnimatedVisibility(visible = loading) { @@ -62,7 +69,7 @@ fun ComputeActionRow( modifier = Modifier.testTag("cancel_button"), ) { Icon( - Icons.Default.Close, + AppIcons.Action.Stop, contentDescription = null, modifier = Modifier.size(Sizing.iconSmall), ) @@ -114,6 +121,7 @@ fun ComputeSection( onCancel: () -> Unit, modifier: Modifier = Modifier, computeLabel: String = stringResource(R.string.compute), + computeIcon: ImageVector = AppIcons.Action.Compute, ) { Column( modifier = modifier, @@ -124,6 +132,7 @@ fun ComputeSection( onCompute = onCompute, onCancel = onCancel, computeLabel = computeLabel, + computeIcon = computeIcon, ) ResultSection(state) } diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleCommandClassifier.kt b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleCommandClassifier.kt new file mode 100644 index 0000000..62afc7f --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleCommandClassifier.kt @@ -0,0 +1,19 @@ +package com.paruh.maxmath.ui.console + +/** + * 控制台命令分类:首版为数值专用,符号命令只给提示;错误文本行号用于 + * 脚本编辑器定位。独立成纯函数,方便单测。 + */ +internal fun isSymbolicCommand(command: String): Boolean { + val pattern = Regex( + """^(?:[A-Za-z_][A-Za-z0-9_]*\s*=\s*)?(syms|sym|solve|dsolve|diff|limit|laplace|ilaplace|fourier|ifourier|ztrans|iztrans|int)(\s|\(|$)""", + RegexOption.IGNORE_CASE, + ) + return pattern.containsMatchIn(command.trim()) +} + +/** 从 Octave 错误文本提取行号:error: ... at line N 或 called from ... line N。 */ +internal fun parseErrorLine(errorText: String): Int? { + val regex = Regex("line\\s+(\\d+)", RegexOption.IGNORE_CASE) + return regex.findAll(errorText).lastOrNull()?.groupValues?.get(1)?.toIntOrNull() +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleDetailScreens.kt b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleDetailScreens.kt new file mode 100644 index 0000000..fe58cdf --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleDetailScreens.kt @@ -0,0 +1,219 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) + +package com.paruh.maxmath.ui.console + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.paruh.maxmath.R +import com.paruh.maxmath.ui.components.FeedbackTextButton +import com.paruh.maxmath.ui.components.LatexResult +import com.paruh.maxmath.ui.icons.AppIcons +import com.paruh.maxmath.ui.theme.MathMonoStyle +import com.paruh.maxmath.ui.theme.Sizing +import com.paruh.maxmath.ui.theme.Spacing + +internal const val VARIABLE_DETAIL_VALUE_TAG = "variable_detail_value" +internal const val OCTAVE_PLOT_SCREEN_TAG = "octave_plot_screen" + +@Composable +fun VariableDetailScreen( + state: ConsoleUiState, + viewModel: ConsoleViewModel, + onBack: () -> Unit, +) { + val name = state.selectedVariableName + val variable = state.workspace.firstOrNull { it.name == name } + val previewText = state.previewText + val previewJson = state.previewJson + val context = LocalContext.current + Scaffold( + topBar = { + TopAppBar( + title = { Text(name ?: stringResource(R.string.console_variable_details)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + AppIcons.Navigation.Back, + contentDescription = stringResource(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (variable == null) { + Text(stringResource(R.string.console_variable_missing)) + return@Column + } + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(variable.name, style = MaterialTheme.typography.titleLarge) + Text( + "${variable.className} ${variable.dims.joinToString("×")}", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + formatBytes(variable.bytes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (state.running) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator() + Text(stringResource(R.string.console_loading_variable)) + } + } + state.failure?.let { ConsoleFailureCard(it) } + if (!state.running && state.failure != null) { + Button(onClick = viewModel::retrySelectedVariable) { + Icon( + AppIcons.Action.Retry, + contentDescription = null, + modifier = Modifier.size(Sizing.iconSmall), + ) + Spacer(Modifier.width(Spacing.xs)) + Text(stringResource(R.string.console_retry)) + } + } + if (previewText != null) { + Card( + Modifier + .fillMaxWidth() + .testTag(VARIABLE_DETAIL_VALUE_TAG), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.console_preview), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + if (previewJson != null) { + Text(stringResource(R.string.console_latex)) + Spacer(Modifier.width(6.dp)) + Switch( + checked = state.showLatex, + onCheckedChange = { viewModel.toggleLatex() }, + ) + } + } + if (state.showLatex && previewJson != null) { + val tex = OctaveLatex.fromValueJson(previewJson) + if (tex != null) LatexResult(tex, previewText) else Text(previewText, style = MathMonoStyle) + } else { + Text(previewText, style = MathMonoStyle) + } + if (state.preview?.truncated == true) { + Text( + stringResource(R.string.console_preview_truncated), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + FeedbackTextButton( + label = stringResource(R.string.console_copy_value), + onClick = { copyVariable(context, name.orEmpty(), previewText) }, + icon = AppIcons.Action.Copy, + ) + } + } + } + } + } +} + +@Composable +fun OctavePlotScreen( + state: ConsoleUiState, + onBack: () -> Unit, + scrollState: androidx.compose.foundation.ScrollState? = null, +) { + val plot = state.plot + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.console_plot_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + AppIcons.Navigation.Back, + contentDescription = stringResource(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + if (plot == null) { + Text( + stringResource(R.string.console_no_plot), + modifier = Modifier.padding(padding).padding(16.dp), + ) + } else { + val plotScroll = scrollState ?: rememberScrollState() + Column( + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 12.dp) + .verticalScroll(plotScroll) + .testTag(OCTAVE_PLOT_SCREEN_TAG), + ) { + OctavePlotPanel( + plot, + Modifier.fillMaxWidth(), + stacked = true, + stackedScrollState = plotScroll, + ) + } + } + } +} + +private fun copyVariable(context: Context, label: String, value: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return + clipboard.setPrimaryClip(ClipData.newPlainText(label, value)) +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleScreen.kt new file mode 100644 index 0000000..2b27bd9 --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleScreen.kt @@ -0,0 +1,850 @@ +@file:OptIn( + androidx.compose.material3.ExperimentalMaterial3Api::class, + androidx.compose.foundation.layout.ExperimentalLayoutApi::class, +) + +package com.paruh.maxmath.ui.console + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.clickable +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.paruh.maxmath.R +import com.paruh.maxmath.engine.OctaveClient +import com.paruh.maxmath.engine.OctaveFailure +import com.paruh.maxmath.engine.OctaveFailureCode +import com.paruh.maxmath.ui.components.FeedbackTextButton +import com.paruh.maxmath.ui.components.LatexResult +import com.paruh.maxmath.ui.icons.AppIcons +import com.paruh.maxmath.ui.theme.MathMonoStyle +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun ConsoleScreen( + onBack: () -> Unit, + onOpenVariable: (String) -> Unit = {}, + onOpenPlot: () -> Unit = {}, + viewModelOverride: ConsoleViewModel? = null, +) { + val context = LocalContext.current + val vm: ConsoleViewModel = viewModelOverride ?: viewModel( + factory = viewModelFactory { + initializer { + ConsoleViewModel(OctaveClient(context.applicationContext)) + } + }, + ) + val state by vm.state.collectAsState() + val store = remember { ScriptStore(context.applicationContext) } + + var tab by rememberSaveable { mutableIntStateOf(0) } + var input by rememberSaveable { mutableStateOf("") } + var history by rememberSaveable { mutableStateOf(listOf()) } + var historyIndex by rememberSaveable { mutableIntStateOf(-1) } + val symbolicHint = stringResource(R.string.console_symbolic_hint) + + ConsoleScaffold( + selectedTab = tab, + onTabSelected = { tab = it }, + onBack = onBack, + ) { + when (tab) { + 0 -> ConsoleTab( + state = state, + input = input, + onInputChange = { input = it }, + onSubmit = { + val cmd = input + input = "" + history = (listOf(cmd) + history).take(100) + historyIndex = -1 + if (isSymbolicCommand(cmd)) { + vm.submitHint(symbolicHint) + } else { + vm.submit(cmd) + } + }, + onCancel = vm::cancel, + onHistoryPrev = { + if (history.isNotEmpty()) { + val next = (historyIndex + 1).coerceAtMost(history.size - 1) + historyIndex = next + input = history[next] + } + }, + onHistoryNext = { + if (historyIndex > 0) { + historyIndex -= 1 + input = history[historyIndex] + } else { + historyIndex = -1 + input = "" + } + }, + timeoutMs = state.timeoutMs, + onTimeoutChange = vm::setTimeoutMs, + onOpenPlot = onOpenPlot, + ) + 1 -> WorkspaceTab(state, vm, onOpenVariable) + 2 -> ScriptsTab(store, vm, activity = state.activity) + } + } +} + +internal const val CONSOLE_TAB_ROW_TAG = "console_tab_row" +internal const val CONSOLE_COMMAND_INPUT_TAG = "console_command_input" +internal const val CONSOLE_PLOT_CARD_TAG = "console_plot_card" + +@Composable +internal fun ConsoleScaffold( + selectedTab: Int, + onTabSelected: (Int) -> Unit, + onBack: () -> Unit, + contentWindowInsets: WindowInsets = + ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime), + content: @Composable () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.module_console)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(AppIcons.Navigation.Back, contentDescription = stringResource(R.string.back)) + } + }, + ) + }, + contentWindowInsets = contentWindowInsets, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding), + ) { + PrimaryTabRow( + selectedTabIndex = selectedTab, + modifier = Modifier.testTag(CONSOLE_TAB_ROW_TAG), + ) { + Tab( + selected = selectedTab == 0, + onClick = { onTabSelected(0) }, + text = { Text(stringResource(R.string.console_tab_console)) }, + ) + Tab( + selected = selectedTab == 1, + onClick = { onTabSelected(1) }, + text = { Text(stringResource(R.string.console_tab_workspace)) }, + ) + Tab( + selected = selectedTab == 2, + onClick = { onTabSelected(2) }, + text = { Text(stringResource(R.string.console_tab_scripts)) }, + ) + } + Box(Modifier.weight(1f).fillMaxWidth()) { + content() + } + } + } +} + +@Composable +internal fun ConsoleTab( + state: ConsoleUiState, + input: String, + onInputChange: (String) -> Unit, + onSubmit: () -> Unit, + onCancel: () -> Unit, + onHistoryPrev: () -> Unit, + onHistoryNext: () -> Unit, + timeoutMs: Long, + onTimeoutChange: (Long) -> Unit, + onOpenPlot: () -> Unit, +) { + val listState = rememberLazyListState() + val plot = state.plot + val inputDescription = stringResource(R.string.console_input_hint) + Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { + LazyColumn( + state = listState, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) { + items(state.lines) { line -> + Text( + line.text, + style = MathMonoStyle, + fontSize = MaterialTheme.typography.bodySmall.fontSize, + color = when (line.kind) { + ConsoleLineKind.CMD -> MaterialTheme.colorScheme.primary + ConsoleLineKind.ERROR -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.padding(vertical = 1.dp), + ) + } + if (plot != null) { + item { + Card( + onClick = onOpenPlot, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .testTag(CONSOLE_PLOT_CARD_TAG), + ) { + Row( + Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.console_plot_ready), + style = MaterialTheme.typography.titleSmall, + ) + Text( + pluralStringResource( + R.plurals.console_plot_axes_count, + plot.axes.size, + plot.axes.size, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TextButton(onClick = onOpenPlot) { + Icon( + AppIcons.Action.ViewPlot, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.console_view_plot)) + } + } + } + } + } + item { Spacer(Modifier.height(8.dp)) } + } + LaunchedEffect(state.lines.size) { + val count = state.lines.size + if (count > 0) listState.animateScrollToItem(count - 1) + } + + state.failure?.let { failure -> + ConsoleFailureCard(failure) + Spacer(Modifier.height(6.dp)) + } + + if (state.running) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource( + when (state.activity) { + is ConsoleActivity.Starting -> R.string.console_starting + is ConsoleActivity.Running -> R.string.console_running + is ConsoleActivity.Cancelling -> R.string.console_cancelling + ConsoleActivity.Idle -> R.string.console_running + }, + ), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = onCancel, + enabled = state.activity !is ConsoleActivity.Cancelling, + ) { + Icon( + AppIcons.Action.Stop, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.cancel)) + } + } + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.console_timeout), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + listOf(30_000L to "30s", 120_000L to "120s", 300_000L to "300s").forEach { (ms, label) -> + TextButton( + onClick = { onTimeoutChange(ms) }, + enabled = timeoutMs != ms, + ) { + Text(label, style = MaterialTheme.typography.labelSmall) + } + } + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onHistoryPrev) { + Icon( + AppIcons.Action.HistoryPrevious, + contentDescription = stringResource(R.string.console_history_previous), + ) + } + IconButton(onClick = onHistoryNext) { + Icon( + AppIcons.Action.HistoryNext, + contentDescription = stringResource(R.string.console_history_next), + ) + } + OutlinedTextField( + value = input, + onValueChange = onInputChange, + modifier = Modifier + .weight(1f) + .testTag(CONSOLE_COMMAND_INPUT_TAG) + .semantics { contentDescription = inputDescription }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + singleLine = false, + minLines = 1, + maxLines = 4, + ) + Spacer(Modifier.width(6.dp)) + Button( + onClick = onSubmit, + enabled = state.activity is ConsoleActivity.Idle && input.isNotBlank(), + ) { + Icon( + AppIcons.Action.Run, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.console_run)) + } + } + } +} + +@Composable +private fun WorkspaceTab( + state: ConsoleUiState, + vm: ConsoleViewModel, + onOpenVariable: (String) -> Unit, +) { + Column(Modifier.fillMaxSize().padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.console_workspace, state.workspace.size), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = vm::refreshWorkspace, enabled = !state.running) { + Icon(AppIcons.Action.Refresh, contentDescription = stringResource(R.string.console_refresh)) + } + TextButton( + onClick = { vm.clearVariable("") }, + enabled = !state.running, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ), + ) { + Icon( + AppIcons.Action.ClearAll, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.console_clear_all)) + } + } + if (state.workspace.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.console_workspace_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(Modifier.fillMaxSize()) { + items(state.workspace, key = { it.name }) { v -> + Card( + onClick = { + if (!state.running) { + vm.openVariable(v.name) + onOpenVariable(v.name) + } + }, + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(v.name, style = MaterialTheme.typography.bodyMedium) + Text( + "${v.className} ${v.dims.joinToString("×")}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + formatBytes(v.bytes), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + IconButton( + onClick = { vm.clearVariable(v.name) }, + enabled = !state.running, + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.error, + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ), + ) { + Icon( + AppIcons.Action.Delete, + contentDescription = stringResource(R.string.console_delete_var), + ) + } + } + } + } + } + } + } +} + +@Composable +private fun ScriptsTab(store: ScriptStore, vm: ConsoleViewModel, activity: ConsoleActivity) { + val context = LocalContext.current + var scripts by remember { mutableStateOf(store.list()) } + var selected by remember { mutableStateOf(null) } + var content by remember { mutableStateOf(TextFieldValue()) } + var errorLine by remember { mutableStateOf(null) } + val editorScroll = rememberScrollState() + val gutterScroll = rememberScrollState() + val density = androidx.compose.ui.platform.LocalDensity.current + val lineHeightPx = with(density) { 22.dp.toPx() } + val scope = rememberCoroutineScope() + val cursorPosition = remember(content.text, content.selection.start) { + scriptCursorPosition(content.text, content.selection.start) + } + LaunchedEffect(editorScroll.value, gutterScroll.maxValue) { + gutterScroll.scrollTo(editorScroll.value.coerceAtMost(gutterScroll.maxValue)) + } + fun reload() { + scripts = store.list() + } + + val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + val name = store.importFrom(context, uri) + if (name != null) { + selected = name + val text = store.read(name) + content = TextFieldValue(text, TextRange(text.length)) + reload() + } + } + } + val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + if (uri != null) { + selected?.let { store.exportTo(context, it, uri) } + } + } + + Column(Modifier.fillMaxSize().padding(12.dp)) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { importLauncher.launch(arrayOf("text/plain", "application/octet-stream")) }, + enabled = activity is ConsoleActivity.Idle, + ) { + Icon( + AppIcons.Action.Import, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.console_import)) + } + OutlinedButton( + onClick = { + selected?.let { name -> exportLauncher.launch(name) } + }, + enabled = selected != null && activity is ConsoleActivity.Idle, + ) { + Icon( + AppIcons.Action.Export, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.console_export)) + } + OutlinedButton( + onClick = { + var name = "script.m" + var i = 1 + while (scripts.any { it.name == name }) { + name = "script$i.m" + i++ + } + store.save(name, "% $name\n") + selected = name + val text = store.read(name) + content = TextFieldValue(text, TextRange(text.length)) + reload() + }, + enabled = activity is ConsoleActivity.Idle, + ) { + Icon( + AppIcons.Action.NewScript, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.console_new_script)) + } + } + if (selected != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(selected!!, style = MaterialTheme.typography.labelLarge, modifier = Modifier.weight(1f)) + Button( + onClick = { + val name = selected ?: return@Button + val saved = store.save(name, content.text) + errorLine = null + vm.runScript(saved, content.text) { failure -> + errorLine = failure.location?.line + ?: parseErrorLine(failure.diagnosticText()) + errorLine?.let { line -> + scope.launch { + editorScroll.scrollTo(((line - 1) * lineHeightPx).toInt().coerceAtLeast(0)) + } + } + } + }, + enabled = activity is ConsoleActivity.Idle, + ) { + Icon( + AppIcons.Action.Run, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.console_run_script)) + } + if (activity !is ConsoleActivity.Idle) { + CircularProgressIndicator(modifier = Modifier.width(22.dp).height(22.dp)) + TextButton( + onClick = vm::cancel, + enabled = activity !is ConsoleActivity.Cancelling, + ) { + Icon( + AppIcons.Action.Stop, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.cancel)) + } + } + IconButton( + onClick = { + selected?.let { store.delete(it) } + selected = null + content = TextFieldValue() + reload() + }, + enabled = activity is ConsoleActivity.Idle, + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.error, + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ), + ) { + Icon( + AppIcons.Action.Delete, + contentDescription = stringResource(R.string.console_delete_script), + ) + } + } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (errorLine != null) { + Text( + stringResource(R.string.console_error_line, errorLine!!), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } + Spacer(Modifier.weight(1f)) + Text( + stringResource( + R.string.console_cursor_position, + cursorPosition.line, + cursorPosition.column, + cursorPosition.totalLines, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(Modifier.weight(1f).fillMaxWidth().heightIn(min = 160.dp)) { + Column( + modifier = Modifier + .verticalScroll(gutterScroll, enabled = false) + .padding(end = 6.dp), + ) { + content.text.lines().forEachIndexed { index, _ -> + Text( + "${index + 1}", + style = MathMonoStyle, + fontSize = 12.sp, + lineHeight = 22.sp, + color = if (index + 1 == errorLine) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + OutlinedTextField( + value = content, + onValueChange = { + content = it + errorLine = null + }, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(editorScroll), + enabled = activity is ConsoleActivity.Idle, + textStyle = MathMonoStyle.copy(lineHeight = 22.sp), + ) + } + // 草稿自动保存:停止输入 500ms 后写回沙箱,避免误触返回丢失内容。 + LaunchedEffect(selected, content.text) { + val name = selected ?: return@LaunchedEffect + if (content.text.isNotEmpty()) { + delay(500) + store.save(name, content.text) + } + } + } else { + LazyColumn(Modifier.weight(1f).fillMaxWidth()) { + items(scripts, key = { it.name }) { s -> + Card( + onClick = { + selected = s.name + val text = store.read(s.name) + content = TextFieldValue(text, TextRange(text.length)) + }, + enabled = activity is ConsoleActivity.Idle, + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), + ) { + Row(Modifier.padding(12.dp)) { + Text(s.name, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Text( + formatBytes(s.size), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} + +internal data class ScriptCursorPosition( + val line: Int, + val column: Int, + val totalLines: Int, +) + +internal fun scriptCursorPosition(text: String, cursor: Int): ScriptCursorPosition { + val safeCursor = cursor.coerceIn(0, text.length) + val beforeCursor = text.substring(0, safeCursor) + val lastNewline = beforeCursor.lastIndexOf('\n') + return ScriptCursorPosition( + line = beforeCursor.count { it == '\n' } + 1, + column = safeCursor - lastNewline, + totalLines = text.count { it == '\n' } + 1, + ) +} + +@Composable +internal fun ConsoleFailureCard(failure: OctaveFailure) { + var expanded by rememberSaveable(failure.code, failure.stage, failure.message) { + mutableStateOf(false) + } + val context = LocalContext.current + val diagnostic = remember(failure) { failure.diagnosticText() } + val summary = when (failure.code) { + OctaveFailureCode.INSTALL_FAILED -> stringResource(R.string.console_failure_install) + OctaveFailureCode.LINK_FAILED -> stringResource(R.string.console_failure_link) + OctaveFailureCode.START_FAILED -> stringResource(R.string.console_failure_start) + OctaveFailureCode.TIMEOUT, + OctaveFailureCode.CLIENT_DEADLINE, + -> stringResource(R.string.console_failure_timeout) + OctaveFailureCode.MEMORY_LIMIT -> stringResource(R.string.console_failure_memory) + OctaveFailureCode.IPC_ERROR, + OctaveFailureCode.PROTOCOL_ERROR, + OctaveFailureCode.IO_ERROR, + -> stringResource(R.string.console_failure_ipc) + OctaveFailureCode.BIND_FAILED, + OctaveFailureCode.NULL_BINDER, + OctaveFailureCode.SERVICE_DISCONNECTED, + -> stringResource(R.string.console_failure_service) + OctaveFailureCode.CANCELLED -> stringResource(R.string.console_failure_cancelled) + OctaveFailureCode.PROCESS_EXITED -> stringResource(R.string.console_failure_exited) + else -> failure.message + } + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Column(Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(AppIcons.Status.Warning, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.error_detail), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + Crossfade( + targetState = expanded, + animationSpec = tween(durationMillis = 120), + label = "consoleErrorDetailIcon", + ) { isExpanded -> + Icon( + if (isExpanded) AppIcons.Status.Collapse else AppIcons.Status.Expand, + contentDescription = stringResource( + if (isExpanded) R.string.collapse_details else R.string.expand_details, + ), + ) + } + } + Spacer(Modifier.height(4.dp)) + if (expanded) { + Text( + diagnostic, + style = MathMonoStyle, + modifier = Modifier.heightIn(max = 240.dp).verticalScroll(rememberScrollState()), + ) + FeedbackTextButton( + label = stringResource(R.string.copy_error), + onClick = { copyConsoleError(context, diagnostic) }, + icon = AppIcons.Action.Copy, + ) + } else { + Text( + summary, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +private fun copyConsoleError(context: Context, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return + clipboard.setPrimaryClip(ClipData.newPlainText("Octave error", text)) +} + + +internal fun formatBytes(bytes: Long): String = when { + bytes >= 1_000_000 -> "%.1f MB".format(bytes / 1e6) + bytes >= 1_000 -> "%.1f KB".format(bytes / 1e3) + else -> "$bytes B" +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleViewModel.kt b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleViewModel.kt new file mode 100644 index 0000000..05ecb33 --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleViewModel.kt @@ -0,0 +1,480 @@ +package com.paruh.maxmath.ui.console + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.paruh.maxmath.engine.OctaveClearTask +import com.paruh.maxmath.engine.OctaveEvalTask +import com.paruh.maxmath.engine.OctaveEvent +import com.paruh.maxmath.engine.OctaveFailure +import com.paruh.maxmath.engine.OctaveFailureCode +import com.paruh.maxmath.engine.OctaveFailureStage +import com.paruh.maxmath.engine.OctaveFigure +import com.paruh.maxmath.engine.OctaveGateway +import com.paruh.maxmath.engine.OctavePreview +import com.paruh.maxmath.engine.OctavePreviewTask +import com.paruh.maxmath.engine.OctaveRequest +import com.paruh.maxmath.engine.OctaveResetTask +import com.paruh.maxmath.engine.OctaveResponse +import com.paruh.maxmath.engine.OctaveRunScriptTask +import com.paruh.maxmath.engine.OctaveTask +import com.paruh.maxmath.engine.OctaveVariable +import com.paruh.maxmath.engine.OctaveWhosTask +import java.io.File +import java.util.UUID +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +enum class ConsoleLineKind { CMD, OUTPUT, ERROR, INFO } + +data class ConsoleLine(val text: String, val kind: ConsoleLineKind) + +/** Explicit request lifecycle; Cancelling remains busy until its handshake terminates. */ +sealed interface ConsoleActivity { + val requestId: String? + + data object Idle : ConsoleActivity { + override val requestId: String? = null + } + + data class Starting(override val requestId: String) : ConsoleActivity + data class Running(override val requestId: String) : ConsoleActivity + data class Cancelling(override val requestId: String) : ConsoleActivity +} + +data class ConsoleUiState( + val lines: List = emptyList(), + val activity: ConsoleActivity = ConsoleActivity.Idle, + val workspace: List = emptyList(), + val plot: OctaveFigure? = null, + val failure: OctaveFailure? = null, + val preview: OctavePreview? = null, + val showLatex: Boolean = false, + val timeoutMs: Long = OctaveRequest.DEFAULT_TIMEOUT_MS, + val selectedVariableName: String? = null, +) { + val running: Boolean get() = activity !is ConsoleActivity.Idle + val previewText: String? get() = preview?.text + val previewJson: String? get() = preview?.latexValueJson() + val error: String? get() = failure?.message +} + +class ConsoleViewModel( + private val gateway: OctaveGateway, + private val requestIdFactory: () -> String = { UUID.randomUUID().toString() }, +) : ViewModel() { + private val _state = MutableStateFlow(ConsoleUiState()) + val state: StateFlow = _state.asStateFlow() + + private var requestJob: Job? = null + private var cancelJob: Job? = null + private var generation = 0L + private var activeRequestId: String? = null + private var pendingTerminal: PendingTerminal? = null + + fun submit(command: String) { + val trimmed = command.trim() + if (trimmed.isEmpty() || !isIdle()) return + append(ConsoleLine(">> $trimmed", ConsoleLineKind.CMD)) + run(OctaveEvalTask(trimmed)) + } + + /** Adds an informational line without entering Octave. */ + fun submitHint(message: String) { + if (!isIdle()) return + append(ConsoleLine(message, ConsoleLineKind.INFO)) + } + + fun runScript(name: String, content: String, onError: (OctaveFailure) -> Unit = {}) { + if (!isIdle()) return + append(ConsoleLine(">> run $name", ConsoleLineKind.CMD)) + run(OctaveRunScriptTask(script = content, name = name), onError = onError) + } + + fun refreshWorkspace() { + if (isIdle()) run(OctaveWhosTask, quiet = true) + } + + fun openVariable(name: String) { + if (!isIdle()) return + if (_state.value.workspace.none { it.name == name }) return + _state.update { + it.copy( + selectedVariableName = name, + preview = null, + showLatex = false, + failure = null, + ) + } + run(OctavePreviewTask(name), quiet = true) + } + + fun retrySelectedVariable() { + val name = _state.value.selectedVariableName ?: return + if (!isIdle()) return + _state.update { it.copy(preview = null, failure = null, showLatex = false) } + run(OctavePreviewTask(name), quiet = true) + } + + fun clearVariable(name: String) { + if (isIdle()) run(OctaveClearTask(name.ifBlank { null })) + } + + fun reset() { + if (isIdle()) run(OctaveResetTask) + } + + fun cancel() { + val requestId = activeRequestId ?: return + if (_state.value.activity is ConsoleActivity.Cancelling) return + val requestGeneration = generation + _state.update { it.copy(activity = ConsoleActivity.Cancelling(requestId)) } + cancelJob?.cancel() + cancelJob = viewModelScope.launch { + val terminal = try { + gateway.cancel(requestId) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + OctaveEvent.Failure( + requestId, + OctaveFailure( + OctaveFailureCode.UNKNOWN, + OctaveFailureStage.CANCELLATION, + "Unable to cancel Octave request", + error.message, + ), + ) + } + if (!isCurrent(requestGeneration, requestId)) return@launch + + if (terminal is OctaveEvent.Done) { + // The service queues this acknowledgement after the worker has reaped the old + // process generation. Only this path may unlock the next request immediately. + generation += 1 + activeRequestId = null + val discarded = pendingTerminal + pendingTerminal = null + deletePlotArtifact(discarded?.response) + val oldRequest = requestJob + requestJob = null + oldRequest?.cancel() + _state.update { it.copy(activity = ConsoleActivity.Idle) } + return@launch + } + + val cancellationFailure = (terminal as? OctaveEvent.Failure)?.failure + ?: OctaveFailure( + OctaveFailureCode.PROTOCOL_ERROR, + OctaveFailureStage.CANCELLATION, + "Invalid cancellation response", + ) + val deferred = pendingTerminal + pendingTerminal = null + if (deferred != null) { + activeRequestId = null + completeResponse(deferred.response, deferred.quiet, deferred.onError) + } else { + // A transport/deadline failure does not prove the child stopped. Keep the UI + // single-flight and continue listening for the original RUN terminal. + _state.update { current -> + current.copy( + activity = ConsoleActivity.Running(requestId), + failure = cancellationFailure.takeUnless { + it.code == OctaveFailureCode.CANCEL_NOT_ACTIVE + } ?: current.failure, + ) + } + } + } + } + + fun dismissFailure() { + _state.update { it.copy(failure = null) } + } + + fun toggleLatex() { + _state.update { current -> + if (current.previewJson == null) current else current.copy(showLatex = !current.showLatex) + } + } + + fun setTimeoutMs(timeoutMs: Long) { + if (isIdle() && timeoutMs > 0) _state.update { it.copy(timeoutMs = timeoutMs) } + } + + private fun isIdle(): Boolean = _state.value.activity is ConsoleActivity.Idle + + private fun append(line: ConsoleLine) { + _state.update { it.copy(lines = appendBounded(it.lines, line)) } + } + + private fun run( + task: OctaveTask, + quiet: Boolean = false, + onError: (OctaveFailure) -> Unit = {}, + ) { + if (!isIdle()) return + val requestId = requestIdFactory().ifBlank { UUID.randomUUID().toString() } + val requestGeneration = ++generation + activeRequestId = requestId + pendingTerminal = null + val request = OctaveRequest(requestId, task, _state.value.timeoutMs) + _state.update { + it.copy( + activity = ConsoleActivity.Starting(requestId), + failure = null, + ) + } + + requestJob = viewModelScope.launch { + try { + val response = gateway.run(request) { event -> + handleEvent(event, requestGeneration, requestId, quiet) + } + if (!isCurrent(requestGeneration, requestId)) return@launch + if (_state.value.activity is ConsoleActivity.Cancelling) { + pendingTerminal = PendingTerminal(response, quiet, onError) + return@launch + } + if (response.failure?.code == OctaveFailureCode.CLIENT_DEADLINE) { + // The client has issued request-scoped cancellation, but the service has not + // yet acknowledged that the old generation is reaped. Keep single-flight. + _state.update { + it.copy( + activity = ConsoleActivity.Cancelling(requestId), + failure = response.failure, + ) + } + confirmDeadlineCancellation( + requestGeneration, + requestId, + response, + quiet, + onError, + ) + return@launch + } + activeRequestId = null + completeResponse(response, quiet, onError) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (!isCurrent(requestGeneration, requestId)) return@launch + val failure = OctaveFailure( + OctaveFailureCode.UNKNOWN, + OctaveFailureStage.RESPONSE, + "Octave request failed", + error.stackTraceToString(), + ) + if (_state.value.activity is ConsoleActivity.Cancelling) { + pendingTerminal = PendingTerminal( + OctaveResponse.failed(requestId, failure), + quiet, + onError, + ) + return@launch + } + activeRequestId = null + showFailure(failure, quiet, onError) + } + } + } + + private fun handleEvent( + event: OctaveEvent, + requestGeneration: Long, + requestId: String, + quiet: Boolean, + ) { + if (!isCurrent(requestGeneration, requestId) || event.requestId != requestId) return + when (event) { + is OctaveEvent.Started -> _state.update { current -> + if (current.activity is ConsoleActivity.Starting) { + current.copy(activity = ConsoleActivity.Running(requestId)) + } else { + current + } + } + is OctaveEvent.Output -> { + if (!quiet && _state.value.activity !is ConsoleActivity.Cancelling) { + val text = event.text.trimEnd('\r', '\n') + if (text.isNotEmpty()) append(ConsoleLine(text, ConsoleLineKind.OUTPUT)) + } + } + is OctaveEvent.Done, + is OctaveEvent.Failure, + -> Unit // The gateway returns exactly one terminal result to the coroutine above. + } + } + + private suspend fun confirmDeadlineCancellation( + requestGeneration: Long, + requestId: String, + deadlineResponse: OctaveResponse, + quiet: Boolean, + onError: (OctaveFailure) -> Unit, + ) { + var terminal: OctaveEvent? = null + repeat(DEADLINE_CANCEL_ATTEMPTS) { attempt -> + terminal = runCatching { gateway.cancel(requestId) }.getOrNull() + if (!isCurrent(requestGeneration, requestId)) return + if (terminal is OctaveEvent.Done || + (terminal as? OctaveEvent.Failure)?.failure?.code == + OctaveFailureCode.CANCEL_NOT_ACTIVE + ) { + activeRequestId = null + showFailure( + deadlineResponse.effectiveFailure() ?: return, + quiet, + onError, + ) + return + } + if (attempt + 1 < DEADLINE_CANCEL_ATTEMPTS) delay(DEADLINE_CANCEL_RETRY_MS) + } + // The RUN channel is already gone, so keeping Running here would strand the UI forever. + // The service remains the source of truth and will reject a new request as BUSY if the + // old worker somehow survived every request-scoped cancellation attempt. + activeRequestId = null + showFailure(deadlineResponse.effectiveFailure() ?: return, quiet, onError) + } + + private suspend fun completeResponse( + response: OctaveResponse, + quiet: Boolean, + onError: (OctaveFailure) -> Unit, + ) { + val failure = response.effectiveFailure() + if (failure != null) { + response.workspace?.let { workspace -> + _state.update { it.copy(workspace = workspace) } + } + showFailure(failure, quiet, onError) + return + } + + val (newPlot, plotFailure) = readPlot(response) + _state.update { current -> + current.copy( + activity = ConsoleActivity.Idle, + workspace = response.workspace ?: current.workspace, + plot = newPlot ?: current.plot, + preview = response.preview, + showLatex = false, + failure = plotFailure, + lines = if (plotFailure != null && !quiet) { + appendBounded( + current.lines, + ConsoleLine(plotFailure.message, ConsoleLineKind.ERROR), + ) + } else { + current.lines + }, + ) + } + plotFailure?.let(onError) + } + + private fun showFailure( + failure: OctaveFailure, + quiet: Boolean, + onError: (OctaveFailure) -> Unit, + ) { + _state.update { current -> + current.copy( + activity = ConsoleActivity.Idle, + failure = failure, + lines = if (!quiet && failure.code == OctaveFailureCode.EXECUTION_FAILED) { + appendBounded(current.lines, ConsoleLine(failure.message, ConsoleLineKind.ERROR)) + } else { + current.lines + }, + ) + } + onError(failure) + } + + /** Reads bounded same-UID plot output and always removes the per-request file. */ + private suspend fun readPlot(response: OctaveResponse): Pair { + if (response.plotPath == null && response.plotSpec == null) return null to null + return withContext(Dispatchers.IO) { + val path = response.plotPath + val raw = if (path != null) { + val file = File(path) + try { + if (!file.isFile) { + return@withContext null to plotFailure("Octave plot file is missing", path) + } + if (file.length() > MAX_PLOT_BYTES) { + return@withContext null to plotFailure( + "Octave plot is too large", + "path=$path\nbytes=${file.length()}\nlimit=$MAX_PLOT_BYTES", + ) + } + file.readText() + } catch (error: Exception) { + return@withContext null to plotFailure("Unable to read Octave plot", error.message) + } finally { + runCatching { file.delete() } + } + } else { + response.plotSpec + } + if (raw == null) return@withContext null to null + val figure = OctaveFigure.fromJson(raw) + ?: return@withContext null to plotFailure("Invalid Octave plot response", raw.take(4_096)) + if (path != null && (figure.protocolVersion != 1 || figure.requestId != response.id)) { + return@withContext null to plotFailure( + "Octave plot response does not match the request", + "expected=${response.id} actual=${figure.requestId} version=${figure.protocolVersion}", + ) + } + figure to null + } + } + + private fun plotFailure(message: String, details: String?): OctaveFailure = OctaveFailure( + OctaveFailureCode.PROTOCOL_ERROR, + OctaveFailureStage.RESPONSE, + message, + details, + ) + + private fun deletePlotArtifact(response: OctaveResponse?) { + response?.plotPath?.let { path -> runCatching { File(path).delete() } } + } + + private fun isCurrent(expectedGeneration: Long, requestId: String): Boolean = + generation == expectedGeneration && activeRequestId == requestId + + private fun appendBounded(lines: List, line: ConsoleLine): List { + val result = (lines + line).takeLast(MAX_CONSOLE_LINES).toMutableList() + var characters = result.sumOf { it.text.length.coerceAtMost(MAX_CONSOLE_CHARS + 1) } + while (result.isNotEmpty() && characters > MAX_CONSOLE_CHARS) { + characters -= result.removeAt(0).text.length.coerceAtMost(MAX_CONSOLE_CHARS + 1) + } + return result + } + + private data class PendingTerminal( + val response: OctaveResponse, + val quiet: Boolean, + val onError: (OctaveFailure) -> Unit, + ) + + companion object { + private const val MAX_PLOT_BYTES = 4L * 1024L * 1024L + private const val MAX_CONSOLE_CHARS = 2 * 1024 * 1024 + private const val MAX_CONSOLE_LINES = 4_096 + private const val DEADLINE_CANCEL_ATTEMPTS = 3 + private const val DEADLINE_CANCEL_RETRY_MS = 1_000L + } +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/OctaveLatex.kt b/app/src/main/java/com/paruh/maxmath/ui/console/OctaveLatex.kt new file mode 100644 index 0000000..5e1bceb --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/console/OctaveLatex.kt @@ -0,0 +1,64 @@ +package com.paruh.maxmath.ui.console + +import org.json.JSONArray +import org.json.JSONObject +import java.util.Locale +import kotlin.math.abs +import kotlin.math.log10 +import kotlin.math.pow + +/** + * 把 Octave 预览的结构化 JSON 值转成 LaTeX,供控制台的结果切换渲染。 + * 只处理标量/向量/矩阵;字符串与其它类型直接回落纯文本。 + */ +object OctaveLatex { + + fun fromValueJson(json: String): String? { + return runCatching { + val arr = JSONArray(json) + val rows = arr.length() + if (rows == 0) return null + val first = arr.optJSONArray(0) + if (first == null) { + // 标量或行向量 + return "\\begin{pmatrix}${arr.joinToString(" & ") { num(it) }}\\end{pmatrix}" + } + val cols = first.length() + if (rows > 50 || cols > 50) return null + buildString { + append("\\begin{pmatrix}\n") + for (i in 0 until rows) { + val row = arr.getJSONArray(i) + append(row.joinToString(" & ") { num(it) }) + if (i < rows - 1) append("\\\\\n") + } + append("\n\\end{pmatrix}") + } + }.getOrNull() + } + + private fun JSONArray.joinToString(separator: String, transform: (Any?) -> String): String = + buildString { + for (i in 0 until length()) { + if (i > 0) append(separator) + append(transform(opt(i))) + } + } + + private fun num(value: Any?): String = when (value) { + is Number -> formatNumber(value.toDouble()) + is JSONArray -> "\\ldots" + null, JSONObject.NULL -> "\\mathrm{NaN}" + else -> "\\ldots" + } + + private fun formatNumber(v: Double): String { + if (v.isNaN()) return "\\mathrm{NaN}" + if (v.isInfinite()) return if (v > 0) "+\\infty" else "-\\infty" + if (v == 0.0) return "0" + val abs = abs(v) + val digits = if (abs >= 1e5 || abs < 1e-4) 2 else 4 + val text = String.format(Locale.ROOT, "%.${digits}g", v) + return if (text.contains('.')) text.trimEnd('0').trimEnd('.') else text + } +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/OctavePlotPanel.kt b/app/src/main/java/com/paruh/maxmath/ui/console/OctavePlotPanel.kt new file mode 100644 index 0000000..391f196 --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/console/OctavePlotPanel.kt @@ -0,0 +1,723 @@ +package com.paruh.maxmath.ui.console + +import android.graphics.Paint as AndroidPaint +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Card +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.paruh.maxmath.engine.OctaveAxes +import com.paruh.maxmath.engine.OctaveFigure +import com.paruh.maxmath.engine.OctaveLine +import com.paruh.maxmath.ui.plot.PlotRange +import com.paruh.maxmath.ui.plot.PlotTicks +import com.paruh.maxmath.ui.plot.gl.GlMesh +import com.paruh.maxmath.ui.plot.gl.GlPlotKind +import com.paruh.maxmath.ui.plot.gl.GlViewState +import com.paruh.maxmath.ui.plot.gl.PlotGlController +import com.paruh.maxmath.ui.plot.gl.PlotGlSurface +import com.paruh.maxmath.ui.plot.GlGestureMath +import com.paruh.maxmath.ui.theme.GlPalette +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +/** + * Octave 控制台的绘图面板:按 subplot 布局渲染 2D 折线、3D 曲面/折线 + * 与等高线。每个子图的手势相互独立。 + */ +@Composable +fun OctavePlotPanel( + figure: OctaveFigure, + modifier: Modifier = Modifier, + stacked: Boolean = false, + stackedScrollState: ScrollState? = null, +) { + if (figure.axes.isEmpty()) return + val rows = figure.rows.coerceAtLeast(1) + val cols = figure.cols.coerceAtLeast(1) + Column( + modifier = modifier.testTag(OCTAVE_PLOT_PANEL_TAG), + verticalArrangement = Arrangement.spacedBy(if (stacked) 12.dp else 4.dp), + ) { + if (stacked) { + figure.axes.sortedBy { it.position }.forEach { axes -> + Card( + Modifier + .fillMaxWidth() + .testTag("$OCTAVE_SUBPLOT_TAG_PREFIX${axes.position}"), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1.35f) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + OctaveAxesView(axes, stackedScrollState) + } + } + } + return@Column + } + for (r in 0 until rows) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + for (c in 0 until cols) { + val index = r * cols + c + 1 + val axes = figure.axes.firstOrNull { it.position == index } + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(1.35f) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + if (axes != null) { + OctaveAxesView(axes) + } + } + } + } + } + } +} + +internal const val OCTAVE_PLOT_PANEL_TAG = "octave_plot_panel" +internal const val OCTAVE_SUBPLOT_TAG_PREFIX = "octave_subplot_" + +@Composable +private fun OctaveAxesView(axes: OctaveAxes, pageScroll: ScrollState? = null) { + Box(Modifier.fillMaxSize()) { + when { + axes.type == "3d" && axes.surfaces.isNotEmpty() -> + OctaveGlPanel(GlPlotKind.SURFACE, axes, pageScroll) + axes.type == "contour" && axes.contours.isNotEmpty() -> + OctaveGlPanel(GlPlotKind.CONTOUR, axes, pageScroll) + axes.type == "image" && axes.images.isNotEmpty() -> OctaveHeatmapPanel(axes, pageScroll) + axes.type == "3d" && axes.lines3d.isNotEmpty() -> OctaveLine3dCanvas(axes, pageScroll) + else -> OctaveLineCanvas(axes, pageScroll) + } + // 标题与坐标轴名用覆盖层,GL 画布不重复画文字。 + if (axes.title.isNotBlank()) { + Text( + axes.title, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.align(Alignment.TopCenter).padding(top = 2.dp), + ) + } + if (axes.xlabel.isNotBlank()) { + Text( + axes.xlabel, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 1.dp), + ) + } + if (axes.ylabel.isNotBlank()) { + Text( + axes.ylabel, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp), + ) + } + if (axes.zlabel.isNotBlank()) { + Text( + axes.zlabel, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier + .align(Alignment.CenterEnd) + .rotate(-90f) + .padding(end = 2.dp), + ) + } + if (axes.legend.isNotEmpty()) { + Column( + modifier = Modifier.align(Alignment.TopEnd).padding(top = 2.dp, end = 4.dp), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + axes.legend.take(6).forEach { item -> + Text(item, style = MaterialTheme.typography.labelSmall, fontSize = 9.sp) + } + } + } + } +} + +private val lineColors = listOf( + Color(0xFF1F77B4), + Color(0xFFFF7F0E), + Color(0xFF2CA02C), + Color(0xFFD62728), + Color(0xFF9467BD), + Color(0xFF8C564B), + Color(0xFFE377C2), + Color(0xFF7F7F7F), +) + +@Composable +private fun OctaveLineCanvas(axes: OctaveAxes, pageScroll: ScrollState? = null) { + var range by remember(axes) { + mutableStateOf(defaultRange(axes)) + } + var size by remember { mutableStateOf(Size(1f, 1f)) } + val annotationColor = MaterialTheme.colorScheme.onSurface.toArgb() + val annotationSizePx = with(LocalDensity.current) { 11.sp.toPx() } + val annotationPaint = remember(annotationColor, annotationSizePx) { + AndroidPaint(AndroidPaint.ANTI_ALIAS_FLAG).apply { + color = annotationColor + textSize = annotationSizePx + } + } + Canvas( + modifier = Modifier + .fillMaxSize() + .onSizeChanged { size = Size(it.width.toFloat(), it.height.toFloat()) } + .pointerInput(axes) { + detectTransformGestures { _, pan, zoom, _ -> + range = panZoom(range, remainingPlotPan(pan, zoom, pageScroll), zoom, size) + } + }, + ) { + drawLines(axes, range, size, annotationPaint) + } +} + +private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawLines( + axes: OctaveAxes, + range: PlotRange, + size: Size, + annotationPaint: AndroidPaint, +) { + if (size.width <= 0f || size.height <= 0f) return + val w = range.width + val h = range.height + if (w <= 0.0 || h <= 0.0) return + fun sx(x: Double): Float = ((x - range.xMin) / w * size.width).toFloat() + fun sy(y: Double): Float = ((range.yMax - y) / h * size.height).toFloat() + + // 背景与网格 + drawRect(Color.White) + if (axes.grid) { + val gridColor = Color(0xFFE0E0E0) + val gx = DoubleArray(PlotTicks.capacity(range.xMin, range.xMax, PlotTicks.TARGET_2D)) + val n = PlotTicks.into(gx, range.xMin, range.xMax, PlotTicks.TARGET_2D) + for (i in 0 until n) { + drawLine(gridColor, Offset(sx(gx[i]), 0f), Offset(sx(gx[i]), size.height), 1f) + } + val gy = DoubleArray(PlotTicks.capacity(range.yMin, range.yMax, PlotTicks.TARGET_2D)) + val m = PlotTicks.into(gy, range.yMin, range.yMax, PlotTicks.TARGET_2D) + for (i in 0 until m) { + drawLine(gridColor, Offset(0f, sy(gy[i])), Offset(size.width, sy(gy[i])), 1f) + } + } + + // 坐标轴与边框 + drawLine(Color(0xFF666666), Offset(0f, sy(0.0)), Offset(size.width, sy(0.0)), 1.2f) + drawLine(Color(0xFF666666), Offset(sx(0.0), 0f), Offset(sx(0.0), size.height), 1.2f) + + // 数据线 + axes.lines.forEachIndexed { index, line -> + val style = LineStyle.parse(line.style) + val color = style.color ?: lineColors[index % lineColors.size] + val path = Path() + var started = false + val n = min(line.x.size, line.y.size) + for (i in 0 until n) { + val x = line.x[i] + val y = line.y[i] + if (!x.isFinite() || !y.isFinite()) { + started = false + continue + } + val sxv = sx(x) + val syv = sy(y) + if (!started) { + path.moveTo(sxv, syv) + started = true + } else { + path.lineTo(sxv, syv) + } + if (style.marker != null && i % max(1, n / 40) == 0) { + drawCircle(color, radius = 3f, center = Offset(sxv, syv)) + } + } + drawPath(path, color, style = Stroke(width = 2.2f)) + } + + axes.texts.forEach { annotation -> + if (annotation.x.isFinite() && annotation.y.isFinite() && annotation.text.isNotEmpty()) { + drawContext.canvas.nativeCanvas.drawText( + annotation.text, + sx(annotation.x) + 4f, + sy(annotation.y) - 4f, + annotationPaint, + ) + } + } +} + +private fun panZoom(range: PlotRange, pan: Offset, zoom: Float, size: Size): PlotRange { + val factor = 1f / zoom.coerceIn(0.2f, 8f) + val w = range.width * factor + val h = range.height * factor + val cx = range.xMin + range.width / 2 + val cy = range.yMin + range.height / 2 + val dx = pan.x / size.width.coerceAtLeast(1f) * w + val dy = pan.y / size.height.coerceAtLeast(1f) * h + return PlotRange( + xMin = cx - w / 2 - dx, + xMax = cx + w / 2 - dx, + yMin = cy - h / 2 + dy, + yMax = cy + h / 2 + dy, + ) +} + +private fun defaultRange(axes: OctaveAxes): PlotRange = + computeRange(axes, includeLines = true) + +/** 2D 与 GL 共用的数据范围:含 2D 折线时加 5% 边距,纯网格不加。 */ +private fun computeRange(axes: OctaveAxes, includeLines: Boolean): PlotRange { + var xMin = Double.POSITIVE_INFINITY + var xMax = Double.NEGATIVE_INFINITY + var yMin = Double.POSITIVE_INFINITY + var yMax = Double.NEGATIVE_INFINITY + if (includeLines) { + axes.lines.forEach { line -> + val n = min(line.x.size, line.y.size) + for (i in 0 until n) { + val x = line.x[i] + val y = line.y[i] + if (x.isFinite()) { + xMin = min(xMin, x) + xMax = max(xMax, x) + } + if (y.isFinite()) { + yMin = min(yMin, y) + yMax = max(yMax, y) + } + } + } + } + axes.surfaces.forEach { s -> + s.x.forEach { if (it.isFinite()) { xMin = min(xMin, it); xMax = max(xMax, it) } } + s.y.forEach { if (it.isFinite()) { yMin = min(yMin, it); yMax = max(yMax, it) } } + } + axes.contours.forEach { c -> + c.x.forEach { if (it.isFinite()) { xMin = min(xMin, it); xMax = max(xMax, it) } } + c.y.forEach { if (it.isFinite()) { yMin = min(yMin, it); yMax = max(yMax, it) } } + } + if (!xMin.isFinite()) xMin = -1.0 + if (!xMax.isFinite()) xMax = 1.0 + if (!yMin.isFinite()) yMin = -1.0 + if (!yMax.isFinite()) yMax = 1.0 + if (xMax <= xMin) { + xMax = xMin + 1 + } + if (yMax <= yMin) { + yMax = yMin + 1 + } + val xPad = if (includeLines) (xMax - xMin) * 0.05 else 0.0 + val yPad = if (includeLines) (yMax - yMin) * 0.05 else 0.0 + var r = PlotRange( + xMin = xMin - xPad, + xMax = xMax + xPad, + yMin = yMin - yPad, + yMax = yMax + yPad, + ) + axes.xlim?.takeIf { it.size == 2 && it[1] > it[0] }?.let { + r = PlotRange(it[0], it[1], r.yMin, r.yMax) + } + axes.ylim?.takeIf { it.size == 2 && it[1] > it[0] }?.let { + r = PlotRange(r.xMin, r.xMax, it[0], it[1]) + } + return r +} + +/** 简化 linespec 解析:颜色字符与标记(虚线绘制暂不实现)。 */ +private data class ParsedLineStyle(val color: Color?, val marker: Char?) + +private object LineStyle { + fun parse(style: String): ParsedLineStyle { + var color: Color? = null + var marker: Char? = null + style.forEach { ch -> + when (ch) { + 'r' -> color = Color(0xFFD62728) + 'g' -> color = Color(0xFF2CA02C) + 'b' -> color = Color(0xFF1F77B4) + 'k' -> color = Color(0xFF222222) + 'm' -> color = Color(0xFF9467BD) + 'c' -> color = Color(0xFF17BECF) + 'y' -> color = Color(0xFFFFD700) + 'w' -> color = Color.White + '.' -> marker = '.' + 'o', '+', '*', 'x', 's', 'd', '^', 'v', '<', '>' -> marker = ch + } + } + return ParsedLineStyle(color, marker) + } +} + +@Composable +private fun OctaveGlPanel(kind: GlPlotKind, axes: OctaveAxes, pageScroll: ScrollState? = null) { + val mesh = remember(axes, kind) { + when (kind) { + GlPlotKind.SURFACE -> buildSurfaceMesh(axes) + GlPlotKind.CONTOUR -> buildContourMesh(axes) + } + } + val range = remember(axes) { computeRange(axes, includeLines = false) } + if (mesh == null) return + val controller = remember { PlotGlController() } + var state by remember(axes, kind) { + mutableStateOf(octaveGlViewState(axes, kind)) + } + var size by remember { mutableStateOf(Size(1f, 1f)) } + LaunchedEffect(mesh, range, axes.azimuth, axes.elevation) { + controller.setMesh(mesh, range) + controller.setState(state) + } + Box( + Modifier + .fillMaxSize() + .onSizeChanged { size = Size(it.width.toFloat(), it.height.toFloat()) } + .pointerInput(axes, kind) { + detectTransformGestures { _, pan, zoom, _ -> + val plotPan = remainingPlotPan(pan, zoom, pageScroll) + state = when (kind) { + GlPlotKind.SURFACE -> GlGestureMath.apply3d(state, plotPan, zoom) + GlPlotKind.CONTOUR -> GlGestureMath.applyContour( + state, plotPan, zoom, size.width, size.height, + ) + } + controller.setState(state) + } + }, + ) { + PlotGlSurface( + controller = controller, + modifier = Modifier.fillMaxSize(), + palette = GlPalette.Light, + ) + if (axes.colorbar) { + val limits = axes.colorLimits(mesh.zMin.toDouble(), mesh.zMax.toDouble()) + ColorBar( + colormap = axes.colormap, + zMin = limits.first, + zMax = limits.second, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 24.dp, end = 4.dp), + ) + } + } +} + +@Composable +private fun OctaveHeatmapPanel(axes: OctaveAxes, pageScroll: ScrollState? = null) { + val image = axes.images.firstOrNull() ?: return + val finite = image.colors.filter(Double::isFinite) + val fallbackMin = finite.minOrNull() ?: 0.0 + val fallbackMax = finite.maxOrNull()?.takeIf { it > fallbackMin } ?: (fallbackMin + 1.0) + val limits = axes.colorLimits(fallbackMin, fallbackMax) + Box(Modifier.fillMaxSize()) { + Canvas( + Modifier + .fillMaxSize() + .pointerInput(axes) { + detectTransformGestures { _, pan, zoom, _ -> + remainingPlotPan(pan, zoom, pageScroll) + } + }, + ) { + drawRect(Color.White) + if (image.rows <= 0 || image.cols <= 0) return@Canvas + val cellWidth = size.width / image.cols + val cellHeight = size.height / image.rows + val span = (limits.second - limits.first).takeIf { it > 0.0 } ?: 1.0 + for (row in 0 until image.rows) { + val displayRow = if (axes.yDirection.equals("reverse", ignoreCase = true)) { + row + } else { + image.rows - row - 1 + } + for (column in 0 until image.cols) { + val value = image.colors.getOrNull(row * image.cols + column) ?: Double.NaN + val color = if (value.isFinite()) { + MatlabColormaps.color( + axes.colormap, + ((value - limits.first) / span).toFloat().coerceIn(0f, 1f), + ) + } else { + Color.Transparent + } + drawRect( + color = color, + topLeft = Offset(column * cellWidth, displayRow * cellHeight), + size = Size(cellWidth + 1f, cellHeight + 1f), + ) + } + } + val xRange = image.x.finiteRange(1.0, image.cols.toDouble()) + val yRange = image.y.finiteRange(1.0, image.rows.toDouble()) + val xSpan = (xRange.second - xRange.first).takeIf { it > 0.0 } ?: 1.0 + val ySpan = (yRange.second - yRange.first).takeIf { it > 0.0 } ?: 1.0 + fun sx(value: Double): Float = + ((value - xRange.first) / xSpan * size.width).toFloat() + fun sy(value: Double): Float = if (axes.yDirection.equals("reverse", true)) { + ((value - yRange.first) / ySpan * size.height).toFloat() + } else { + ((yRange.second - value) / ySpan * size.height).toFloat() + } + axes.lines.forEachIndexed { index, line -> + val style = LineStyle.parse(line.style) + val color = style.color ?: lineColors[index % lineColors.size] + val path = Path() + var started = false + val count = min(line.x.size, line.y.size) + for (point in 0 until count) { + val x = line.x[point] + val y = line.y[point] + if (!x.isFinite() || !y.isFinite()) { + started = false + continue + } + val position = Offset(sx(x), sy(y)) + if (started) path.lineTo(position.x, position.y) + else { + path.moveTo(position.x, position.y) + started = true + } + if (style.marker != null) drawCircle(color, 4f, position) + } + drawPath(path, color, style = Stroke(width = 2.2f)) + } + } + if (axes.colorbar) { + ColorBar( + colormap = axes.colormap, + zMin = limits.first, + zMax = limits.second, + modifier = Modifier.align(Alignment.TopEnd).padding(top = 24.dp, end = 4.dp), + ) + } + } +} + +private fun DoubleArray.finiteRange(defaultMin: Double, defaultMax: Double): Pair { + var minimum = Double.POSITIVE_INFINITY + var maximum = Double.NEGATIVE_INFINITY + for (value in this) { + if (value.isFinite()) { + minimum = min(minimum, value) + maximum = max(maximum, value) + } + } + return if (minimum.isFinite() && maximum.isFinite() && maximum > minimum) { + minimum to maximum + } else { + defaultMin to defaultMax + } +} + +private fun OctaveAxes.colorLimits(fallbackMin: Double, fallbackMax: Double): Pair { + val values = clim + return if (values != null && values.size >= 2 && values[0].isFinite() && + values[1].isFinite() && values[1] > values[0] + ) { + values[0] to values[1] + } else { + fallbackMin to fallbackMax + } +} + +private fun remainingPlotPan(pan: Offset, zoom: Float, pageScroll: ScrollState?): Offset { + if (pageScroll == null || abs(zoom - 1f) > 0.01f || pan.y == 0f) return pan + val consumed = pageScroll.dispatchRawDelta(-pan.y) + return Offset(pan.x, pan.y + consumed) +} + +internal fun octaveGlViewState(axes: OctaveAxes, kind: GlPlotKind): GlViewState = + GlViewState( + kind = kind, + azimuthDeg = axes.azimuth.toFloat(), + elevationDeg = axes.elevation.toFloat(), + ) + +private fun buildSurfaceMesh(axes: OctaveAxes): GlMesh? { + val s = axes.surfaces.firstOrNull() ?: return null + return PlotDataMesh.surface(s.x, s.y, s.z, s.rows, s.cols, axes.colormap) +} + +private fun buildContourMesh(axes: OctaveAxes): GlMesh? { + val c = axes.contours.firstOrNull() ?: return null + val levelCount = if (c.levels.size >= 2) c.levels.size else 10 + return PlotDataMesh.contour(c.x, c.y, c.z, c.rows, c.cols, axes.colormap, levelCount) +} + +@Composable +private fun ColorBar(colormap: String, zMin: Double, zMax: Double, modifier: Modifier = Modifier) { + Box(modifier) { + Column( + modifier = Modifier + .testTag("octave_color_legend") + .width(112.dp) + .height(42.dp) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.92f)) + .padding(4.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Canvas( + Modifier + .fillMaxWidth() + .height(12.dp), + ) { + val steps = 64 + val stepWidth = size.width / steps + for (i in 0 until steps) { + val t = i / (steps - 1f) + drawRect( + color = MatlabColormaps.color(colormap, t), + topLeft = Offset(i * stepWidth, 0f), + size = Size(stepWidth + 1f, size.height), + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + PlotTicks.formatValue(zMin), + style = MaterialTheme.typography.labelSmall, + fontSize = 8.sp, + ) + Text( + PlotTicks.formatValue(zMax), + style = MaterialTheme.typography.labelSmall, + fontSize = 8.sp, + textAlign = TextAlign.End, + ) + } + } + } +} + +@Composable +private fun OctaveLine3dCanvas(axes: OctaveAxes, pageScroll: ScrollState? = null) { + var azimuth by remember(axes) { mutableFloatStateOf(axes.azimuth.toFloat()) } + var elevation by remember(axes) { mutableFloatStateOf(axes.elevation.toFloat()) } + var size by remember { mutableStateOf(Size(1f, 1f)) } + Canvas( + Modifier + .fillMaxSize() + .onSizeChanged { size = Size(it.width.toFloat(), it.height.toFloat()) } + .pointerInput(axes) { + detectTransformGestures { _, pan, zoom, _ -> + val plotPan = remainingPlotPan(pan, zoom, pageScroll) + azimuth = (azimuth - plotPan.x * 0.5f) % 360f + elevation = (elevation + plotPan.y * 0.5f).coerceIn(-89f, 89f) + } + }, + ) { + drawRect(Color.White) + var xMin = Double.POSITIVE_INFINITY + var xMax = Double.NEGATIVE_INFINITY + var yMin = Double.POSITIVE_INFINITY + var yMax = Double.NEGATIVE_INFINITY + var zMin = Double.POSITIVE_INFINITY + var zMax = Double.NEGATIVE_INFINITY + axes.lines3d.forEach { l -> + l.x.forEach { if (it.isFinite()) { xMin = min(xMin, it); xMax = max(xMax, it) } } + l.y.forEach { if (it.isFinite()) { yMin = min(yMin, it); yMax = max(yMax, it) } } + l.z.forEach { if (it.isFinite()) { zMin = min(zMin, it); zMax = max(zMax, it) } } + } + if (!xMin.isFinite()) return@Canvas + val sx0 = (azimuth * PI / 180f).toFloat() + val sy0 = (elevation * PI / 180f).toFloat() + val cosA = cos(sx0) + val sinA = sin(sx0) + val cosE = cos(sy0) + val sinE = sin(sy0) + val cx = (xMin + xMax) / 2 + val cy = (yMin + yMax) / 2 + val cz = (zMin + zMax) / 2 + val scale = size.minDimension / maxOf(xMax - xMin, yMax - yMin, zMax - zMin) * 0.8f + fun proj(x: Double, y: Double, z: Double): Offset { + val xr = x - cx + val yr = y - cy + val zr = z - cz + val x1 = cosA * xr - sinA * yr + val y1 = sinA * xr + cosA * yr + val z1 = zr + val x2 = x1 + val y2 = cosE * y1 - sinE * z1 + val z2 = sinE * y1 + cosE * z1 + return Offset( + (size.width / 2 + x2 * scale).toFloat(), + (size.height / 2 - y2 * scale).toFloat(), + ) + } + axes.lines3d.forEachIndexed { index, line -> + val color = lineColors[index % lineColors.size] + val path = Path() + var started = false + val n = minOf(line.x.size, line.y.size, line.z.size) + for (i in 0 until n) { + val p = proj(line.x[i], line.y[i], line.z[i]) + if (!line.x[i].isFinite() || !line.y[i].isFinite() || !line.z[i].isFinite()) { + started = false + continue + } + if (!started) { + path.moveTo(p.x, p.y) + started = true + } else { + path.lineTo(p.x, p.y) + } + } + drawPath(path, color, style = Stroke(width = 2.2f)) + } + } +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/PlotDataMesh.kt b/app/src/main/java/com/paruh/maxmath/ui/console/PlotDataMesh.kt new file mode 100644 index 0000000..ac926b3 --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/console/PlotDataMesh.kt @@ -0,0 +1,401 @@ +package com.paruh.maxmath.ui.console + +import androidx.compose.ui.graphics.Color +import com.paruh.maxmath.ui.plot.gl.GlMesh +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.sqrt + +/** + * MATLAB/Octave 常用 colormap 的近似实现(0..1 采样)。 + * 2D 画布与 GL 顶点颜色共用同一份映射,保证 colorbar 与图面一致。 + */ +object MatlabColormaps { + + fun color(name: String, t: Float): Color { + val x = t.coerceIn(0f, 1f) + return when (name.lowercase()) { + "jet" -> jet(x) + "hot" -> Color(1f, x, x * x) + "gray" -> Color(x, x, x) + "autumn" -> Color(1f, x, 0f) + "cool" -> Color(x, 1f - x, 1f) + "hsv" -> hsv(x) + "parula", "viridis", "" -> viridis(x) + else -> viridis(x) + } + } + + fun rgba(name: String, t: Float, alpha: Float = 1f): FloatArray { + val c = color(name, t) + return floatArrayOf(c.red, c.green, c.blue, alpha) + } + + private fun viridis(t: Float): Color { + // 简化版 viridis:首末端点加两个中间控制色,足够区分等值面。 + val stops = listOf( + 0.000f to Color(0.267f, 0.005f, 0.329f), + 0.300f to Color(0.230f, 0.322f, 0.546f), + 0.520f to Color(0.128f, 0.567f, 0.551f), + 0.740f to Color(0.369f, 0.789f, 0.383f), + 1.000f to Color(0.993f, 0.906f, 0.144f), + ) + return lerp(stops, t) + } + + private fun jet(t: Float): Color { + val stops = listOf( + 0.000f to Color(0f, 0f, 0.5f), + 0.125f to Color(0f, 0f, 1f), + 0.375f to Color(0f, 1f, 1f), + 0.625f to Color(1f, 1f, 0f), + 0.875f to Color(1f, 0f, 0f), + 1.000f to Color(0.5f, 0f, 0f), + ) + return lerp(stops, t) + } + + private fun hsv(t: Float): Color { + val h = t * 6f + val x = 1f - kotlin.math.abs(h % 2f - 1f) + return when { + h < 1f -> Color(1f, x, 0f) + h < 2f -> Color(x, 1f, 0f) + h < 3f -> Color(0f, 1f, x) + h < 4f -> Color(0f, x, 1f) + h < 5f -> Color(x, 0f, 1f) + else -> Color(1f, 0f, x) + } + } + + private fun lerp(stops: List>, t: Float): Color { + if (t <= stops.first().first) return stops.first().second + if (t >= stops.last().first) return stops.last().second + for (i in 1 until stops.size) { + val (t0, c0) = stops[i - 1] + val (t1, c1) = stops[i] + if (t <= t1) { + val f = ((t - t0) / (t1 - t0)).coerceIn(0f, 1f) + return Color( + c0.red + (c1.red - c0.red) * f, + c0.green + (c1.green - c0.green) * f, + c0.blue + (c1.blue - c0.blue) * f, + ) + } + } + return stops.last().second + } +} + +/** + * 由 Octave 数据网格构建 GL 网格(行列数任意,不要求 n×n)。 + * 曲面带逐顶点法线;等高线把 z 编码为热力图色并生成等值线段。 + */ +object PlotDataMesh { + + const val MAX_CELLS = 65_535 + + fun surface( + xs: DoubleArray, + ys: DoubleArray, + z: DoubleArray, + rows: Int, + cols: Int, + colormap: String = "viridis", + ): GlMesh? { + if (rows < 2 || cols < 2 || z.size < rows * cols) return null + if (rows * cols > MAX_CELLS) return null + if (!validCoordinates(xs, ys, rows, cols)) return null + val positions = FloatArray(rows * cols * 3) + val normals = FloatArray(rows * cols * 3) + val colors = FloatArray(rows * cols * 4) + val zf = FloatArray(rows * cols) + var zMin = Float.POSITIVE_INFINITY + var zMax = Float.NEGATIVE_INFINITY + for (i in 0 until rows) { + for (j in 0 until cols) { + val idx = i * cols + j + val zv = z[idx].toFloat() + zf[idx] = zv + positions[idx * 3] = xAt(xs, idx, j, rows, cols).toFloat() + positions[idx * 3 + 1] = yAt(ys, idx, i, rows, cols).toFloat() + positions[idx * 3 + 2] = zv + if (zv.isFinite()) { + if (zv < zMin) zMin = zv + if (zv > zMax) zMax = zv + } + } + } + if (!zMin.isFinite()) zMin = 0f + if (!zMax.isFinite()) zMax = 1f + val span = if (zMax > zMin) zMax - zMin else 1f + for (v in 0 until rows * cols) { + val t = if (zf[v].isFinite()) (zf[v] - zMin) / span else 0f + val c = MatlabColormaps.rgba(colormap, t) + System.arraycopy(c, 0, colors, v * 4, 4) + colors[v * 4 + 3] = if (zf[v].isFinite()) 1f else 0f + } + + val indices = IntArray((rows - 1) * (cols - 1) * 6) + var written = 0 + for (i in 0 until rows - 1) { + for (j in 0 until cols - 1) { + val a = i * cols + j + val b = a + 1 + val c = a + cols + 1 + val d = a + cols + if (finite(zf, a, b, c, d)) { + indices[written] = a + indices[written + 1] = b + indices[written + 2] = c + indices[written + 3] = a + indices[written + 4] = c + indices[written + 5] = d + written += 6 + accumulateNormal(positions, normals, a, b, c, d) + } + } + } + for (v in 0 until rows * cols) { + val nx = normals[v * 3] + val ny = normals[v * 3 + 1] + val nz = normals[v * 3 + 2] + val len = sqrt(nx * nx + ny * ny + nz * nz) + if (len > 1e-6f) { + normals[v * 3] = nx / len + normals[v * 3 + 1] = ny / len + normals[v * 3 + 2] = nz / len + } else { + normals[v * 3 + 2] = 1f + } + } + return GlMesh( + positions = positions, + normals = normals, + colors = colors, + indices = if (written == indices.size) indices else indices.copyOf(written), + contourLines = FloatArray(0), + zMin = zMin, + zMax = zMax, + ) + } + + fun contour( + xs: DoubleArray, + ys: DoubleArray, + z: DoubleArray, + rows: Int, + cols: Int, + colormap: String = "viridis", + levelCount: Int = 10, + ): GlMesh? { + if (rows < 2 || cols < 2 || z.size < rows * cols) return null + if (rows * cols > MAX_CELLS) return null + if (!validCoordinates(xs, ys, rows, cols)) return null + val positions = FloatArray(rows * cols * 3) + val normals = FloatArray(rows * cols * 3) + val colors = FloatArray(rows * cols * 4) + val zf = FloatArray(rows * cols) + var zMin = Float.POSITIVE_INFINITY + var zMax = Float.NEGATIVE_INFINITY + for (i in 0 until rows) { + for (j in 0 until cols) { + val idx = i * cols + j + val zv = z[idx].toFloat() + zf[idx] = zv + positions[idx * 3] = xAt(xs, idx, j, rows, cols).toFloat() + positions[idx * 3 + 1] = yAt(ys, idx, i, rows, cols).toFloat() + normals[idx * 3 + 2] = 1f + if (zv.isFinite()) { + if (zv < zMin) zMin = zv + if (zv > zMax) zMax = zv + } + } + } + if (!zMin.isFinite()) zMin = 0f + if (!zMax.isFinite()) zMax = 1f + val span = if (zMax > zMin) zMax - zMin else 1f + for (v in 0 until rows * cols) { + val t = if (zf[v].isFinite()) (zf[v] - zMin) / span else 0f + val c = MatlabColormaps.rgba(colormap, t) + System.arraycopy(c, 0, colors, v * 4, 4) + colors[v * 4 + 3] = if (zf[v].isFinite()) 1f else 0f + } + val indices = IntArray((rows - 1) * (cols - 1) * 6) + var written = 0 + for (i in 0 until rows - 1) { + for (j in 0 until cols - 1) { + val a = i * cols + j + val b = a + 1 + val c = a + cols + 1 + val d = a + cols + if (finite(zf, a, b, c, d)) { + indices[written] = a + indices[written + 1] = b + indices[written + 2] = c + indices[written + 3] = a + indices[written + 4] = c + indices[written + 5] = d + written += 6 + } + } + } + return GlMesh( + positions = positions, + normals = normals, + colors = colors, + indices = if (written == indices.size) indices else indices.copyOf(written), + contourLines = contourLines(xs, ys, zf, rows, cols, zMin, zMax, levelCount), + zMin = zMin, + zMax = zMax, + ) + } + + private fun finite(zf: FloatArray, a: Int, b: Int, c: Int, d: Int): Boolean = + zf[a].isFinite() && zf[b].isFinite() && zf[c].isFinite() && zf[d].isFinite() + + /** + * Octave's surf/mesh bridge serializes X and Y as full meshgrid matrices, + * while older artifacts may contain the compact x/y vectors. Keep both wire + * shapes, but never read a flattened Y matrix as though it were a vector: for + * an n x n mesh that made every row use Y[0] and collapsed the surface to a + * plane. + */ + private fun validCoordinates( + xs: DoubleArray, + ys: DoubleArray, + rows: Int, + cols: Int, + ): Boolean { + val cells = rows * cols + val validX = xs.size >= cells || xs.size >= cols + val validY = ys.size >= cells || ys.size >= rows + return validX && validY + } + + private fun xAt( + xs: DoubleArray, + cellIndex: Int, + column: Int, + rows: Int, + cols: Int, + ): Double = if (xs.size >= rows * cols) xs[cellIndex] else xs[column] + + private fun yAt( + ys: DoubleArray, + cellIndex: Int, + row: Int, + rows: Int, + cols: Int, + ): Double = if (ys.size >= rows * cols) ys[cellIndex] else ys[row] + + private fun accumulateNormal( + positions: FloatArray, + normals: FloatArray, + a: Int, + b: Int, + c: Int, + d: Int, + ) { + val ax = positions[a * 3]; val ay = positions[a * 3 + 1]; val az = positions[a * 3 + 2] + val bx = positions[b * 3]; val by = positions[b * 3 + 1]; val bz = positions[b * 3 + 2] + val dx = positions[d * 3]; val dy = positions[d * 3 + 1]; val dz = positions[d * 3 + 2] + var nx = (by - ay) * (dz - az) - (bz - az) * (dy - ay) + var ny = (bz - az) * (dx - ax) - (bx - ax) * (dz - az) + var nz = (bx - ax) * (dy - ay) - (by - ay) * (dx - ax) + val len = sqrt(nx * nx + ny * ny + nz * nz) + if (len > 1e-8f) { + nx /= len; ny /= len; nz /= len + addNormal(normals, a, nx, ny, nz) + addNormal(normals, b, nx, ny, nz) + addNormal(normals, c, nx, ny, nz) + addNormal(normals, d, nx, ny, nz) + } + } + + private fun addNormal(normals: FloatArray, v: Int, nx: Float, ny: Float, nz: Float) { + normals[v * 3] += nx + normals[v * 3 + 1] += ny + normals[v * 3 + 2] += nz + } + + /** Marching squares(矩形网格版),输出 GL_LINES 顶点序列。 */ + private fun contourLines( + xs: DoubleArray, + ys: DoubleArray, + zs: FloatArray, + rows: Int, + cols: Int, + zMin: Float, + zMax: Float, + levelCount: Int, + ): FloatArray { + if (zMax <= zMin || levelCount <= 0) return FloatArray(0) + val divisions = levelCount + 1 + val levels = FloatArray(levelCount) { zMin + (zMax - zMin) * (it + 1) / divisions } + val toLevelIndex = divisions.toDouble() / (zMax - zMin).toDouble() + var lines = FloatArray(1024) + var count = 0 + val hits = DoubleArray(8) + for (i in 0 until rows - 1) { + for (j in 0 until cols - 1) { + val a = i * cols + j + val b = a + 1 + val c = a + cols + 1 + val d = a + cols + val za = zs[a]; val zb = zs[b]; val zc = zs[c]; val zd = zs[d] + if (!finite(zs, a, b, c, d)) continue + val cellMin = minOf(minOf(za, zb), minOf(zc, zd)) + val cellMax = maxOf(maxOf(za, zb), maxOf(zc, zd)) + val kLo = (ceil((cellMin - zMin).toDouble() * toLevelIndex).toInt() - 2).coerceAtLeast(0) + val kHi = floor((cellMax - zMin).toDouble() * toLevelIndex).toInt().coerceAtMost(levelCount - 1) + for (k in kLo..kHi) { + val level = levels[k] + if (level < cellMin || level > cellMax) continue + val ax = xAt(xs, a, j, rows, cols) + val ay = yAt(ys, a, i, rows, cols) + val bx = xAt(xs, b, j + 1, rows, cols) + val by = yAt(ys, b, i, rows, cols) + val cx = xAt(xs, c, j + 1, rows, cols) + val cy = yAt(ys, c, i + 1, rows, cols) + val dx = xAt(xs, d, j, rows, cols) + val dy = yAt(ys, d, i + 1, rows, cols) + var hitCount = 0 + hitCount = addHit(hits, hitCount, za, zb, ax, ay, bx, by, level) + hitCount = addHit(hits, hitCount, zb, zc, bx, by, cx, cy, level) + hitCount = addHit(hits, hitCount, zc, zd, cx, cy, dx, dy, level) + hitCount = addHit(hits, hitCount, zd, za, dx, dy, ax, ay, level) + if (hitCount == 2) { + if (count + 4 > lines.size) lines = lines.copyOf(lines.size * 2) + lines[count++] = hits[0].toFloat() + lines[count++] = hits[1].toFloat() + lines[count++] = hits[2].toFloat() + lines[count++] = hits[3].toFloat() + } + } + } + } + return lines.copyOf(count) + } + + private fun addHit( + hits: DoubleArray, + count: Int, + z1: Float, + z2: Float, + x1: Double, + y1: Double, + x2: Double, + y2: Double, + level: Float, + ): Int { + if (count >= 4) return count + if ((z1 <= level && level <= z2) || (z2 <= level && level <= z1)) { + val t = if (z2 == z1) 0.5 else ((level - z1) / (z2 - z1)).toDouble() + hits[count * 2] = x1 + (x2 - x1) * t + hits[count * 2 + 1] = y1 + (y2 - y1) * t + return count + 1 + } + return count + } +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/ScriptStore.kt b/app/src/main/java/com/paruh/maxmath/ui/console/ScriptStore.kt new file mode 100644 index 0000000..d1878ff --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/console/ScriptStore.kt @@ -0,0 +1,71 @@ +package com.paruh.maxmath.ui.console + +import android.content.Context +import android.net.Uri +import java.io.File + +/** + * 脚本仓库:Octave 运行沙箱内的 .m 文件。 + * SAF 导入/导出由界面层调用 [importFrom]/[exportTo] 完成,运行目录始终固定。 + */ +class ScriptStore(context: Context) { + + private val scriptsDir = File(context.filesDir, "octave/work/scripts").apply { mkdirs() } + + data class ScriptFile(val name: String, val size: Long, val modified: Long) + + fun list(): List = + scriptsDir.listFiles { f -> f.isFile && f.extension == "m" } + ?.map { ScriptFile(it.name, it.length(), it.lastModified()) } + ?.sortedByDescending { it.modified } + ?: emptyList() + + fun read(name: String): String { + val f = file(name) ?: return "" + return if (f.exists()) f.readText() else "" + } + + fun save(name: String, content: String): String { + val safe = sanitize(name) + File(scriptsDir, safe).writeText(content) + return safe + } + + fun delete(name: String) { + file(name)?.delete() + } + + /** 通过 SAF 导入:把外部文件内容复制进沙箱(保留原文件名)。 */ + fun importFrom(context: Context, uri: Uri): String? { + val name = uri.lastPathSegment?.substringAfterLast('/')?.ifBlank { null } + ?: return null + val safe = sanitize(name) + val target = File(scriptsDir, safe) + context.contentResolver.openInputStream(uri)?.use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } ?: return null + return safe + } + + /** 通过 SAF 导出:把沙箱脚本内容写到用户选择的 URI。 */ + fun exportTo(context: Context, name: String, uri: Uri): Boolean { + val f = file(name) ?: return false + context.contentResolver.openOutputStream(uri)?.use { output -> + f.inputStream().use { input -> input.copyTo(output) } + } ?: return false + return true + } + + private fun file(name: String): File? { + val safe = sanitize(name) + val f = File(scriptsDir, safe) + return if (f.exists()) f else null + } + + private fun sanitize(name: String): String { + val safe = name.replace(Regex("[^A-Za-z0-9_.\\-]"), "_") + return safe.ifBlank { "script.m" }.let { + if (it.endsWith(".m")) it else "$it.m" + } + } +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/icons/AppIcons.kt b/app/src/main/java/com/paruh/maxmath/ui/icons/AppIcons.kt new file mode 100644 index 0000000..26ce784 --- /dev/null +++ b/app/src/main/java/com/paruh/maxmath/ui/icons/AppIcons.kt @@ -0,0 +1,419 @@ +package com.paruh.maxmath.ui.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.unit.dp + +/** + * MaxMath's compact, monochrome icon language. + * + * Every icon uses the same 24 x 24 canvas, rounded 1.75-unit strokes, and a + * generous optical safe area. Neutral geometry keeps the set crisp and + * consistent at Material icon sizes. + */ +object AppIcons { + + object Module { + val Console: ImageVector by lazy { + icon( + name = "AppModuleConsole", + paths = listOf( + "M7,4 H17 A3,3 0 0,1 20,7 V17 A3,3 0 0,1 17,20 H7 " + + "A3,3 0 0,1 4,17 V7 A3,3 0 0,1 7,4 Z", + "M7.5,9 L11,12 L7.5,15", + "M13.5,15 H17", + ), + ) + } + + val Matrix: ImageVector by lazy { + icon( + name = "AppModuleMatrix", + paths = listOf( + "M7,4 H4 V20 H7", + "M17,4 H20 V20 H17", + "M9,8 H9.01 M15,8 H15.01 M9,16 H9.01 M15,16 H15.01", + ), + ) + } + + val Equations: ImageVector by lazy { + icon( + name = "AppModuleEquations", + paths = listOf( + "M8,4 H7 C5.9,4 5.5,4.7 5.5,5.8 V9 C5.5,10.2 4.8,11 3.5,12 " + + "C4.8,13 5.5,13.8 5.5,15 V18.2 C5.5,19.3 5.9,20 7,20 H8", + "M11,7 H19 M11,9 H19", + "M11,15 H19 M11,17 H19", + ), + ) + } + + val Polynomial: ImageVector by lazy { + icon( + name = "AppModulePolynomial", + paths = listOf( + "M4,19 H20", + "M4,15 C7,6 10,6 12,12 C14,18 17,18 20,7", + ), + ) + } + + val Vector: ImageVector by lazy { + icon( + name = "AppModuleVector", + paths = listOf( + "M5,19 L19,5", + "M12,5 H19 V12", + "M5,19 H11", + "M5,19 V13", + ), + ) + } + + val Quadratic: ImageVector by lazy { + icon( + name = "AppModuleQuadratic", + paths = listOf( + "M4,18 H20", + "M7,20 V4", + "M4,7 C7,20 15,20 20,7", + ), + ) + } + + val Calculus: ImageVector by lazy { + icon( + name = "AppModuleCalculus", + paths = listOf( + "M15,4 C11.5,3 10.5,5 10,8 L8,17 C7.5,20 5.5,21 3.5,19", + "M18,10 V18 M18,16 C16.5,18.5 14,17.5 14,15.5 C14,13.5 16.5,12.5 18,14", + ), + ) + } + + val Plot: ImageVector by lazy { + icon( + name = "AppModulePlot", + paths = listOf( + "M4,4 V20 H20", + "M6,16 C8,15 9.5,10 12,11.5 S15,17 19,7", + ), + ) + } + + val all: List + get() = listOf(Console, Matrix, Equations, Polynomial, Vector, Quadratic, Calculus, Plot) + } + + object Navigation { + val Back: ImageVector by lazy { + icon( + name = "AppNavigationBack", + paths = listOf( + "M20,12 H5", + "M11,18 L5,12 L11,6", + ), + autoMirror = true, + ) + } + + val Language: ImageVector by lazy { + icon( + name = "AppNavigationLanguage", + paths = listOf( + "M12,3 A9,9 0 1,1 12,21 A9,9 0 1,1 12,3 Z", + "M12,3 C8.5,6.5 8.5,17.5 12,21", + "M12,3 C15.5,6.5 15.5,17.5 12,21", + "M3.5,12 H20.5", + ), + ) + } + } + + object Action { + val Add: ImageVector by lazy { + icon("AppActionAdd", listOf("M12,5 V19", "M5,12 H19")) + } + + val Remove: ImageVector by lazy { + icon("AppActionRemove", listOf("M5,12 H19")) + } + + val Compute: ImageVector by lazy { + icon( + name = "AppActionCompute", + paths = listOf( + "M6.5,3.5 H17.5 A2.5,2.5 0 0,1 20,6 V18 A2.5,2.5 0 0,1 17.5,20.5 " + + "H6.5 A2.5,2.5 0 0,1 4,18 V6 A2.5,2.5 0 0,1 6.5,3.5 Z", + "M8,8 H16", + "M8,15 H10.5 M15.5,12.5 V17.5 M13,15 H18", + ), + ) + } + + val Run: ImageVector by lazy { + icon( + name = "AppActionRun", + paths = emptyList(), + filledPaths = listOf( + "M9.4,5.4 C8.8,5.05 8,5.48 8,6.2 V17.8 C8,18.52 8.8,18.95 9.4,18.6 " + + "L18.4,13.2 C19.2,12.72 19.2,11.28 18.4,10.8 Z", + ), + ) + } + + val Stop: ImageVector by lazy { + icon( + name = "AppActionStop", + paths = emptyList(), + filledPaths = listOf( + "M9,7 H15 A2,2 0 0,1 17,9 V15 A2,2 0 0,1 15,17 H9 " + + "A2,2 0 0,1 7,15 V9 A2,2 0 0,1 9,7 Z", + ), + ) + } + + val Copy: ImageVector by lazy { + icon( + name = "AppActionCopy", + paths = listOf( + "M10.5,8 H17.5 A2.5,2.5 0 0,1 20,10.5 V17.5 A2.5,2.5 0 0,1 17.5,20 " + + "H10.5 A2.5,2.5 0 0,1 8,17.5 V10.5 A2.5,2.5 0 0,1 10.5,8 Z", + "M16,8 V6.5 A2.5,2.5 0 0,0 13.5,4 H6.5 A2.5,2.5 0 0,0 4,6.5 " + + "V13.5 A2.5,2.5 0 0,0 6.5,16 H8", + ), + ) + } + + val ViewPlot: ImageVector by lazy { + icon( + name = "AppActionViewPlot", + paths = listOf( + "M3,12 C5.2,8.5 8.2,6.5 12,6.5 C15.8,6.5 18.8,8.5 21,12 " + + "C18.8,15.5 15.8,17.5 12,17.5 C8.2,17.5 5.2,15.5 3,12 Z", + "M8.5,13.5 L11,10.5 L13,13 L15.5,10", + ), + ) + } + + val Refresh: ImageVector by lazy { + icon( + name = "AppActionRefresh", + paths = listOf( + "M20,5 V11 H14", + "M19.2,10 A7.8,7.8 0 1,0 19.6,15", + ), + ) + } + + val Delete: ImageVector by lazy { + icon( + name = "AppActionDelete", + paths = listOf( + "M4,7 H20", + "M9,7 V4 H15 V7", + "M6.5,7 L7.3,20 H16.7 L17.5,7", + "M10,11 V16 M14,11 V16", + ), + ) + } + + val ClearAll: ImageVector by lazy { + icon( + name = "AppActionClearAll", + paths = listOf( + "M5.2,14.8 L13.5,6.5 A2,2 0 0,1 16.3,6.5 L19.5,9.7 A2,2 0 0,1 19.5,12.5 " + + "L12.8,19.2 H7 A2,2 0 0,1 5.6,18.6 L4.6,17.6 A2,2 0 0,1 5.2,14.8 Z", + "M9.5,19.2 L17.7,11 M15.5,19.2 H20", + ), + ) + } + + val Import: ImageVector by lazy { + icon( + name = "AppActionImport", + paths = listOf( + "M12,4 V16", + "M7.5,11.5 L12,16 L16.5,11.5", + "M5,19.5 H19", + ), + ) + } + + val Export: ImageVector by lazy { + icon( + name = "AppActionExport", + paths = listOf( + "M12,16 V4 M7.5,8.5 L12,4 L16.5,8.5", + "M7,12 H5.5 A1.5,1.5 0 0,0 4,13.5 V19 A1.5,1.5 0 0,0 5.5,20.5 " + + "H18.5 A1.5,1.5 0 0,0 20,19 V13.5 A1.5,1.5 0 0,0 18.5,12 H17", + ), + ) + } + + val NewScript: ImageVector by lazy { + icon( + name = "AppActionNewScript", + paths = listOf( + "M6,3.5 H14 L19,8.5 V20.5 H6 Z", + "M14,3.5 V8.5 H19", + "M12.5,11.5 V17.5 M9.5,14.5 H15.5", + ), + ) + } + + val SaveImage: ImageVector by lazy { + icon( + name = "AppActionSaveImage", + paths = listOf( + "M6.5,4 H17.5 A3,3 0 0,1 20.5,7 V17 A3,3 0 0,1 17.5,20 H6.5 " + + "A3,3 0 0,1 3.5,17 V7 A3,3 0 0,1 6.5,4 Z", + "M12,7 V15 M8.5,11.5 L12,15 L15.5,11.5 M8,18 H16", + ), + ) + } + + val HistoryPrevious: ImageVector by lazy { + icon( + name = "AppActionHistoryPrevious", + paths = listOf( + "M12,20 V7", + "M6.5,12.5 L12,7 L17.5,12.5", + ), + ) + } + + val HistoryNext: ImageVector by lazy { + icon( + name = "AppActionHistoryNext", + paths = listOf( + "M12,4 V17", + "M6.5,11.5 L12,17 L17.5,11.5", + ), + ) + } + + val Regenerate: ImageVector by lazy { + icon( + name = "AppActionRegenerate", + paths = listOf( + "M20,7 V12 H15 M4,17 V12 H9", + "M6.1,8 A7.5,7.5 0 0,1 19.2,12 M4.8,12 A7.5,7.5 0 0,0 17.9,16", + ), + ) + } + + val Retry: ImageVector by lazy { + icon( + name = "AppActionRetry", + paths = listOf( + "M20,7 V12 H15", + "M19.2,12 A7.5,7.5 0 1,0 18,16.1", + ), + ) + } + } + + object Status { + val Warning: ImageVector by lazy { + icon( + name = "AppStatusWarning", + paths = listOf( + "M12,3.5 A8.5,8.5 0 1,1 12,20.5 A8.5,8.5 0 1,1 12,3.5 Z", + "M12,7.5 V13 M12,16.5 H12.01", + ), + ) + } + + val Expand: ImageVector by lazy { + icon("AppStatusExpand", listOf("M6,9 L12,15 L18,9")) + } + + val Collapse: ImageVector by lazy { + icon("AppStatusCollapse", listOf("M6,15 L12,9 L18,15")) + } + + val Check: ImageVector by lazy { + icon( + name = "AppStatusCheck", + paths = listOf("M6,12.5 L10.5,17 L18.5,8"), + ) + } + + val Success: ImageVector by lazy { + icon( + name = "AppStatusSuccess", + paths = listOf( + "M12,3.5 A8.5,8.5 0 1,1 12,20.5 A8.5,8.5 0 1,1 12,3.5 Z", + "M8,12.2 L10.7,15 L16.5,9", + ), + ) + } + } + + val all: List + get() = Module.all + listOf( + Navigation.Back, + Navigation.Language, + Action.Add, + Action.Remove, + Action.Compute, + Action.Run, + Action.Stop, + Action.Copy, + Action.ViewPlot, + Action.Refresh, + Action.Delete, + Action.ClearAll, + Action.Import, + Action.Export, + Action.NewScript, + Action.SaveImage, + Action.HistoryPrevious, + Action.HistoryNext, + Action.Regenerate, + Action.Retry, + Status.Warning, + Status.Expand, + Status.Collapse, + Status.Check, + Status.Success, + ) + + private fun icon( + name: String, + paths: List, + filledPaths: List = emptyList(), + autoMirror: Boolean = false, + ): ImageVector { + val builder = ImageVector.Builder( + name = name, + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + autoMirror = autoMirror, + ) + paths.forEach { path -> + builder.addPath( + pathData = PathParser().parsePathString(path).toNodes(), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.75f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) + } + filledPaths.forEach { path -> + builder.addPath( + pathData = PathParser().parsePathString(path).toNodes(), + fill = SolidColor(Color.Black), + ) + } + return builder.build() + } +} diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt index 6d05be7..aca4627 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt @@ -5,7 +5,7 @@ import com.paruh.maxmath.ui.plot.gl.GlViewState /** * GL 绘图手势 → 视图状态换算(纯函数,可单测)。 - * 3D:单指拖动旋转(上下方向与屏幕一致:上移 = 仰角增大)、双指捏合缩放; + * 3D:单指拖动旋转(物体跟随手指方向)、双指捏合缩放; * 等高线:拖动平移、捏合缩放。 */ object GlGestureMath { @@ -15,11 +15,20 @@ object GlGestureMath { private const val MAX_ZOOM = 8f fun apply3d(state: GlViewState, pan: Offset, zoom: Float): GlViewState = state.copy( - azimuthDeg = state.azimuthDeg + pan.x * DEGREES_PER_PIXEL, - elevationDeg = (state.elevationDeg - pan.y * DEGREES_PER_PIXEL).coerceIn(0f, 180f), + azimuthDeg = wrapDegrees(state.azimuthDeg + pan.x * DEGREES_PER_PIXEL), + elevationDeg = wrapDegrees(state.elevationDeg + pan.y * DEGREES_PER_PIXEL), zoom = (state.zoom * zoom).coerceIn(MIN_ZOOM, MAX_ZOOM), ) + /** + * 把欧拉角限制到一个稳定周期,但不设置旋转端点。 + * 旧实现把仰角夹在 0..180 度,手指到达两端后继续拖动不会再有响应。 + */ + internal fun wrapDegrees(value: Float): Float { + val wrapped = (value + 180f) % 360f + return (if (wrapped < 0f) wrapped + 360f else wrapped) - 180f + } + fun applyContour(state: GlViewState, pan: Offset, zoom: Float, imgW: Float, imgH: Float): GlViewState = state.copy( panX = state.panX + pan.x / imgW * 2f * (imgW / imgH), diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt index 5763ab3..fc78b3d 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt @@ -2,6 +2,7 @@ package com.paruh.maxmath.ui.plot import android.graphics.Bitmap import android.graphics.Paint as AndroidPaint +import androidx.core.graphics.createBitmap import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -200,7 +201,7 @@ object Plot2DPainter { textSizePx: Float = 12f, palette: PlotPalette = PlotPalette.Light, ): Bitmap { - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas = Canvas(android.graphics.Canvas(bitmap)) draw( canvas, diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt index ff8ac6b..4083850 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope import com.paruh.maxmath.engine.CalcRequest import com.paruh.maxmath.engine.CalcResponse import com.paruh.maxmath.engine.EngineClient -import com.paruh.maxmath.engine.MaximaEngine import com.paruh.maxmath.engine.PlotAnnotations import com.paruh.maxmath.engine.PlotKind import com.paruh.maxmath.engine.PlotTask @@ -54,10 +53,11 @@ class EnginePlotEngine(context: Context) : PlotEngine { */ class PlotViewModel( private val engine: PlotEngine, - private val context: Context, + context: Context, private val ioDispatcher: CoroutineDispatcher = Dispatchers.Default, ) : ViewModel() { + private val resources = context.applicationContext.resources private val _state = MutableStateFlow(PlotUiState()) val state: StateFlow = _state.asStateFlow() @@ -84,9 +84,6 @@ class PlotViewModel( job?.cancel() // 迟到的重采样会把用户刚输入的范围盖回旧值。 resampleJob?.cancel() - if (task.kind == PlotKind.PLOT_2D) { - MaximaEngine.cancel() - } // 只改入口状态,不清空图像:重绘期间上一张图继续留在屏幕上。 // 完成时 regenerate2d/regenerateGl 仍整体赋值一个新的 PlotUiState, // 这正是 2D↔3D 切换能丢掉另一种模式残留产物的原因,不要改成 copy。 @@ -103,7 +100,6 @@ class PlotViewModel( fun cancel() { job?.cancel() resampleJob?.cancel() - MaximaEngine.cancel() _state.update { it.copy(loading = false) } } @@ -124,7 +120,7 @@ class PlotViewModel( } catch (e: Exception) { _state.value = PlotUiState( loading = false, - error = e.message ?: context.getString(R.string.error_plot_failed), + error = e.message ?: resources.getString(R.string.error_plot_failed), ) return } @@ -139,7 +135,7 @@ class PlotViewModel( } else { PlotUiState( loading = false, - error = response.error ?: context.getString(R.string.error_plot_failed), + error = response.error ?: resources.getString(R.string.error_plot_failed), ) } } @@ -157,7 +153,7 @@ class PlotViewModel( val range = parseRange(task).getOrElse { e -> _state.value = PlotUiState( loading = false, - error = e.message ?: context.getString(R.string.error_plot_params_invalid), + error = e.message ?: resources.getString(R.string.error_plot_params_invalid), ) return } @@ -204,9 +200,9 @@ class PlotViewModel( private fun parseRange(task: PlotTask): Result = runCatching { fun number(raw: String, invalidRes: Int): Double = raw.trim().toDoubleOrNull() - ?: throw IllegalArgumentException(context.getString(invalidRes, raw.trim())) + ?: throw IllegalArgumentException(resources.getString(invalidRes, raw.trim())) fun greaterThan(large: Double, small: Double, orderRes: Int) { - require(large > small) { context.getString(orderRes) } + require(large > small) { resources.getString(orderRes) } } val xMin = number(task.xMin, R.string.error_plot_xmin_invalid) val xMax = number(task.xMax, R.string.error_plot_xmax_invalid) @@ -218,7 +214,7 @@ class PlotViewModel( } private fun expressionError(e: Exception): String = - context.getString(R.string.error_plot_expression_invalid, e.message ?: "") + resources.getString(R.string.error_plot_expression_invalid, e.message ?: "") private fun annotationsFrom(extra: JSONObject?): PlotAnnotations { // 引擎标注是辅助信息:格式异常时降级为空标注,绝不能让整张图失败。 diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphAtlas.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphAtlas.kt index 65d62a4..c415916 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphAtlas.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphAtlas.kt @@ -6,6 +6,7 @@ import android.graphics.Color import android.graphics.Paint import android.opengl.GLES20 import android.opengl.GLUtils +import androidx.core.graphics.createBitmap /** * 坐标轴数值标签用的字形图集:一张纹理,加上排版用的 [metrics]。 @@ -64,7 +65,7 @@ internal class GlyphAtlas private constructor( val atlasWidth = nextPowerOfTwo(kotlin.math.ceil(x).toInt().coerceAtLeast(1)) val atlasHeight = nextPowerOfTwo(kotlin.math.ceil(cellHeight).toInt().coerceAtLeast(1)) - val bitmap = Bitmap.createBitmap(atlasWidth, atlasHeight, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(atlasWidth, atlasHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) val baseline = padding - fm.top for (i in 0 until n) { diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/HomeScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/HomeScreen.kt index 67628c8..81b2907 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/screens/HomeScreen.kt @@ -39,8 +39,8 @@ import androidx.compose.ui.res.stringResource import com.paruh.maxmath.R import com.paruh.maxmath.ui.AppLanguage import com.paruh.maxmath.ui.AppLocale -import com.paruh.maxmath.ui.ModeIcons import com.paruh.maxmath.ui.Routes +import com.paruh.maxmath.ui.icons.AppIcons import com.paruh.maxmath.ui.theme.Sizing import com.paruh.maxmath.ui.theme.Spacing @@ -52,13 +52,14 @@ private data class ModuleEntry( ) private val modules = listOf( - ModuleEntry(Routes.MATRIX, ModeIcons.Matrix, R.string.module_matrix, R.string.module_matrix_desc), - ModuleEntry(Routes.SYSTEM, ModeIcons.Equations, R.string.module_system, R.string.module_system_desc), - ModuleEntry(Routes.POLYNOMIAL, ModeIcons.Polynomial, R.string.module_polynomial, R.string.module_polynomial_desc), - ModuleEntry(Routes.VECTOR, ModeIcons.Vector, R.string.module_vector, R.string.module_vector_desc), - ModuleEntry(Routes.QUADRATIC, ModeIcons.Quadratic, R.string.module_quadratic, R.string.module_quadratic_desc), - ModuleEntry(Routes.CALCULUS, ModeIcons.Calculus, R.string.module_calculus, R.string.module_calculus_desc), - ModuleEntry(Routes.PLOT, ModeIcons.Plot, R.string.module_plot, R.string.module_plot_desc), + ModuleEntry(Routes.CONSOLE, AppIcons.Module.Console, R.string.module_console, R.string.module_console_desc), + ModuleEntry(Routes.MATRIX, AppIcons.Module.Matrix, R.string.module_matrix, R.string.module_matrix_desc), + ModuleEntry(Routes.SYSTEM, AppIcons.Module.Equations, R.string.module_system, R.string.module_system_desc), + ModuleEntry(Routes.POLYNOMIAL, AppIcons.Module.Polynomial, R.string.module_polynomial, R.string.module_polynomial_desc), + ModuleEntry(Routes.VECTOR, AppIcons.Module.Vector, R.string.module_vector, R.string.module_vector_desc), + ModuleEntry(Routes.QUADRATIC, AppIcons.Module.Quadratic, R.string.module_quadratic, R.string.module_quadratic_desc), + ModuleEntry(Routes.CALCULUS, AppIcons.Module.Calculus, R.string.module_calculus, R.string.module_calculus_desc), + ModuleEntry(Routes.PLOT, AppIcons.Module.Plot, R.string.module_plot, R.string.module_plot_desc), ) @Composable @@ -106,7 +107,7 @@ fun HomeScreen(onNavigate: (String) -> Unit) { // 这里再给 description 会让读屏重复播报。 contentDescription = null, modifier = Modifier.size(Sizing.moduleIcon), - tint = MaterialTheme.colorScheme.primary, + tint = MaterialTheme.colorScheme.tertiary, ) Spacer(Modifier.width(Spacing.l)) Column { @@ -136,7 +137,7 @@ private fun LanguageMenu() { modifier = Modifier.testTag("language_menu_button"), ) { Icon( - ModeIcons.LanguageGlobe, + AppIcons.Navigation.Language, contentDescription = stringResource(R.string.language), tint = MaterialTheme.colorScheme.onSurface, ) @@ -145,6 +146,17 @@ private fun LanguageMenu() { AppLanguage.entries.forEach { language -> DropdownMenuItem( text = { Text(stringResource(language.labelRes)) }, + trailingIcon = if (current == language) { + { + Icon( + AppIcons.Status.Check, + contentDescription = null, + modifier = Modifier.size(Sizing.iconSmall), + ) + } + } else { + null + }, onClick = { expanded = false if (current != language) { diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/MatrixScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/MatrixScreen.kt index 2136fae..f829f18 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/screens/MatrixScreen.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/screens/MatrixScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -38,8 +39,8 @@ fun MatrixScreen(onBack: () -> Unit) { val vm: CalcViewModel = viewModel() val context = LocalContext.current val state by vm.state.collectAsState() - var rows by rememberSaveable { mutableStateOf(2) } - var cols by rememberSaveable { mutableStateOf(2) } + var rows by rememberSaveable { mutableIntStateOf(2) } + var cols by rememberSaveable { mutableIntStateOf(2) } var op by rememberSaveable { mutableStateOf(MatrixKind.DET) } var advanced by rememberSaveable { mutableStateOf(false) } var rawText by rememberSaveable { mutableStateOf("") } diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt index 0b0fee8..1c17333 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt @@ -61,10 +61,12 @@ import com.paruh.maxmath.engine.PlotKind import com.paruh.maxmath.engine.PlotTask import com.paruh.maxmath.ui.CalcUiState import com.paruh.maxmath.ui.components.ComputeActionRow +import com.paruh.maxmath.ui.components.FeedbackTextButton import com.paruh.maxmath.ui.components.ModuleScaffold import com.paruh.maxmath.ui.components.NumberField import com.paruh.maxmath.ui.components.OpChipRow import com.paruh.maxmath.ui.components.ResultCard +import com.paruh.maxmath.ui.icons.AppIcons import com.paruh.maxmath.ui.theme.LocalPlotPalette import com.paruh.maxmath.ui.theme.Sizing import com.paruh.maxmath.ui.theme.Spacing @@ -281,11 +283,19 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) { onCompute = { regenerate() }, onCancel = vm::cancel, computeLabel = stringResource(R.string.regenerate), + computeIcon = AppIcons.Action.Regenerate, ) // 刻意保持原样传 CalcUiState(loading, error):response 恒为 null, // 绘图页复用的就是这张标准进度/错误卡片,PlotScreenStateTest 盯着这点。 ResultCard( - state = CalcUiState(loading = state.loading, error = state.error), + state = CalcUiState( + activity = if (state.loading) { + com.paruh.maxmath.ui.CalcActivity.Running("plot") + } else { + com.paruh.maxmath.ui.CalcActivity.Idle + }, + error = state.error, + ), onCopyTex = {}, onCopyPlain = {}, ) @@ -461,9 +471,11 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) { } } Row(horizontalArrangement = Arrangement.spacedBy(Spacing.s)) { - TextButton(onClick = { saveCurrent() }) { - Text(stringResource(R.string.save_png)) - } + FeedbackTextButton( + label = stringResource(R.string.save_png), + onClick = { saveCurrent() }, + icon = AppIcons.Action.SaveImage, + ) if (mode == PlotKind.PLOT_2D) { TextButton(onClick = { touchCoord = null }) { Text(stringResource(R.string.coord_hint)) diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/QuadraticFormScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/QuadraticFormScreen.kt index 602d57f..b57823f 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/screens/QuadraticFormScreen.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/screens/QuadraticFormScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -35,7 +36,7 @@ fun QuadraticFormScreen(onBack: () -> Unit) { val vm: CalcViewModel = viewModel() val context = LocalContext.current val state by vm.state.collectAsState() - var n by rememberSaveable { mutableStateOf(2) } + var n by rememberSaveable { mutableIntStateOf(2) } var op by rememberSaveable { mutableStateOf(MatrixKind.QUAD_EXPAND) } var advanced by rememberSaveable { mutableStateOf(false) } var variables by rememberSaveable { mutableStateOf("x1,x2") } diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/VectorSpaceScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/VectorSpaceScreen.kt index 6cd7656..20a28ea 100644 --- a/app/src/main/java/com/paruh/maxmath/ui/screens/VectorSpaceScreen.kt +++ b/app/src/main/java/com/paruh/maxmath/ui/screens/VectorSpaceScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -34,8 +35,8 @@ fun VectorSpaceScreen(onBack: () -> Unit) { val vm: CalcViewModel = viewModel() val context = LocalContext.current val state by vm.state.collectAsState() - var dims by rememberSaveable { mutableStateOf(2) } - var count by rememberSaveable { mutableStateOf(2) } + var dims by rememberSaveable { mutableIntStateOf(2) } + var count by rememberSaveable { mutableIntStateOf(2) } var op by rememberSaveable { mutableStateOf(VectorKind.INNER) } var advanced by rememberSaveable { mutableStateOf(false) } var cells by rememberSaveable(stateSaver = MatrixCellsSaver) { diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index f60a43a..beadae5 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -5,9 +5,17 @@ android:viewportWidth="108" android:viewportHeight="108"> + android:fillColor="#00000000" + android:pathData="M25,78 L25,35 C25,30.6 28.6,27 33,27 C35.6,27 38,28.3 39.6,30.4 L46.8,40.3 C48.4,42.5 51.6,42.5 53.2,40.3 L60.4,30.4 C61.8,28.4 63.6,27 66,27 L66,78" + android:strokeColor="#B85F42" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:strokeWidth="6" /> + android:fillColor="#00000000" + android:pathData="M73,30 C73.9,26.8 76.1,25 79,25 C81.7,25 83,26.8 83,29.4 C83,33.5 79.1,35.8 73,41.5 L83,41.5" + android:strokeColor="#B85F42" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:strokeWidth="6" /> diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index a8a8fa5..5c84730 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 0cc9f57..3c5e8fe 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -4,6 +4,8 @@ MaxMath Higher algebra & plotting powered by Maxima + Octave Console + MATLAB-style CLI, workspace and .m scripts Matrices Determinant, inverse, transpose, rank, trace Equations @@ -31,12 +33,63 @@ Show details Hide details Copy error - Starting the engine, this may take a few seconds + Preparing the Maxima engine… + Cancelling calculation… Image generated Decrease %1$s Increase %1$s Advanced mode (raw Maxima syntax) + Console + Workspace + Scripts + Enter an Octave/MATLAB command + Run + Previous command + Next command + Variable preview + LaTeX + Workspace variables (%1$d) + Workspace is empty + Refresh + Clear + Delete variable + Variable details + This variable no longer exists. Return and refresh the workspace. + Loading variable… + Retry + Copy value + This variable is large, so only a summary or truncated value is shown. + Plot generated + + %1$d subplot + %1$d subplots + + View plot + Octave plot + There is no plot to view. + Import + Export + New + Run script + Delete script + Timeout: + The console is numeric-only in v1; symbolic commands like syms/solve/diff/int belong to the Calculus, Matrix or Polynomial modes (Maxima engine). + Script error at line %1$d + Ln %1$d/%3$d, Col %2$d + Starting Octave… + Octave is running… + Cancelling, please wait… + Octave runtime installation or integrity check failed + Octave dynamic linking failed + Octave failed to start + Octave timed out + Octave exceeded the 1.5 GB memory limit + Octave response protocol or process communication failed + Unable to connect to the Octave service + Calculation cancelled + Octave process exited unexpectedly + Rows Columns Matrix (blank cells are 0) @@ -75,8 +128,6 @@ Equations e.g. x+y=1; 2x-y=3 Dimension - Vector u - Vector v Vectors Variables (default x1,x2,…) u₁ … uₙ @@ -100,7 +151,6 @@ Tap image for coordinates Function plot - Engine not ready: %1$s Language Follow system diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 078e982..ab3e74c 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,6 +1,6 @@ - #000000 + #F3EEE7