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)
[](https://github.com/ParuhParhat/MaxMath/actions/workflows/ci.yml)
[](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)
[](https://github.com/ParuhParhat/MaxMath/actions/workflows/ci.yml)
[](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