feat(theme): vendor + theme schemaui to deepseek navy palette (config UI)

The schemaui-0.12.0 crate the contributor brought in via #365 ships
hardcoded Color::Gray / Color::DarkGray / Color::White / Color::Yellow
references across its rendering components. Visually it clashed with
the rest of deepseek-tui — the editor area read as gray-on-black on a
TUI that's otherwise navy ink + sky accents. Two ship-day options
weren't acceptable: defaulting back to the legacy modal lost the new
editor's UX, and living with gray was off-brand.

This commit forks schemaui at 0.12.0 into vendor/schemaui-0.12.0 and
themes the rendering layer to match deepseek-tui's palette. The patch
is wired in via a workspace-level [patch.crates-io] override so the
deepseek-tui Cargo.toml continues to depend on `schemaui = "0.12.0"`
and would automatically resolve back to crates.io if we ever drop the
override (e.g. once upstream lands a ColorTheme API).

Changes inside the vendored fork:

- New `src/deepseek_palette.rs` with the brand RGB values:
  SURFACE_INK / SURFACE_RAISED for backgrounds, BORDER_DIM /
  BORDER_ACTIVE for chrome, TEXT_PRIMARY / TEXT_MUTED / TEXT_DIM,
  ACCENT_SKY / ACCENT_BLUE / ACCENT_PURPLE, and STATUS_OK / WARN /
  ERROR. Values mirror crates/tui/src/palette.rs in the workspace.
- `src/lib.rs` exposes the palette module under `cfg(feature = "tui")`.
- `src/tui/view/frame.rs::draw` paints a navy backdrop across the
  full frame area before any child widget renders, so any cell that
  doesn't get explicitly written reads as ink instead of the terminal
  default.
- `tabstrip.rs`, `overlay.rs`, `popup.rs`, `body.rs`, `sections.rs`,
  `footer.rs`, `help.rs`, `fields.rs`: every Color::Gray / DarkGray /
  White / Yellow / Cyan / Blue / Magenta / Red / Green / LightBlue
  swapped out for a deepseek_palette token, plus explicit `bg(...)`
  fills on the top-level Block styles and Paragraph wrappers.
- `Cargo.toml` adds an empty `[workspace]` so the vendored crate
  builds standalone (its dev-deps don't drift into ours).

Workspace-level changes:

- `Cargo.toml` adds `[patch.crates-io] schemaui = { path =
  "vendor/schemaui-0.12.0" }`. Production deepseek-tui builds pick up
  the themed fork transparently.
- `.gitignore` excludes `vendor/.../web/ui/node_modules/` (15 MB of
  npm artefacts the Rust build doesn't need) and the vendored
  Cargo.lock (regenerated locally per build).

Verification:
- cargo build --workspace --all-features: clean
- cargo clippy --workspace --all-targets --all-features --locked: clean
- cargo test --workspace: 1777 passed, 0 failed
- /config inside `deepseek` now opens a navy-themed editor matching
  the rest of the TUI; tabs, body panel, footer, popup, and help
  overlay all read on brand.

Future work tracked separately: upstream a `with_theme(ColorTheme)`
builder API to schemaui so we can drop the fork. Until then, sync the
fork against new schemaui releases when we want their fixes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Hunter BownandClaude Opus 4.7 committed 2026-05-02 15:45:12 -05:00
1 parent 4bdc23f9d1
commit ed597ccc9e
171 files changed
+28913 -3

No files matched your search

+6
View File
@@ -74,3 +74,9 @@ apps/
.claude/worktrees/
.worktrees/
.ace-tool/
# Vendored schemaui — exclude bulky web frontend artefacts
# (built bundle is small; node_modules is 15MB and unneeded for the Rust build).
vendor/schemaui-0.12.0/web/ui/node_modules/
vendor/schemaui-0.12.0/web/ui/.vite/
vendor/schemaui-0.12.0/Cargo.lock
Generated
+1 -3
View File
@@ -4278,12 +4278,10 @@ dependencies = [
[[package]]
name = "schemaui"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3337ab3373698fc2f2460beb48476e5be13a220e14ed542cf910cc2e10f36177"
dependencies = [
"anyhow",
"axum",
"crossterm 0.29.0",
"crossterm 0.28.1",
"include_dir",
"indexmap",
"jsonschema",
+3
View File
@@ -42,3 +42,6 @@ toml = "0.9.7"
tower-http = { version = "0.6", features = ["cors"] }
tracing = "0.1"
uuid = { version = "1.11", features = ["v4"] }
[patch.crates-io]
schemaui = { path = "vendor/schemaui-0.12.0" }
+1
View File
@@ -0,0 +1 @@
{"v":1}
+7
View File
@@ -0,0 +1,7 @@
{
"git": {
"sha1": "5d591ae751a0b910704795f534e5c331a56641fc",
"dirty": true
},
"path_in_vcs": ""
}
+204
View File
@@ -0,0 +1,204 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
name = "schemaui"
version = "0.12.0"
authors = ["unic <[email protected]>"]
build = "build.rs"
exclude = [
"web/ui/node_modules/**",
"web/ui/.vite/**",
"web/ui/.tmp/**",
"web/ui/.DS_Store",
"docs/**/*.gif",
"docs/**/*.mp4",
]
include = [
"Cargo.toml",
"build.rs",
"keymap/default.keymap.json",
"src/**",
"web/dist/**",
"README.md",
"README.ZH.md",
"LICENSE*",
]
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "A Rust library for generating TUI and Web UIs from JSON Schemas for configuration management."
documentation = "https://docs.rs/schemaui"
readme = "README.md"
keywords = [
"tui",
"schema",
"configuration",
"terminal",
"cross-platform",
]
categories = [
"command-line-interface",
"config",
]
license = "MIT OR Apache-2.0"
repository = "https://github.com/yuniqueunic/schemaui"
[features]
all_formats = [
"json",
"yaml",
"toml",
]
debug = []
default = [
"tui",
"json",
]
full = [
"all_formats",
"tui",
"web",
]
json = []
precompile = []
toml = ["dep:toml"]
tui = [
"dep:crossterm",
"dep:ratatui",
"dep:unicode-width",
]
web = [
"dep:include_dir",
"dep:axum",
"dep:tokio",
]
web-types = ["dep:ts-rs"]
yaml = ["dep:serde_yaml"]
[lib]
name = "schemaui"
path = "src/lib.rs"
[dependencies.anyhow]
version = "1"
[dependencies.axum]
version = "0.8"
features = [
"json",
"http1",
"tokio",
"original-uri",
]
optional = true
default-features = false
[dependencies.crossterm]
version = "0"
optional = true
[dependencies.include_dir]
version = "0.7"
optional = true
[dependencies.indexmap]
version = "2"
features = [
"serde",
"std",
]
default-features = false
[dependencies.jsonschema]
version = "0.46"
default-features = false
[dependencies.percent-encoding]
version = "2"
features = ["alloc"]
default-features = false
[dependencies.ratatui]
version = "0.30"
features = ["crossterm"]
optional = true
default-features = false
[dependencies.regex]
version = "1"
[dependencies.serde]
version = "1"
features = ["derive"]
default-features = false
[dependencies.serde_json]
version = "1"
features = ["preserve_order"]
[dependencies.serde_yaml]
version = "0.9"
optional = true
[dependencies.sha2]
version = "0.11"
default-features = false
[dependencies.tokio]
version = "1"
features = [
"rt-multi-thread",
"net",
"sync",
]
optional = true
[dependencies.toml]
version = "1"
optional = true
[dependencies.ts-rs]
version = "12"
features = ["serde-compat"]
optional = true
[dependencies.unicode-width]
version = "0.2"
optional = true
[dev-dependencies.ureq]
version = "3"
features = ["rustls"]
default-features = false
[build-dependencies.serde]
version = "1"
features = ["derive"]
default-features = false
[build-dependencies.serde_json]
version = "1"
[build-dependencies.unicode-width]
version = "0.2"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = "debuginfo"
[workspace]
+116
View File
@@ -0,0 +1,116 @@
[workspace]
members = ["schemaui-cli"]
[package]
name = "schemaui"
authors = ["unic <[email protected]>"]
version = "0.12.0"
edition = "2024"
keywords = ["tui", "schema", "configuration", "terminal", "cross-platform"]
categories = ["command-line-interface", "config"]
repository = "https://github.com/yuniqueunic/schemaui"
documentation = "https://docs.rs/schemaui"
description = "A Rust library for generating TUI and Web UIs from JSON Schemas for configuration management."
license = "MIT OR Apache-2.0"
readme = "README.md"
exclude = [
"web/ui/node_modules/**",
"web/ui/.vite/**",
"web/ui/.tmp/**",
"web/ui/.DS_Store",
"docs/**/*.gif",
"docs/**/*.mp4",
]
include = [
"Cargo.toml",
"build.rs",
"keymap/default.keymap.json",
"src/**",
"web/dist/**",
"README.md",
"README.ZH.md",
"LICENSE*",
]
[features]
default = ["tui", "json"]
full = ["all_formats", "tui", "web"]
tui = ["dep:crossterm", "dep:ratatui", "dep:unicode-width"]
web = ["dep:include_dir", "dep:axum", "dep:tokio"]
web-types = ["dep:ts-rs"]
all_formats = ["json", "yaml", "toml"]
json = []
yaml = ["dep:serde_yaml"]
toml = ["dep:toml"]
precompile = []
# use debug features for debugging which is more flexible than debug_assertions
debug = []
[dependencies]
anyhow = "1"
crossterm = { version = "0", optional = true }
jsonschema = { version = "0.46", default-features = false }
indexmap = { version = "2", default-features = false, features = ["serde", "std"] }
ratatui = { version = "0.30", default-features = false, features = ["crossterm"], optional = true }
serde = { version = "1", default-features = false, features = ["derive"] }
percent-encoding = { version = "2", default-features = false, features = ["alloc"] }
sha2 = { version = "0.11", default-features = false }
unicode-width = { version = "0.2", optional = true }
regex = "1"
include_dir = { version = "0.7", optional = true }
axum = { version = "0.8", default-features = false, features = ["json", "http1", "tokio", "original-uri"], optional = true }
tokio = { version = "1", features = [
"rt-multi-thread",
"net",
"sync",
], optional = true }
ts-rs = { version = "12", optional = true, features = ["serde-compat"] }
# For serialization formats
serde_json = { version = "1", features = ["preserve_order"] }
serde_yaml = { version = "0.9", optional = true }
toml = { version = "1", optional = true }
[[example]]
name = "demo"
path = "examples/demo.rs"
required-features = ["tui"]
[[example]]
name = "tui_artifact_codegen"
path = "examples/tui_artifact_codegen.rs"
required-features = ["tui", "precompile"]
[[example]]
name = "tui_artifact_demo"
path = "examples/tui_artifact_demo.rs"
required-features = ["tui", "precompile"]
[[example]]
name = "ui_layout_dump"
path = "examples/ui_layout_dump.rs"
[[example]]
name = "web_snapshot_codegen"
path = "examples/web_snapshot_codegen.rs"
required-features = ["web", "precompile"]
[dev-dependencies]
ureq = { version = "3", default-features = false, features = ["rustls"] }
[build-dependencies]
serde = { version = "1", default-features = false, features = ["derive"] }
serde_json = "1"
unicode-width = "0.2"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = "debuginfo"
+18
View File
@@ -0,0 +1,18 @@
Copyright 2025 unic
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+187
View File
@@ -0,0 +1,187 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the
copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other
entities that control, are controlled by, or are under common control with
that entity. For the purposes of this definition, "control" means (i) the
power, direct or indirect, to cause the direction or management of such
entity, whether by contract or otherwise, or (ii) ownership of fifty percent
(50%) or more of the outstanding shares, or (iii) beneficial ownership of
such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising
permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation source, and
configuration files.
"Object" form shall mean any form resulting from mechanical transformation or
translation of a Source form, including but not limited to compiled object
code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form,
made available under the License, as indicated by a copyright notice that is
included in or attached to the work (an example is provided in the Appendix
below).
"Derivative Works" shall mean any work, whether in Source or Object form,
that is based on (or derived from) the Work and for which the editorial
revisions, annotations, elaborations, or other modifications represent, as a
whole, an original work of authorship. For the purposes of this License,
Derivative Works shall not include works that remain separable from, or
merely link (or bind by name) to the interfaces of, the Work and Derivative
Works thereof.
"Contribution" shall mean any work of authorship, including the original
version of the Work and any modifications or additions to that Work or
Derivative Works thereof, that is intentionally submitted to Licensor for
inclusion in the Work by the copyright owner or by an individual or Legal
Entity authorized to submit on behalf of the copyright owner. For the
purposes of this definition, "submitted" means any form of electronic,
verbal, or written communication sent to the Licensor or its representatives,
including but not limited to communication on electronic mailing lists,
source code control systems, and issue tracking systems that are managed by,
or on behalf of, the Licensor for the purpose of discussing and improving the
Work, but excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on
behalf of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this
License, each Contributor hereby grants to You a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable copyright license to
reproduce, prepare Derivative Works of, publicly display, publicly perform,
sublicense, and distribute the Work and such Derivative Works in Source or
Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License,
each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
no-charge, royalty-free, irrevocable (except as stated in this section)
patent license to make, have made, use, offer to sell, sell, import, and
otherwise transfer the Work, where such license applies only to those patent
claims licensable by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s) with the
Work to which such Contribution(s) was submitted. If You institute patent
litigation against any entity (including a cross-claim or counterclaim in a
lawsuit) alleging that the Work or a Contribution incorporated within the
Work constitutes direct or contributory patent infringement, then any patent
licenses granted to You under this License for that Work shall terminate as
of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or
Derivative Works thereof in any medium, with or without modifications, and in
Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy
of this License; and
(b) You must cause any modified files to carry prominent notices stating that
You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You
distribute, all copyright, patent, trademark, and attribution notices from
the Source form of the Work, excluding those notices that do not pertain to
any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution,
then any Derivative Works that You distribute must include a readable copy of
the attribution notices contained within such NOTICE file, excluding those
notices that do not pertain to any part of the Derivative Works, in at least
one of the following places: within a NOTICE text file distributed as part of
the Derivative Works; within the Source form or documentation, if provided
along with the Derivative Works; or, within a display generated by the
Derivative Works, if and wherever such third-party notices normally appear.
The contents of the NOTICE file are for informational purposes only and do
not modify the License. You may add Your own attribution notices within
Derivative Works that You distribute, alongside or as an addendum to the
NOTICE text from the Work, provided that such additional attribution notices
cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may
provide additional or different license terms and conditions for use,
reproduction, or distribution of Your modifications, or for any such
Derivative Works as a whole, provided Your use, reproduction, and
distribution of the Work otherwise complies with the conditions stated in
this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any
Contribution intentionally submitted for inclusion in the Work by You to the
Licensor shall be under the terms and conditions of this License, without any
additional terms or conditions. Notwithstanding the above, nothing herein
shall supersede or modify the terms of any separate license agreement you may
have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names,
trademarks, service marks, or product names of the Licensor, except as
required for reasonable and customary use in describing the origin of the
Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
writing, Licensor provides the Work (and each Contributor provides its
Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied, including, without limitation, any
warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or
FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining
the appropriateness of using or redistributing the Work and assume any risks
associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in
tort (including negligence), contract, or otherwise, unless required by
applicable law (such as deliberate and grossly negligent acts) or agreed to
in writing, shall any Contributor be liable to You for damages, including any
direct, indirect, special, incidental, or consequential damages of any
character arising as a result of this License or out of the use or inability
to use the Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all other
commercial damages or losses), even if such Contributor has been advised of
the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or
Derivative Works thereof, You may choose to offer, and charge a fee for,
acceptance of support, warranty, indemnity, or other liability obligations
and/or rights consistent with this License. However, in accepting such
obligations, You may act only on Your own behalf and on Your sole
responsibility, not on behalf of any other Contributor, and only if You agree
to indemnify, defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason of your
accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 unic
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
+575
View File
@@ -0,0 +1,575 @@
<div align="center">
<a href="https://signature4u.vercel.app/schemaui?font=satisfy&fontSize=153&speed=2.8&charSpacing=0&borderRadius=0&cardPadding=24&fill=multi&fill1=001bb7&fill2=ec4899&stroke=001bb7&stroke2=ec4899&strokeMode=multi&strokeEnabled=1&bg=transparent&bgMode=solid&bg2=1e3a8a&texture=cross&texColor=566486&texSize=30&texThickness=1&texOpacity=0.4&colors=001bb7-001bb7-001bb7-001bb7-001bb7-001bb7-ff8040-fcb53b&linkFillStroke=1" target="_blank">
<img src="https://signature4u.vercel.app/api/sign?text=schemaui&font=satisfy&fontSize=153&speed=2.8&charSpacing=0&borderRadius=0&cardPadding=24&fill=multi&fill1=001bb7&fill2=ec4899&stroke=001bb7&stroke2=ec4899&strokeMode=multi&strokeEnabled=1&bg=transparent&bgMode=solid&bg2=1e3a8a&texture=cross&texColor=566486&texSize=30&texThickness=1&texOpacity=0.4&colors=001bb7-001bb7-001bb7-001bb7-001bb7-001bb7-ff8040-fcb53b&linkFillStroke=1" align="center" alt="schemaui signature"/>
</a>
</div>
[![Crates.io](https://img.shields.io/crates/v/schemaui.svg)](https://crates.io/crates/schemaui)
[![Documentation](https://docs.rs/schemaui/badge.svg)](https://docs.rs/schemaui)
[![License](https://img.shields.io/crates/l/schemaui)](https://github.com/yuniqueunic/schemaui#license)
![Crates.io Total Downloads](https://img.shields.io/crates/d/schemaui)
<!-- ![Deps.rs Crate Dependencies (latest)](https://img.shields.io/deps-rs/schemaui/latest) -->
<div align="center">
<a href="https://asciinema.org/a/7IBbhRJAUBlIQaPWSrspEgZtE" target="_blank">
<img src="https://asciinema.org/a/7IBbhRJAUBlIQaPWSrspEgZtE.svg" width="500" />
</a>
[English](./README.md) | [中文文档](./README.ZH.md)
</div>
`schemaui` 将 JSON Schema
文档转换为由`ratatui`、`crossterm`和`jsonschema`驱动的完全交互式的终端用户界面。
该库解析丰富的模式(嵌套部分、`$ref`、数组、键值映射、模式属性等),将其转换为可导航的表单树,将其呈现为键盘优先的编辑器,并在每次编辑后验证结果,以便用户在保存之前始终可以看到完整的错误列表。
<!-- AUTO-GENERATED:CLI-QUICKLINK:BEGIN -->
> CLI 可用:`schemaui-cli` 会安装 `schemaui` 可执行文件。想直接使用 CLI?跳转到
> [CLI 安装与用法](#cli-schemaui-cli)。
<!-- AUTO-GENERATED:CLI-QUICKLINK:END -->
## 功能亮点
- **模式保真度** –
`draft-07`,包括`$ref`、`definitions`、、`patternProperties`、枚举、数值范围以及嵌套的对象/数组。
- **部分和覆盖层** –
顶层属性成为根标签,嵌套对象被展平为部分,复杂节点(复合体、键值集合、数组条目)打开具有自身验证器的专用覆盖层。
- **即时验证** –
每次按键都可以触发`jsonschema::Validator`,所有错误(字段作用域 +
全局)都被收集并一起显示。
- **可插拔的 I/O** – `io::input`可以处理
JSON/YAML/TOML(通过功能标志),而`io::output`可以输出到标准输出和/或任何启用格式的多个文件。
- **内置 CLI** –
`schemaui-cli`提供了与库相同的流程,包括多目标输出、stdin/内联规范和聚合诊断。
## Config Schema 自动检测
当你只传 `--config` 而不显式传 `--schema` 时,`schemaui-cli`
现在会按以下优先级解析 schema:
1. 显式 `--schema`
2. 配置文件内声明的 schema
3. `schema_from_data_value` 自动推断
支持的声明形式:
- **JSON**:根级 `$schema`
- **TOML**:`#:schema ./schema.json`
- **YAML**:`# yaml-language-server: $schema=...`
- **YAML fallback**:`# @schema ...`
本地与远程 schema 都支持。相对本地路径会按配置文件所在目录解析;如果配置来自
stdin/内联文本,则回退到当前工作目录。对于 JSON 配置,根级 `$schema`
只作为元数据使用,会在内存中的 defaults
里剥离,避免编辑器提示字段污染最终校验与输出。
远程 `http(s)` schema 加载只存在于 `schemaui-cli`,并且 CLI 默认开启
`remote-schema` feature;如果你希望二进制只保留本地能力,可以关闭它。 `schemaui`
这个库默认不会开启任何远程 schema 加载能力。
两者的默认 feature 按使用场景刻意分开:
- `schemaui-cli` 默认走“开箱即用”路径:TUI + Web + 远程 schema 加载都开启。
- `schemaui` 作为库默认开 `tui + json`,这样保留最基础的 JSON
能力,同时不默认引入 Web 与联网相关能力。
- `json`、`yaml`、`toml` 都是真正落在代码路径上的 feature
gate;三者不能全关,否则会直接给出清晰的编译期错误。
参考:
- JSON Schema
`$schema`:<https://json-schema.org/understanding-json-schema/reference/schema>
- Taplo `#:schema`
指令:<https://taplo.tamasfe.dev/configuration/directives.html>
- YAML language server
modeline:<https://github.com/redhat-developer/yaml-language-server>
## 快速开始
```toml
[dependencies]
schemaui = "0.12.0"
serde_json = "1"
```
```rust
use anyhow::Result;
use schemaui::prelude::*;
use serde_json::json;
fn main() -> Result<()> {
let schema = json!({
"$schema": "http://json-schema.org/draft-07/schema# ",
"title": "服务运行时",
"type": "object",
"properties": {
"metadata": {
"type": "object",
"properties": {
"serviceName": {"type": "string"},
"environment": {
"type": "string",
"enum": ["dev", "staging", "prod"]
}
},
"required": ["serviceName"]
},
"runtime": {
"type": "object",
"properties": {
"http": {
"type": "object",
"properties": {
"host": {"type": "string", "default": "0.0.0.0"},
"port": {"type": "integer", "minimum": 1024, "maximum": 65535}
}
}
}
}
},
"required": ["metadata", "runtime"]
});
let config = serde_json::json!({
"metadata": { "name": "demo-service" },
"runtime": { "http": { "host": "127.0.0.1", "port": 8080 } }
});
let value = SchemaUI::new(config)
.with_schema(schema)
.with_title("SchemaUI 演示")
.with_description("基于现有配置和校验 schema 进行编辑")
.run(FrontendOptions::Tui(UiOptions::default()))?;
println!("{}", serde_json::to_string_pretty(&value)?);
Ok(())
}
```
## 公共 API 入口
在作为库集成 schemaui 时,主要的入口包括:
- **高层运行入口**:`SchemaUI`、`DocumentInput`、`FrontendOptions`、
`ServeOptions`、`UiOptions`
- **TUI 运行时**:`crate::tui::session::TuiFrontend`(适合通过
`SchemaUI::run_with_frontend` 注入自定义前端)
- **TUI 状态**:`crate::tui::state::*`(例如
`FormState`、`FormCommand`、`FormEngine`、`SectionState` 等)
- **Schema 后端**:`crate::ui_ast::build_ui_ast` 配合
`crate::tui::model::form_schema_from_ui_ast`(先生成规范 UI AST,再派生
`FormSchema`)
## 架构快照
```
┌─────────────┐ 解析/合并 ┌──────────────┐ 布局+类型 ┌───────────────┐
│ io::input ├────────────────▶│ schema ├────────────────────▶│ tui::state │
└─────────────┘ │ (loader/ │ │ (FormState, │
│ resolver/ │ │ sections, │
┌─────────────┐ 输出值 │ build_form_ │ FormSchema │ reducers) │
│ io::output ◀─────────────────┴────pipeline──┘ └────────┬──────┘
└─────────────┘ 焦点/编辑 │
│
┌──────▼──────────┐
│ tui::app::runtime │
│ (输入路由器, │
│ 覆盖层, 状态) │
└──────┬──────────┘
│ 绘制
┌──────▼──────────┐
│ tui::view::* │
│ (ratatui 视图) │
└─────────────────┘
```
此布局反映了`src/`下的实际模块,便于将任何代码更改映射到其架构责任。
## 输入与输出设计
- `io::input::parse_document_str`将
JSON/YAML/TOML(通过`serde_json`、`serde_yaml`、`toml`)转换为`serde_json::Value`。功能标志(`json`、`yaml`、`toml`)不仅控制依赖项,也会真实控制`DocumentFormat`的解析与探测路径。
- `schema_from_data_value/str`从活动配置中推断模式,注入草稿 -07
元数据和默认值,以便 UI 加载现有值。
- `schema_with_defaults`将规范模式与用户数据合并,通过`properties`、`patternProperties`、`additionalProperties`、`dependencies`、`dependentSchemas`、数组和`$ref`目标传播默认值,而不修改原始树。
- `io::output::OutputOptions`封装了序列化格式、美观/紧凑切换以及`OutputDestination::{Stdout, File}`的向量。支持多个目标;冲突在输出前被捕获。
- `OutputOptions::render` 负责把最终的 `serde_json::Value` 渲染成 JSON/YAML/TOML
文本,`OutputOptions::write` 则在 `SchemaUI::run*` 返回后显式输出到 stdout /
文件。
## JSON Schema → TUI 映射
`build_ui_ast` 先把解析后的模式规范化为 UI AST,再由 `form_schema_from_ui_ast`
将每个子树映射为 `FormSection`/`FieldSchema`:
| 模式功能 | 结果控件 |
| ------------------------------------------------------------ | ------------------------------------------------------- |
| `type: string`, `integer`, `number` | 带有数值保护的内联文本编辑器 |
| `type: boolean` | 切换/复选框 |
| `enum` | 弹出选择器(单选或多选用于数组枚举) |
| 数组 | 内联列表摘要 + 每个项目的覆盖层编辑器 |
| `patternProperties`, `propertyNames`, `additionalProperties` | 带有模式支持验证的键值编辑器 |
| `$ref`, `definitions` | 在布局前解析;被视为内联模式 |
| `oneOf` / `anyOf` | 变体选择器 + 覆盖层表单,将非活动变体排除在最终负载之外 |
根对象生成标签;嵌套对象成为带有面包屑标题的部分。每个字段记录其 JSON
指针(例如`/runtime/http/port`),以便焦点管理和验证可以精确映射错误。
## 验证生命周期
- `jsonschema::validator_for`在`SchemaUI::run`开始时编译完整模式一次。
- 每次编辑都会触发`FormCommand::FieldEdited`。`FormEngine`通过`FormState::try_build_value`重建当前文档,运行验证器,并将错误反馈到`FieldState`或全局状态行。
- 覆盖层(复合变体、键值映射、列表条目)会根据当前正在编辑的子模式启动自己的验证器,因此问题会在离开覆盖层之前浮出水面。
```
┌─────────────┐ 解析模式 ┌───────────────────────────────┐ 膨胀状态 ┌────────────┐
│ SchemaUI::run├──────────▶│ form_schema_from_ui_ast ├───────────────▶│ FormState │
└─────┬───────┘ │ (tui::model::FormSchema) │ └──────┬─────┘
│ validator_for() └───────────────────────────────┘ 编辑 │
│ ┌──────▼─────────┐
└────────────────────────────────────────────────────── ▶│ app::runtime │
│ (状态, 输入) │
└──────┬─────────┘
│ FormCommand
┌──────▼──────────┐
│ FormEngine │
│ + jsonschema │
└─────────────────┘
```
`App`是`FormState`的唯一所有者;即使是覆盖层编辑也会通过`FormEngine`流动,以保持验证规则集中。
## TUI 构建块与快捷键
- **快捷键单一来源** –
`keymap/default.keymap.json`列出了每个快捷键(上下文、组合键、动作)。`app::keymap::keymap_source!()`宏将此文件拉入二进制文件中,`InputRouter`使用它对`KeyEvent`进行分类,运行时页脚从相同的数据中呈现帮助文本——保持文档和行为
DRY。
- **根标签与部分** –
焦点通过`Ctrl+J / Ctrl+L`(根)和`Ctrl+Tab / Ctrl+Shift+Tab`(部分)循环。普通`Tab`/`Shift+Tab`在各个字段之间移动。
- **字段** –
渲染标签、描述和内联错误消息。枚举/复合字段显示当前选择;数组总结长度和选定条目。
- **弹出窗口与覆盖层** – 按下`Enter`键打开枚举/oneOf
选择器的弹出窗口;`Ctrl+E`打开复合编辑器的全屏覆盖层。覆盖层暴露集合快捷键(`Ctrl+N`、`Ctrl+D`、`Ctrl+←/→`、`Ctrl+↑/↓`)以及`Ctrl+S`提交。
- **状态与帮助** –
页脚突出显示脏状态、未解决的验证错误和上下文感知帮助文本。当自动验证启用时,每次编辑都会立即更新这些计数器。
### 自动生成的快捷键参考
<!-- AUTO-GENERATED:SHORTCUTS:BEGIN -->
#### 默认上下文
| 快捷键 | 动作 | 类型 |
| ------------------- | ------------------------------------- | ------ |
| `Tab` / `Down` | 下一个字段 | `命令` |
| `BackTab` / `Up` | 上一个字段 | `命令` |
| `Ctrl+Tab` | 下一个分区 | `命令` |
| `Ctrl+Shift+Tab` | 上一个分区 | `命令` |
| `Ctrl+L` | 下一个根标签 | `命令` |
| `Ctrl+J` | 上一个根标签 | `命令` |
| `Enter` | 打开弹窗 / 应用选择 | `命令` |
| `Ctrl+E` | 打开复合编辑器 | `命令` |
| `Ctrl+S` | 保存并验证(覆盖层保持打开) | `命令` |
| `Ctrl+Q` / `Ctrl+C` | 退出(脏状态需确认) | `命令` |
| `Esc` | 取消 / 清除状态(覆盖层:弹出当前层) | `命令` |
| `Ctrl+?` / `Ctrl+H` | 显示帮助与错误摘要 | `命令` |
#### 集合上下文
| 快捷键 | 动作 | 类型 |
| ------------------- | ------------------ | ------ |
| `Ctrl+E` | 打开复合编辑器 | `命令` |
| `Ctrl+N` | 添加条目 | `命令` |
| `Ctrl+D` | 删除条目 | `命令` |
| `Ctrl+Left` | 选择上一个条目 | `命令` |
| `Ctrl+Right` | 选择下一个条目 | `命令` |
| `Ctrl+Up` | 条目上移 | `命令` |
| `Ctrl+Down` | 条目下移 | `命令` |
| `Ctrl+?` / `Ctrl+H` | 显示帮助与错误摘要 | `命令` |
#### 覆盖层上下文
| 快捷键 | 动作 | 类型 |
| ------------------- | ------------------------------------- | ------ |
| `Tab` / `Down` | 下一个字段 | `命令` |
| `BackTab` / `Up` | 上一个字段 | `命令` |
| `Ctrl+N` | 添加条目 | `命令` |
| `Ctrl+D` | 删除条目 | `命令` |
| `Ctrl+Left` | 选择上一个条目 | `命令` |
| `Ctrl+Right` | 选择下一个条目 | `命令` |
| `Ctrl+Up` | 条目上移 | `命令` |
| `Ctrl+Down` | 条目下移 | `命令` |
| `Ctrl+S` | 保存并验证(覆盖层保持打开) | `命令` |
| `Esc` | 取消 / 清除状态(覆盖层:弹出当前层) | `命令` |
| `Ctrl+?` / `Ctrl+H` | 显示帮助与错误摘要 | `命令` |
#### 帮助上下文
| 快捷键 | 动作 | 类型 |
| --------------------------- | -------------- | ------ |
| `Esc` / `Ctrl+H` / `Ctrl+?` | 关闭帮助 | `命令` |
| `Tab` | 下一页错误 | `命令` |
| `BackTab` | 上一页错误 | `命令` |
| `Up` / `k` | 快捷键向上滚动 | `命令` |
| `Down` / `j` | 快捷键向下滚动 | `命令` |
| `PageUp` | 快捷键上翻页 | `命令` |
| `PageDown` | 快捷键下翻页 | `命令` |
| `Home` | 快捷键跳到顶部 | `命令` |
| `End` | 快捷键跳到底部 | `命令` |
| `h` | 错误文本左滚 | `命令` |
| `l` | 错误文本右滚 | `命令` |
#### 文本字段上下文
| 快捷键 | 动作 | 类型 |
| ----------- | -------------- | ---------- |
| `Left` | 光标左移 | `局部编辑` |
| `Right` | 光标右移 | `局部编辑` |
| `Home` | 跳到行首 | `局部编辑` |
| `End` | 跳到行尾 | `局部编辑` |
| `Backspace` | 删除前一个字符 | `局部编辑` |
| `Delete` | 删除后一个字符 | `局部编辑` |
| `Ctrl+W` | 删除前一个单词 | `局部编辑` |
| `Ctrl+Z` | 撤销文本编辑 | `局部编辑` |
| `Ctrl+Y` | 重做文本编辑 | `局部编辑` |
#### 数值字段上下文
| 快捷键 | 动作 | 类型 |
| ------------- | -------------- | ---------- |
| `Left` | 数值减一步 | `局部编辑` |
| `Right` | 数值加一步 | `局部编辑` |
| `Shift+Left` | 数值快速减一步 | `局部编辑` |
| `Shift+Right` | 数值快速加一步 | `局部编辑` |
| `Backspace` | 删除前一个字符 | `局部编辑` |
| `Delete` | 删除后一个字符 | `局部编辑` |
| `Ctrl+Z` | 撤销数值编辑 | `局部编辑` |
| `Ctrl+Y` | 重做数值编辑 | `局部编辑` |
<!-- AUTO-GENERATED:SHORTCUTS:END -->
### 快捷键系统
将每个快捷键放入`keymap/default.keymap.json`中,以便运行时逻辑、帮助覆盖层和自动生成的
README 快捷键参考都使用单一信息源。
- **格式** – 每个 JSON
对象声明一个`id`、英文`description`、双语`descriptionZh`、`contexts`(任何`"default"`、`"collection"`、`"overlay"`、`"help"`、`"text"`、`"numeric"`),一个`action`区分联合类型以及文本`combos`列表。例如:
```json
{
"id": "list.move.up",
"description": "Move entry up",
"descriptionZh": "条目上移",
"contexts": ["collection", "overlay"],
"action": { "kind": "ListMove", "delta": -1 },
"combos": ["Ctrl+Up"]
}
```
- **宏 + 解析器** – `app::keymap::keymap_source!()` `include_str!`s
JSON,`once_cell::sync::Lazy`在启动时一次解析,并将每个组合键编译为`KeyPattern`(键码、所需修饰符、美观显示字符串)。
- **集成** –
`InputRouter::classify`委托给`keymap::classify_key`,该函数返回嵌入在 JSON
中的`KeyAction`。`keymap::help_text`根据`KeymapContext`过滤绑定,连接用于`StatusLine`和覆盖层说明的片段。
- **生成文档** – `build.rs`会解析 `keymap/default.keymap.json`,并通过显式 HTML
marker 刷新 `README.md` 与 `README.ZH.md` 中的快捷键块;因此常规 Cargo
构建就会让双语快捷键参考与运行时行为保持同步。
- **扩展** – 要添加快捷键,编辑
JSON,选择暴露帮助文本的上下文,并在引入新的语义命令时在`KeyBindingMap`中连接结果`KeyAction`。
## 运行时层
| 层 | 模块 (s) | 责任 |
| ------------ | ------------------------------------------------------------- | -------------------------------------------------------------- |
| 摄取 | `io::input`, `schema::loader`, `schema::resolver` | 解析 JSON/TOML/YAML,解析`$ref`,并规范化元数据。 |
| 布局类型 | `ui_ast::build_ui_ast`, `tui::model::form_schema_from_ui_ast` | 从规范 UI AST 生成 `FormSchema`(根/部分/字段)。 |
| 表单状态 | `tui::state::{form_state, section, field}` | 跟踪焦点、指针、脏标志、强制转换和错误。 |
| 命令与简化器 | `tui::state::{actions, reducers}`, `tui::app::validation` | 定义 `FormCommand`,突变状态,并路由验证结果。 |
| 运行时控制器 | `tui::app::{runtime, overlay, popup, status, keymap}` | 事件循环,输入路由器分发,覆盖层生命周期,帮助文本,状态更新。 |
| 呈现 | `tui::view` 和 `tui::view::components::*` | 通过 `ratatui` 呈现标签、字段列表、弹出窗口、覆盖层和页脚。 |
每个模块保持在约 600 行代码以下(硬上限 800),以尊重 KISS
原则并使重构易于管理。
## CLI (`schemaui-cli`)
<!-- AUTO-GENERATED:CLI-INSTALL:BEGIN -->
### 安装
安装后的实际可执行文件名始终是 `schemaui`,所以常规入口仍然是
`schemaui -c ./config.json`。
选择下面任意一种支持的分发方式:
#### Cargo(`cargo install`)
使用 Cargo 从 crates.io 编译安装。
```bash
cargo install schemaui-cli
```
#### Cargo binstall
通过 cargo-binstall 拉取预构建的 GitHub release 二进制。
```bash
cargo binstall schemaui-cli
```
#### Homebrew
在 macOS 或 Linux 上通过仓库 tap 安装。
```bash
brew install YuniqueUnic/schemaui/schemaui
```
#### Scoop
在 Windows 上通过仓库内维护的 Scoop manifest 安装。
```bash
scoop install https://raw.githubusercontent.com/YuniqueUnic/schemaui/main/packaging/scoop/schemaui-cli.json
```
#### 直接下载
从 `https://github.com/YuniqueUnic/schemaui/releases/latest`
下载对应平台压缩包,解压 `schemaui` / `schemaui.exe` 后放到 `PATH` 中。
#### winget manifests
使用 `packaging/winget` 中的 versioned manifests 配合
`winget install --manifest <dir>` 安装,或将其提交到社区仓库。
<!-- AUTO-GENERATED:CLI-INSTALL:END -->
```bash
schemaui \
--schema ./schema.json \
--config ./defaults.yaml \
-o - \
-o ./config.toml ./config.json
```
```
┌────────┐ clap args ┌──────────────┐ read stdin/files ┌─────────────┐
│ CLI ├─────────────▶│ InputSource ├─────────────────▶│ io::input │
└────┬───┘ └──────┬───────┘ └────┬────────┘
│ diagnostics │ schema/default Value │
┌────▼─────────┐ ┌──────▼──────┐ |
│Diagnostic │◀───────┤ FormatHint │ │
│Collector │ └──────┬──────┘ │
└────┬─────────┘ │ pass if clean │
│ │ │
┌────▼────────┐ build options └────────────┐ │
│Output logic ├────────────────────────────▶│ OutputOptions │
└────┬────────┘ └────────────┬─────┘
│ SchemaUI::new / with_* ┌───▼────────┐
└──────────────────────────────────────────────▶│ SchemaUI │
│ (library) │
└────────────┘
```
- 输入 – `--schema` /
`--config`接受文件路径、内联有效载荷或`-`用于标准输入(但不能同时使用两者)。如果只提供配置,CLI
通过`schema_from_data_value`推断模式。
- 诊断 –
`DiagnosticCollector`累积格式问题、功能标志不匹配、标准输入冲突和现有输出文件,以执行前的诊断。
- 输出 –
`-o/--output`可重复使用,并且可以混合文件路径与`-`用于标准输出。当未设置目标时,工具直接写到
stdout;如果你明确想走回退文件,可以传`--temp-file <PATH>`。扩展名决定格式;拒绝冲突的扩展名。
- 标志 – `--no-pretty`切换紧凑输出,`--force/--yes`允许覆盖文件,`--title` /
`--description`分别传递到`SchemaUI::with_title` /
`SchemaUI::with_description`。
- Shell completion – `schemaui completion <bash|zsh|fish|powershell>` 会通过
`clap_complete` 从同一套 `clap` 命令树生成补全脚本。
## 关键依赖项
| 库 | 用途 |
| ------------------------------------------- | ----------------------------------------- |
| `serde`, `serde_json`, `serde_yaml`, `toml` | 解析和序列化模式/配置数据。 |
| `schemars` | draft-07 模式表示,由 `schema` 模块使用。 |
| `jsonschema` | 表单和覆盖层的运行时验证。 |
| `ratatui` | 渲染小部件、布局、覆盖层和页脚。 |
| `crossterm` | 输入路由器消耗的终端事件。 |
| `indexmap` | 模式遍历的顺序保持映射。 |
| `once_cell` | 懒解析快捷键 JSON。 |
| `clap`, `clap_complete`, `anyhow` (CLI) | 参数解析、Shell 补全生成和友好的诊断。 |
## 文档映射
- `README.md` – 概述 + 架构快照(英文版本为事实来源)。
- `README.ZH.md` – 本中文概览文档(尽量与英文保持同步)。
- `docs/en/structure_design.md` – 英文版模式/布局/运行时设计,带有流程图。
- `docs/zh/structure_design.md` – 本中文架构指南,与英文版对应。
- `docs/en/cli_usage.md` – 英文 CLI 使用手册(输入、输出、管道、示例)。
- `docs/zh/cli_usage.zh.md` – 中文 CLI 使用手册,与英文版对应。
## 开发
- 定期运行`cargo fmt && cargo test`;大多数模块通过`include!`嵌入`tests/`中的文件,以覆盖私有
API。
- 将模块保持在约 600 行代码以下(硬上限
800)。一旦行为增长,就拆分帮助程序,以保持 KISS 完整。
- 优先使用成熟的库(`serde_*`、`schemars`、`jsonschema`、`ratatui`、`crossterm`、`once_cell`),除非更改微不足道,否则不要编写定制代码。
- 每当管道、快捷键或 CLI 语义演变时,更新`docs/*`,以确保面向用户文档的真实性。
## 参考项目
1. https://github.com/rjsf-team/react-jsonschema-form
2. https://ui-schema.bemit.codes/examples
## 路线图
- [x] 在运行时解析 JSON Schema 并生成 TUI
- [x] 在运行时解析 JSON Schema 并生成 Web UI
- [x] 在编译时解析 JSON Schema,然后生成 TUI 代码,为运行时暴露必要的 API
- [x] 在编译时解析 JSON Schema,然后生成 Web UI 代码,为运行时暴露必要的 API
- [ ] 在运行时解析 JSON Schema 并生成交互式 CLI
- [ ] 在编译时解析 JSON Schema,然后生成交互式 CLI 代码,为运行时暴露必要的 API
## 许可证
根据您的选择,本项目可在以下许可证下授权:
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) 或
http://www.apache.org/licenses/LICENSE-2.0 )
- MIT license ([LICENSE-MIT](LICENSE-MIT) 或 http://opensource.org/licenses/MIT
)
### 贡献
欢迎贡献!请随时提交拉取请求。
祝您编程愉快!
## Star 历史
<a href="https://www.star-history.com/#YuniqueUnic/schemaui&type=date&legend=top-left">
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="
https://api.star-history.com/svg?repos=YuniqueUnic/schemaui&type=date&legend=top-left&theme=dark
"
/>
<source
media="(prefers-color-scheme: light)"
srcset="
https://api.star-history.com/svg?repos=YuniqueUnic/schemaui&type=date&legend=top-left
"
/>
<img
alt="Star History Chart"
src="https://api.star-history.com/svg?repos=YuniqueUnic/schemaui&type=date&legend=top-left"
/>
</picture>
</a>
+668
View File
@@ -0,0 +1,668 @@
<div align="center">
<a href="https://signature4u.vercel.app/schemaui?font=satisfy&fontSize=153&speed=2.8&charSpacing=0&borderRadius=0&cardPadding=24&fill=multi&fill1=001bb7&fill2=ec4899&stroke=001bb7&stroke2=ec4899&strokeMode=multi&strokeEnabled=1&bg=transparent&bgMode=solid&bg2=1e3a8a&texture=cross&texColor=566486&texSize=30&texThickness=1&texOpacity=0.4&colors=001bb7-001bb7-001bb7-001bb7-001bb7-001bb7-ff8040-fcb53b&linkFillStroke=1" target="_blank">
<img src="https://signature4u.vercel.app/api/sign?text=schemaui&font=satisfy&fontSize=153&speed=2.8&charSpacing=0&borderRadius=0&cardPadding=24&fill=multi&fill1=001bb7&fill2=ec4899&stroke=001bb7&stroke2=ec4899&strokeMode=multi&strokeEnabled=1&bg=transparent&bgMode=solid&bg2=1e3a8a&texture=cross&texColor=566486&texSize=30&texThickness=1&texOpacity=0.4&colors=001bb7-001bb7-001bb7-001bb7-001bb7-001bb7-ff8040-fcb53b&linkFillStroke=1" align="center" alt="schemaui signature"/>
</a>
</div>
[![Crates.io](https://img.shields.io/crates/v/schemaui.svg)](https://crates.io/crates/schemaui)
[![Documentation](https://docs.rs/schemaui/badge.svg)](https://docs.rs/schemaui)
[![License](https://img.shields.io/crates/l/schemaui)](https://github.com/yuniqueunic/schemaui#license)
![Crates.io Total Downloads](https://img.shields.io/crates/d/schemaui)
<!-- ![Deps.rs Crate Dependencies (latest)](https://img.shields.io/deps-rs/schemaui/latest) -->
<div align="center">
<a href="https://asciinema.org/a/7IBbhRJAUBlIQaPWSrspEgZtE" target="_blank">
<img src="https://asciinema.org/a/7IBbhRJAUBlIQaPWSrspEgZtE.svg" width="500" />
</a>
[English](./README.md) | [中文文档](./README.ZH.md)
</div>
`schemaui` turns JSON Schema documents into fully interactive terminal UIs
powered by `ratatui`, `crossterm`, and `jsonschema`.
The library parses rich schemas (nested sections, `$ref`, arrays, key/value
maps, pattern properties…) into a navigable form tree, renders it as a
keyboard-first editor, and validates the result after every edit so users always
see the full list of issues before saving.
<!-- AUTO-GENERATED:CLI-QUICKLINK:BEGIN -->
> CLI available: `schemaui-cli` installs the `schemaui` binary. Prefer the CLI?
> Jump to [CLI installation and usage](#cli-schemaui-cli).
<!-- AUTO-GENERATED:CLI-QUICKLINK:END -->
## Feature Highlights
- **Schema fidelity** – draft-07 compatible, including `$ref`, `definitions`,
`patternProperties`, enums, numeric ranges, and nested objects/arrays.
- **Sections & overlays** – top-level properties become root tabs, nested
objects are flattened into sections, and complex nodes (composites, key/value
collections, array entries) open dedicated overlays with their own validators.
- **Immediate validation** – every keystroke can trigger
`jsonschema::Validator`, and all errors (field-scoped + global) are collected
and displayed together.
- **Pluggable I/O** – `io::input` ingests JSON/YAML/TOML (feature-gated) while
`io::output` can emit to stdout and/or multiple files in any enabled format.
- **Batteries-included CLI** – `schemaui-cli` offers the same pipeline as the
library, including multi-destination output, stdin/inline specs, and
aggregated diagnostics.
- **Embedded Web UI** – enabling the `web` feature bundles a browser UI and
exposes helpers under `schemaui::web::session` so host applications can serve
the experience without reimplementing the stack.
## Config Schema Auto-Detection
When you launch the CLI with `--config` and omit `--schema`, `schemaui-cli` now
resolves the schema in this order:
1. Explicit `--schema`
2. A schema declaration embedded in the config document
3. Fallback inference via `schema_from_data_value`
Supported declarations:
- **JSON**: root `$schema`
- **TOML**: `#:schema ./schema.json`
- **YAML**: `# yaml-language-server: $schema=...`
- **YAML fallback**: `# @schema ...`
Both local and remote schema references are supported. Relative local paths are
resolved against the config file directory, while inline/stdin configs fall back
to the current working directory. JSON `$schema` metadata is stripped from the
in-memory defaults before validation/output so editor hints do not leak into the
final config payload.
Remote `http(s)` schema loading exists only in `schemaui-cli`, and the CLI
enables the `remote-schema` feature by default. Disable it if you want a
local-only binary surface; the `schemaui` library crate does not enable any
remote schema loading by default.
Feature defaults are intentionally split by audience:
- `schemaui-cli` defaults to the convenient, batteries-included path: TUI +
Web + remote schema loading.
- `schemaui` defaults to `tui + json`, so library consumers keep JSON support
without pulling in Web or remote/network-related surface area by default.
- `json`, `yaml`, and `toml` are real code-level gates. Keep at least one of
them enabled; disabling all three triggers a clear compile-time error.
References:
- JSON Schema `$schema`:
<https://json-schema.org/understanding-json-schema/reference/schema>
- Taplo directives (`#:schema`):
<https://taplo.tamasfe.dev/configuration/directives.html>
- YAML language server modeline:
<https://github.com/redhat-developer/yaml-language-server>
## Quick Start
```toml
[dependencies]
schemaui = "0.12.0"
serde_json = "1"
```
```rust,ignore
use anyhow::Result;
use schemaui::prelude::*;
use serde_json::json;
fn main() -> Result<()> {
let schema = json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Service Runtime",
"type": "object",
"properties": {
"metadata": {
"type": "object",
"properties": {
"serviceName": {"type": "string"},
"environment": {
"type": "string",
"enum": ["dev", "staging", "prod"]
}
},
"required": ["serviceName"]
},
"runtime": {
"type": "object",
"properties": {
"http": {
"type": "object",
"properties": {
"host": {"type": "string", "default": "0.0.0.0"},
"port": {"type": "integer", "minimum": 1024, "maximum": 65535}
}
}
}
}
},
"required": ["metadata", "runtime"]
});
let config = serde_json::json!({
"metadata": { "name": "demo-service" },
"runtime": { "http": { "host": "127.0.0.1", "port": 8080 } }
});
let value = SchemaUI::new(config)
.with_schema(schema)
.with_title("SchemaUI Demo")
.with_description("Edit an existing config against a validation schema")
.run(FrontendOptions::Tui(UiOptions::default()))?;
println!("{}", serde_json::to_string_pretty(&value)?);
Ok(())
}
```
## Public API surface
For library integrations, the main entry points are:
- **High-level runtime**: `SchemaUI`, `DocumentInput`, `FrontendOptions`,
`ServeOptions`, and `UiOptions`
- **TUI runtime**: `crate::tui::session::TuiFrontend` for custom frontend
injection via `SchemaUI::run_with_frontend`
- **TUI state**: `crate::tui::state::*` (for example `FormState`, `FormCommand`,
`FormEngine`, `SectionState`)
- **Schema backend**: `crate::ui_ast::build_ui_ast` together with
`crate::tui::model::form_schema_from_ui_ast` (builds `FormSchema` from the
canonical UI AST)
## Architecture Snapshot
```text
┌─────────────┐ parse/merge ┌───────────────┐ layout + typing ┌───────────────┐
│ io::input ├─────────────────▶│ schema ├───────────────────────▶│ tui::state │
└─────────────┘ │ (loader / │ │ (FormState, │
│ resolver / │ │ sections, │
┌─────────────┐ emit Value │ build_form_ │ FormSchema │ reducers) │
│ io::output ◀──────────────────┴────pipeline───┘ └────────┬──────┘
└─────────────┘ focus/edits│
│
┌──────────▼──────────┐
│ tui::app::runtime │
│ (InputRouter, │
│ overlays, status) │
└──────────┬──────────┘
│ draw
┌──────────▼──────────┐
│ tui::view::* │
│ (ratatui view) │
└─────────────────────┘
```
This layout mirrors the actual modules under `src/`, making it easy to map any
code change to its architectural responsibility.
## Input & Output Design
- `io::input::parse_document_str` converts JSON/YAML/TOML (via `serde_json`,
`serde_yaml`, `toml`) into `serde_json::Value`. Feature flags (`json`, `yaml`,
`toml`) keep dependencies lean, and the same gates also drive `DocumentFormat`
parsing/probing at compile time.
- `schema_from_data_value/str` infers schemas from live configs, injecting
draft-07 metadata and defaults so UIs load pre-existing values.
- `schema_with_defaults` merges canonical schemas with user data, propagating
defaults through `properties`, `patternProperties`, `additionalProperties`,
`dependencies`, `dependentSchemas`, arrays, and `$ref` targets without
mutating the original tree.
- `io::output::OutputOptions` encapsulates serialization format, pretty/compact
toggle, and a vector of `OutputDestination::{Stdout, File}`. Multiple
destinations are supported; conflicts are caught before emission.
- `OutputOptions::render` turns the final `serde_json::Value` into
JSON/YAML/TOML text, and `OutputOptions::write` sends that payload to
stdout/files explicitly after `SchemaUI::run*` returns.
## Web UI Mode
The optional `web` feature bundles the files under `web/dist/` directly into the
crate and exposes high-level helpers for hosting the browser UI. Basic usage:
```rust,no_run
use schemaui::web::session::{
ServeOptions,
WebSessionBuilder,
bind_session,
};
async fn run() -> anyhow::Result<()> {
let schema = serde_json::json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"host": {"type": "string", "default": "127.0.0.1"},
"port": {"type": "integer", "default": 8080}
},
"required": ["host", "port"]
});
let config = WebSessionBuilder::new(schema)
.with_title("Service Config")
.build()?;
let session = bind_session(config, ServeOptions::default()).await?;
println!("visit http://{}/", session.local_addr());
let value = session.run().await?;
println!("final JSON: {}", serde_json::to_string_pretty(&value)?);
Ok(())
}
```
The helper spawns an Axum router that exposes `/api/session`, `/api/save`, and
`/api/exit` alongside the embedded static assets. Library users can either call
`bind_session`/`serve_session` for a turnkey flow or reuse
`session_router/WebSessionBuilder` to integrate the UI into an existing HTTP
stack. The official CLI (`schemaui-cli web …`) is merely a thin wrapper around
these APIs.
## JSON Schema → TUI Mapping
`build_ui_ast` resolves the schema into the canonical UI AST, and
`form_schema_from_ui_ast` maps that tree into `FormSection`/`FieldSchema` for
the TUI runtime:
| Schema feature | Resulting control |
| ------------------------------------------------------------ | -------------------------------------------------------------------------------- |
| `type: string`, `integer`, `number` | Inline text editors with numeric guards |
| `type: boolean` | Toggle/checkbox |
| `enum` | Popup selector (single or multi-select for array enums) |
| Arrays | Inline list summary + overlay editor per item |
| `patternProperties`, `propertyNames`, `additionalProperties` | Key/Value editor with schema-backed validation |
| `$ref`, `definitions` | Resolved before layout; treated like inline schemas |
| `oneOf` / `anyOf` | Variant chooser + overlay form, keeps inactive variants out of the final payload |
Root objects spawn tabs; nested objects become sections with breadcrumb titles.
Every field records its JSON pointer (for example `/runtime/http/port`) so focus
management and validation can map errors back precisely.
## Validation Lifecycle
- `jsonschema::validator_for` compiles the complete schema once when
`SchemaUI::run` begins.
- Each edit dispatches `FormCommand::FieldEdited`. `FormEngine` rebuilds the
current document via `FormState::try_build_value`, runs the validator, and
feeds errors back into `FieldState` or the global status line.
- Overlays (composite variants, key/value maps, list entries) spin up their own
validators built from the sub-schema currently being edited. Nested overlays
live on a stack, so each level validates in place before changes flow back to
the parent form.
```text
┌─────────────┐ parse schema ┌─────────────────┐ inflate state ┌────────────┐
│ SchemaUI::run├────────────▶│ domain::parse ├───────────────▶│ FormState │
└─────┬───────┘ │ (schema::layout)│ └─────┬──────┘
│ validator_for() └─────────────────┘ edits │
│ ┌──────▼─────────┐
└────────────────────────────────────────────────────── ▶│ app::runtime │
│ (status, input)│
└──────┬─────────┘
│ FormCommand
┌──────▼──────────┐
│ FormEngine │
│ + jsonschema │
└─────────────────┘
```
`App` is the sole owner of `FormState`; even overlay edits flow through
`FormEngine` so validation rules stay centralized.
## TUI Building Blocks & Shortcuts
- **Single source for shortcuts** – `keymap/default.keymap.json` lists every
shortcut (context, combos, action). The `app::keymap::keymap_source!()` macro
pulls this file into the binary, `InputRouter` uses it to classify
`KeyEvent`s, and the runtime footer renders help text from the same
data—keeping docs and behavior DRY.
- **Root tabs & sections** – focus cycles with `Ctrl+J / Ctrl+L` (roots) and
`Ctrl+Tab / Ctrl+Shift+Tab` (sections). Ordinary `Tab`/`Shift+Tab` walk
individual fields.
- **Fields** – render labels, descriptions, and inline error messages.
Enum/composite fields show the current selection; arrays summarize length and
selected entry.
- **Popups & overlays** – pressing `Enter` opens a popup for enums/oneOf
selectors; `Ctrl+E` pushes a full-screen overlay editor for composites,
key/value pairs, and array items. Overlays expose collection shortcuts
(`Ctrl+N`, `Ctrl+D`, `Ctrl+←/→`, `Ctrl+↑/↓`), `Ctrl+S` saves the active level
without closing, and `Esc` / `Ctrl+Q` pops a single overlay.
- **Status & help** – the footer highlights dirty state, outstanding validation
errors, and context-aware help text. When auto-validate is enabled, each edit
updates these counters immediately.
### Generated shortcut reference
<!-- AUTO-GENERATED:SHORTCUTS:BEGIN -->
#### Default context
| Shortcut | Action | Kind |
| ------------------- | --------------------------------------------------- | --------- |
| `Tab` / `Down` | Next field | `command` |
| `BackTab` / `Up` | Previous field | `command` |
| `Ctrl+Tab` | Next section | `command` |
| `Ctrl+Shift+Tab` | Previous section | `command` |
| `Ctrl+L` | Next root tab | `command` |
| `Ctrl+J` | Previous root tab | `command` |
| `Enter` | Open popup / apply selection | `command` |
| `Ctrl+E` | Open composite editor | `command` |
| `Ctrl+S` | Save & validate (overlays stay open) | `command` |
| `Ctrl+Q` / `Ctrl+C` | Quit (confirm if dirty) | `command` |
| `Esc` | Cancel / clear status (overlays: pop current level) | `command` |
| `Ctrl+?` / `Ctrl+H` | Show help and error summary | `command` |
#### Collection context
| Shortcut | Action | Kind |
| ------------------- | --------------------------- | --------- |
| `Ctrl+E` | Open composite editor | `command` |
| `Ctrl+N` | Add entry | `command` |
| `Ctrl+D` | Remove entry | `command` |
| `Ctrl+Left` | Select previous entry | `command` |
| `Ctrl+Right` | Select next entry | `command` |
| `Ctrl+Up` | Move entry up | `command` |
| `Ctrl+Down` | Move entry down | `command` |
| `Ctrl+?` / `Ctrl+H` | Show help and error summary | `command` |
#### Overlay context
| Shortcut | Action | Kind |
| ------------------- | --------------------------------------------------- | --------- |
| `Tab` / `Down` | Next field | `command` |
| `BackTab` / `Up` | Previous field | `command` |
| `Ctrl+N` | Add entry | `command` |
| `Ctrl+D` | Remove entry | `command` |
| `Ctrl+Left` | Select previous entry | `command` |
| `Ctrl+Right` | Select next entry | `command` |
| `Ctrl+Up` | Move entry up | `command` |
| `Ctrl+Down` | Move entry down | `command` |
| `Ctrl+S` | Save & validate (overlays stay open) | `command` |
| `Esc` | Cancel / clear status (overlays: pop current level) | `command` |
| `Ctrl+?` / `Ctrl+H` | Show help and error summary | `command` |
#### Help context
| Shortcut | Action | Kind |
| --------------------------- | ------------------------ | --------- |
| `Esc` / `Ctrl+H` / `Ctrl+?` | Close help | `command` |
| `Tab` | Next error page | `command` |
| `BackTab` | Previous error page | `command` |
| `Up` / `k` | Scroll shortcuts up | `command` |
| `Down` / `j` | Scroll shortcuts down | `command` |
| `PageUp` | Page shortcuts up | `command` |
| `PageDown` | Page shortcuts down | `command` |
| `Home` | Jump shortcuts to top | `command` |
| `End` | Jump shortcuts to bottom | `command` |
| `h` | Scroll error text left | `command` |
| `l` | Scroll error text right | `command` |
#### Text field context
| Shortcut | Action | Kind |
| ----------- | ------------------------- | ------------ |
| `Left` | Move cursor left | `local edit` |
| `Right` | Move cursor right | `local edit` |
| `Home` | Jump to line start | `local edit` |
| `End` | Jump to line end | `local edit` |
| `Backspace` | Delete previous character | `local edit` |
| `Delete` | Delete next character | `local edit` |
| `Ctrl+W` | Delete previous word | `local edit` |
| `Ctrl+Z` | Undo text edit | `local edit` |
| `Ctrl+Y` | Redo text edit | `local edit` |
#### Numeric field context
| Shortcut | Action | Kind |
| ------------- | ------------------------- | ------------ |
| `Left` | Step value down | `local edit` |
| `Right` | Step value up | `local edit` |
| `Shift+Left` | Fast step value down | `local edit` |
| `Shift+Right` | Fast step value up | `local edit` |
| `Backspace` | Delete previous character | `local edit` |
| `Delete` | Delete next character | `local edit` |
| `Ctrl+Z` | Undo numeric edit | `local edit` |
| `Ctrl+Y` | Redo numeric edit | `local edit` |
<!-- AUTO-GENERATED:SHORTCUTS:END -->
### Keymap system
Put every shortcut into `keymap/default.keymap.json`, so runtime logic, help
overlays, and generated README shortcut references all consume a single source
of truth.
- **Format** – each JSON object declares an `id`, human-readable `description`,
bilingual `descriptionZh`, `contexts` (any of `"default"`, `"collection"`,
`"overlay"`, `"help"`, `"text"`, `"numeric"`), an `action` discriminated
union, and a list of textual `combos`. For example:
```json
{
"id": "list.move.up",
"description": "Move entry up",
"descriptionZh": "条目上移",
"contexts": ["collection", "overlay"],
"action": { "kind": "ListMove", "delta": -1 },
"combos": ["Ctrl+Up"]
}
```
- **Macro + parser** – `app::keymap::keymap_source!()` `include_str!`s the JSON,
`once_cell::sync::Lazy` parses it once at startup, and each combo is compiled
into a `KeyPattern` (key code, required modifiers, pretty display string).
- **Integration** – `InputRouter::classify` delegates to `keymap::classify_key`,
which returns the `KeyAction` embedded in the JSON. `keymap::help_text`
filters bindings by `KeymapContext`, concatenating snippets used by
`StatusLine` and overlay instructions.
- **Generated docs** – `build.rs` parses `keymap/default.keymap.json` and
refreshes the shortcut blocks in `README.md` and `README.ZH.md` using explicit
HTML markers, so normal Cargo builds keep the bilingual reference in sync with
runtime behavior.
- **Extending** – to add a shortcut, edit the JSON, choose the contexts that
should expose the help text, and wire the resulting `KeyAction` inside
`KeyBindingMap` if a new semantic command is introduced.
## Runtime Layers
| Layer | Module(s) | Responsibilities |
| ------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Ingestion | `io::input`, `schema::loader`, `schema::resolver` | Parse JSON/TOML/YAML, resolve `$ref`, and normalize metadata. |
| Layout typing | `ui_ast::build_ui_ast`, `tui::model::form_schema_from_ui_ast` | Produce `FormSchema` (roots/sections/fields) from the canonical UI AST. |
| Form state | `tui::state::{form_state, section, field}` | Track focus, pointers, dirty flags, coercions, and errors. |
| Commands & reducers | `tui::state::{actions, reducers}`, `tui::app::validation` | Define `FormCommand`, mutate state, and route validation results. |
| Runtime controller | `tui::app::{runtime, overlay, popup, status, keymap}` | Event loop, InputRouter dispatch, overlay lifecycle, help text, status updates. |
| Presentation | `tui::view` and `tui::view::components::*` | Render tabs, field lists, popups, overlays, and footer via `ratatui`. |
Each module is kept under ~600 LOC (hard cap 800) to honor the KISS principle
and make refactors manageable.
## CLI (`schemaui-cli`)
<!-- AUTO-GENERATED:CLI-INSTALL:BEGIN -->
### Install
The installed binary is always named `schemaui`, so the normal entry point is
`schemaui -c ./config.json`.
Choose one of the supported channels:
#### Cargo (`cargo install`)
Build from crates.io with Cargo.
```bash
cargo install schemaui-cli
```
#### Cargo binstall
Fetch prebuilt GitHub release binaries through cargo-binstall.
```bash
cargo binstall schemaui-cli
```
#### Homebrew
Install from the repository tap on macOS or Linux.
```bash
brew install YuniqueUnic/schemaui/schemaui
```
#### Scoop
Install on Windows from the repository-hosted Scoop manifest.
```bash
scoop install https://raw.githubusercontent.com/YuniqueUnic/schemaui/main/packaging/scoop/schemaui-cli.json
```
#### Direct download
Download the matching archive from
`https://github.com/YuniqueUnic/schemaui/releases/latest`, extract `schemaui` /
`schemaui.exe`, and place it on your `PATH`.
#### winget manifests
Use the versioned manifests in `packaging/winget` with
`winget install --manifest <dir>`, or submit them upstream to the community
repository.
<!-- AUTO-GENERATED:CLI-INSTALL:END -->
```bash
schemaui \
--schema ./schema.json \
--config ./defaults.yaml \
-o - \
-o ./config.toml ./config.json
```
```text
┌────────┐ clap args ┌──────────────┐ read stdin/files ┌─────────────┐
│ CLI ├─────────────▶│ InputSource ├─────────────────▶│ io::input │
└────┬───┘ └──────┬───────┘ └────┬────────┘
│ diagnostics │ schema/default Value │
┌────▼─────────┐ ┌──────▼──────┐ |
│Diagnostic │◀───────┤ FormatHint │ │
│Collector │ └──────┬──────┘ │
└────┬─────────┘ │ pass if clean │
│ │ │
┌────▼────────┐ build options └────────────┐ │
│Output logic ├────────────────────────────▶│ OutputOptions │
└────┬────────┘ └────────────┬─────┘
│ SchemaUI::new / with_* ┌───▼────────┐
└──────────────────────────────────────────────▶│ SchemaUI │
│ (library) │
└────────────┘
```
- Inputs – `--schema` / `--config` accept file paths, inline payloads, or `-`
for stdin (but not both simultaneously). If only config is provided the CLI
infers a schema via `schema_from_data_value`.
- Diagnostics – `DiagnosticCollector` accumulates format issues, feature flag
mismatches, stdin conflicts, and existing output files before execution.
- Outputs – `-o/--output` is repeatable and may mix file paths with `-` for
stdout. When no destination is set, the tool writes to stdout; pass
`--temp-file <PATH>` if you explicitly want a fallback file. Extensions
dictate formats; conflicting extensions are rejected.
- Flags – `--no-pretty` toggles compact output, `--force/--yes` allows
overwriting files, and `--title` / `--description` wire through to
`SchemaUI::with_title` / `SchemaUI::with_description`.
- Shell completion – `schemaui completion <bash|zsh|fish|powershell>` emits
completion scripts from the same `clap` command graph via `clap_complete`.
## Key Dependencies
| Crate | Purpose |
| ------------------------------------------- | -------------------------------------------------------------- |
| `serde`, `serde_json`, `serde_yaml`, `toml` | Parsing and serializing schema/config data. |
| `schemars` | Draft-07 schema representation used by the `schema` module. |
| `jsonschema` | Runtime validation for forms and overlays. |
| `ratatui` | Rendering widgets, layouts, overlays, and footer. |
| `crossterm` | Terminal events consumed by `InputRouter`. |
| `indexmap` | Order-preserving maps for schema traversal. |
| `once_cell` | Lazy parsing of the keymap JSON. |
| `clap`, `clap_complete`, `anyhow` (CLI) | Argument parsing, shell completion, and ergonomic diagnostics. |
## Documentation Map
- `README.md` – overview + architecture snapshot (source of truth).
- `README.ZH.md` – Chinese overview kept in sync with this README.
- `docs/en/structure_design.md` – detailed schema/layout/runtime design with
flow diagrams.
- `docs/zh/structure_design.md` – Chinese mirror of the architecture guide.
- `docs/en/cli_usage.md` – CLI-specific manual (inputs, outputs, piping,
samples).
- `docs/zh/cli_usage.zh.md` – Chinese mirror of the CLI usage guide.
## Development
- Run `cargo fmt && cargo test` regularly; most modules embed their tests by
`include!`ing files from `tests/` so private APIs stay covered.
- Keep modules below ~600 LOC (hard cap 800). Split helpers as soon as behavior
grows to keep KISS intact.
- Prefer mature crates (`serde_*`, `schemars`, `jsonschema`, `ratatui`,
`crossterm`) over bespoke code unless the change is trivial.
- Update `docs/*` whenever pipelines, shortcuts, or CLI semantics evolve so
user-facing documentation stays truthful.
## References
1. https://github.com/rjsf-team/react-jsonschema-form
2. https://ui-schema.bemit.codes/examples
## Roadmap
- [x] parse json schema at runtime and generate a TUI
- [x] parse json schema at runtime and generate a Web UI
- [x] parse json schema at compile time Then generate the code for TUI, expose
necessary APIs for runtime.
- [x] parse json schema at compile time Then generate the code for Web UI,
expose necessary APIs for runtime.
- [ ] parse json schema at runtime and generate a Interactive CLI
- [ ] parse json schema at compile time Then generate the code for Interactive
CLI, expose necessary APIs for runtime.
Licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
http://www.apache.org/licenses/LICENSE-2.0)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.
### Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Happy hacking!
## Star History
<a href="https://www.star-history.com/#YuniqueUnic/schemaui&type=date&legend=top-left">
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="
https://api.star-history.com/svg?repos=YuniqueUnic/schemaui&type=date&legend=top-left&theme=dark
"
/>
<source
media="(prefers-color-scheme: light)"
srcset="
https://api.star-history.com/svg?repos=YuniqueUnic/schemaui&type=date&legend=top-left
"
/>
<img
alt="Star History Chart"
src="https://api.star-history.com/svg?repos=YuniqueUnic/schemaui&type=date&legend=top-left"
/>
</picture>
</a>
+280
View File
@@ -0,0 +1,280 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use unicode_width::UnicodeWidthStr;
const SHORTCUTS_BEGIN: &str = "<!-- AUTO-GENERATED:SHORTCUTS:BEGIN -->";
const SHORTCUTS_END: &str = "<!-- AUTO-GENERATED:SHORTCUTS:END -->";
const CONTEXT_ORDER: [&str; 6] = [
"default",
"collection",
"overlay",
"help",
"text",
"numeric",
];
fn default_dispatch() -> bool {
true
}
#[derive(Debug, Deserialize)]
struct KeymapDocEntry {
id: String,
description: String,
#[serde(rename = "descriptionZh")]
description_zh: Option<String>,
contexts: Vec<String>,
#[serde(default = "default_dispatch")]
dispatch: bool,
combos: Vec<String>,
}
#[derive(Clone, Copy)]
enum Locale {
En,
Zh,
}
impl Locale {
fn table_headers(self) -> (&'static str, &'static str, &'static str) {
match self {
Locale::En => ("Shortcut", "Action", "Kind"),
Locale::Zh => ("快捷键", "动作", "类型"),
}
}
fn context_heading(self, context: &str) -> &'static str {
match (self, context) {
(Locale::En, "default") => "Default context",
(Locale::En, "collection") => "Collection context",
(Locale::En, "overlay") => "Overlay context",
(Locale::En, "help") => "Help context",
(Locale::En, "text") => "Text field context",
(Locale::En, "numeric") => "Numeric field context",
(Locale::Zh, "default") => "默认上下文",
(Locale::Zh, "collection") => "集合上下文",
(Locale::Zh, "overlay") => "覆盖层上下文",
(Locale::Zh, "help") => "帮助上下文",
(Locale::Zh, "text") => "文本字段上下文",
(Locale::Zh, "numeric") => "数值字段上下文",
_ => "Unknown context",
}
}
fn description(self, entry: &KeymapDocEntry) -> &str {
match self {
Locale::En => &entry.description,
Locale::Zh => entry
.description_zh
.as_deref()
.expect("missing descriptionZh while rendering zh docs"),
}
}
fn kind(self, dispatch: bool) -> &'static str {
match (self, dispatch) {
(Locale::En, true) => "command",
(Locale::En, false) => "local edit",
(Locale::Zh, true) => "命令",
(Locale::Zh, false) => "局部编辑",
}
}
}
fn main() {
for path in [
"build.rs",
"README.md",
"README.ZH.md",
"keymap/default.keymap.json",
] {
println!("cargo:rerun-if-changed={path}");
}
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is missing"));
if let Err(error) = sync_shortcut_docs(&manifest_dir) {
panic!("failed to sync README shortcut docs: {error}");
}
}
fn sync_shortcut_docs(root: &Path) -> Result<(), String> {
let keymap_path = root.join("keymap/default.keymap.json");
let keymap_text =
fs::read_to_string(&keymap_path).map_err(|err| format!("read {keymap_path:?}: {err}"))?;
let entries: Vec<KeymapDocEntry> =
serde_json::from_str(&keymap_text).map_err(|err| format!("parse keymap JSON: {err}"))?;
validate_entries(&entries)?;
update_marked_block(
&root.join("README.md"),
&render_shortcut_block(&entries, Locale::En),
)?;
update_marked_block(
&root.join("README.ZH.md"),
&render_shortcut_block(&entries, Locale::Zh),
)?;
Ok(())
}
fn validate_entries(entries: &[KeymapDocEntry]) -> Result<(), String> {
for entry in entries {
if entry.combos.is_empty() {
return Err(format!(
"keymap entry {} must declare at least one combo",
entry.id
));
}
if entry.contexts.is_empty() {
return Err(format!(
"keymap entry {} must declare at least one context",
entry.id
));
}
if entry
.description_zh
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
return Err(format!(
"keymap entry {} must provide non-empty descriptionZh",
entry.id
));
}
for context in &entry.contexts {
if !CONTEXT_ORDER.contains(&context.as_str()) {
return Err(format!(
"keymap entry {} uses unsupported context {}",
entry.id, context
));
}
}
}
Ok(())
}
fn render_shortcut_block(entries: &[KeymapDocEntry], locale: Locale) -> String {
let (shortcut_header, action_header, kind_header) = locale.table_headers();
let mut lines = Vec::new();
for context in CONTEXT_ORDER {
let context_entries = entries
.iter()
.filter(|entry| entry.contexts.iter().any(|item| item == context))
.collect::<Vec<_>>();
if context_entries.is_empty() {
continue;
}
lines.push(format!("#### {}", locale.context_heading(context)));
lines.push(String::new());
let rows = context_entries
.into_iter()
.map(|entry| {
vec![
entry
.combos
.iter()
.map(|combo| format!("`{}`", escape_markdown_cell(combo)))
.collect::<Vec<_>>()
.join(" / "),
escape_markdown_cell(locale.description(entry)),
format!("`{}`", locale.kind(entry.dispatch)),
]
})
.collect::<Vec<_>>();
lines.extend(render_markdown_table(
[shortcut_header, action_header, kind_header],
&rows,
));
if context != CONTEXT_ORDER[CONTEXT_ORDER.len() - 1] {
lines.push(String::new());
}
}
lines.join("\n").trim_end().to_string()
}
fn render_markdown_table<const N: usize>(headers: [&str; N], rows: &[Vec<String>]) -> Vec<String> {
let mut widths = headers.map(UnicodeWidthStr::width);
for row in rows {
for (index, cell) in row.iter().enumerate().take(N) {
widths[index] = widths[index].max(UnicodeWidthStr::width(cell.as_str()));
}
}
let header_line = render_markdown_row(headers.iter().copied(), &widths);
let separator_line =
render_markdown_row(widths.iter().map(|width| "-".repeat(*width)), &widths);
let mut lines = vec![header_line, separator_line];
for row in rows {
lines.push(render_markdown_row(
row.iter().take(N).map(String::as_str),
&widths,
));
}
lines
}
fn render_markdown_row<I, S>(cells: I, widths: &[usize]) -> String
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut row = String::new();
for (index, cell) in cells.into_iter().enumerate() {
let cell = cell.as_ref();
row.push('|');
row.push(' ');
row.push_str(cell);
let padding = widths[index].saturating_sub(UnicodeWidthStr::width(cell));
row.push_str(&" ".repeat(padding));
row.push(' ');
}
row.push('|');
row
}
fn escape_markdown_cell(input: &str) -> String {
input.replace('|', "\\|")
}
fn update_marked_block(path: &Path, generated: &str) -> Result<(), String> {
let original = fs::read_to_string(path).map_err(|err| format!("read {path:?}: {err}"))?;
let begin_matches = original.match_indices(SHORTCUTS_BEGIN).collect::<Vec<_>>();
let end_matches = original.match_indices(SHORTCUTS_END).collect::<Vec<_>>();
if begin_matches.len() != 1 || end_matches.len() != 1 {
return Err(format!(
"{path:?} must contain exactly one {SHORTCUTS_BEGIN} marker and one {SHORTCUTS_END} marker"
));
}
let begin = begin_matches[0].0;
let end = end_matches[0].0;
if begin >= end {
return Err(format!("invalid marker order in {path:?}"));
}
let prefix = &original[..begin + SHORTCUTS_BEGIN.len()];
let suffix = &original[end..];
let updated = format!("{prefix}\n\n{generated}\n\n{suffix}");
if updated != original {
fs::write(path, updated).map_err(|err| format!("write {path:?}: {err}"))?;
}
Ok(())
}
+702
View File
@@ -0,0 +1,702 @@
[
{
"id": "field.next",
"description": "Next field",
"descriptionZh": "下一个字段",
"contexts": [
"default",
"overlay"
],
"action": {
"kind": "fieldStep",
"delta": 1
},
"combos": [
"Tab",
"Down"
]
},
{
"id": "field.prev",
"description": "Previous field",
"descriptionZh": "上一个字段",
"contexts": [
"default",
"overlay"
],
"action": {
"kind": "fieldStep",
"delta": -1
},
"combos": [
"BackTab",
"Up"
]
},
{
"id": "field.text.cursor.left",
"description": "Move cursor left",
"descriptionZh": "光标左移",
"contexts": [
"text"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Left"
]
},
{
"id": "field.text.cursor.right",
"description": "Move cursor right",
"descriptionZh": "光标右移",
"contexts": [
"text"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Right"
]
},
{
"id": "field.text.cursor.home",
"description": "Jump to line start",
"descriptionZh": "跳到行首",
"contexts": [
"text"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Home"
]
},
{
"id": "field.text.cursor.end",
"description": "Jump to line end",
"descriptionZh": "跳到行尾",
"contexts": [
"text"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"End"
]
},
{
"id": "field.text.delete.backward",
"description": "Delete previous character",
"descriptionZh": "删除前一个字符",
"contexts": [
"text"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Backspace"
]
},
{
"id": "field.text.delete.forward",
"description": "Delete next character",
"descriptionZh": "删除后一个字符",
"contexts": [
"text"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Delete"
]
},
{
"id": "field.text.delete.word",
"description": "Delete previous word",
"descriptionZh": "删除前一个单词",
"contexts": [
"text"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Ctrl+W"
]
},
{
"id": "field.text.undo",
"description": "Undo text edit",
"descriptionZh": "撤销文本编辑",
"contexts": [
"text"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Ctrl+Z"
]
},
{
"id": "field.text.redo",
"description": "Redo text edit",
"descriptionZh": "重做文本编辑",
"contexts": [
"text"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Ctrl+Y"
]
},
{
"id": "field.numeric.step.left",
"description": "Step value down",
"descriptionZh": "数值减一步",
"contexts": [
"numeric"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Left"
]
},
{
"id": "field.numeric.step.right",
"description": "Step value up",
"descriptionZh": "数值加一步",
"contexts": [
"numeric"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Right"
]
},
{
"id": "field.numeric.fast-step.left",
"description": "Fast step value down",
"descriptionZh": "数值快速减一步",
"contexts": [
"numeric"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Shift+Left"
]
},
{
"id": "field.numeric.fast-step.right",
"description": "Fast step value up",
"descriptionZh": "数值快速加一步",
"contexts": [
"numeric"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Shift+Right"
]
},
{
"id": "field.numeric.delete.backward",
"description": "Delete previous character",
"descriptionZh": "删除前一个字符",
"contexts": [
"numeric"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Backspace"
]
},
{
"id": "field.numeric.delete.forward",
"description": "Delete next character",
"descriptionZh": "删除后一个字符",
"contexts": [
"numeric"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Delete"
]
},
{
"id": "field.numeric.undo",
"description": "Undo numeric edit",
"descriptionZh": "撤销数值编辑",
"contexts": [
"numeric"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Ctrl+Z"
]
},
{
"id": "field.numeric.redo",
"description": "Redo numeric edit",
"descriptionZh": "重做数值编辑",
"contexts": [
"numeric"
],
"dispatch": false,
"action": {
"kind": "none"
},
"combos": [
"Ctrl+Y"
]
},
{
"id": "help.close",
"description": "Close help",
"descriptionZh": "关闭帮助",
"contexts": [
"help"
],
"action": {
"kind": "helpClose"
},
"combos": [
"Esc",
"Ctrl+H",
"Ctrl+?"
]
},
{
"id": "help.page.next",
"description": "Next error page",
"descriptionZh": "下一页错误",
"contexts": [
"help"
],
"action": {
"kind": "helpPageStep",
"delta": 1
},
"combos": [
"Tab"
]
},
{
"id": "help.page.prev",
"description": "Previous error page",
"descriptionZh": "上一页错误",
"contexts": [
"help"
],
"action": {
"kind": "helpPageStep",
"delta": -1
},
"combos": [
"BackTab"
]
},
{
"id": "help.shortcuts.scroll.up",
"description": "Scroll shortcuts up",
"descriptionZh": "快捷键向上滚动",
"contexts": [
"help"
],
"action": {
"kind": "helpShortcutScroll",
"delta": -1
},
"combos": [
"Up",
"k"
]
},
{
"id": "help.shortcuts.scroll.down",
"description": "Scroll shortcuts down",
"descriptionZh": "快捷键向下滚动",
"contexts": [
"help"
],
"action": {
"kind": "helpShortcutScroll",
"delta": 1
},
"combos": [
"Down",
"j"
]
},
{
"id": "help.shortcuts.page.up",
"description": "Page shortcuts up",
"descriptionZh": "快捷键上翻页",
"contexts": [
"help"
],
"action": {
"kind": "helpShortcutPage",
"delta": -1
},
"combos": [
"PageUp"
]
},
{
"id": "help.shortcuts.page.down",
"description": "Page shortcuts down",
"descriptionZh": "快捷键下翻页",
"contexts": [
"help"
],
"action": {
"kind": "helpShortcutPage",
"delta": 1
},
"combos": [
"PageDown"
]
},
{
"id": "help.shortcuts.home",
"description": "Jump shortcuts to top",
"descriptionZh": "快捷键跳到顶部",
"contexts": [
"help"
],
"action": {
"kind": "helpShortcutHome"
},
"combos": [
"Home"
]
},
{
"id": "help.shortcuts.end",
"description": "Jump shortcuts to bottom",
"descriptionZh": "快捷键跳到底部",
"contexts": [
"help"
],
"action": {
"kind": "helpShortcutEnd"
},
"combos": [
"End"
]
},
{
"id": "help.errors.scroll.left",
"description": "Scroll error text left",
"descriptionZh": "错误文本左滚",
"contexts": [
"help"
],
"action": {
"kind": "helpErrorScroll",
"delta": -1
},
"combos": [
"h"
]
},
{
"id": "help.errors.scroll.right",
"description": "Scroll error text right",
"descriptionZh": "错误文本右滚",
"contexts": [
"help"
],
"action": {
"kind": "helpErrorScroll",
"delta": 1
},
"combos": [
"l"
]
},
{
"id": "section.next",
"description": "Next section",
"descriptionZh": "下一个分区",
"contexts": [
"default"
],
"action": {
"kind": "sectionStep",
"delta": 1
},
"combos": [
"Ctrl+Tab"
]
},
{
"id": "section.prev",
"description": "Previous section",
"descriptionZh": "上一个分区",
"contexts": [
"default"
],
"action": {
"kind": "sectionStep",
"delta": -1
},
"combos": [
"Ctrl+Shift+Tab"
]
},
{
"id": "root.next",
"description": "Next root tab",
"descriptionZh": "下一个根标签",
"contexts": [
"default"
],
"action": {
"kind": "rootStep",
"delta": 1
},
"combos": [
"Ctrl+L"
]
},
{
"id": "root.prev",
"description": "Previous root tab",
"descriptionZh": "上一个根标签",
"contexts": [
"default"
],
"action": {
"kind": "rootStep",
"delta": -1
},
"combos": [
"Ctrl+J"
]
},
{
"id": "popup.toggle",
"description": "Open popup / apply selection",
"descriptionZh": "打开弹窗 / 应用选择",
"contexts": [
"default"
],
"action": {
"kind": "togglePopup"
},
"combos": [
"Enter"
]
},
{
"id": "composite.edit",
"description": "Open composite editor",
"descriptionZh": "打开复合编辑器",
"contexts": [
"default",
"collection"
],
"action": {
"kind": "editComposite"
},
"combos": [
"Ctrl+E"
]
},
{
"id": "list.add",
"description": "Add entry",
"descriptionZh": "添加条目",
"contexts": [
"collection",
"overlay"
],
"action": {
"kind": "listAddEntry"
},
"combos": [
"Ctrl+N"
]
},
{
"id": "list.remove",
"description": "Remove entry",
"descriptionZh": "删除条目",
"contexts": [
"collection",
"overlay"
],
"action": {
"kind": "listRemoveEntry"
},
"combos": [
"Ctrl+D"
]
},
{
"id": "list.select.prev",
"description": "Select previous entry",
"descriptionZh": "选择上一个条目",
"contexts": [
"collection",
"overlay"
],
"action": {
"kind": "listSelect",
"delta": -1
},
"combos": [
"Ctrl+Left"
]
},
{
"id": "list.select.next",
"description": "Select next entry",
"descriptionZh": "选择下一个条目",
"contexts": [
"collection",
"overlay"
],
"action": {
"kind": "listSelect",
"delta": 1
},
"combos": [
"Ctrl+Right"
]
},
{
"id": "list.move.up",
"description": "Move entry up",
"descriptionZh": "条目上移",
"contexts": [
"collection",
"overlay"
],
"action": {
"kind": "listMove",
"delta": -1
},
"combos": [
"Ctrl+Up"
]
},
{
"id": "list.move.down",
"description": "Move entry down",
"descriptionZh": "条目下移",
"contexts": [
"collection",
"overlay"
],
"action": {
"kind": "listMove",
"delta": 1
},
"combos": [
"Ctrl+Down"
]
},
{
"id": "app.save",
"description": "Save & validate (overlays stay open)",
"descriptionZh": "保存并验证(覆盖层保持打开)",
"contexts": [
"default",
"overlay"
],
"action": {
"kind": "save"
},
"combos": [
"Ctrl+S"
]
},
{
"id": "app.quit",
"description": "Quit (confirm if dirty)",
"descriptionZh": "退出(脏状态需确认)",
"contexts": [
"default"
],
"action": {
"kind": "quit"
},
"combos": [
"Ctrl+Q",
"Ctrl+C"
]
},
{
"id": "status.reset",
"description": "Cancel / clear status (overlays: pop current level)",
"descriptionZh": "取消 / 清除状态(覆盖层:弹出当前层)",
"contexts": [
"default",
"overlay"
],
"action": {
"kind": "resetStatus"
},
"combos": [
"Esc"
]
},
{
"id": "help.show",
"description": "Show help and error summary",
"descriptionZh": "显示帮助与错误摘要",
"contexts": [
"default",
"collection",
"overlay"
],
"action": {
"kind": "showHelp"
},
"combos": [
"Ctrl+?",
"Ctrl+H"
]
}
]
+151
View File
@@ -0,0 +1,151 @@
# SchemaUI 脚本目录
本目录包含用于构建、测试和部署 SchemaUI 的实用脚本。
## 📋 脚本列表
### 构建脚本
#### build-web.sh
构建 Web UI 界面的脚本。
```bash
./scripts/build-web.sh
```
- 清理旧的构建文件
- 运行 pnpm build:embedded
- 生成生产环境的静态文件
#### feature-matrix.sh
运行 Rust feature matrix 编译测试的脚本。
```bash
./scripts/feature-matrix.sh smoke
./scripts/feature-matrix.sh exhaustive
```
- `smoke`:跑默认 smoke matrix
- `exhaustive`:设置 `SCHEMAUI_EXHAUSTIVE_FEATURE_MATRIX=1` 跑全量合法组合
- 优先使用 `uv run --with pytest`;若本机已安装 `python3 + pytest` 也可直接运行
- 默认开启 live progress 输出,避免多轮 `cargo check` 时看起来像“卡住”
#### deps-duplicates.sh
查看 workspace 全量 feature 图中的重复依赖:
```bash
./scripts/deps-duplicates.sh
```
- 等价于 `cargo tree --duplicates --workspace --all-features`
- 用于配合 compile-time 优化与依赖审计
### 更新脚本
#### update-cli-dependency.sh
更新 CLI 依赖的脚本。
```bash
./scripts/update-cli-dependency.sh
```
- 更新 Cargo.toml 中的依赖版本
- 确保依赖兼容性
- 依赖 `python3`(可通过 `PYTHON_BIN` 环境变量覆盖)
#### update-readme-version.sh
更新 README 文件中版本号的脚本。
```bash
./scripts/update-readme-version.sh <new-version>
```
- 自动更新所有 README 中的版本号
- 保持文档版本一致性
- 依赖 `python3`(可通过 `PYTHON_BIN` 环境变量覆盖)
#### sync-package-manifests.py
从已发布的 `schemaui-cli` GitHub release 拉取真实 asset URL / SHA256,
并同步生成 Homebrew / Scoop / winget 分发文件。
```bash
python3 scripts/sync-package-manifests.py --tag schemaui-cli-v0.4.1
python3 scripts/sync-package-manifests.py --tag schemaui-cli-v0.4.1 --check
```
- 默认读取 `schemaui-cli/Cargo.toml` 的版本
- `--check` 只校验是否同步,不改文件
- 使用 `GITHUB_TOKEN` / `GH_TOKEN` 可提升 GitHub API 额度
#### sync-install-docs.py
从 `packaging/install/install-methods.json` 读取安装方式定义,并回写:
- `README.md`
- `README.ZH.md`
- `docs/en/cli_usage.md`
```bash
python3 scripts/sync-install-docs.py
python3 scripts/sync-install-docs.py --check
```
- 使用显式 marker block,只更新 CLI 快捷入口与 installation section
- `--check` 只校验文档是否已同步,不改文件
- 写回文档时会使用 `deno fmt` 归一化 Markdown 输出;`--check` 可在无 `deno`
环境下运行
## 🚀 发布入口
如果要用 `cargo release` 发布 `schemaui-cli`,仓库已经把 tag 约定和 GitHub
workflow 串好了:
```bash
just release-cli-dry-run patch
just release-cli patch
```
- `just release-cli` 等价于
`cargo release <level> --package schemaui-cli --execute`
- 发布时会运行 `release.toml` 里的 pre-release hook,更新 web bundle / CLI 依赖
/ README 版本
- 推送到 GitHub 后:
- `push main` 会触发 `prek-checks`、`release-plz-pr`、`release-plz-release`
- `push schemaui-cli-v* tag` 会自动创建 GitHub Release,并继续触发 `CD`
## 🐛 故障排除
### 常见问题
1. **权限错误**
```bash
chmod +x scripts/*.sh
```
2. **pnpm 未找到**
```bash
npm install -g pnpm
```
3. **端口已占用**
```bash
lsof -i :5175 # 查看占用端口的进程
kill -9 <PID> # 终止进程
```
## 📚 相关文档
- [构建文档](../docs/en/structure_design.md)
- [测试文档](../tests/README.md)
- [justfile](../justfile) - Make 替代工具配置
- `just feature-matrix` / `just feature-matrix-exhaustive`
- `just deps-duplicates`
+37
View File
@@ -0,0 +1,37 @@
use anyhow::Result;
use jsonschema::Validator;
use serde_json::Value;
use crate::core::ui_ast::{UiAst, UiLayout};
#[cfg(feature = "tui")]
use crate::tui::app::UiOptions;
#[cfg(feature = "web")]
use crate::web::session::ServeOptions;
/// Shared context prepared by the core pipeline and consumed by frontends
/// (TUI, Web, or others).
#[derive(Debug)]
pub struct FrontendContext {
pub title: Option<String>,
pub description: Option<String>,
pub ui_ast: UiAst,
pub layout: UiLayout,
pub initial_data: Value,
pub schema: Value,
pub validator: Validator,
}
/// Built-in runtime targets exposed by the high-level `SchemaUI` API.
#[derive(Debug, Clone)]
pub enum FrontendOptions {
#[cfg(feature = "tui")]
Tui(UiOptions),
#[cfg(feature = "web")]
Web(ServeOptions),
}
/// Pluggable frontend interface. A frontend receives a `FrontendContext`,
/// renders an interactive UI, and returns the final edited value.
pub trait Frontend {
fn run(self, ctx: FrontendContext) -> Result<Value>;
}
+11
View File
@@ -0,0 +1,11 @@
pub mod frontend;
pub mod pipeline;
pub mod schema_ui;
pub mod io {
pub use crate::io::*;
}
pub mod ui_ast {
pub use crate::ui_ast::*;
}
+118
View File
@@ -0,0 +1,118 @@
use anyhow::Result;
use jsonschema::validator_for;
use serde_json::{Map, Value};
use crate::core::frontend::{Frontend, FrontendContext};
use crate::core::io::input::schema_with_defaults;
use crate::core::ui_ast::{UiAst, UiAstBundle, build_ui_ast_bundle};
use crate::schema::metadata::root_schema_header;
/// Core pipeline for preparing a `FrontendContext` from a base JSON Schema,
/// optional title, and optional default data.
///
/// This is the shared part of the flow:
///
/// ```text
/// io::input -> (schema, defaults) -> enriched schema -> ui_ast -> FrontendContext
/// ```
#[derive(Debug)]
enum UiAstSource {
Runtime,
Prepared(UiAstBundle),
}
#[derive(Debug)]
pub struct SchemaPipeline {
schema: Value,
title: Option<String>,
description: Option<String>,
defaults: Option<Value>,
ui_ast_source: UiAstSource,
}
impl SchemaPipeline {
pub fn new(schema: Value) -> Self {
Self {
schema,
title: None,
description: None,
defaults: None,
ui_ast_source: UiAstSource::Runtime,
}
}
pub fn with_title(mut self, title: Option<String>) -> Self {
self.title = title;
self
}
pub fn with_description(mut self, description: Option<String>) -> Self {
self.description = description;
self
}
pub fn with_defaults(mut self, defaults: Option<Value>) -> Self {
self.defaults = defaults;
self
}
/// Provide a prepared UiAst to be used instead of building one at runtime.
/// If `ast` is None, the pipeline falls back to runtime UiAst building.
pub fn with_prepared_ui_ast(mut self, ast: Option<UiAst>) -> Self {
if let Some(ast) = ast {
self.ui_ast_source = UiAstSource::Prepared(UiAstBundle::from_ui_ast(ast));
}
self
}
/// Provide a prepared bundle of shared UI artifacts.
///
/// This lets the runtime reuse both `UiAst` and `UiLayout`, instead of
/// rebuilding layout-oriented structures from the schema again.
pub fn with_prepared_ui_bundle(mut self, bundle: Option<UiAstBundle>) -> Self {
if let Some(bundle) = bundle {
self.ui_ast_source = UiAstSource::Prepared(bundle);
}
self
}
pub(crate) fn build_frontend_context(self) -> Result<FrontendContext> {
let SchemaPipeline {
schema,
title,
description,
defaults,
ui_ast_source,
} = self;
let data = defaults.unwrap_or_else(|| Value::Object(Map::new()));
let enriched = schema_with_defaults(&schema, &data);
let (schema_title, schema_description) = root_schema_header(&enriched);
let validator = validator_for(&enriched)?;
let bundle = match ui_ast_source {
UiAstSource::Runtime => build_ui_ast_bundle(&enriched)?,
UiAstSource::Prepared(bundle) => bundle,
};
let (ui_ast, layout) = bundle.into_parts();
Ok(FrontendContext {
title: title.or(schema_title),
description: description.or(schema_description),
ui_ast,
layout,
initial_data: data,
schema: enriched,
validator,
})
}
#[allow(dead_code)]
pub fn run_with_frontend<F>(self, frontend: F) -> Result<Value>
where
F: Frontend,
{
let ctx = self.build_frontend_context()?;
frontend.run(ctx)
}
}
+289
View File
@@ -0,0 +1,289 @@
use anyhow::Result;
use serde_json::Value;
use crate::core::frontend::{Frontend, FrontendOptions};
use crate::core::pipeline::SchemaPipeline;
use crate::io::{
self, DocumentFormat,
input::{looks_like_json_schema, parse_document_auto},
};
#[cfg(feature = "tui")]
use crate::precompile::TuiArtifacts;
use crate::precompile::UiArtifactBundle;
#[cfg(feature = "tui")]
use crate::tui::app::UiOptions;
#[cfg(feature = "tui")]
use crate::tui::session::TuiFrontend;
use crate::ui_ast::{UiAst, UiAstBundle};
#[cfg(feature = "web")]
use crate::web::{frontend::WebFrontend, session::ServeOptions as WebServeOptions};
#[derive(Debug, Clone)]
pub enum DocumentInput {
Value(Value),
Text(String),
}
impl DocumentInput {
pub fn into_value(self) -> Result<Value> {
match self {
Self::Value(value) => Ok(value),
Self::Text(contents) => parse_document_auto(&contents),
}
}
}
impl From<Value> for DocumentInput {
fn from(value: Value) -> Self {
Self::Value(value)
}
}
impl From<&Value> for DocumentInput {
fn from(value: &Value) -> Self {
Self::Value(value.clone())
}
}
impl From<String> for DocumentInput {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<&str> for DocumentInput {
fn from(value: &str) -> Self {
Self::Text(value.to_string())
}
}
#[derive(Debug)]
struct ResolvedRuntimeInputs {
schema: Value,
defaults: Option<Value>,
}
#[derive(Debug)]
pub struct SchemaUI {
document: Option<DocumentInput>,
validation_schema: Option<DocumentInput>,
title: Option<String>,
description: Option<String>,
ui_ast: Option<UiAst>,
ui_bundle: Option<UiAstBundle>,
ui_artifact_bundle: Option<UiArtifactBundle>,
#[cfg(feature = "tui")]
tui_artifacts: Option<TuiArtifacts>,
}
impl SchemaUI {
pub fn new(document: impl Into<DocumentInput>) -> Self {
Self {
document: Some(document.into()),
validation_schema: None,
title: None,
description: None,
ui_ast: None,
ui_bundle: None,
ui_artifact_bundle: None,
#[cfg(feature = "tui")]
tui_artifacts: None,
}
}
pub fn from_schema(schema: impl Into<DocumentInput>) -> Self {
let mut ui = Self::new(Value::Object(Default::default()));
ui.document = None;
ui.validation_schema = Some(schema.into());
ui
}
pub fn from_schema_str(contents: &str, format: DocumentFormat) -> Result<Self> {
let schema = io::input::parse_document_str(contents, format)?;
Ok(Self::from_schema(schema))
}
pub fn from_data_value(value: Value) -> Self {
Self::new(value)
}
pub fn from_data_str(contents: &str, format: DocumentFormat) -> Result<Self> {
let value = io::input::parse_document_str(contents, format)?;
Ok(Self::new(value))
}
pub fn from_schema_and_data(schema: Value, defaults: Value) -> Self {
Self::new(defaults).with_schema(schema)
}
/// Provide a prepared UiAst so the runtime pipeline can skip rebuilding it
/// from the schema.
pub fn with_ui_ast(mut self, ast: UiAst) -> Self {
self.ui_ast = Some(ast);
self
}
/// Provide a prepared bundle containing shared UI artifacts.
pub fn with_ui_bundle(mut self, bundle: UiAstBundle) -> Self {
self.ui_bundle = Some(bundle);
self
}
/// Provide a prepared UI artifact bundle, including shared UI and
/// frontend-specific derived structures.
pub fn with_ui_artifact_bundle(mut self, bundle: UiArtifactBundle) -> Self {
self.ui_artifact_bundle = Some(bundle);
self
}
/// Provide prepared TUI artifacts. When set, the TUI frontend will use
/// these instead of deriving TUI-specific structures from the UiAst at
/// runtime.
#[cfg(feature = "tui")]
pub fn with_tui_artifacts(mut self, artifacts: TuiArtifacts) -> Self {
self.tui_artifacts = Some(artifacts);
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_schema(mut self, schema: impl Into<DocumentInput>) -> Self {
self.validation_schema = Some(schema.into());
self
}
pub fn with_data(mut self, document: impl Into<DocumentInput>) -> Self {
self.document = Some(document.into());
self
}
pub fn with_default_data(mut self, defaults: &Value) -> Self {
self.document = Some(DocumentInput::Value(defaults.clone()));
self
}
fn resolve_inputs(&self) -> Result<ResolvedRuntimeInputs> {
let document = self
.document
.clone()
.map(DocumentInput::into_value)
.transpose()?;
let validation_schema = self
.validation_schema
.clone()
.map(DocumentInput::into_value)
.transpose()?;
if let Some(schema) = validation_schema {
return Ok(ResolvedRuntimeInputs {
schema,
defaults: document,
});
}
let Some(document) = document else {
return Ok(ResolvedRuntimeInputs {
schema: Value::Object(Default::default()),
defaults: None,
});
};
if looks_like_json_schema(&document) {
Ok(ResolvedRuntimeInputs {
schema: document,
defaults: None,
})
} else {
Ok(ResolvedRuntimeInputs {
schema: io::input::schema_from_data_value(&document),
defaults: Some(document),
})
}
}
#[cfg(feature = "tui")]
pub(crate) fn build_tui_frontend(&self, options: UiOptions) -> TuiFrontend {
let tui_artifacts = self.tui_artifacts.clone().or_else(|| {
self.ui_artifact_bundle
.as_ref()
.map(|bundle| bundle.tui.clone())
});
TuiFrontend {
options,
tui_artifacts,
}
}
pub fn run(self, frontend: FrontendOptions) -> Result<Value> {
match frontend {
#[cfg(feature = "tui")]
FrontendOptions::Tui(options) => {
let frontend = self.build_tui_frontend(options);
self.run_with_frontend(frontend)
}
#[cfg(feature = "web")]
FrontendOptions::Web(serve) => self.run_web(serve),
}
}
/// Run explicitly in TUI mode using default `UiOptions`.
#[cfg(feature = "tui")]
pub fn run_tui(self) -> Result<Value> {
self.run(FrontendOptions::Tui(UiOptions::default()))
}
/// Run in Web mode, using the given serve options to configure the
/// temporary HTTP server.
#[cfg(feature = "web")]
pub fn run_web(self, serve: WebServeOptions) -> Result<Value> {
self.run_with_frontend(WebFrontend { serve })
}
/// Run in Web mode from an existing async runtime.
#[cfg(feature = "web")]
pub async fn run_web_async(self, serve: WebServeOptions) -> Result<Value> {
let ctx = self.into_frontend_context()?;
WebFrontend { serve }.run_async(ctx).await
}
pub fn run_with_frontend<F>(self, frontend: F) -> Result<Value>
where
F: Frontend,
{
let ctx = self.into_frontend_context()?;
frontend.run(ctx)
}
fn into_frontend_context(self) -> Result<crate::core::frontend::FrontendContext> {
let inputs = self.resolve_inputs()?;
let SchemaUI {
document: _,
validation_schema: _,
title,
description,
ui_ast,
ui_bundle,
ui_artifact_bundle,
#[cfg(feature = "tui")]
tui_artifacts: _,
} = self;
let ui_bundle = ui_artifact_bundle.map(|bundle| bundle.ui).or(ui_bundle);
let pipeline = SchemaPipeline::new(inputs.schema)
.with_title(title)
.with_description(description)
.with_defaults(inputs.defaults)
.with_prepared_ui_ast(ui_ast)
.with_prepared_ui_bundle(ui_bundle);
pipeline.build_frontend_context()
}
}
+69
View File
@@ -0,0 +1,69 @@
#![allow(dead_code)]
//! Deepseek-TUI brand palette — vendored override (forked v0.12.0).
//!
//! Upstream schemaui ships hardcoded `Color::Gray` / `Color::DarkGray` /
//! `Color::White` / `Color::Yellow` / `Color::Cyan` / `Color::Magenta`
//! references across its rendering components. The deepseek-tui fork
//! routes every render path through this module so the schemaui editor
//! visually matches the rest of the TUI (navy ink + sky accents).
//!
//! These are intentionally exact RGB values (not 16-color named indices)
//! so they read identically on every modern terminal regardless of
//! terminal-palette overrides. Sourced from
//! `crates/tui/src/palette.rs` in the deepseek-tui workspace — keep in
//! sync if the upstream brand palette ever shifts.
use ratatui::style::Color;
// === Surface backgrounds ===
/// Primary navy ink — chrome backgrounds, panel fills.
pub const SURFACE_INK: Color = Color::Rgb(11, 21, 38);
/// Slightly lifted navy — popup / overlay surfaces sit on top of INK
/// at a small luminance bump so they read as floating without being
/// off-brand.
pub const SURFACE_RAISED: Color = Color::Rgb(17, 30, 52);
// === Borders + chrome ===
pub const BORDER_DIM: Color = Color::Rgb(48, 64, 92);
pub const BORDER_ACTIVE: Color = Color::Rgb(106, 174, 242);
// === Text ===
/// Primary text color — high-contrast on the navy ink.
pub const TEXT_PRIMARY: Color = Color::Rgb(220, 230, 240);
/// Secondary text — labels, body prose at slightly reduced contrast.
pub const TEXT_MUTED: Color = Color::Rgb(160, 175, 195);
/// Tertiary text — placeholders, defaults, low-importance hints.
pub const TEXT_DIM: Color = Color::Rgb(110, 124, 148);
// === Brand accents ===
/// Sky-blue — the primary accent color (selected tab, focus marker).
pub const ACCENT_SKY: Color = Color::Rgb(106, 174, 242);
/// Saturated brand blue — links, descriptions, secondary accent.
pub const ACCENT_BLUE: Color = Color::Rgb(80, 132, 220);
/// Soft purple — used by upstream for description spans; kept as an
/// alternate accent for variety in the editor's hint text.
pub const ACCENT_PURPLE: Color = Color::Rgb(168, 132, 220);
// === Status ===
pub const STATUS_OK: Color = Color::Rgb(120, 200, 130);
pub const STATUS_WARN: Color = Color::Rgb(232, 172, 80);
pub const STATUS_ERROR: Color = Color::Rgb(232, 102, 102);
/// Default body style — text on the navy ink surface.
#[inline]
#[must_use]
pub fn body_style() -> ratatui::style::Style {
ratatui::style::Style::default()
.fg(TEXT_PRIMARY)
.bg(SURFACE_INK)
}
/// Block / overlay style — slightly lifted surface for floating panels.
#[inline]
#[must_use]
pub fn surface_style() -> ratatui::style::Style {
ratatui::style::Style::default()
.fg(TEXT_PRIMARY)
.bg(SURFACE_RAISED)
}
+194
View File
@@ -0,0 +1,194 @@
use std::{fmt, path::Path};
#[cfg(not(any(feature = "json", feature = "yaml", feature = "toml")))]
compile_error!("schemaui requires at least one document format feature: json, yaml, or toml");
/// Supported data formats for input/output layers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocumentFormat {
#[cfg(feature = "json")]
Json,
#[cfg(feature = "yaml")]
Yaml,
#[cfg(feature = "toml")]
Toml,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocumentFormatProbe {
Known(DocumentFormat),
UnsupportedFeature {
format_name: &'static str,
feature_flag: &'static str,
},
Unknown,
}
impl Default for DocumentFormat {
fn default() -> Self {
#[cfg(feature = "json")]
{
Self::Json
}
#[cfg(all(not(feature = "json"), feature = "yaml"))]
{
Self::Yaml
}
#[cfg(all(not(feature = "json"), not(feature = "yaml"), feature = "toml"))]
{
Self::Toml
}
}
}
impl fmt::Display for DocumentFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
#[cfg(feature = "json")]
DocumentFormat::Json => write!(f, "json"),
#[cfg(feature = "yaml")]
DocumentFormat::Yaml => write!(f, "yaml"),
#[cfg(feature = "toml")]
DocumentFormat::Toml => write!(f, "toml"),
}
}
}
impl DocumentFormat {
/// Parse a format keyword (json/yaml/toml) into a `DocumentFormat`.
pub fn from_keyword(keyword: &str) -> Result<Self, String> {
match Self::probe_keyword(keyword) {
DocumentFormatProbe::Known(format) => Ok(format),
DocumentFormatProbe::UnsupportedFeature {
format_name,
feature_flag,
} => Err(format!(
"format '{format_name}' requires the '{feature_flag}' feature"
)),
DocumentFormatProbe::Unknown => Err(format!(
"unsupported format '{}', available: {}",
keyword,
Self::keyword_list().join(", ")
)),
}
}
/// Try to infer a format from a file extension.
pub fn from_extension(path: &Path) -> Option<Self> {
match Self::probe_extension(path) {
DocumentFormatProbe::Known(format) => Some(format),
DocumentFormatProbe::UnsupportedFeature { .. } | DocumentFormatProbe::Unknown => None,
}
}
pub fn probe_keyword(keyword: &str) -> DocumentFormatProbe {
match keyword.to_ascii_lowercase().as_str() {
#[cfg(feature = "json")]
"json" => DocumentFormatProbe::Known(DocumentFormat::Json),
#[cfg(not(feature = "json"))]
"json" => DocumentFormatProbe::UnsupportedFeature {
format_name: "json",
feature_flag: "json",
},
#[cfg(feature = "yaml")]
"yaml" | "yml" => DocumentFormatProbe::Known(DocumentFormat::Yaml),
#[cfg(not(feature = "yaml"))]
"yaml" | "yml" => DocumentFormatProbe::UnsupportedFeature {
format_name: "yaml",
feature_flag: "yaml",
},
#[cfg(feature = "toml")]
"toml" => DocumentFormatProbe::Known(DocumentFormat::Toml),
#[cfg(not(feature = "toml"))]
"toml" => DocumentFormatProbe::UnsupportedFeature {
format_name: "toml",
feature_flag: "toml",
},
_ => DocumentFormatProbe::Unknown,
}
}
pub fn probe_extension(path: &Path) -> DocumentFormatProbe {
let Some(ext) = path.extension() else {
return DocumentFormatProbe::Unknown;
};
let ext = ext.to_string_lossy().to_ascii_lowercase();
match ext.as_str() {
#[cfg(feature = "json")]
"json" => DocumentFormatProbe::Known(DocumentFormat::Json),
#[cfg(not(feature = "json"))]
"json" => DocumentFormatProbe::UnsupportedFeature {
format_name: "json",
feature_flag: "json",
},
#[cfg(feature = "yaml")]
"yaml" | "yml" => DocumentFormatProbe::Known(DocumentFormat::Yaml),
#[cfg(not(feature = "yaml"))]
"yaml" | "yml" => DocumentFormatProbe::UnsupportedFeature {
format_name: "yaml",
feature_flag: "yaml",
},
#[cfg(feature = "toml")]
"toml" => DocumentFormatProbe::Known(DocumentFormat::Toml),
#[cfg(not(feature = "toml"))]
"toml" => DocumentFormatProbe::UnsupportedFeature {
format_name: "toml",
feature_flag: "toml",
},
_ => DocumentFormatProbe::Unknown,
}
}
pub fn keyword_list() -> Vec<&'static str> {
vec![
#[cfg(feature = "json")]
"json",
#[cfg(feature = "yaml")]
"yaml",
#[cfg(feature = "toml")]
"toml",
]
}
pub fn available_formats() -> Vec<DocumentFormat> {
vec![
#[cfg(feature = "json")]
DocumentFormat::Json,
#[cfg(feature = "yaml")]
DocumentFormat::Yaml,
#[cfg(feature = "toml")]
DocumentFormat::Toml,
]
}
pub fn format_list() -> &'static str {
#[cfg(all(feature = "json", feature = "yaml", feature = "toml"))]
{
"JSON/YAML/TOML"
}
#[cfg(all(feature = "json", feature = "yaml", not(feature = "toml")))]
{
"JSON/YAML"
}
#[cfg(all(feature = "json", not(feature = "yaml"), feature = "toml"))]
{
"JSON/TOML"
}
#[cfg(all(not(feature = "json"), feature = "yaml", feature = "toml"))]
{
"YAML/TOML"
}
#[cfg(all(feature = "json", not(feature = "yaml"), not(feature = "toml")))]
{
"JSON"
}
#[cfg(all(not(feature = "json"), feature = "yaml", not(feature = "toml")))]
{
"YAML"
}
#[cfg(all(not(feature = "json"), not(feature = "yaml"), feature = "toml"))]
{
"TOML"
}
}
}
+559
View File
@@ -0,0 +1,559 @@
use std::{borrow::Cow, collections::HashSet};
use anyhow::{Context, Result};
use regex::Regex;
use serde_json::{Map, Number, Value, json};
use super::DocumentFormat;
const JSON_SCHEMA_DRAFT: &str = "http://json-schema.org/draft-07/schema#";
/// Parse structured data in any supported format into a `serde_json::Value`.
pub fn parse_document_str(contents: &str, format: DocumentFormat) -> Result<Value> {
match format {
#[cfg(feature = "json")]
DocumentFormat::Json => {
serde_json::from_str::<Value>(contents).with_context(|| "failed to parse JSON document")
}
#[cfg(feature = "yaml")]
DocumentFormat::Yaml => {
serde_yaml::from_str::<Value>(contents).with_context(|| "failed to parse YAML document")
}
#[cfg(feature = "toml")]
DocumentFormat::Toml => toml::from_str::<toml::Value>(contents)
.with_context(|| "failed to parse TOML document")
.and_then(|value| {
serde_json::to_value(value).context("failed to convert TOML to JSON")
}),
}
}
/// Parse an inline document by trying every enabled format.
///
/// The default crate format is attempted first, followed by the remaining
/// enabled formats. This is useful for ergonomic APIs that accept either a
/// `serde_json::Value` or raw inline text without an explicit format hint.
pub fn parse_document_auto(contents: &str) -> Result<Value> {
let preferred = DocumentFormat::default();
match parse_document_str(contents, preferred) {
Ok(value) => Ok(value),
Err(primary) => {
for candidate in DocumentFormat::available_formats() {
if candidate == preferred {
continue;
}
if let Ok(value) = parse_document_str(contents, candidate) {
return Ok(value);
}
}
Err(anyhow::anyhow!(
"failed to parse inline document: tried {} (first error: {primary})",
DocumentFormat::format_list()
))
}
}
}
/// Convert structured data into a JSON Schema with inferred defaults.
pub fn schema_from_data_str(contents: &str, format: DocumentFormat) -> Result<Value> {
let value = parse_document_str(contents, format)?;
Ok(schema_from_data_value(&value))
}
/// Convert structured data into a JSON Schema with inferred defaults.
pub fn schema_from_data_value(value: &Value) -> Value {
let mut schema = infer_schema(value);
if let Value::Object(ref mut map) = schema {
map.entry("$schema".to_string())
.or_insert_with(|| Value::String(JSON_SCHEMA_DRAFT.to_string()));
}
schema
}
/// Merge user-provided data into an existing schema as `default` values.
pub fn schema_with_defaults(schema: &Value, defaults: &Value) -> Value {
let mut enriched = schema.clone();
DefaultApplier::new().apply(&mut enriched, defaults);
enriched
}
/// Heuristic used when callers pass a single document and expect SchemaUI to
/// decide whether it is a JSON Schema or already-instantiated config data.
pub fn looks_like_json_schema(value: &Value) -> bool {
let obj = match value.as_object() {
Some(map) => map,
None => return false,
};
if obj
.get("properties")
.and_then(Value::as_object)
.map(|props| props.is_empty())
.unwrap_or(true)
{
return false;
}
if obj.contains_key("$schema") {
return true;
}
if matches!(obj.get("type"), Some(Value::String(kind)) if kind == "object") {
return true;
}
if let Some(props) = obj.get("properties").and_then(Value::as_object) {
let mut scored = 0usize;
for value in props.values() {
if value.get("type").is_some() {
scored += 1;
}
if value.get("properties").is_some() {
scored += 1;
}
if value.get("enum").is_some() {
scored += 1;
}
}
return scored >= 2;
}
false
}
struct DefaultApplier {
active_refs: HashSet<String>,
}
impl DefaultApplier {
fn new() -> Self {
Self {
active_refs: HashSet::new(),
}
}
fn apply(&mut self, root: &mut Value, defaults: &Value) {
self.apply_at(root, "", defaults);
}
fn apply_at(&mut self, root: &mut Value, pointer: &str, defaults: &Value) {
let Some(schema) = root.pointer_mut(pointer) else {
return;
};
let Some(schema_obj) = schema.as_object_mut() else {
return;
};
schema_obj.insert("default".to_string(), defaults.clone());
let mut tasks: Vec<(String, Cow<'_, Value>)> = Vec::new();
if let Some(default_map) = defaults.as_object() {
if let Some(properties) = schema_obj.get("properties").and_then(Value::as_object) {
for key in properties.keys() {
if let Some(value) = default_map.get(key) {
tasks.push((
child_pointer(pointer, &["properties", key]),
Cow::Borrowed(value),
));
}
}
}
if let Some(patterns) = schema_obj
.get("patternProperties")
.and_then(Value::as_object)
{
for (pattern, _) in patterns {
if let Some(matched) = pattern_defaults(pattern, default_map) {
tasks.push((
child_pointer(pointer, &["patternProperties", pattern]),
Cow::Owned(Value::Object(matched)),
));
}
}
}
if let Some(additional_schema) = schema_obj.get("additionalProperties")
&& additional_schema.is_object()
{
let excluded: HashSet<&str> = schema_obj
.get("properties")
.and_then(Value::as_object)
.map(|props| props.keys().map(|k| k.as_str()).collect())
.unwrap_or_default();
let extras = default_map
.iter()
.filter(|(key, _)| !excluded.contains(key.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<Map<_, _>>();
if !extras.is_empty() {
tasks.push((
child_pointer(pointer, &["additionalProperties"]),
Cow::Owned(Value::Object(extras)),
));
}
}
if let Some(deps) = schema_obj.get("dependencies").and_then(Value::as_object) {
for (prop, dep_schema) in deps {
if default_map.contains_key(prop) && dep_schema.is_object() {
tasks.push((
child_pointer(pointer, &["dependencies", prop]),
Cow::Borrowed(defaults),
));
}
}
}
if let Some(deps) = schema_obj
.get("dependentSchemas")
.and_then(Value::as_object)
{
for (prop, dep_schema) in deps {
if default_map.contains_key(prop) && dep_schema.is_object() {
tasks.push((
child_pointer(pointer, &["dependentSchemas", prop]),
Cow::Borrowed(defaults),
));
}
}
}
}
if let Some(default_array) = defaults.as_array()
&& let Some(items) = schema_obj.get("items")
{
match items {
Value::Array(tuple) => {
for (idx, _) in tuple.iter().enumerate() {
if let Some(value) = default_array.get(idx) {
let idx_str = idx.to_string();
tasks.push((
child_pointer(pointer, &["items", &idx_str]),
Cow::Borrowed(value),
));
}
}
}
Value::Object(_) => {
if let Some(first) = default_array.first() {
tasks.push((child_pointer(pointer, &["items"]), Cow::Borrowed(first)));
}
}
_ => {}
}
}
for keyword in ["oneOf", "anyOf", "allOf"] {
if let Some(Value::Array(branches)) = schema_obj.get(keyword) {
for idx in 0..branches.len() {
let idx_str = idx.to_string();
tasks.push((
child_pointer(pointer, &[keyword, &idx_str]),
Cow::Borrowed(defaults),
));
}
}
}
let mut ref_targets = Vec::new();
if let Some(Value::String(reference)) = schema_obj.get("$ref")
&& let Some(target_pointer) = pointer_from_reference(reference)
&& self.active_refs.insert(target_pointer.clone())
{
ref_targets.push(target_pointer.clone());
tasks.push((target_pointer, Cow::Borrowed(defaults)));
}
for (child_pointer, value) in tasks {
self.apply_at(root, &child_pointer, value.as_ref());
}
for pointer in ref_targets {
self.active_refs.remove(&pointer);
}
}
}
fn pointer_from_reference(reference: &str) -> Option<String> {
if let Some(path) = reference.strip_prefix('#') {
if path.is_empty() {
Some(String::new())
} else if path.starts_with('/') {
Some(path.to_string())
} else {
Some(format!("/{}", path))
}
} else {
None
}
}
fn pattern_defaults(pattern: &str, defaults: &Map<String, Value>) -> Option<Map<String, Value>> {
let regex = Regex::new(pattern).ok()?;
let mut matched = Map::new();
for (key, value) in defaults {
if regex.is_match(key) {
matched.insert(key.clone(), value.clone());
}
}
if matched.is_empty() {
None
} else {
Some(matched)
}
}
#[allow(clippy::if_same_then_else)]
fn child_pointer(base: &str, segments: &[&str]) -> String {
let mut pointer = base.to_string();
for segment in segments {
let escaped = escape_pointer_token(segment);
if pointer.is_empty() {
pointer.push('/');
} else {
pointer.push('/');
}
pointer.push_str(&escaped);
}
pointer
}
fn escape_pointer_token(token: &str) -> String {
token.replace('~', "~0").replace('/', "~1")
}
fn infer_schema(value: &Value) -> Value {
match value {
Value::Null => schema_with_type("null", value),
Value::Bool(_) => schema_with_type("boolean", value),
Value::Number(num) => schema_with_type(number_type(num), value),
Value::String(_) => schema_with_type("string", value),
Value::Array(items) => array_schema(items),
Value::Object(map) => object_schema(map),
}
}
fn schema_with_type(kind: &str, default: &Value) -> Value {
let mut schema = Map::new();
schema.insert("type".to_string(), Value::String(kind.to_string()));
schema.insert("default".to_string(), default.clone());
Value::Object(schema)
}
fn object_schema(values: &Map<String, Value>) -> Value {
let mut properties = Map::new();
let mut required = Vec::new();
for (key, value) in values {
properties.insert(key.clone(), infer_schema(value));
required.push(Value::String(key.clone()));
}
let mut schema = Map::new();
schema.insert("type".to_string(), Value::String("object".to_string()));
schema.insert("default".to_string(), Value::Object(values.clone()));
schema.insert("additionalProperties".to_string(), Value::Bool(true));
if !properties.is_empty() {
schema.insert("properties".to_string(), Value::Object(properties));
}
if !required.is_empty() {
schema.insert("required".to_string(), Value::Array(required));
}
Value::Object(schema)
}
fn array_schema(items: &[Value]) -> Value {
let mut schema = Map::new();
schema.insert("type".to_string(), Value::String("array".to_string()));
schema.insert("default".to_string(), Value::Array(items.to_vec()));
if let Some(item_schema) = infer_items_schema(items) {
schema.insert("items".to_string(), item_schema);
}
Value::Object(schema)
}
fn infer_items_schema(items: &[Value]) -> Option<Value> {
let (first, rest) = items.split_first()?;
let first_schema = infer_schema(first);
let first_signature = strip_defaults(&first_schema);
let mut variants = vec![(first_schema, first_signature)];
for item in rest {
let schema = infer_schema(item);
let signature = strip_defaults(&schema);
if !variants.iter().any(|(_, existing)| *existing == signature) {
variants.push((schema, signature));
}
}
if variants.len() == 1 {
variants.pop().map(|(schema, _)| schema)
} else {
let schemas: Vec<Value> = variants.into_iter().map(|(schema, _)| schema).collect();
Some(json!({ "anyOf": schemas }))
}
}
fn strip_defaults(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut sanitized = Map::new();
for (key, subvalue) in map {
if key == "default" {
continue;
}
sanitized.insert(key.clone(), strip_defaults(subvalue));
}
Value::Object(sanitized)
}
Value::Array(items) => Value::Array(items.iter().map(strip_defaults).collect()),
other => other.clone(),
}
}
fn number_type(number: &Number) -> &'static str {
if number.is_i64() || number.is_u64() {
"integer"
} else {
"number"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_schema_for_object_defaults() {
let value = json!({"host": "localhost", "port": 8080});
let schema = schema_from_data_value(&value);
assert_eq!(schema["properties"]["host"]["default"], json!("localhost"));
assert_eq!(schema["properties"]["port"]["type"], json!("integer"));
assert_eq!(schema["default"], value);
}
#[test]
fn builds_schema_for_array_defaults() {
let value = json!(["a", "b"]);
let schema = schema_from_data_value(&value);
assert_eq!(schema["type"], json!("array"));
assert_eq!(schema["default"], value);
assert_eq!(schema["items"]["type"], json!("string"));
}
#[cfg(feature = "json")]
#[test]
fn parse_json_documents() {
let raw = "{\"enabled\":true}";
let parsed = parse_document_str(raw, DocumentFormat::Json).unwrap();
assert_eq!(parsed["enabled"], Value::Bool(true));
}
#[cfg(feature = "yaml")]
#[test]
fn parse_yaml_documents() {
let raw = "enabled: true\nname: dev";
let parsed = parse_document_str(raw, DocumentFormat::Yaml).unwrap();
assert_eq!(parsed["enabled"], Value::Bool(true));
assert_eq!(parsed["name"], json!("dev"));
}
#[cfg(feature = "toml")]
#[test]
fn parse_toml_documents() {
let raw = "enabled = true\nname = \"dev\"";
let parsed = parse_document_str(raw, DocumentFormat::Toml).unwrap();
assert_eq!(parsed["enabled"], Value::Bool(true));
assert_eq!(parsed["name"], json!("dev"));
}
#[test]
fn merges_defaults_into_existing_schema() {
let schema = json!({
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "integer"}
}
});
let defaults = json!({"host": "localhost", "port": 8080});
let enriched = schema_with_defaults(&schema, &defaults);
assert_eq!(
enriched["properties"]["host"]["default"],
json!("localhost")
);
assert_eq!(enriched["properties"]["port"]["default"], json!(8080));
assert_eq!(enriched["default"], defaults);
}
#[test]
fn merges_array_defaults() {
let schema = json!({
"type": "array",
"items": {"type": "object", "properties": {"tag": {"type": "string"}}}
});
let defaults = json!([{ "tag": "api" }]);
let enriched = schema_with_defaults(&schema, &defaults);
assert_eq!(enriched["default"], defaults);
assert_eq!(
enriched["items"]["properties"]["tag"]["default"],
json!("api")
);
}
#[test]
fn propagates_defaults_through_refs() {
let schema = json!({
"definitions": {
"endpoint": {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "integer"}
}
}
},
"type": "object",
"properties": {
"service": {"$ref": "#/definitions/endpoint"}
}
});
let defaults = json!({
"service": {"host": "localhost", "port": 8080}
});
let enriched = schema_with_defaults(&schema, &defaults);
assert_eq!(
enriched["properties"]["service"]["default"]["host"],
json!("localhost")
);
assert_eq!(
enriched["definitions"]["endpoint"]["properties"]["port"]["default"],
json!(8080)
);
}
#[test]
fn applies_defaults_for_pattern_properties() {
let schema = json!({
"type": "object",
"patternProperties": {
"^env_": {"type": "string"}
}
});
let defaults = json!({"env_api": "v1", "other": "noop"});
let enriched = schema_with_defaults(&schema, &defaults);
assert_eq!(enriched["default"], defaults);
assert_eq!(
enriched["patternProperties"]["^env_"]["default"]["env_api"],
json!("v1")
);
assert!(
enriched["patternProperties"]["^env_"]["default"]
.get("other")
.is_none()
);
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod input;
pub mod output;
mod format;
pub use format::{DocumentFormat, DocumentFormatProbe};
+178
View File
@@ -0,0 +1,178 @@
use std::fs::File;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde_json::Value;
use super::DocumentFormat;
/// Destination for serialized output values.
#[derive(Debug, Clone)]
pub enum OutputDestination {
Stdout,
File(PathBuf),
}
impl OutputDestination {
pub fn file(path: impl AsRef<Path>) -> Self {
OutputDestination::File(path.as_ref().to_path_buf())
}
}
/// Controls how data is serialized after the UI completes.
#[derive(Debug, Clone)]
pub struct OutputOptions {
pub format: DocumentFormat,
pub pretty: bool,
pub destinations: Vec<OutputDestination>,
}
impl OutputOptions {
pub fn new(format: DocumentFormat) -> Self {
Self {
format,
pretty: true,
destinations: vec![OutputDestination::Stdout],
}
}
pub fn with_pretty(mut self, pretty: bool) -> Self {
self.pretty = pretty;
self
}
pub fn with_destinations(mut self, destinations: Vec<OutputDestination>) -> Self {
self.destinations = destinations;
self
}
pub fn add_destination(mut self, destination: OutputDestination) -> Self {
self.destinations.push(destination);
self
}
pub fn render(&self, value: &Value) -> Result<String> {
serialize_value(value, self)
}
pub fn write(&self, value: &Value) -> Result<()> {
if self.destinations.is_empty() {
return Ok(());
}
let payload = self.render(value)?;
for destination in &self.destinations {
write_payload(destination, &payload).with_context(|| match destination {
OutputDestination::Stdout => "failed to write to stdout".to_string(),
OutputDestination::File(path) => {
format!("failed to write to file {}", path.display())
}
})?;
}
Ok(())
}
}
impl Default for OutputOptions {
fn default() -> Self {
Self::new(DocumentFormat::default())
}
}
/// Serialize and write the final value according to the configured format and destinations.
pub fn emit(value: &Value, options: &OutputOptions) -> Result<()> {
options.write(value)
}
fn serialize_value(value: &Value, options: &OutputOptions) -> Result<String> {
match options.format {
#[cfg(feature = "json")]
DocumentFormat::Json => {
if options.pretty {
serde_json::to_string_pretty(value).context("failed to serialize JSON")
} else {
serde_json::to_string(value).context("failed to serialize JSON")
}
}
#[cfg(feature = "yaml")]
DocumentFormat::Yaml => serde_yaml::to_string(value).context("failed to serialize YAML"),
#[cfg(feature = "toml")]
DocumentFormat::Toml => {
if options.pretty {
toml::to_string_pretty(value).context("failed to serialize TOML")
} else {
toml::to_string(value).context("failed to serialize TOML")
}
}
}
}
fn write_payload(destination: &OutputDestination, payload: &str) -> Result<()> {
match destination {
OutputDestination::Stdout => {
let mut stdout = io::stdout();
stdout
.write_all(payload.as_bytes())
.and_then(|_| stdout.write_all(b"\n"))
.context("failed to flush stdout")?;
stdout.flush().context("failed to flush stdout")
}
OutputDestination::File(path) => {
let mut file = File::create(path)?;
file.write_all(payload.as_bytes())?;
file.write_all(b"\n")?;
file.flush()?;
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse_document_str;
use serde_json::json;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn writes_to_stdout_noop_when_not_configured() {
let options = OutputOptions {
format: DocumentFormat::default(),
pretty: true,
destinations: Vec::new(),
};
emit(&json!({"ok": true}), &options).unwrap();
}
#[test]
fn renders_payload_without_writing() {
let options = OutputOptions::default();
let payload = options.render(&json!({"ok": true})).unwrap();
let parsed = parse_document_str(&payload, options.format).unwrap();
assert_eq!(parsed, json!({"ok": true}));
}
#[test]
fn writes_to_file_destination() {
let dir = std::env::temp_dir();
let filename = format!(
"schemaui-test-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let path = dir.join(filename);
let options = OutputOptions {
format: DocumentFormat::default(),
pretty: true,
destinations: vec![OutputDestination::file(&path)],
};
emit(&json!({"ok": true}), &options).unwrap();
let contents = fs::read_to_string(&path).unwrap();
let parsed = parse_document_str(&contents, options.format).unwrap();
assert_eq!(parsed, json!({"ok": true}));
let _ = fs::remove_file(path);
}
}
+60
View File
@@ -0,0 +1,60 @@
#![allow(clippy::doc_overindented_list_items)]
#![doc = include_str!("../README.md")]
mod core;
#[cfg(feature = "tui")]
pub(crate) mod deepseek_palette;
pub mod io;
// #[cfg(feature = "precompile")]
pub mod precompile;
mod schema;
#[cfg(feature = "tui")]
pub mod tui;
pub mod ui_ast;
#[cfg(feature = "web")]
pub mod web;
#[cfg(test)]
pub(crate) mod tests;
pub use core::frontend::FrontendOptions;
pub use core::schema_ui::{DocumentInput, SchemaUI};
pub use io::{
DocumentFormat, DocumentFormatProbe,
input::{
looks_like_json_schema, parse_document_auto, parse_document_str, schema_from_data_str,
schema_from_data_value, schema_with_defaults,
},
output::{OutputDestination, OutputOptions},
};
#[cfg(feature = "tui")]
pub use precompile::TuiArtifacts;
pub use precompile::UiArtifactBundle;
#[cfg(feature = "tui")]
pub use tui::{
app::options::UiOptions,
model::FormSchema,
session::TuiFrontend,
state::LayoutNavModel,
view::{CompositeOverlay, PopupRender, UiContext, draw},
};
#[cfg(feature = "web")]
pub use web::{frontend::WebFrontend, session::ServeOptions};
pub mod prelude {
pub use super::DocumentInput;
pub use super::FrontendOptions;
pub use super::SchemaUI;
#[cfg(feature = "tui")]
pub use super::TuiFrontend;
#[cfg(feature = "tui")]
pub use super::UiOptions;
#[cfg(feature = "tui")]
pub use super::draw;
#[cfg(feature = "tui")]
pub use super::tui::view::{CompositeOverlay, PopupRender, UiContext};
#[cfg(feature = "web")]
pub use super::ServeOptions;
#[cfg(feature = "web")]
pub use super::WebFrontend;
}
+19
View File
@@ -0,0 +1,19 @@
use std::{fs, path::Path};
use anyhow::Result;
use serde_json::Value;
use crate::io::{DocumentFormat, input::parse_document_str};
use crate::ui_ast::build_ui_ast;
use crate::ui_ast::defaults::{DefaultIndex, collect_defaults};
/// Build a DefaultIndex mapping pointers to default values from a schema file.
///
/// This mirrors the backend UiAst default semantics and is suitable for
/// offline analysis or code generation in build scripts and CI jobs.
pub fn collect_defaults_from_file(path: &Path, format: DocumentFormat) -> Result<DefaultIndex> {
let contents = fs::read_to_string(path)?;
let schema: Value = parse_document_str(&contents, format)?;
let ast = build_ui_ast(&schema)?;
Ok(collect_defaults(&ast))
}
+16
View File
@@ -0,0 +1,16 @@
use std::path::Path;
use anyhow::Result;
use crate::io::DocumentFormat;
use crate::precompile::build_ui_ast_bundle_from_file;
use crate::ui_ast::UiLayout;
/// Build a UiLayout tree from a schema file.
///
/// This is a pure helper intended for use in build scripts or external
/// codegen tools. It relies only on the canonical UiAst visitor and the
/// internal ui_ast::layout representation.
pub fn build_ui_layout_from_file(path: &Path, format: DocumentFormat) -> Result<UiLayout> {
Ok(build_ui_ast_bundle_from_file(path, format)?.layout)
}
+228
View File
@@ -0,0 +1,228 @@
use std::{fs, path::Path};
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use crate::io::input::{parse_document_str, schema_with_defaults};
use crate::io::{DocumentFormat, DocumentFormatProbe};
#[cfg(feature = "tui")]
use crate::tui::model::{FormSchema, form_schema_from_ui_ast};
#[cfg(feature = "tui")]
use crate::tui::state::LayoutNavModel;
use crate::ui_ast::{UiAst, UiAstBundle, build_ui_ast_bundle};
pub mod defaults;
pub mod layout;
#[cfg(feature = "tui")]
pub mod tui;
#[cfg(feature = "web")]
pub mod web;
pub const UI_ARTIFACT_BUNDLE_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UiArtifactFingerprint {
pub schema_sha256: String,
pub defaults_sha256: String,
pub input_sha256: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg(feature = "tui")]
pub struct TuiArtifacts {
pub form_schema: FormSchema,
pub layout_nav: LayoutNavModel,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UiArtifactBundle {
pub artifact_version: u32,
pub fingerprint: UiArtifactFingerprint,
pub ui: UiAstBundle,
#[cfg(feature = "tui")]
pub tui: TuiArtifacts,
}
pub fn build_ui_artifact_bundle(
schema: &Value,
defaults: Option<&Value>,
) -> Result<UiArtifactBundle> {
let defaults = defaults
.cloned()
.unwrap_or_else(|| Value::Object(Map::new()));
let enriched = schema_with_defaults(schema, &defaults);
let ui = build_ui_ast_bundle(&enriched)?;
let fingerprint = UiArtifactFingerprint {
schema_sha256: sha256_hex(&stable_value_bytes(schema)?),
defaults_sha256: sha256_hex(&stable_value_bytes(&defaults)?),
input_sha256: sha256_hex(&stable_value_bytes(&Value::Object(Map::from_iter([
("schema".to_string(), stable_value(schema)),
("defaults".to_string(), stable_value(&defaults)),
])))?),
};
#[cfg(feature = "tui")]
let tui = TuiArtifacts {
form_schema: form_schema_from_ui_ast(&ui.ui_ast),
layout_nav: LayoutNavModel::from_uilayout(&ui.layout),
};
Ok(UiArtifactBundle {
artifact_version: UI_ARTIFACT_BUNDLE_VERSION,
fingerprint,
ui,
#[cfg(feature = "tui")]
tui,
})
}
pub fn build_ui_artifact_bundle_from_file(
schema_path: &Path,
schema_format: DocumentFormat,
defaults_path: Option<&Path>,
) -> Result<UiArtifactBundle> {
let schema = read_document_file(schema_path, schema_format)?;
let defaults = if let Some(path) = defaults_path {
let format = resolve_file_format(path, schema_format, "defaults file")?;
Some(read_document_file(path, format)?)
} else {
None
};
build_ui_artifact_bundle(&schema, defaults.as_ref())
}
/// Read a schema file, parse it as JSON/YAML/TOML, and build a UiAst.
pub fn build_ui_ast_from_file(path: &Path, format: DocumentFormat) -> Result<UiAst> {
Ok(build_ui_ast_bundle_from_file(path, format)?.ui_ast)
}
/// Read a schema file, parse it as JSON/YAML/TOML, and build a shared UI
/// artifact bundle.
pub fn build_ui_ast_bundle_from_file(path: &Path, format: DocumentFormat) -> Result<UiAstBundle> {
let schema = read_document_file(path, format)?;
// For compile-time we typically do not apply data-driven defaults.
build_ui_ast_bundle(&schema)
}
/// Serialize a UiAst value to pretty-printed JSON.
pub fn ui_ast_to_json(ast: &UiAst) -> Result<String> {
Ok(serde_json::to_string_pretty(ast)?)
}
/// Generate a Rust module under OUT_DIR that exposes a UiAst JSON constant.
///
/// The generated module will contain:
/// `pub const <const_name>: &str = r#"<UiAst-json>"#;`
pub fn generate_ui_ast_rust_module(
schema_path: &Path,
format: DocumentFormat,
out_module_path: &Path,
const_name: &str,
) -> Result<()> {
let ast = build_ui_ast_from_file(schema_path, format)?;
let json = ui_ast_to_json(&ast)?;
let src = format!(
"pub const {name}: &str = r#\"{json}\"#;\n",
name = const_name,
json = json,
);
fs::write(out_module_path, src)?;
Ok(())
}
/// Decode a UiAst from a JSON string produced by `ui_ast_to_json`.
pub fn decode_ui_ast(json: &str) -> Result<UiAst> {
Ok(serde_json::from_str(json)?)
}
pub fn ui_ast_bundle_to_json(bundle: &UiAstBundle) -> Result<String> {
Ok(serde_json::to_string_pretty(bundle)?)
}
pub fn decode_ui_ast_bundle(json: &str) -> Result<UiAstBundle> {
Ok(serde_json::from_str(json)?)
}
pub fn ui_artifact_bundle_to_json(bundle: &UiArtifactBundle) -> Result<String> {
Ok(serde_json::to_string_pretty(bundle)?)
}
pub fn decode_ui_artifact_bundle(json: &str) -> Result<UiArtifactBundle> {
Ok(serde_json::from_str(json)?)
}
/// Generate a Rust module under OUT_DIR that exposes a constructor for
/// `UiArtifactBundle` built from the given schema and optional defaults.
pub fn generate_ui_artifact_bundle_module(
schema_path: &Path,
format: DocumentFormat,
defaults_path: Option<&Path>,
out_module_path: &Path,
fn_name: &str,
) -> Result<()> {
let bundle = build_ui_artifact_bundle_from_file(schema_path, format, defaults_path)?;
let json = ui_artifact_bundle_to_json(&bundle)?;
let src = format!(
"pub fn {fn_name}() -> schemaui::precompile::UiArtifactBundle {{\n serde_json::from_str::<schemaui::precompile::UiArtifactBundle>(r#\"{json}\"#).expect(\"invalid UI artifact bundle JSON\")\n}}\n",
);
fs::write(out_module_path, src)?;
Ok(())
}
fn read_document_file(path: &Path, format: DocumentFormat) -> Result<Value> {
let contents = fs::read_to_string(path)?;
let schema: Value = parse_document_str(&contents, format)?;
Ok(schema)
}
pub(crate) fn resolve_file_format(
path: &Path,
fallback: DocumentFormat,
label: &str,
) -> Result<DocumentFormat> {
match DocumentFormat::probe_extension(path) {
DocumentFormatProbe::Known(format) => Ok(format),
DocumentFormatProbe::UnsupportedFeature {
format_name,
feature_flag,
} => Err(anyhow!(
"{label} {} requires {format_name} support, but this build lacks the '{feature_flag}' feature",
path.display()
)),
DocumentFormatProbe::Unknown => Ok(fallback),
}
}
fn stable_value_bytes(value: &Value) -> Result<Vec<u8>> {
Ok(serde_json::to_vec(&stable_value(value))?)
}
fn stable_value(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut entries = map.iter().collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let mut normalized = Map::new();
for (key, value) in entries {
normalized.insert(key.clone(), stable_value(value));
}
Value::Object(normalized)
}
Value::Array(values) => Value::Array(values.iter().map(stable_value).collect()),
_ => value.clone(),
}
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(&mut out, "{byte:02x}");
}
out
}
+198
View File
@@ -0,0 +1,198 @@
use std::{fs, path::Path};
use anyhow::Result;
use serde_json::Value;
use crate::io::DocumentFormat;
use crate::precompile::{
TuiArtifacts, build_ui_artifact_bundle, build_ui_artifact_bundle_from_file, resolve_file_format,
};
use crate::tui::model::FormSchema;
use crate::tui::state::LayoutNavModel;
use crate::ui_ast::UiAst;
/// Build UiAst and TUI artifacts from schema/default values.
pub fn build_tui_artifacts(
schema: &Value,
defaults: Option<&Value>,
) -> Result<(UiAst, TuiArtifacts)> {
let bundle = build_ui_artifact_bundle(schema, defaults)?;
Ok((bundle.ui.ui_ast, bundle.tui))
}
/// Build UiAst and TUI artifacts from a schema file.
///
/// This is a pure helper intended for use in build scripts or external
/// codegen tools. It does not depend on any runtime state.
pub fn build_tui_artifacts_from_file(
path: &Path,
format: DocumentFormat,
defaults_path: Option<&Path>,
) -> Result<(UiAst, TuiArtifacts)> {
let bundle = build_ui_artifact_bundle_from_file(path, format, defaults_path)?;
Ok((bundle.ui.ui_ast, bundle.tui))
}
/// Build UiAst and TUI FormSchema from schema/default values.
pub fn build_tui_form_schema(
schema: &Value,
defaults: Option<&Value>,
) -> Result<(UiAst, FormSchema)> {
let (ui_ast, tui) = build_tui_artifacts(schema, defaults)?;
Ok((ui_ast, tui.form_schema))
}
/// Build UiAst and TUI FormSchema from a schema file plus optional defaults.
pub fn build_tui_form_schema_from_file(
path: &Path,
format: DocumentFormat,
defaults_path: Option<&Path>,
) -> Result<(UiAst, FormSchema)> {
let (ui_ast, tui) = build_tui_artifacts_from_file(path, format, defaults_path)?;
Ok((ui_ast, tui.form_schema))
}
/// Build UiAst and a TUI LayoutNavModel from schema/default values.
pub fn build_tui_layout_nav(
schema: &Value,
defaults: Option<&Value>,
) -> Result<(UiAst, LayoutNavModel)> {
let (ui_ast, tui) = build_tui_artifacts(schema, defaults)?;
Ok((ui_ast, tui.layout_nav))
}
/// Build UiAst and a TUI LayoutNavModel from a schema file plus optional
/// defaults.
pub fn build_tui_layout_nav_from_file(
path: &Path,
format: DocumentFormat,
defaults_path: Option<&Path>,
) -> Result<(UiAst, LayoutNavModel)> {
let (ui_ast, tui) = build_tui_artifacts_from_file(path, format, defaults_path)?;
Ok((ui_ast, tui.layout_nav))
}
/// Generate a Rust module under OUT_DIR that exposes a constructor for
/// `TuiArtifacts` built from the given schema and optional defaults values.
pub fn generate_tui_artifacts_module_from_value(
schema: &Value,
defaults: Option<&Value>,
out_module_path: &Path,
fn_name: &str,
) -> Result<()> {
let (_ast, artifacts) = build_tui_artifacts(schema, defaults)?;
let json = serde_json::to_string_pretty(&artifacts)?;
let src = format!(
"pub fn {fn_name}() -> schemaui::TuiArtifacts {{\n serde_json::from_str::<schemaui::TuiArtifacts>(r#\"{json}\"#).expect(\"invalid TUI artifacts JSON\")\n}}\n",
);
fs::write(out_module_path, src)?;
Ok(())
}
/// Generate a Rust module under OUT_DIR that exposes a constructor for
/// `TuiArtifacts` built from the given schema and optional defaults.
pub fn generate_tui_artifacts_module(
schema_path: &Path,
format: DocumentFormat,
defaults_path: Option<&Path>,
out_module_path: &Path,
fn_name: &str,
) -> Result<()> {
let (schema, defaults) = load_schema_and_defaults(schema_path, format, defaults_path)?;
generate_tui_artifacts_module_from_value(&schema, defaults.as_ref(), out_module_path, fn_name)
}
/// Generate a Rust module under OUT_DIR that exposes a constructor for
/// `FormSchema` built from the given schema and optional defaults values.
pub fn generate_tui_form_schema_module_from_value(
schema: &Value,
defaults: Option<&Value>,
out_module_path: &Path,
fn_name: &str,
) -> Result<()> {
let (_ast, form_schema) = build_tui_form_schema(schema, defaults)?;
let json = serde_json::to_string_pretty(&form_schema)?;
let src = format!(
"pub fn {fn_name}() -> schemaui::FormSchema {{\n serde_json::from_str::<schemaui::FormSchema>(r#\"{json}\"#).expect(\"invalid generated FormSchema JSON\")\n}}\n",
);
fs::write(out_module_path, src)?;
Ok(())
}
/// Generate a Rust module under OUT_DIR that exposes a constructor for
/// `FormSchema` built from the given schema and optional defaults.
///
/// The generated module will contain a function with the given `fn_name`:
///
/// ```ignore
/// pub fn <fn_name>() -> schemaui::FormSchema { ... }
/// ```
///
/// The implementation deserializes `FormSchema` from an embedded JSON
/// representation produced at codegen time.
pub fn generate_tui_form_schema_module(
schema_path: &Path,
format: DocumentFormat,
defaults_path: Option<&Path>,
out_module_path: &Path,
fn_name: &str,
) -> Result<()> {
let (schema, defaults) = load_schema_and_defaults(schema_path, format, defaults_path)?;
generate_tui_form_schema_module_from_value(&schema, defaults.as_ref(), out_module_path, fn_name)
}
/// Generate a Rust module under OUT_DIR that exposes a constructor for
/// `LayoutNavModel` built from the given schema and optional defaults values.
pub fn generate_tui_layout_nav_module_from_value(
schema: &Value,
defaults: Option<&Value>,
out_module_path: &Path,
fn_name: &str,
) -> Result<()> {
let (_ast, layout_nav) = build_tui_layout_nav(schema, defaults)?;
let json = serde_json::to_string_pretty(&layout_nav)?;
let src = format!(
"pub fn {fn_name}() -> schemaui::LayoutNavModel {{\n serde_json::from_str::<schemaui::LayoutNavModel>(r#\"{json}\"#).expect(\"invalid generated LayoutNavModel JSON\")\n}}\n",
);
fs::write(out_module_path, src)?;
Ok(())
}
/// Generate a Rust module under OUT_DIR that exposes a constructor for
/// `LayoutNavModel` built from the given schema and optional defaults.
///
/// The generated module will contain a function with the given `fn_name`:
///
/// ```ignore
/// pub fn <fn_name>() -> schemaui::LayoutNavModel { ... }
/// ```
///
/// The implementation deserializes `LayoutNavModel` from an embedded JSON
/// representation produced at codegen time.
pub fn generate_tui_layout_nav_module(
schema_path: &Path,
format: DocumentFormat,
defaults_path: Option<&Path>,
out_module_path: &Path,
fn_name: &str,
) -> Result<()> {
let (schema, defaults) = load_schema_and_defaults(schema_path, format, defaults_path)?;
generate_tui_layout_nav_module_from_value(&schema, defaults.as_ref(), out_module_path, fn_name)
}
fn load_schema_and_defaults(
schema_path: &Path,
format: DocumentFormat,
defaults_path: Option<&Path>,
) -> Result<(Value, Option<Value>)> {
let schema = std::fs::read_to_string(schema_path)?;
let schema = crate::parse_document_str(&schema, format)?;
let defaults = if let Some(path) = defaults_path {
let format = resolve_file_format(path, format, "defaults file")?;
let raw = std::fs::read_to_string(path)?;
Some(crate::parse_document_str(&raw, format)?)
} else {
None
};
Ok((schema, defaults))
}
+84
View File
@@ -0,0 +1,84 @@
use std::{fs, path::Path};
use anyhow::Result;
use serde_json::Value;
use crate::io::{DocumentFormat, input::parse_document_str};
use crate::precompile::{build_ui_artifact_bundle, resolve_file_format};
use crate::schema::metadata::root_schema_header;
use crate::web::session::SessionResponse;
/// Build a minimal Web session snapshot (SessionResponse) from a schema value
/// and optional defaults value.
pub fn build_session_snapshot(
schema_value: &Value,
defaults_value: Option<&Value>,
) -> Result<SessionResponse> {
let defaults_value = defaults_value
.cloned()
.unwrap_or_else(|| Value::Object(Default::default()));
let (title, description) = root_schema_header(schema_value);
let bundle = build_ui_artifact_bundle(schema_value, Some(&defaults_value))?;
let (ui_ast, layout) = bundle.ui.into_parts();
let formats: Vec<String> = DocumentFormat::available_formats()
.into_iter()
.map(|f| f.to_string())
.collect();
Ok(SessionResponse {
title,
description,
ui_ast,
data: defaults_value,
formats,
layout: Some(layout),
})
}
/// Build a minimal Web session snapshot (SessionResponse) from a schema file
/// and optional defaults file. This mirrors the runtime WebSessionBuilder
/// logic but is intended for use at compile-time or in external codegen
/// tools.
pub fn build_session_snapshot_from_files(
schema_path: &Path,
schema_format: DocumentFormat,
defaults_path: Option<&Path>,
) -> Result<SessionResponse> {
let schema_raw = fs::read_to_string(schema_path)?;
let schema_value: Value = parse_document_str(&schema_raw, schema_format)?;
let defaults_value = if let Some(path) = defaults_path {
let format = resolve_file_format(path, schema_format, "defaults file")?;
let raw = fs::read_to_string(path)?;
Some(parse_document_str(&raw, format)?)
} else {
None
};
build_session_snapshot(&schema_value, defaults_value.as_ref())
}
/// Write a SessionResponse snapshot as pretty JSON to a file.
pub fn write_session_snapshot_json(snapshot: &SessionResponse, out_path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(snapshot)?;
fs::write(out_path, json)?;
Ok(())
}
/// Write a TypeScript module exporting a SessionResponse constant. This is
/// useful for static SPA deployments where the session payload is embedded
/// directly into the frontend bundle.
pub fn write_session_snapshot_ts_module(
snapshot: &SessionResponse,
out_path: &Path,
export_name: &str,
) -> Result<()> {
let json = serde_json::to_string_pretty(snapshot)?;
let src = format!(
"import type {{ SessionResponse }} from '@schemaui/types/SessionResponse';\n\nexport const {export_name}: SessionResponse = {json} as const;\n",
);
fs::write(out_path, src)?;
Ok(())
}
+63
View File
@@ -0,0 +1,63 @@
use serde_json::{Map, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SchemaDialect {
Draft7,
Draft202012,
Unknown,
}
impl SchemaDialect {
pub fn detect(schema: &Value) -> Self {
let Some(uri) = schema
.as_object()
.and_then(|obj| obj.get("$schema"))
.and_then(Value::as_str)
else {
return Self::Unknown;
};
if uri.contains("draft-07") {
Self::Draft7
} else if uri.contains("2020-12") {
Self::Draft202012
} else {
Self::Unknown
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RootDialectContext {
pub dialect: SchemaDialect,
pub schema_keyword: Option<Value>,
pub dollar_defs: Option<Value>,
pub definitions: Option<Value>,
}
impl RootDialectContext {
pub fn from_root(schema: &Value) -> Self {
let obj = schema.as_object();
Self {
dialect: SchemaDialect::detect(schema),
schema_keyword: obj.and_then(|map| map.get("$schema").cloned()),
dollar_defs: obj.and_then(|map| map.get("$defs").cloned()),
definitions: obj.and_then(|map| map.get("definitions").cloned()),
}
}
pub fn apply_to_overlay(&self, map: &mut Map<String, Value>) {
if let Some(schema_keyword) = &self.schema_keyword {
map.entry("$schema".to_string())
.or_insert_with(|| schema_keyword.clone());
}
if let Some(definitions) = &self.definitions {
map.entry("definitions".to_string())
.or_insert_with(|| definitions.clone());
}
if let Some(dollar_defs) = &self.dollar_defs {
map.entry("$defs".to_string())
.or_insert_with(|| dollar_defs.clone());
}
}
}
+11
View File
@@ -0,0 +1,11 @@
use anyhow::{Context, Result};
use serde_json::Value;
use super::model::{Schema, SchemaObject};
/// Deserialize a JSON value into an internal `SchemaObject`.
pub fn load_root_schema(value: &Value) -> Result<SchemaObject> {
serde_json::from_value::<Schema>(value.clone())
.map(Schema::into_object)
.context("schema is not a valid JSON Schema document")
}
+105
View File
@@ -0,0 +1,105 @@
#![allow(dead_code)]
use std::collections::HashMap;
use serde_json::Value;
use super::model::SchemaObject;
#[derive(Debug, Clone)]
pub struct SectionInfo {
pub id: String,
pub title: String,
pub description: Option<String>,
}
pub fn general_section_info() -> SectionInfo {
SectionInfo {
id: "general".to_string(),
title: "General".to_string(),
description: None,
}
}
pub fn section_info_for_object(
schema: &SchemaObject,
name: &str,
parent: Option<&SectionInfo>,
) -> SectionInfo {
if let Some(group) = extension_string(schema, "x-group") {
let title =
extension_string(schema, "x-group-title").unwrap_or_else(|| prettify_label(&group));
let description = extension_string(schema, "x-group-description");
return SectionInfo {
id: group,
title,
description,
};
}
SectionInfo {
id: name.to_string(),
title: schema
.metadata
.as_ref()
.and_then(|m| m.title.clone())
.unwrap_or_else(|| prettify_label(name)),
description: schema
.metadata
.as_ref()
.and_then(|m| m.description.clone())
.or_else(|| parent.and_then(|p| p.description.clone())),
}
}
pub fn metadata_map(schema: &SchemaObject) -> HashMap<String, Value> {
schema
.extensions
.iter()
.filter(|(key, _)| key.starts_with("x-"))
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
pub fn root_schema_header(schema: &Value) -> (Option<String>, Option<String>) {
(
root_schema_string(schema, "title"),
root_schema_string(schema, "description"),
)
}
fn root_schema_string(schema: &Value, key: &str) -> Option<String> {
schema.as_object()?.get(key)?.as_str().map(str::to_string)
}
pub fn extension_string(schema: &SchemaObject, key: &str) -> Option<String> {
schema
.extensions
.get(key)
.and_then(|value| value.as_str().map(str::to_string))
}
pub fn prettify_label(raw: &str) -> String {
if raw.is_empty() {
return String::new();
}
let mut result = String::with_capacity(raw.len());
let mut capitalize = true;
for ch in raw.chars() {
if ch == '_' || ch == '-' {
result.push(' ');
capitalize = true;
continue;
}
if capitalize {
result.push(ch.to_ascii_uppercase());
capitalize = false;
} else {
result.push(ch);
}
}
result.trim().to_string()
}
+5
View File
@@ -0,0 +1,5 @@
pub mod dialect;
pub mod loader;
pub mod metadata;
pub mod model;
pub mod resolver;
+281
View File
@@ -0,0 +1,281 @@
use indexmap::IndexMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InstanceType {
Null,
Boolean,
Object,
Array,
Number,
String,
Integer,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SingleOrVec<T> {
Single(Box<T>),
Vec(Vec<T>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Schema {
Bool(bool),
Object(Box<SchemaObject>),
}
impl Schema {
pub fn into_object(self) -> SchemaObject {
match self {
Self::Bool(_) => SchemaObject::default(),
Self::Object(object) => *object,
}
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Metadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, rename = "default", skip_serializing_if = "Option::is_none")]
pub default: Option<Value>,
#[serde(default, skip_serializing_if = "is_false")]
pub deprecated: bool,
#[serde(default, rename = "readOnly", skip_serializing_if = "is_false")]
pub read_only: bool,
#[serde(default, rename = "writeOnly", skip_serializing_if = "is_false")]
pub write_only: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<Value>,
}
impl Metadata {
pub fn is_empty(&self) -> bool {
self.title.is_none()
&& self.description.is_none()
&& self.default.is_none()
&& !self.deprecated
&& !self.read_only
&& !self.write_only
&& self.examples.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct SubschemaValidation {
#[serde(
default,
rename = "allOf",
skip_serializing_if = "option_vec_is_none_or_empty"
)]
pub all_of: Option<Vec<Schema>>,
#[serde(
default,
rename = "anyOf",
skip_serializing_if = "option_vec_is_none_or_empty"
)]
pub any_of: Option<Vec<Schema>>,
#[serde(
default,
rename = "oneOf",
skip_serializing_if = "option_vec_is_none_or_empty"
)]
pub one_of: Option<Vec<Schema>>,
}
impl SubschemaValidation {
pub fn is_empty(&self) -> bool {
option_vec_is_none_or_empty(&self.all_of)
&& option_vec_is_none_or_empty(&self.any_of)
&& option_vec_is_none_or_empty(&self.one_of)
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ArrayValidation {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub items: Option<SingleOrVec<Schema>>,
#[serde(default, rename = "minItems", skip_serializing_if = "Option::is_none")]
pub min_items: Option<u32>,
#[serde(default, rename = "maxItems", skip_serializing_if = "Option::is_none")]
pub max_items: Option<u32>,
}
impl ArrayValidation {
pub fn is_empty(&self) -> bool {
self.items.is_none() && self.min_items.is_none() && self.max_items.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ObjectValidation {
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub properties: IndexMap<String, Schema>,
#[serde(
default,
rename = "patternProperties",
skip_serializing_if = "IndexMap::is_empty"
)]
pub pattern_properties: IndexMap<String, Schema>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required: Vec<String>,
#[serde(
default,
rename = "additionalProperties",
skip_serializing_if = "Option::is_none"
)]
pub additional_properties: Option<Box<Schema>>,
#[serde(
default,
rename = "propertyNames",
skip_serializing_if = "Option::is_none"
)]
pub property_names: Option<Box<Schema>>,
}
impl ObjectValidation {
pub fn is_empty(&self) -> bool {
self.properties.is_empty()
&& self.pattern_properties.is_empty()
&& self.required.is_empty()
&& self.additional_properties.is_none()
&& self.property_names.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct SchemaObject {
pub metadata: Option<Box<Metadata>>,
pub instance_type: Option<SingleOrVec<InstanceType>>,
pub format: Option<String>,
pub enum_values: Option<Vec<Value>>,
pub const_value: Option<Value>,
pub subschemas: Option<Box<SubschemaValidation>>,
pub array: Option<Box<ArrayValidation>>,
pub object: Option<Box<ObjectValidation>>,
pub reference: Option<String>,
pub extensions: Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
struct SchemaObjectRepr {
#[serde(default, skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(default, rename = "default", skip_serializing_if = "Option::is_none")]
default_value: Option<Value>,
#[serde(default, skip_serializing_if = "is_false")]
deprecated: bool,
#[serde(default, rename = "readOnly", skip_serializing_if = "is_false")]
read_only: bool,
#[serde(default, rename = "writeOnly", skip_serializing_if = "is_false")]
write_only: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
examples: Vec<Value>,
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
instance_type: Option<SingleOrVec<InstanceType>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
format: Option<String>,
#[serde(
default,
rename = "enum",
skip_serializing_if = "option_vec_is_none_or_empty"
)]
enum_values: Option<Vec<Value>>,
#[serde(default, rename = "const", skip_serializing_if = "Option::is_none")]
const_value: Option<Value>,
#[serde(flatten)]
subschemas: SubschemaValidation,
#[serde(flatten)]
array: ArrayValidation,
#[serde(flatten)]
object: ObjectValidation,
#[serde(default, rename = "$ref", skip_serializing_if = "Option::is_none")]
reference: Option<String>,
#[serde(default, flatten, skip_serializing_if = "Map::is_empty")]
extensions: Map<String, Value>,
}
impl<'de> Deserialize<'de> for SchemaObject {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let repr = SchemaObjectRepr::deserialize(deserializer)?;
let metadata = Metadata {
title: repr.title,
description: repr.description,
default: repr.default_value,
deprecated: repr.deprecated,
read_only: repr.read_only,
write_only: repr.write_only,
examples: repr.examples,
};
Ok(Self {
metadata: (!metadata.is_empty()).then_some(Box::new(metadata)),
instance_type: repr.instance_type,
format: repr.format,
enum_values: empty_vec_to_none(repr.enum_values),
const_value: repr.const_value,
subschemas: (!repr.subschemas.is_empty()).then_some(Box::new(repr.subschemas)),
array: (!repr.array.is_empty()).then_some(Box::new(repr.array)),
object: (!repr.object.is_empty()).then_some(Box::new(repr.object)),
reference: repr.reference,
extensions: repr.extensions,
})
}
}
impl Serialize for SchemaObject {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let metadata = self.metadata.as_deref().cloned().unwrap_or_default();
let repr = SchemaObjectRepr {
title: metadata.title,
description: metadata.description,
default_value: metadata.default,
deprecated: metadata.deprecated,
read_only: metadata.read_only,
write_only: metadata.write_only,
examples: metadata.examples,
instance_type: self.instance_type.clone(),
format: self.format.clone(),
enum_values: empty_vec_to_none(self.enum_values.clone()),
const_value: self.const_value.clone(),
subschemas: self.subschemas.as_deref().cloned().unwrap_or_default(),
array: self.array.as_deref().cloned().unwrap_or_default(),
object: self.object.as_deref().cloned().unwrap_or_default(),
reference: self.reference.clone(),
extensions: self.extensions.clone(),
};
repr.serialize(serializer)
}
}
fn is_false(value: &bool) -> bool {
!*value
}
fn option_vec_is_none_or_empty<T>(value: &Option<Vec<T>>) -> bool {
match value {
Some(inner) => inner.is_empty(),
None => true,
}
}
fn empty_vec_to_none<T>(value: Option<Vec<T>>) -> Option<Vec<T>> {
match value {
Some(inner) if inner.is_empty() => None,
other => other,
}
}
+104
View File
@@ -0,0 +1,104 @@
use anyhow::{Context, Result, bail};
use percent_encoding::percent_decode_str;
use serde_json::Value;
use super::dialect::RootDialectContext;
use super::model::{Schema, SchemaObject};
#[derive(Debug)]
pub struct SchemaResolver<'a> {
raw: &'a Value,
}
impl<'a> SchemaResolver<'a> {
pub fn new(raw: &'a Value) -> Self {
Self { raw }
}
pub fn resolve_schema(&self, schema: &Schema) -> Result<SchemaObject> {
match schema {
Schema::Bool(_) => Ok(SchemaObject::default()),
Schema::Object(object) => {
if let Some(reference) = &object.reference {
let resolved = self.follow_reference(reference)?;
Ok(overlay_reference_annotations(resolved, object.as_ref()))
} else {
Ok(object.as_ref().clone())
}
}
}
}
pub fn root_dialect_context(&self) -> RootDialectContext {
RootDialectContext::from_root(self.raw)
}
fn follow_reference(&self, reference: &str) -> Result<SchemaObject> {
if let Some(fragment) = reference.strip_prefix('#') {
let decoded = percent_decode_str(fragment)
.decode_utf8()
.context("invalid percent-encoding in $ref")?;
let pointer = if decoded.is_empty() {
String::new()
} else if decoded.starts_with('/') {
decoded.to_string()
} else {
format!("/{}", decoded)
};
let target = self
.raw
.pointer(&pointer)
.with_context(|| format!("reference '{reference}' not found"))?;
let schema: Schema = serde_json::from_value(target.clone())
.with_context(|| format!("reference '{reference}' is not a valid schema"))?;
return self.resolve_schema(&schema);
}
bail!("unsupported reference {reference}")
}
}
pub fn schema_reference(schema: &Schema) -> Option<&str> {
match schema {
Schema::Object(object) => object.reference.as_deref(),
Schema::Bool(_) => None,
}
}
fn overlay_reference_annotations(mut target: SchemaObject, source: &SchemaObject) -> SchemaObject {
if let Some(source_metadata) = source.metadata.as_deref() {
let mut merged = target.metadata.as_deref().cloned().unwrap_or_default();
if let Some(title) = source_metadata.title.clone() {
merged.title = Some(title);
}
if let Some(description) = source_metadata.description.clone() {
merged.description = Some(description);
}
if source_metadata.default.is_some() {
merged.default = source_metadata.default.clone();
}
if source_metadata.deprecated {
merged.deprecated = true;
}
if source_metadata.read_only {
merged.read_only = true;
}
if source_metadata.write_only {
merged.write_only = true;
}
if !source_metadata.examples.is_empty() {
merged.examples = source_metadata.examples.clone();
}
target.metadata = Some(Box::new(merged));
}
if !source.extensions.is_empty() {
for (key, value) in &source.extensions {
if key.starts_with("x-") {
target.extensions.insert(key.clone(), value.clone());
}
}
}
target
}
+1
View File
@@ -0,0 +1 @@
mod schema_ui_tests;
+167
View File
@@ -0,0 +1,167 @@
use anyhow::Result;
use serde_json::{Value, json};
use crate::SchemaUI;
use crate::core::frontend::{Frontend, FrontendContext};
#[cfg(feature = "tui")]
use crate::precompile::build_ui_artifact_bundle;
#[cfg(feature = "tui")]
use crate::tui::app::UiOptions;
struct CaptureFrontend;
impl Frontend for CaptureFrontend {
fn run(self, ctx: FrontendContext) -> Result<Value> {
Ok(json!({
"title": ctx.title,
"description": ctx.description,
"data": ctx.initial_data,
"schema": ctx.schema,
}))
}
}
#[test]
fn schema_ui_accepts_inline_data_and_validation_schema() {
let schema = r#"
{
"title": "Service Config",
"description": "Runtime-validated configuration",
"type": "object",
"properties": {
"host": { "type": "string" },
"port": { "type": "integer" }
}
}
"#;
let result = SchemaUI::new(r#"{ "host": "127.0.0.1", "port": 8080 }"#)
.with_schema(schema)
.run_with_frontend(CaptureFrontend)
.expect("inline document sources should parse at runtime");
assert_eq!(result["title"], "Service Config");
assert_eq!(result["description"], "Runtime-validated configuration");
assert_eq!(result["data"], json!({ "host": "127.0.0.1", "port": 8080 }));
assert_eq!(
result["schema"]["properties"]["host"]["default"],
"127.0.0.1"
);
assert_eq!(result["schema"]["properties"]["port"]["default"], 8080);
}
#[test]
fn schema_ui_infers_schema_from_data_when_no_validation_schema_is_provided() {
let result = SchemaUI::new(json!({
"enabled": true,
"tags": ["blue", "green"]
}))
.run_with_frontend(CaptureFrontend)
.expect("schema inference from config data should succeed");
assert_eq!(
result["data"],
json!({
"enabled": true,
"tags": ["blue", "green"]
})
);
assert_eq!(result["schema"]["type"], "object");
assert_eq!(result["schema"]["properties"]["enabled"]["type"], "boolean");
assert_eq!(result["schema"]["properties"]["tags"]["type"], "array");
}
#[test]
fn schema_ui_treats_schema_like_input_as_schema_when_no_explicit_validation_schema_exists() {
let schema = json!({
"title": "Schema-like input",
"description": "Keep schema-first usage ergonomic",
"type": "object",
"properties": {
"enabled": { "type": "boolean" }
}
});
let result = SchemaUI::new(schema)
.run_with_frontend(CaptureFrontend)
.expect("schema-like documents should be treated as validation schemas");
assert_eq!(result["title"], "Schema-like input");
assert_eq!(result["description"], "Keep schema-first usage ergonomic");
assert_eq!(result["data"], json!({}));
assert_eq!(result["schema"]["properties"]["enabled"]["type"], "boolean");
}
#[test]
fn schema_ui_description_override_wins_over_schema_description() {
let schema = json!({
"title": "Service Config",
"description": "Schema description",
"type": "object",
"properties": {
"enabled": { "type": "boolean" }
}
});
let result = SchemaUI::from_schema(schema)
.with_title("CLI override")
.with_description("Manual description")
.run_with_frontend(CaptureFrontend)
.expect("description overrides should flow into frontend context");
assert_eq!(result["title"], "CLI override");
assert_eq!(result["description"], "Manual description");
}
#[cfg(feature = "tui")]
#[test]
fn schema_ui_builds_tui_frontend_from_frontend_options_payload() {
let schema = json!({
"title": "Service Config",
"type": "object",
"properties": {
"enabled": { "type": "boolean" }
}
});
let defaults = json!({ "enabled": true });
let bundle = build_ui_artifact_bundle(&schema, Some(&defaults))
.expect("artifact bundle should build for TUI frontend preparation");
let options = UiOptions::default()
.with_auto_validate(false)
.with_confirm_exit(false);
let frontend = SchemaUI::new(defaults)
.with_schema(schema)
.with_ui_artifact_bundle(bundle.clone())
.build_tui_frontend(options.clone());
assert!(!frontend.options.auto_validate);
assert!(!frontend.options.confirm_exit);
assert_eq!(frontend.tui_artifacts, Some(bundle.tui));
}
#[test]
fn nullable_scalar_schema_marks_field_nullable_and_preserves_null_default() {
let schema = json!({
"type": "object",
"properties": {
"data-size": {
"type": ["integer", "null"],
"default": null
},
"bind-address-v6": {
"type": ["string", "null"],
"format": "ipv6",
"default": null
}
}
});
let result = SchemaUI::from_schema(schema)
.run_with_frontend(CaptureFrontend)
.expect("nullable schema should build frontend context");
let data = &result["schema"]["properties"]["data-size"];
assert_eq!(data["default"], Value::Null);
assert_eq!(data["type"], json!(["integer", "null"]));
}
+93
View File
@@ -0,0 +1,93 @@
use std::path::Path;
use crate::{DocumentFormat, DocumentFormatProbe};
#[test]
fn default_format_matches_first_available_format() {
let available = DocumentFormat::available_formats();
assert!(!available.is_empty());
assert_eq!(DocumentFormat::default(), available[0]);
}
#[test]
fn enabled_keywords_round_trip() {
for format in DocumentFormat::available_formats() {
let keyword = format.to_string();
assert_eq!(DocumentFormat::from_keyword(&keyword), Ok(format));
}
}
#[test]
fn enabled_extensions_round_trip() {
#[cfg(feature = "json")]
assert_eq!(
DocumentFormat::probe_extension(Path::new("config.json")),
DocumentFormatProbe::Known(DocumentFormat::Json)
);
#[cfg(feature = "yaml")]
{
assert_eq!(
DocumentFormat::probe_extension(Path::new("config.yaml")),
DocumentFormatProbe::Known(DocumentFormat::Yaml)
);
assert_eq!(
DocumentFormat::probe_extension(Path::new("config.yml")),
DocumentFormatProbe::Known(DocumentFormat::Yaml)
);
}
#[cfg(feature = "toml")]
assert_eq!(
DocumentFormat::probe_extension(Path::new("config.toml")),
DocumentFormatProbe::Known(DocumentFormat::Toml)
);
}
#[cfg(not(feature = "json"))]
#[test]
fn json_keyword_reports_missing_feature() {
assert_eq!(
DocumentFormat::from_keyword("json"),
Err("format 'json' requires the 'json' feature".to_string())
);
assert_eq!(
DocumentFormat::probe_extension(Path::new("config.json")),
DocumentFormatProbe::UnsupportedFeature {
format_name: "json",
feature_flag: "json",
}
);
}
#[cfg(not(feature = "yaml"))]
#[test]
fn yaml_keyword_reports_missing_feature() {
assert_eq!(
DocumentFormat::from_keyword("yaml"),
Err("format 'yaml' requires the 'yaml' feature".to_string())
);
assert_eq!(
DocumentFormat::probe_extension(Path::new("config.yaml")),
DocumentFormatProbe::UnsupportedFeature {
format_name: "yaml",
feature_flag: "yaml",
}
);
}
#[cfg(not(feature = "toml"))]
#[test]
fn toml_keyword_reports_missing_feature() {
assert_eq!(
DocumentFormat::from_keyword("toml"),
Err("format 'toml' requires the 'toml' feature".to_string())
);
assert_eq!(
DocumentFormat::probe_extension(Path::new("config.toml")),
DocumentFormatProbe::UnsupportedFeature {
format_name: "toml",
feature_flag: "toml",
}
);
}
+1
View File
@@ -0,0 +1 @@
pub(crate) mod format_tests;
+11
View File
@@ -0,0 +1,11 @@
#[cfg(feature = "json")]
pub(crate) mod api;
pub(crate) mod io;
#[cfg(feature = "json")]
pub(crate) mod schema;
#[cfg(all(feature = "json", feature = "tui"))]
pub(crate) mod tui;
#[cfg(feature = "json")]
pub(crate) mod ui_ast;
#[cfg(all(feature = "json", feature = "web"))]
pub(crate) mod web;
@@ -0,0 +1,519 @@
use std::{
fs,
path::{Path, PathBuf},
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
use serde_json::{Value, json};
use crate::io::{DocumentFormat, input::parse_document_str, input::schema_with_defaults};
use crate::precompile;
use crate::precompile::tui as ct_tui;
use crate::tui::model::form_schema_from_ui_ast;
use crate::tui::state::LayoutNavModel;
use crate::ui_ast::{build_ui_ast, index::collect_pointers, layout::build_ui_layout};
fn schema_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("schemas")
.join("test-comprehensive.schema.json")
}
fn ultra_complex_example_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("examples")
.join("ultra-complex.schema.json")
}
fn defaults_value() -> Value {
json!({
"simpleTypes": {
"text": "hello from defaults",
"number": 7,
"toggle": true,
"dropdown": "option2"
}
})
}
fn unique_temp_dir(prefix: &str) -> PathBuf {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"schemaui_{prefix}_{}_{}",
std::process::id(),
stamp
))
}
fn write_defaults_file(prefix: &str, defaults: &Value) -> (PathBuf, PathBuf) {
let out_dir = unique_temp_dir(prefix);
fs::create_dir_all(&out_dir).expect("tmp dir creatable");
let defaults_path = out_dir.join("defaults.json");
fs::write(
&defaults_path,
serde_json::to_vec_pretty(defaults).expect("serialize defaults"),
)
.expect("write defaults file");
(out_dir, defaults_path)
}
#[test]
fn generated_ui_ast_matches_runtime_for_comprehensive_schema() {
let path = schema_path();
let generated_ast =
precompile::build_ui_ast_from_file(&path, DocumentFormat::Json).expect("build UiAst");
let contents = std::fs::read_to_string(&path).expect("schema file readable");
let schema_value: Value =
parse_document_str(&contents, DocumentFormat::Json).expect("schema parses at runtime");
let runtime_ast = build_ui_ast(&schema_value).expect("runtime UiAst");
let generated_pointers = collect_pointers(&generated_ast);
let runtime_pointers = collect_pointers(&runtime_ast);
assert_eq!(runtime_pointers, generated_pointers);
}
#[test]
fn ultra_complex_example_builds_artifacts_and_keeps_recursive_tree_fields() {
let path = ultra_complex_example_path();
let bundle = precompile::build_ui_artifact_bundle_from_file(&path, DocumentFormat::Json, None)
.expect("build UI artifact bundle for ultra-complex example");
let pointers = collect_pointers(&bundle.ui.ui_ast);
assert!(
pointers.contains("/recursiveTree"),
"pointers: {:?}",
pointers
);
assert!(
pointers.contains("/recursiveTree/name"),
"recursive tree should still expose concrete fields"
);
assert!(
pointers.contains("/recursiveTree/children"),
"recursive tree should keep the children array boundary"
);
let recursive_root = bundle
.tui
.form_schema
.roots
.iter()
.find(|root| root.id == "recursiveTree")
.expect("recursive tree root");
let section = recursive_root
.sections
.first()
.expect("recursive tree section");
let child_field = section
.fields
.iter()
.find(|field| field.pointer == "/recursiveTree/children")
.expect("recursive children field");
assert!(
matches!(child_field.kind, crate::tui::model::FieldKind::Array(_)),
"recursive children should remain an array field"
);
}
#[test]
fn ui_ast_json_roundtrip_preserves_structure() {
let path = schema_path();
let original =
precompile::build_ui_ast_from_file(&path, DocumentFormat::Json).expect("build UiAst");
let json = precompile::ui_ast_to_json(&original).expect("UiAst to JSON");
let decoded = precompile::decode_ui_ast(&json).expect("decode UiAst from JSON");
assert_eq!(original, decoded);
}
#[test]
fn ui_ast_bundle_json_roundtrip_preserves_structure() {
let path = schema_path();
let original = precompile::build_ui_ast_bundle_from_file(&path, DocumentFormat::Json)
.expect("build UiAst bundle");
let json = precompile::ui_ast_bundle_to_json(&original).expect("UiAst bundle to JSON");
let decoded = precompile::decode_ui_ast_bundle(&json).expect("decode UiAst bundle from JSON");
assert_eq!(original, decoded);
}
#[test]
fn ui_artifact_bundle_roundtrip_preserves_structure() {
let path = schema_path();
let defaults = defaults_value();
let (out_dir, defaults_path) = write_defaults_file("ui_artifact_bundle_roundtrip", &defaults);
let original = precompile::build_ui_artifact_bundle_from_file(
&path,
DocumentFormat::Json,
Some(&defaults_path),
)
.expect("build UI artifact bundle");
let json =
precompile::ui_artifact_bundle_to_json(&original).expect("UI artifact bundle to JSON");
let decoded =
precompile::decode_ui_artifact_bundle(&json).expect("decode UI artifact bundle from JSON");
assert_eq!(original, decoded);
let _ = fs::remove_file(&defaults_path);
let _ = fs::remove_dir_all(&out_dir);
}
#[test]
fn ui_artifact_bundle_version_and_fingerprint_track_schema_and_defaults() {
let path = schema_path();
let defaults_a = json!({"simpleTypes": {"text": "a", "dropdown": "option1"}});
let defaults_b = json!({"simpleTypes": {"text": "b", "dropdown": "option2"}});
let contents = fs::read_to_string(&path).expect("schema file readable");
let schema_value: Value =
parse_document_str(&contents, DocumentFormat::Json).expect("schema parses at runtime");
let bundle_a = precompile::build_ui_artifact_bundle(&schema_value, Some(&defaults_a))
.expect("build bundle a");
let bundle_a_again = precompile::build_ui_artifact_bundle(&schema_value, Some(&defaults_a))
.expect("build bundle a again");
let bundle_b = precompile::build_ui_artifact_bundle(&schema_value, Some(&defaults_b))
.expect("build bundle b");
assert_eq!(
bundle_a.artifact_version,
precompile::UI_ARTIFACT_BUNDLE_VERSION
);
assert_eq!(bundle_a.fingerprint, bundle_a_again.fingerprint);
assert_eq!(
bundle_a.fingerprint.schema_sha256,
bundle_b.fingerprint.schema_sha256
);
assert_ne!(
bundle_a.fingerprint.defaults_sha256,
bundle_b.fingerprint.defaults_sha256
);
assert_ne!(
bundle_a.fingerprint.input_sha256,
bundle_b.fingerprint.input_sha256
);
}
#[test]
fn generated_form_schema_matches_runtime_form_schema() {
let path = schema_path();
let defaults = defaults_value();
let (out_dir, defaults_path) = write_defaults_file("tui_form_schema", &defaults);
// 1) Generated-artifact build via precompile::tui helper.
let (ct_ast, ct_form) =
ct_tui::build_tui_form_schema_from_file(&path, DocumentFormat::Json, Some(&defaults_path))
.expect("build FormSchema artifact");
// 2) Runtime-style build from the same schema file.
let contents = fs::read_to_string(&path).expect("schema file readable");
let schema_value: Value =
parse_document_str(&contents, DocumentFormat::Json).expect("schema parses at runtime");
let enriched = schema_with_defaults(&schema_value, &defaults);
let rt_ast = build_ui_ast(&enriched).expect("runtime UiAst");
let rt_form = form_schema_from_ui_ast(&rt_ast);
// 3) Assert UiAst and FormSchema equivalence (via JSON for FormSchema).
if ct_ast != rt_ast {
let ct_ptrs = collect_pointers(&ct_ast);
let rt_ptrs = collect_pointers(&rt_ast);
eprintln!(
"UiAst mismatch: ct_pointers_len={} rt_pointers_len={}",
ct_ptrs.len(),
rt_ptrs.len()
);
let ct_sample: Vec<_> = ct_ptrs.iter().take(8).cloned().collect();
let rt_sample: Vec<_> = rt_ptrs.iter().take(8).cloned().collect();
eprintln!(" ct_pointers_sample={:?}", ct_sample);
eprintln!(" rt_pointers_sample={:?}", rt_sample);
// Persist full UiAst JSON to a temporary directory for manual diffing.
let out_dir = std::env::var("CARGO_TARGET_TMPDIR")
.map(PathBuf::from)
.unwrap_or_else(|_| {
PathBuf::from(
std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".to_string()),
)
.join("tmp_tui_artifacts")
});
if let Err(err) = fs::create_dir_all(&out_dir) {
eprintln!(" failed to create UiAst debug dir {:?}: {err}", out_dir);
} else {
let ct_json = serde_json::to_string_pretty(&ct_ast).unwrap();
let rt_json = serde_json::to_string_pretty(&rt_ast).unwrap();
let ct_path = out_dir.join("ct_ui_ast.json");
let rt_path = out_dir.join("rt_ui_ast.json");
if let Err(err) = fs::write(&ct_path, ct_json) {
eprintln!(" failed to write {:?}: {err}", ct_path);
}
if let Err(err) = fs::write(&rt_path, rt_json) {
eprintln!(" failed to write {:?}: {err}", rt_path);
}
eprintln!(" wrote UiAst JSON to {:?}", out_dir);
}
}
assert_eq!(ct_ast, rt_ast, "generated and runtime UiAst must match");
let ct_form_json = serde_json::to_value(&ct_form).expect("serialize ct FormSchema");
let rt_form_json = serde_json::to_value(&rt_form).expect("serialize rt FormSchema");
if ct_form_json != rt_form_json {
eprintln!(
"FormSchema JSON mismatch: ct_roots={} rt_roots={}",
ct_form.roots.len(),
rt_form.roots.len()
);
if let (Some(ct_root), Some(rt_root)) = (ct_form.roots.first(), rt_form.roots.first()) {
eprintln!(
" root[0]: ct_id={} ct_sections={} rt_id={} rt_sections={}",
ct_root.id,
ct_root.sections.len(),
rt_root.id,
rt_root.sections.len()
);
}
}
assert_eq!(
ct_form_json, rt_form_json,
"generated and runtime FormSchema JSON must match"
);
let _ = fs::remove_file(&defaults_path);
let _ = fs::remove_dir_all(&out_dir);
}
#[test]
fn generated_layout_nav_matches_runtime_layout_nav() {
let path = schema_path();
let defaults = defaults_value();
let (out_dir, defaults_path) = write_defaults_file("tui_layout_nav", &defaults);
// 1) Generated-artifact build via precompile::tui helper.
let (ct_ast, ct_nav): (_, LayoutNavModel) =
ct_tui::build_tui_layout_nav_from_file(&path, DocumentFormat::Json, Some(&defaults_path))
.expect("build LayoutNavModel artifact");
// 2) Runtime-style build from the same schema file.
let contents = fs::read_to_string(&path).expect("schema file readable");
let schema_value: Value =
parse_document_str(&contents, DocumentFormat::Json).expect("schema parses at runtime");
let enriched = schema_with_defaults(&schema_value, &defaults);
let rt_ast = build_ui_ast(&enriched).expect("runtime UiAst");
let rt_layout = build_ui_layout(&rt_ast);
let rt_nav = LayoutNavModel::from_uilayout(&rt_layout);
// 3) Assert UiAst and LayoutNavModel equivalence (via JSON for LayoutNavModel).
if ct_ast != rt_ast {
let ct_ptrs = collect_pointers(&ct_ast);
let rt_ptrs = collect_pointers(&rt_ast);
eprintln!(
"UiAst mismatch: ct_pointers_len={} rt_pointers_len={}",
ct_ptrs.len(),
rt_ptrs.len()
);
let ct_sample: Vec<_> = ct_ptrs.iter().take(8).cloned().collect();
let rt_sample: Vec<_> = rt_ptrs.iter().take(8).cloned().collect();
eprintln!(" ct_pointers_sample={:?}", ct_sample);
eprintln!(" rt_pointers_sample={:?}", rt_sample);
}
assert_eq!(ct_ast, rt_ast, "generated and runtime UiAst must match");
let ct_nav_json = serde_json::to_value(&ct_nav).expect("serialize ct LayoutNavModel");
let rt_nav_json = serde_json::to_value(&rt_nav).expect("serialize rt LayoutNavModel");
if ct_nav_json != rt_nav_json {
eprintln!(
"LayoutNavModel JSON mismatch: ct_roots={} rt_roots={}",
ct_nav.roots.len(),
rt_nav.roots.len()
);
if let (Some(ct_root), Some(rt_root)) = (ct_nav.roots.first(), rt_nav.roots.first()) {
eprintln!(
" root[0]: ct_id={} ct_sections={} rt_id={} rt_sections={}",
ct_root.id,
ct_root.sections.len(),
rt_root.id,
rt_root.sections.len()
);
if let (Some(ct_section), Some(rt_section)) =
(ct_root.sections.first(), rt_root.sections.first())
{
eprintln!(
" section[0]: ct_id={} ct_pointers_len={} rt_id={} rt_pointers_len={}",
ct_section.id,
ct_section.pointers.len(),
rt_section.id,
rt_section.pointers.len()
);
let ct_sample: Vec<_> = ct_section.pointers.iter().take(8).cloned().collect();
let rt_sample: Vec<_> = rt_section.pointers.iter().take(8).cloned().collect();
eprintln!(" ct_section_pointers_sample={:?}", ct_sample);
eprintln!(" rt_section_pointers_sample={:?}", rt_sample);
}
}
}
assert_eq!(
ct_nav_json, rt_nav_json,
"generated and runtime LayoutNavModel JSON must match"
);
let _ = fs::remove_file(&defaults_path);
let _ = fs::remove_dir_all(&out_dir);
}
#[test]
fn generated_artifact_modules_compile_and_construct_artifacts() {
let path = schema_path();
let defaults = defaults_value();
// 1) Generate modules into a temporary directory under target/.
let (out_dir, defaults_path) = write_defaults_file("artifact_compile_smoke", &defaults);
let tui_module = out_dir.join("tui_artifacts.rs");
let form_module = out_dir.join("tui_form_schema.rs");
let nav_module = out_dir.join("tui_layout_nav.rs");
let bundle_module = out_dir.join("ui_artifact_bundle.rs");
ct_tui::generate_tui_artifacts_module(
&path,
DocumentFormat::Json,
Some(&defaults_path),
&tui_module,
"tui_artifacts",
)
.expect("generate TuiArtifacts module");
ct_tui::generate_tui_form_schema_module(
&path,
DocumentFormat::Json,
Some(&defaults_path),
&form_module,
"tui_form_schema",
)
.expect("generate FormSchema module");
ct_tui::generate_tui_layout_nav_module(
&path,
DocumentFormat::Json,
Some(&defaults_path),
&nav_module,
"tui_layout_nav",
)
.expect("generate LayoutNavModel module");
precompile::generate_ui_artifact_bundle_module(
&path,
DocumentFormat::Json,
Some(&defaults_path),
&bundle_module,
"ui_artifact_bundle",
)
.expect("generate UI artifact bundle module");
// 2) Sanity-check that the generated files exist and contain the expected
// function names.
let tui_src = fs::read_to_string(&tui_module).expect("tui module readable");
assert!(tui_src.contains("fn tui_artifacts"));
let form_src = fs::read_to_string(&form_module).expect("form module readable");
assert!(form_src.contains("fn tui_form_schema"));
let nav_src = fs::read_to_string(&nav_module).expect("nav module readable");
assert!(nav_src.contains("fn tui_layout_nav"));
let bundle_src = fs::read_to_string(&bundle_module).expect("bundle module readable");
assert!(bundle_src.contains("fn ui_artifact_bundle"));
// 3) Compile the generated modules inside a temporary crate and construct
// the generated artifacts at runtime.
let smoke_crate_dir = out_dir.join("compile_smoke");
fs::create_dir_all(smoke_crate_dir.join("src")).expect("smoke crate src creatable");
fs::write(
smoke_crate_dir.join("Cargo.toml"),
smoke_crate_manifest(env!("CARGO_MANIFEST_DIR")),
)
.expect("write smoke crate manifest");
fs::write(
smoke_crate_dir.join("src/main.rs"),
smoke_crate_main(&tui_module, &form_module, &nav_module, &bundle_module),
)
.expect("write smoke crate main");
let target_dir = smoke_crate_dir.join("target");
let output = Command::new("cargo")
.args(["run", "--quiet"])
.current_dir(&smoke_crate_dir)
.env("CARGO_TARGET_DIR", &target_dir)
.output()
.expect("run smoke crate");
if !output.status.success() {
eprintln!("stdout:\n{}", String::from_utf8_lossy(&output.stdout));
eprintln!("stderr:\n{}", String::from_utf8_lossy(&output.stderr));
}
assert!(
output.status.success(),
"smoke crate should compile and run"
);
let _ = fs::remove_dir_all(&out_dir);
}
fn smoke_crate_manifest(manifest_dir: &str) -> String {
format!(
r#"[package]
name = "schemaui-precompile-smoke"
version = "0.1.0"
edition = "2024"
[dependencies]
schemaui = {{ path = "{manifest_dir}", default-features = false, features = ["json", "tui", "precompile"] }}
serde_json = "1"
"#
)
}
fn smoke_crate_main(
tui_module: &Path,
form_module: &Path,
nav_module: &Path,
bundle_module: &Path,
) -> String {
format!(
r##"include!(r#"{tui}"#);
include!(r#"{form}"#);
include!(r#"{nav}"#);
include!(r#"{bundle}"#);
fn main() {{
let tui = tui_artifacts();
let form = tui_form_schema();
let nav = tui_layout_nav();
let bundle = ui_artifact_bundle();
assert!(!tui.form_schema.roots.is_empty());
assert!(!tui.layout_nav.roots.is_empty());
assert!(!form.roots.is_empty());
assert!(!nav.roots.is_empty());
assert!(!bundle.ui.ui_ast.roots.is_empty());
assert_eq!(tui.form_schema, form);
assert_eq!(tui.layout_nav, nav);
assert_eq!(bundle.tui, tui);
assert_eq!(
bundle.artifact_version,
schemaui::precompile::UI_ARTIFACT_BUNDLE_VERSION
);
}}
"##,
tui = tui_module.display(),
form = form_module.display(),
nav = nav_module.display(),
bundle = bundle_module.display(),
)
}
@@ -0,0 +1,52 @@
use serde_json::json;
use crate::schema::dialect::{RootDialectContext, SchemaDialect};
#[test]
fn detects_draft_07_from_schema_keyword() {
let schema = json!({
"$schema": "http://json-schema.org/draft-07/schema#"
});
assert_eq!(SchemaDialect::detect(&schema), SchemaDialect::Draft7);
}
#[test]
fn detects_2020_12_from_schema_keyword() {
let schema = json!({
"$schema": "https://json-schema.org/draft/2020-12/schema"
});
assert_eq!(SchemaDialect::detect(&schema), SchemaDialect::Draft202012);
}
#[test]
fn root_dialect_context_applies_schema_and_definition_keywords_to_overlay() {
let root = json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"bar": { "type": "string" }
},
"definitions": {
"legacy": { "type": "integer" }
}
});
let context = RootDialectContext::from_root(&root);
let mut overlay = serde_json::Map::new();
overlay.insert("type".to_string(), json!("object"));
context.apply_to_overlay(&mut overlay);
assert_eq!(
overlay.get("$schema"),
root.as_object().and_then(|obj| obj.get("$schema"))
);
assert_eq!(
overlay.get("$defs"),
root.as_object().and_then(|obj| obj.get("$defs"))
);
assert_eq!(
overlay.get("definitions"),
root.as_object().and_then(|obj| obj.get("definitions"))
);
}
+565
View File
@@ -0,0 +1,565 @@
use crate::{
tui::model::{FieldKind, FieldSchema, FormSchema, FormSection, form_schema_from_ui_ast},
ui_ast::build_ui_ast,
};
use serde_json::{Value, json};
fn runtime_form_schema(schema: &Value) -> FormSchema {
let ast = build_ui_ast(schema).expect("ui ast");
form_schema_from_ui_ast(&ast)
}
fn find_field_by_pointer<'a>(
sections: &'a [FormSection],
pointer: &str,
) -> Option<&'a FieldSchema> {
for section in sections {
if let Some(field) = section.fields.iter().find(|field| field.pointer == pointer) {
return Some(field);
}
if let Some(found) = find_field_by_pointer(&section.children, pointer) {
return Some(found);
}
}
None
}
#[test]
fn builds_nested_sections_and_general_root() {
let schema = json!({
"type": "object",
"definitions": {
"duration": {
"type": "object",
"properties": {
"value": {"type": "integer"}
}
}
},
"properties": {
"metadata": {
"type": "object",
"properties": {
"serviceName": {"type": "string"}
}
},
"runtime": {
"type": "object",
"properties": {
"http": {
"type": "object",
"properties": {
"port": {"type": "integer"},
"limits": {
"type": "object",
"properties": {
"timeout": {"$ref": "#/definitions/duration"}
}
}
}
}
}
},
"generalFlag": {"type": "string"}
}
});
let form = runtime_form_schema(&schema);
assert_eq!(form.roots.len(), 3);
let runtime = form
.roots
.iter()
.find(|root| root.id == "runtime")
.expect("runtime root");
assert_eq!(runtime.sections.len(), 1);
let http = &runtime.sections[0];
assert_eq!(http.children.len(), 1);
let http_child = &http.children[0];
assert_eq!(http_child.fields.len(), 1);
}
#[test]
fn composite_variants_keep_definitions() {
let schema = json!({
"type": "object",
"definitions": {
"endpoint": {
"type": "object",
"properties": {
"url": {"type": "string"}
}
}
},
"properties": {
"notifications": {
"type": "object",
"properties": {
"channel": {
"oneOf": [
{
"title": "HTTP",
"properties": {
"type": {"const": "http"},
"target": {"$ref": "#/definitions/endpoint"}
},
"required": ["type", "target"]
}
]
}
}
}
}
});
let form = runtime_form_schema(&schema);
let notifications = form
.roots
.iter()
.find(|root| root.id == "notifications")
.expect("notifications root");
let section = notifications.sections.first().expect("section");
let channel = section
.fields
.iter()
.find(|field| field.name == "channel")
.expect("channel field");
match &channel.kind {
FieldKind::Composite(composite) => {
assert!(
composite
.variants
.first()
.and_then(|variant| {
variant
.schema
.get("definitions")
.or_else(|| variant.schema.get("$defs"))
})
.is_some(),
"variant schema should embed definitions"
);
}
other => panic!("expected composite field, got {:?}", other),
}
}
#[test]
fn notifications_sections_do_not_duplicate_parent_field() {
let schema = json!({
"type": "object",
"properties": {
"notifications": {
"type": "object",
"properties": {
"channels": {"type": "array", "items": {"type": "string"}},
"templates": {
"type": "object",
"additionalProperties": {"type": "string"}
}
},
"additionalProperties": false
}
}
});
let form = runtime_form_schema(&schema);
let notifications = form
.roots
.iter()
.find(|root| root.id == "notifications")
.expect("notifications root");
let section = notifications.sections.first().expect("section");
let names: Vec<_> = section
.fields
.iter()
.map(|field| field.name.clone())
.collect();
assert_eq!(names, vec!["channels", "templates"]);
}
#[test]
fn additional_properties_true_does_not_add_phantom_field() {
let schema = json!({
"type": "object",
"properties": {
"room": {
"type": "object",
"properties": {
"max_size": {"type": "integer"}
},
"additionalProperties": true
}
}
});
let form = runtime_form_schema(&schema);
let room = form
.roots
.iter()
.find(|root| root.id == "room")
.expect("room root");
let section = room.sections.first().expect("room section");
assert!(
section.fields.iter().all(|field| field.name != "room"),
"room section should only contain actual child fields"
);
let names: Vec<_> = section
.fields
.iter()
.map(|field| field.name.clone())
.collect();
assert_eq!(names, vec!["max_size"]);
}
#[test]
fn sample_config_schema_keeps_only_user_fields() {
let schema: Value = serde_json::from_str(include_str!("../../../examples/config-schema.json"))
.expect("example schema");
let form = runtime_form_schema(&schema);
assert_eq!(form.roots.len(), 1, "only general root expected");
let general = form
.roots
.iter()
.find(|root| root.id == "general")
.expect("general root");
let section = general.sections.first().expect("section");
let names: Vec<_> = section
.fields
.iter()
.map(|field| field.name.clone())
.collect();
assert!(names.contains(&"username".to_string()));
assert!(names.contains(&"email".to_string()));
assert!(names.contains(&"phone".to_string()));
assert!(names.contains(&"tags".to_string()));
assert!(names.iter().all(|name| {
name != "$schema" && name != "title" && name != "type" && name != "required"
}));
}
#[test]
fn pattern_properties_become_key_value_fields() {
let schema = json!({
"type": "object",
"properties": {
"labels": {
"type": "object",
"patternProperties": {
"^[a-z]+$": {"type": "string"}
},
"additionalProperties": false
}
}
});
let form = runtime_form_schema(&schema);
let field = find_field(&form, |field| field.name == "labels").expect("labels field");
assert!(matches!(field.kind, FieldKind::KeyValue(_)));
}
#[test]
fn arrays_without_item_schema_fallback_to_json_array() {
let schema = json!({
"type": "object",
"properties": {
"expose_headers": {
"type": "array",
"description": "headers exposed via CORS"
}
}
});
let form = runtime_form_schema(&schema);
let field =
find_field(&form, |field| field.name == "expose_headers").expect("expose_headers field");
match &field.kind {
FieldKind::Array(inner) => assert!(matches!(inner.as_ref(), FieldKind::Json)),
other => panic!("expected array kind, got {:?}", other),
}
}
#[test]
fn multi_level_refs_are_resolved() {
let schema = json!({
"type": "object",
"definitions": {
"duration": {
"type": "object",
"properties": {
"value": {"type": "integer"}
}
},
"timeout": {
"$ref": "#/definitions/duration"
}
},
"properties": {
"runtime": {
"type": "object",
"properties": {
"limits": {
"type": "object",
"properties": {
"requestTimeout": {"$ref": "#/definitions/timeout"}
}
}
}
}
}
});
let form = runtime_form_schema(&schema);
let field = find_field(&form, |field| {
field
.pointer
.ends_with("/runtime/limits/requestTimeout/value")
})
.expect("requestTimeout value field");
assert_eq!(field.name, "value");
}
#[test]
fn anyof_variant_titles_reflect_shape() {
let schema: Value = serde_json::from_str(include_str!("../../../examples/complex.schema.json"))
.expect("complex schema");
let form = runtime_form_schema(&schema);
let pointer = "/c/c1/c2/options";
let field = form
.roots
.iter()
.find_map(|root| find_field_by_pointer(&root.sections, pointer))
.expect("options field");
match &field.kind {
FieldKind::Composite(meta) => {
let titles: Vec<_> = meta
.variants
.iter()
.map(|variant| variant.title.as_str())
.collect();
assert!(
titles.contains(&"List<string>"),
"variant titles: {:?}",
titles
);
assert!(
titles.contains(&"List<integer>"),
"variant titles: {:?}",
titles
);
}
other => panic!("expected composite field, got {:?}", other),
}
}
#[test]
fn allof_properties_merge_into_object_section() {
let schema = json!({
"type": "object",
"properties": {
"settings": {
"type": "object",
"allOf": [
{"properties": {"enabled": {"type": "boolean"}}},
{"properties": {"threshold": {"type": "integer"}}}
]
}
}
});
let form = runtime_form_schema(&schema);
let settings_root = form
.roots
.iter()
.find(|root| root.id == "settings")
.expect("settings root");
let section = settings_root.sections.first().expect("settings section");
let names: Vec<_> = section
.fields
.iter()
.map(|field| field.name.as_str())
.collect();
assert!(
names.contains(&"enabled"),
"merged schema should expose 'enabled'"
);
assert!(
names.contains(&"threshold"),
"merged schema should expose 'threshold'"
);
}
#[test]
fn referenced_field_keeps_instance_metadata() {
let schema = json!({
"definitions": {
"duration_ms": {
"title": "Definition Title",
"description": "Definition description",
"type": "integer"
}
},
"type": "object",
"properties": {
"timeout": {
"$ref": "#/definitions/duration_ms",
"title": "Request Timeout",
"description": "Per-request timeout",
"default": 5
}
}
});
let form = runtime_form_schema(&schema);
let field = find_field(&form, |field| field.pointer == "/timeout").expect("timeout field");
assert_eq!(field.title, "Request Timeout");
assert_eq!(field.description.as_deref(), Some("Per-request timeout"));
assert_eq!(field.default, Some(json!(5)));
assert!(matches!(field.kind, FieldKind::Integer));
}
#[test]
fn recursive_refs_collapse_to_json_array_field_without_overflow() {
let schema = json!({
"type": "object",
"properties": {
"tree": {"$ref": "#/definitions/treeNode"}
},
"definitions": {
"treeNode": {
"type": "object",
"properties": {
"name": {"type": "string"},
"children": {
"type": "array",
"items": {"$ref": "#/definitions/treeNode"}
}
},
"required": ["name"]
}
}
});
let form = runtime_form_schema(&schema);
let root = form
.roots
.iter()
.find(|root| root.id == "tree")
.expect("tree root section");
let section = root.sections.first().expect("tree section");
let name_field = find_field(&form, |field| field.pointer == "/tree/name").expect("name field");
assert!(matches!(name_field.kind, FieldKind::String));
let children_field = section
.fields
.iter()
.find(|field| field.pointer == "/tree/children")
.expect("children field");
match &children_field.kind {
FieldKind::Array(inner) => {
assert!(
matches!(inner.as_ref(), FieldKind::Composite(_)),
"recursive descendants should stay editable via a lazy composite boundary, got {:?}",
inner
);
}
other => panic!(
"expected array field for recursive children, got {:?}",
other
),
}
}
#[test]
fn form_schema_preserves_declared_root_and_field_order() {
let schema = json!({
"type": "object",
"properties": {
"zeta": {
"type": "object",
"title": "Zeta",
"properties": {
"second": {"type": "string"},
"first": {"type": "string"},
"network": {
"type": "object",
"title": "Network",
"properties": {
"port": {"type": "integer"}
}
},
"auth": {
"type": "object",
"title": "Auth",
"properties": {
"user": {"type": "string"}
}
}
}
},
"alpha": {
"type": "object",
"title": "Alpha",
"properties": {
"enabled": {"type": "boolean"}
}
}
}
});
let form = runtime_form_schema(&schema);
let root_ids: Vec<_> = form.roots.iter().map(|root| root.id.as_str()).collect();
assert_eq!(
root_ids,
vec!["zeta", "alpha"],
"form roots should follow schema declaration order for top-level object sections",
);
let zeta = form
.roots
.iter()
.find(|root| root.id == "zeta")
.expect("zeta root");
let zeta_section = zeta.sections.first().expect("zeta section");
let field_names: Vec<_> = zeta_section
.fields
.iter()
.map(|field| field.name.as_str())
.collect();
assert_eq!(
field_names,
vec!["second", "first"],
"fields inside a section should preserve schema declaration order",
);
let child_titles: Vec<_> = zeta_section
.children
.iter()
.map(|section| section.title.as_str())
.collect();
assert_eq!(
child_titles,
vec!["Network", "Auth"],
"nested sections should preserve schema declaration order",
);
}
fn find_field(form: &FormSchema, predicate: impl Fn(&FieldSchema) -> bool) -> Option<&FieldSchema> {
for root in &form.roots {
if let Some(field) = find_in_sections(&root.sections, &predicate) {
return Some(field);
}
}
None
}
fn find_in_sections<'a>(
sections: &'a [FormSection],
predicate: &impl Fn(&FieldSchema) -> bool,
) -> Option<&'a FieldSchema> {
for section in sections {
for field in &section.fields {
if predicate(field) {
return Some(field);
}
}
if let Some(found) = find_in_sections(&section.children, predicate) {
return Some(found);
}
}
None
}
+7
View File
@@ -0,0 +1,7 @@
#[cfg(feature = "tui")]
mod artifact_bundle_tests;
mod dialect_tests;
#[cfg(feature = "tui")]
mod layout_tests;
mod pipeline_artifact_tests;
mod resolver_tests;
@@ -0,0 +1,138 @@
use std::{fs, path::PathBuf};
use anyhow::Result;
use serde::Serialize;
use serde_json::{Value, json};
use crate::core::frontend::{Frontend, FrontendContext};
use crate::core::pipeline::SchemaPipeline;
use crate::io::{DocumentFormat, input::parse_document_str};
use crate::precompile::build_ui_artifact_bundle;
use crate::ui_ast::{UiAst, UiAstBundle, UiLayout};
fn schema_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("schemas")
.join("test-comprehensive.schema.json")
}
fn schema_value() -> Value {
let path = schema_path();
let contents = fs::read_to_string(path).expect("schema file readable");
parse_document_str(&contents, DocumentFormat::Json).expect("schema parses")
}
fn defaults_value() -> Value {
json!({
"simpleTypes": {
"text": "hello from defaults",
"number": 7,
"toggle": true,
"dropdown": "option2"
}
})
}
fn recursive_schema_value() -> Value {
json!({
"type": "object",
"properties": {
"tree": {"$ref": "#/definitions/treeNode"}
},
"definitions": {
"treeNode": {
"type": "object",
"properties": {
"name": {"type": "string"},
"children": {
"type": "array",
"items": {"$ref": "#/definitions/treeNode"}
}
}
}
}
})
}
#[derive(Serialize)]
struct CapturedFrontendContext {
ui_ast: UiAst,
layout: UiLayout,
schema: Value,
initial_data: Value,
}
#[derive(Debug)]
struct CaptureFrontend;
impl Frontend for CaptureFrontend {
fn run(self, ctx: FrontendContext) -> Result<Value> {
Ok(serde_json::to_value(CapturedFrontendContext {
ui_ast: ctx.ui_ast,
layout: ctx.layout,
schema: ctx.schema,
initial_data: ctx.initial_data,
})?)
}
}
fn capture_pipeline_output(schema: Value, defaults: Value, bundle: Option<UiAstBundle>) -> Value {
let pipeline = SchemaPipeline::new(schema)
.with_defaults(Some(defaults))
.with_prepared_ui_bundle(bundle);
pipeline
.run_with_frontend(CaptureFrontend)
.expect("pipeline capture succeeds")
}
#[test]
fn schema_pipeline_with_prepared_ui_bundle_matches_runtime_context() {
let schema = schema_value();
let defaults = defaults_value();
let bundle = build_ui_artifact_bundle(&schema, Some(&defaults))
.expect("build UI artifact bundle")
.ui;
let runtime = capture_pipeline_output(schema.clone(), defaults.clone(), None);
let prepared = capture_pipeline_output(schema, defaults, Some(bundle));
assert_eq!(runtime, prepared);
}
#[test]
fn schema_pipeline_handles_recursive_schema_without_stack_overflow() {
let runtime = capture_pipeline_output(
recursive_schema_value(),
Value::Object(serde_json::Map::new()),
None,
);
let ui_roots = runtime["ui_ast"]["roots"]
.as_array()
.expect("ui_ast roots array");
assert_eq!(ui_roots.len(), 1);
assert_eq!(ui_roots[0]["pointer"], json!("/tree"));
let tree_children = runtime["ui_ast"]["roots"][0]["kind"]["children"]
.as_array()
.expect("tree child nodes");
let child_pointers: Vec<_> = tree_children
.iter()
.filter_map(|child| child["pointer"].as_str())
.collect();
assert!(child_pointers.contains(&"/tree/name"));
assert!(child_pointers.contains(&"/tree/children"));
let layout_roots = runtime["layout"]["roots"].as_array().expect("layout roots");
assert_eq!(layout_roots.len(), 1);
let field_pointers = layout_roots[0]["sections"][0]["field_pointers"]
.as_array()
.expect("layout field pointers");
let field_pointers: Vec<_> = field_pointers
.iter()
.filter_map(|value| value.as_str())
.collect();
assert!(field_pointers.contains(&"/tree/name"));
assert!(field_pointers.contains(&"/tree/children"));
}
@@ -0,0 +1,162 @@
use crate::schema::{loader::load_root_schema, resolver::SchemaResolver};
use serde_json::json;
#[test]
fn resolves_definition_reference() {
let raw = json!({
"definitions": {
"duration": {
"type": "object",
"properties": {
"value": {"type": "integer"}
}
}
},
"type": "object",
"properties": {
"timeout": {"$ref": "#/definitions/duration"}
}
});
let root = load_root_schema(&raw).expect("valid root schema");
let resolver = SchemaResolver::new(&raw);
let timeout_schema = root
.object
.as_ref()
.unwrap()
.properties
.get("timeout")
.unwrap();
let resolved = resolver
.resolve_schema(timeout_schema)
.expect("resolution succeeds");
assert!(resolved.object.is_some());
assert!(
resolved
.object
.as_ref()
.unwrap()
.properties
.contains_key("value")
);
}
#[test]
fn resolves_pointer_reference() {
let raw = json!({
"type": "object",
"properties": {
"base": {
"type": "object",
"properties": {
"url": {"type": "string"}
}
},
"clone": {"$ref": "#/properties/base"}
}
});
let root = load_root_schema(&raw).expect("valid root schema");
let resolver = SchemaResolver::new(&raw);
let clone_schema = root
.object
.as_ref()
.unwrap()
.properties
.get("clone")
.unwrap();
let resolved = resolver
.resolve_schema(clone_schema)
.expect("resolution succeeds");
let object = resolved.object.expect("object schema");
assert!(object.properties.contains_key("url"));
}
#[test]
fn preserves_instance_metadata_and_extensions_when_resolving_reference() {
let raw = json!({
"definitions": {
"duration": {
"title": "Definition Title",
"description": "Definition description",
"type": "integer"
}
},
"type": "object",
"properties": {
"timeout": {
"$ref": "#/definitions/duration",
"title": "Request Timeout",
"description": "Per-request timeout",
"default": 5,
"x-group": "advanced",
"x-group-title": "Advanced Settings"
}
}
});
let root = load_root_schema(&raw).expect("valid root schema");
let resolver = SchemaResolver::new(&raw);
let timeout_schema = root
.object
.as_ref()
.unwrap()
.properties
.get("timeout")
.unwrap();
let resolved = resolver
.resolve_schema(timeout_schema)
.expect("resolution succeeds");
let metadata = resolved.metadata.expect("metadata preserved");
assert_eq!(metadata.title.as_deref(), Some("Request Timeout"));
assert_eq!(metadata.description.as_deref(), Some("Per-request timeout"));
assert_eq!(metadata.default, Some(json!(5)));
assert_eq!(
resolved
.extensions
.get("x-group")
.and_then(serde_json::Value::as_str),
Some("advanced")
);
assert_eq!(
resolved.instance_type,
load_root_schema(&raw["definitions"]["duration"])
.expect("definition schema")
.instance_type
);
}
#[test]
fn preserves_instance_metadata_for_pointer_references() {
let raw = json!({
"type": "object",
"properties": {
"base": {
"title": "Base URL",
"type": "string"
},
"clone": {
"$ref": "#/properties/base",
"title": "Service URL",
"description": "Instance-specific description"
}
}
});
let root = load_root_schema(&raw).expect("valid root schema");
let resolver = SchemaResolver::new(&raw);
let clone_schema = root
.object
.as_ref()
.unwrap()
.properties
.get("clone")
.unwrap();
let resolved = resolver
.resolve_schema(clone_schema)
.expect("resolution succeeds");
let metadata = resolved.metadata.expect("metadata preserved");
assert_eq!(metadata.title.as_deref(), Some("Service URL"));
assert_eq!(
metadata.description.as_deref(),
Some("Instance-specific description")
);
}
@@ -0,0 +1,271 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use jsonschema::validator_for;
use ratatui::layout::Rect;
use serde_json::{Value, json};
use crate::{
tui::{
app::{App, UiOptions},
model::form_schema_from_ui_ast,
state::FormState,
},
ui_ast::build_ui_ast,
};
fn runtime_form_schema(schema: &Value) -> crate::tui::model::FormSchema {
let ast = build_ui_ast(schema).expect("ui ast");
form_schema_from_ui_ast(&ast)
}
fn build_app(schema: Value) -> App {
let form_schema = runtime_form_schema(&schema);
let form_state = FormState::from_schema(&form_schema);
let validator = validator_for(&schema).expect("validator");
App::new(form_state, validator, UiOptions::default())
}
fn required_string_object(field_count: usize) -> Value {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
for idx in 0..field_count {
let name = format!("field_{idx}");
properties.insert(
name.clone(),
json!({
"type": "string",
"minLength": 1
}),
);
required.push(Value::String(name));
}
json!({
"type": "object",
"properties": properties,
"required": required
})
}
fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
#[test]
fn help_overlay_keeps_shortcuts_visible_when_no_errors_exist() {
let schema = json!({
"type": "object",
"properties": {
"name": {"type": "string"}
}
});
let mut app = build_app(schema);
let viewport = Rect::new(0, 0, 140, 18);
app.toggle_help_overlay_for_test(viewport);
let snapshot = app
.help_overlay_snapshot_for_test(viewport)
.expect("help overlay snapshot");
assert!(
snapshot.total_shortcuts >= 10,
"shortcut table should stay dense and visible"
);
assert_eq!(
snapshot.visible_errors, 0,
"error page should be empty when form is valid"
);
assert_eq!(snapshot.total_errors, 0, "total errors should be zero");
assert_eq!(snapshot.current_page, 1);
assert_eq!(snapshot.total_pages, 1);
assert!(
snapshot.summary.contains("only the error list paginates"),
"summary should explain that shortcuts remain visible while only errors page"
);
}
#[test]
fn help_overlay_paginates_errors_without_hiding_shortcuts() {
let schema = required_string_object(12);
let mut app = build_app(schema);
let viewport = Rect::new(0, 0, 140, 18);
app.handle_key_for_test(key(KeyCode::Char('s'), KeyModifiers::CONTROL))
.expect("trigger validation");
app.toggle_help_overlay_for_test(viewport);
let first_page = app
.help_overlay_snapshot_for_test(viewport)
.expect("first help page");
assert!(
first_page.total_shortcuts >= 10,
"shortcuts should stay visible on every page"
);
assert!(
first_page.visible_errors > 0 && first_page.visible_errors < 12,
"first page should show a non-empty error slice rather than the whole list"
);
assert_eq!(
first_page.total_errors, 12,
"all errors should still be counted"
);
assert_eq!(first_page.current_page, 1);
assert_eq!(first_page.total_pages, 2);
app.handle_key_for_test(key(KeyCode::Tab, KeyModifiers::NONE))
.expect("next help page");
let second_page = app
.help_overlay_snapshot_for_test(viewport)
.expect("second help page");
assert_eq!(
second_page.total_shortcuts, first_page.total_shortcuts,
"shortcut count should not change"
);
assert_eq!(
second_page.visible_errors,
12 - first_page.visible_errors,
"second page should show the remaining errors"
);
assert_eq!(second_page.total_errors, 12);
assert_eq!(second_page.current_page, 2);
assert_eq!(second_page.total_pages, 2);
}
#[test]
fn help_overlay_error_page_size_tracks_terminal_height() {
let schema = required_string_object(24);
let short_viewport = Rect::new(0, 0, 140, 16);
let tall_viewport = Rect::new(0, 0, 140, 28);
let mut short_app = build_app(schema.clone());
short_app
.handle_key_for_test(key(KeyCode::Char('s'), KeyModifiers::CONTROL))
.expect("trigger validation");
short_app.toggle_help_overlay_for_test(short_viewport);
let short_page = short_app
.help_overlay_snapshot_for_test(short_viewport)
.expect("short viewport page");
let mut tall_app = build_app(schema);
tall_app
.handle_key_for_test(key(KeyCode::Char('s'), KeyModifiers::CONTROL))
.expect("trigger validation");
tall_app.toggle_help_overlay_for_test(tall_viewport);
let tall_page = tall_app
.help_overlay_snapshot_for_test(tall_viewport)
.expect("tall viewport page");
assert_eq!(short_page.total_errors, 24);
assert_eq!(tall_page.total_errors, 24);
assert!(
short_page.visible_errors < tall_page.visible_errors,
"taller terminal should fit more errors per page"
);
assert!(
short_page.total_pages > tall_page.total_pages,
"taller terminal should require fewer pages"
);
}
#[test]
fn help_overlay_shortcuts_can_scroll_on_short_terminals() {
let schema = json!({
"type": "object",
"properties": {
"name": {"type": "string"}
}
});
let mut app = build_app(schema);
let viewport = Rect::new(0, 0, 140, 14);
app.toggle_help_overlay_for_test(viewport);
let before = app
.help_overlay_snapshot_for_test(viewport)
.expect("initial help snapshot");
assert!(
before.visible_shortcuts < before.total_shortcuts,
"short terminal should clip the shortcut table and require scrolling"
);
assert_eq!(before.shortcut_offset, 0);
app.handle_key_for_test(key(KeyCode::Down, KeyModifiers::NONE))
.expect("scroll help shortcuts down");
let after = app
.help_overlay_snapshot_for_test(viewport)
.expect("scrolled help snapshot");
assert_eq!(after.shortcut_offset, 1);
assert_eq!(after.visible_shortcuts, before.visible_shortcuts);
assert_eq!(after.total_shortcuts, before.total_shortcuts);
}
#[test]
fn footer_help_includes_string_edit_shortcuts_for_string_fields() {
let schema = json!({
"type": "object",
"properties": {
"name": {"type": "string"}
}
});
let app = build_app(schema);
let help = app.current_help_text_for_test().expect("string help");
assert!(help.contains("Tab/Down -> Next field"));
assert!(help.contains("Left -> Move cursor left"));
assert!(help.contains("Right -> Move cursor right"));
assert!(help.contains("Ctrl+W -> Delete previous word"));
}
#[test]
fn footer_help_includes_numeric_shortcuts_for_number_fields() {
let schema = json!({
"type": "object",
"properties": {
"threshold": {"type": "number"}
}
});
let app = build_app(schema);
let help = app.current_help_text_for_test().expect("numeric help");
assert!(help.contains("Tab/Down -> Next field"));
assert!(help.contains("Left -> Step value down"));
assert!(help.contains("Right -> Step value up"));
assert!(help.contains("Shift+Left -> Fast step value down"));
assert!(help.contains("Shift+Right -> Fast step value up"));
}
#[test]
fn help_overlay_error_column_supports_horizontal_scroll_with_h_and_l() {
let schema = json!({
"type": "object",
"properties": {
"name": {"type": "string"}
}
});
let mut app = build_app(schema);
let long_message = "this validation error is intentionally long so horizontal scrolling can reveal the hidden tail of the message in the help overlay";
assert!(
app.form_state_mut_for_test()
.set_error("/name", long_message.to_string()),
"expected test field to exist"
);
let viewport = Rect::new(0, 0, 72, 14);
app.toggle_help_overlay_for_test(viewport);
let before = app
.help_overlay_snapshot_for_test(viewport)
.expect("initial help snapshot");
assert_eq!(before.error_offset, 0);
app.handle_key_for_test(key(KeyCode::Char('l'), KeyModifiers::NONE))
.expect("scroll error text right");
let after = app
.help_overlay_snapshot_for_test(viewport)
.expect("scrolled help snapshot");
assert!(after.error_offset > 0, "expected horizontal error scroll");
app.handle_key_for_test(key(KeyCode::Char('h'), KeyModifiers::NONE))
.expect("scroll error text left");
let restored = app
.help_overlay_snapshot_for_test(viewport)
.expect("restored help snapshot");
assert_eq!(restored.error_offset, 0);
}
+91
View File
@@ -0,0 +1,91 @@
use crate::tui::app::{
input::{InputRouter, KeyAction},
keymap::{self, KeymapContext},
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
fn router() -> InputRouter {
InputRouter::new(keymap::default_store())
}
#[test]
fn ctrl_jl_cycle_root_sections() {
let router = router();
let prev = router.classify(&key(KeyCode::Char('j'), KeyModifiers::CONTROL));
let next = router.classify(&key(KeyCode::Char('l'), KeyModifiers::CONTROL));
assert!(matches!(prev, KeyAction::RootStep(-1)));
assert!(matches!(next, KeyAction::RootStep(1)));
}
#[test]
fn ctrl_tab_maps_to_section_steps() {
let router = router();
let next = router.classify(&key(KeyCode::Tab, KeyModifiers::CONTROL));
let prev = router.classify(&key(
KeyCode::Tab,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
assert!(matches!(next, KeyAction::SectionStep(1)));
assert!(matches!(prev, KeyAction::SectionStep(-1)));
}
#[test]
fn shift_tab_triggers_previous_field() {
let router = router();
let action = router.classify(&key(KeyCode::BackTab, KeyModifiers::SHIFT));
assert!(matches!(action, KeyAction::FieldStep(-1)));
}
#[test]
fn help_only_text_bindings_do_not_override_raw_input() {
let router = router();
let left = router.classify(&key(KeyCode::Left, KeyModifiers::NONE));
let backspace = router.classify(&key(KeyCode::Backspace, KeyModifiers::NONE));
let ctrl_w = router.classify(&key(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert!(matches!(left, KeyAction::Input(event) if event.code == KeyCode::Left));
assert!(matches!(backspace, KeyAction::Input(event) if event.code == KeyCode::Backspace));
assert!(matches!(ctrl_w, KeyAction::Input(event)
if event.code == KeyCode::Char('w') && event.modifiers == KeyModifiers::CONTROL));
}
#[test]
fn default_keymap_exposes_text_and_numeric_help_contexts() {
let store = keymap::default_store();
let text_help = store
.help_text(KeymapContext::TextInput)
.expect("text input help");
assert!(text_help.contains("Left -> Move cursor left"));
assert!(text_help.contains("Right -> Move cursor right"));
assert!(text_help.contains("Ctrl+W -> Delete previous word"));
assert!(text_help.contains("Ctrl+Z -> Undo text edit"));
assert!(text_help.contains("Ctrl+Y -> Redo text edit"));
let numeric_help = store
.help_text(KeymapContext::NumericInput)
.expect("numeric input help");
assert!(numeric_help.contains("Left -> Step value down"));
assert!(numeric_help.contains("Right -> Step value up"));
assert!(numeric_help.contains("Shift+Left -> Fast step value down"));
assert!(numeric_help.contains("Shift+Right -> Fast step value up"));
assert!(numeric_help.contains("Ctrl+Z -> Undo numeric edit"));
assert!(numeric_help.contains("Ctrl+Y -> Redo numeric edit"));
}
#[test]
fn help_context_bindings_are_only_classified_when_requested() {
let store = keymap::default_store();
let esc = key(KeyCode::Esc, KeyModifiers::NONE);
assert!(!matches!(store.classify(&esc), Some(KeyAction::HelpClose)));
assert!(matches!(
store.classify_for_contexts(&esc, &[KeymapContext::Help]),
Some(KeyAction::HelpClose)
));
}
+4
View File
@@ -0,0 +1,4 @@
mod help_overlay_tests;
mod input_tests;
mod overlay_stack_tests;
mod ultra_complex_overlay_tests;
@@ -0,0 +1,538 @@
use crate::tui::app::{App, UiOptions};
use crate::tui::model::form_schema_from_ui_ast;
use crate::tui::state::FormState;
use crate::ui_ast::build_ui_ast;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use jsonschema::validator_for;
use serde_json::{Value, json};
fn runtime_form_schema(schema: &Value) -> crate::tui::model::FormSchema {
let ast = build_ui_ast(schema).expect("ui ast");
form_schema_from_ui_ast(&ast)
}
fn build_nested_overlay_app() -> App {
let schema = json!({
"type": "object",
"properties": {
"service": {
"oneOf": [
{
"title": "http",
"type": "object",
"properties": {
"routes": {
"type": "array",
"default": [
{"type": "standard", "path": "/"},
{"type": "standard", "path": "/status"}
],
"items": {
"oneOf": [
{
"title": "Standard Route",
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "standard"
},
"path": {"type": "string"}
},
"required": ["type", "path"]
},
{
"title": "Regex Route",
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "regex"
},
"pattern": {"type": "string"}
},
"required": ["type", "pattern"]
}
]
}
}
}
}
]
}
}
});
let form_schema = runtime_form_schema(&schema);
let form_state = FormState::from_schema(&form_schema);
let validator = validator_for(&schema).expect("validator");
App::new(form_state, validator, UiOptions::default())
}
fn build_key_value_overlay_app() -> App {
let schema = json!({
"type": "object",
"properties": {
"headers": {
"type": "object",
"additionalProperties": {"type": "string"},
"default": {
"X-One": "1",
"X-Two": "2"
}
}
}
});
let form_schema = runtime_form_schema(&schema);
let form_state = FormState::from_schema(&form_schema);
let validator = validator_for(&schema).expect("validator");
App::new(form_state, validator, UiOptions::default())
}
fn build_scalar_array_overlay_app() -> App {
let schema = json!({
"type": "object",
"properties": {
"tags": {
"type": "array",
"default": ["a", "b"],
"items": {"type": "string"}
}
}
});
let form_schema = runtime_form_schema(&schema);
let form_state = FormState::from_schema(&form_schema);
let validator = validator_for(&schema).expect("validator");
App::new(form_state, validator, UiOptions::default())
}
fn build_invalid_composite_overlay_app() -> App {
let schema = json!({
"type": "object",
"properties": {
"service": {
"oneOf": [
{
"title": "http",
"type": "object",
"properties": {
"port": {"type": "integer"}
},
"required": ["port"]
}
]
}
}
});
let form_schema = runtime_form_schema(&schema);
let form_state = FormState::from_schema(&form_schema);
let validator = validator_for(&schema).expect("validator");
App::new(form_state, validator, UiOptions::default())
}
fn activate_service_variant(app: &mut App) {
let form_state = app.form_state_mut_for_test();
let field = form_state
.field_mut_by_pointer("/service")
.expect("service field present");
field.apply_composite_selection(0, None);
}
fn focus_field(state: &mut FormState, pointer: &str) {
for (root_idx, root) in state.roots.iter().enumerate() {
for (section_idx, section) in root.sections.iter().enumerate() {
if let Some(field_idx) = section.fields.iter().position(|field| {
field.schema.pointer == pointer || field.schema.pointer.ends_with(pointer)
}) {
state.set_root_index(root_idx);
state.set_section_index(section_idx);
state.set_field_index(field_idx);
return;
}
}
}
let mut available = Vec::new();
for root in &state.roots {
for section in &root.sections {
for field in &section.fields {
available.push(field.schema.pointer.clone());
}
}
}
panic!("field {pointer} not found; available: {available:?}");
}
fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
// Test removed: Ctrl+S behavior with nested overlays is complex and needs redesign
#[test]
fn esc_pops_only_top_overlay() {
let mut app = build_nested_overlay_app();
activate_service_variant(&mut app);
app.open_overlay_for_test();
{
let overlay_form = app
.active_overlay_form_state_for_test()
.expect("overlay form");
focus_field(overlay_form, "/routes");
}
app.open_overlay_for_test();
assert_eq!(app.overlay_depth_for_test(), 2);
app.handle_key_for_test(key(KeyCode::Esc, KeyModifiers::NONE))
.expect("close overlay2");
assert_eq!(
app.overlay_depth_for_test(),
1,
"esc closes only top overlay",
);
app.handle_key_for_test(key(KeyCode::Esc, KeyModifiers::NONE))
.expect("close overlay1");
assert_eq!(app.overlay_depth_for_test(), 0);
}
#[test]
fn ctrl_arrows_manage_entries_without_closing_overlay() {
let mut app = build_nested_overlay_app();
activate_service_variant(&mut app);
app.open_overlay_for_test();
{
let overlay_form = app
.active_overlay_form_state_for_test()
.expect("overlay form level1");
focus_field(overlay_form, "/routes");
}
app.open_overlay_for_test();
assert_eq!(app.overlay_depth_for_test(), 2);
assert_eq!(app.overlay_selected_entry_for_test(), Some(0));
app.handle_key_for_test(key(KeyCode::Right, KeyModifiers::CONTROL))
.expect("ctrl+right");
assert_eq!(app.overlay_depth_for_test(), 2);
assert_eq!(app.overlay_selected_entry_for_test(), Some(1));
app.handle_key_for_test(key(KeyCode::Left, KeyModifiers::CONTROL))
.expect("ctrl+left");
assert_eq!(app.overlay_selected_entry_for_test(), Some(0));
app.handle_key_for_test(key(KeyCode::Left, KeyModifiers::CONTROL))
.expect("ctrl+left boundary");
assert_eq!(app.overlay_depth_for_test(), 2);
assert_eq!(app.overlay_selected_entry_for_test(), Some(0));
app.handle_key_for_test(key(KeyCode::Down, KeyModifiers::CONTROL))
.expect("ctrl+down reorder");
assert_eq!(app.overlay_depth_for_test(), 2);
assert_eq!(
app.overlay_selected_entry_for_test(),
Some(1),
"entry should move to index 1 after reorder",
);
app.handle_key_for_test(key(KeyCode::Up, KeyModifiers::CONTROL))
.expect("ctrl+up reorder back");
assert_eq!(app.overlay_depth_for_test(), 2);
assert_eq!(app.overlay_selected_entry_for_test(), Some(0));
}
#[test]
fn entry_focus_respects_arrow_keys() {
let mut app = build_nested_overlay_app();
activate_service_variant(&mut app);
app.open_overlay_for_test();
{
let overlay_form = app
.active_overlay_form_state_for_test()
.expect("overlay form level1");
focus_field(overlay_form, "/routes");
}
app.open_overlay_for_test();
assert_eq!(app.overlay_depth_for_test(), 2);
assert_eq!(app.overlay_entry_focus_for_test(), Some(true));
app.handle_key_for_test(key(KeyCode::Down, KeyModifiers::NONE))
.expect("down from entry focus jumps into fields");
assert_eq!(app.overlay_entry_focus_for_test(), Some(false));
app.handle_key_for_test(key(KeyCode::Up, KeyModifiers::NONE))
.expect("up from first field returns to entries");
assert_eq!(app.overlay_entry_focus_for_test(), Some(true));
assert_eq!(
app.overlay_selected_entry_for_test(),
Some(1),
"up from first field should cycle to previous entry",
);
}
#[test]
fn composite_overlay_uses_layout_nav_for_default_focus() {
let mut app = build_nested_overlay_app();
activate_service_variant(&mut app);
// Focus the top-level /service field to open its composite overlay.
{
let form_state = app.form_state_mut_for_test();
focus_field(form_state, "/service");
}
app.open_overlay_for_test();
assert_eq!(app.overlay_depth_for_test(), 1);
let overlay_form = app
.active_overlay_form_state_for_test()
.expect("overlay form level1");
// The overlay FormState should carry a LayoutNavModel derived from UiLayout.
assert!(overlay_form.layout_nav().is_some());
let pointer = overlay_form
.focused_field()
.map(|field| field.schema.pointer.clone())
.unwrap_or_else(|| "<none>".to_string());
// For the `service` variant schema, the first layout field is `/routes`.
assert_eq!(pointer, "/routes");
// layout-driven first-field focus should be idempotent and succeed.
assert!(overlay_form.focus_first_field_with_layout());
let pointer_after = overlay_form
.focused_field()
.map(|field| field.schema.pointer.clone())
.unwrap_or_else(|| "<none>".to_string());
assert_eq!(pointer_after, "/routes");
}
#[test]
fn scalar_array_overlay_opens_for_primitive_array_field() {
let mut app = build_scalar_array_overlay_app();
{
let form_state = app.form_state_mut_for_test();
focus_field(form_state, "/tags");
}
app.open_overlay_for_test();
assert_eq!(
app.overlay_depth_for_test(),
1,
"scalar array overlay should open"
);
assert_eq!(
app.overlay_selected_entry_for_test(),
Some(0),
"first scalar array entry should be selected by default",
);
}
#[test]
fn scalar_array_overlay_can_delete_all_entries_without_recreating_last() {
let mut app = build_scalar_array_overlay_app();
{
let form_state = app.form_state_mut_for_test();
focus_field(form_state, "/tags");
}
// Open scalar array overlay for /tags.
app.open_overlay_for_test();
assert_eq!(
app.overlay_depth_for_test(),
1,
"scalar array overlay should open before deletions",
);
fn tags_len(app: &mut App) -> usize {
let form_state = app.form_state_mut_for_test();
let field = form_state
.field_mut_by_pointer("/tags")
.expect("tags field present");
field
.composite_list_panel()
.map(|(entries, _selected)| entries.len())
.unwrap_or(0)
}
assert_eq!(tags_len(&mut app), 2, "default tags array has two entries");
// First Ctrl+D: remove one entry; overlay should remain open.
app.handle_key_for_test(key(KeyCode::Char('d'), KeyModifiers::CONTROL))
.expect("first Ctrl+D in scalar array overlay");
assert_eq!(
app.overlay_depth_for_test(),
1,
"overlay should stay open while scalar array is non-empty",
);
assert_eq!(
tags_len(&mut app),
1,
"one entry should remain after first deletion",
);
// Second Ctrl+D: remove last entry; overlay should close and list become empty.
app.handle_key_for_test(key(KeyCode::Char('d'), KeyModifiers::CONTROL))
.expect("second Ctrl+D in scalar array overlay");
assert_eq!(
app.overlay_depth_for_test(),
0,
"deleting the last entry should close the scalar array overlay instead of reopening it",
);
assert_eq!(
tags_len(&mut app),
0,
"scalar array field should be truly empty after deleting the last entry",
);
}
#[test]
fn nested_list_overlay_deleting_all_entries_returns_to_parent_overlay() {
let mut app = build_nested_overlay_app();
activate_service_variant(&mut app);
// Open overlay1 for the `service` composite field.
app.open_overlay_for_test();
{
let overlay_form = app
.active_overlay_form_state_for_test()
.expect("overlay form level1");
focus_field(overlay_form, "/routes");
}
// Open overlay2 for the `routes` list.
app.open_overlay_for_test();
assert_eq!(
app.overlay_depth_for_test(),
2,
"expected nested list overlay depth to be 2 before deletions",
);
// First deletion: list still has entries, so list overlay should stay at depth 2.
app.handle_key_for_test(key(KeyCode::Char('d'), KeyModifiers::CONTROL))
.expect("Ctrl+D to delete first route entry");
assert_eq!(
app.overlay_depth_for_test(),
2,
"list overlay should remain open while composite list still has entries",
);
// Second deletion: removing last entry should close only the list overlay and
// return to the parent (service) overlay at depth 1, without reopening a new
// list overlay with an auto-created entry.
app.handle_key_for_test(key(KeyCode::Char('d'), KeyModifiers::CONTROL))
.expect("Ctrl+D to delete last route entry");
assert_eq!(
app.overlay_depth_for_test(),
1,
"after deleting the last list entry, control should return to the parent overlay",
);
assert_eq!(
app.overlay_selected_entry_for_test(),
None,
"no list entry should be selected once the list is empty",
);
}
#[test]
fn key_value_overlay_can_delete_all_entries_without_recreating_last() {
let mut app = build_key_value_overlay_app();
{
let form_state = app.form_state_mut_for_test();
focus_field(form_state, "/headers");
}
// Open key/value overlay for /headers.
app.open_overlay_for_test();
assert_eq!(
app.overlay_depth_for_test(),
1,
"key/value overlay should open before deletions",
);
fn headers_len(app: &mut App) -> usize {
let form_state = app.form_state_mut_for_test();
let field = form_state
.field_mut_by_pointer("/headers")
.expect("headers field present");
field
.composite_list_panel()
.map(|(entries, _selected)| entries.len())
.unwrap_or(0)
}
assert_eq!(
headers_len(&mut app),
2,
"default headers map should expose two entries in the collection panel",
);
// First Ctrl+D: remove one entry; overlay should remain open.
app.handle_key_for_test(key(KeyCode::Char('d'), KeyModifiers::CONTROL))
.expect("first Ctrl+D in key/value overlay");
assert_eq!(
app.overlay_depth_for_test(),
1,
"overlay should stay open while key/value map still has entries",
);
assert_eq!(
headers_len(&mut app),
1,
"one entry should remain after first deletion",
);
// Second Ctrl+D: remove last entry; overlay should close and map become empty.
app.handle_key_for_test(key(KeyCode::Char('d'), KeyModifiers::CONTROL))
.expect("second Ctrl+D in key/value overlay");
assert_eq!(
app.overlay_depth_for_test(),
0,
"deleting the last entry should close the key/value overlay instead of reopening it",
);
assert_eq!(
headers_len(&mut app),
0,
"key/value field should be truly empty after deleting the last entry",
);
}
#[test]
fn save_overlay_stack_rejects_invalid_composite_payloads() {
let mut app = build_invalid_composite_overlay_app();
activate_service_variant(&mut app);
{
let form_state = app.form_state_mut_for_test();
focus_field(form_state, "/service");
}
let before = app
.form_state_mut_for_test()
.field_by_pointer("/service")
.expect("service field")
.current_value()
.expect("service value before save");
app.open_overlay_for_test();
{
let overlay_form = app
.active_overlay_form_state_for_test()
.expect("service overlay form");
overlay_form
.field_mut_by_pointer("/port")
.expect("port field")
.seed_value(&json!("oops"));
}
assert!(
!app.save_overlay_stack_to_root(),
"invalid composite overlay should not be committed to the host form"
);
assert_eq!(app.overlay_depth_for_test(), 1);
let after = app
.form_state_mut_for_test()
.field_by_pointer("/service")
.expect("service field")
.current_value()
.expect("service value after rejected save");
assert_eq!(after, before);
}
@@ -0,0 +1,195 @@
use std::{fs, path::PathBuf};
use crate::{
tui::{
app::{App, UiOptions},
model::{FieldKind, form_schema_from_ui_ast},
state::FormState,
},
ui_ast::build_ui_ast,
};
use jsonschema::validator_for;
use serde_json::{Value, json};
const TREE_NODE_SCHEMA_PTR: &str = "/definitions/treeNode";
const MATRIX_SCHEMA_PTR: &str = "/properties/level1/properties/level2/properties/config/oneOf/1/properties/settings/properties/level3/properties/level4/properties/matrix";
const RECURSIVE_CHILDREN_PTR: &str = "/recursiveTree/children";
const MATRIX_PTR: &str = "/matrix";
fn ultra_complex_schema_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("examples")
.join("ultra-complex.schema.json")
}
fn load_ultra_complex_schema() -> Value {
let contents = fs::read_to_string(ultra_complex_schema_path()).expect("ultra schema readable");
serde_json::from_str(&contents).expect("ultra schema json")
}
fn wrap_property_schema(root_schema: &Value, field_name: &str, property_schema: Value) -> Value {
let mut wrapped = json!({
"type": "object",
"properties": {
field_name: property_schema
}
});
if let Some(definitions) = root_schema
.get("definitions")
.cloned()
.or_else(|| root_schema.get("$defs").cloned())
&& let Some(object) = wrapped.as_object_mut()
{
object.insert("definitions".to_string(), definitions.clone());
}
wrapped
}
fn build_runtime_app_from_example_fragment(pointer: &str, field_name: &str) -> App {
let root_schema = load_ultra_complex_schema();
let fragment = root_schema
.pointer(pointer)
.cloned()
.unwrap_or_else(|| panic!("schema fragment {pointer} not found"));
let schema = wrap_property_schema(&root_schema, field_name, fragment);
let ast = build_ui_ast(&schema).expect("ui ast");
let form_schema = form_schema_from_ui_ast(&ast);
let form_state = FormState::from_schema(&form_schema);
let validator = validator_for(&schema).expect("validator");
App::new(form_state, validator, UiOptions::default())
}
fn focus_field(state: &mut FormState, pointer: &str) {
for (root_idx, root) in state.roots.iter().enumerate() {
for (section_idx, section) in root.sections.iter().enumerate() {
if let Some(field_idx) = section.fields.iter().position(|field| {
field.schema.pointer == pointer || field.schema.pointer.ends_with(pointer)
}) {
state.set_root_index(root_idx);
state.set_section_index(section_idx);
state.set_field_index(field_idx);
return;
}
}
}
panic!("field {pointer} not found");
}
#[test]
fn ultra_complex_recursive_tree_children_overlay_supports_nested_depth() {
let mut app = build_runtime_app_from_example_fragment(TREE_NODE_SCHEMA_PTR, "recursiveTree");
{
let form_state = app.form_state_mut_for_test();
focus_field(form_state, RECURSIVE_CHILDREN_PTR);
}
app.open_overlay_for_test();
assert_eq!(
app.overlay_depth_for_test(),
1,
"recursiveTree.children should open its first node editor"
);
{
let overlay = app
.active_overlay_form_state_for_test()
.expect("first overlay form");
assert!(overlay.field_by_pointer("/name").is_some());
assert!(overlay.field_by_pointer("/children").is_some());
overlay
.field_mut_by_pointer("/name")
.expect("name field")
.seed_value(&json!("child-1"));
focus_field(overlay, "/children");
}
app.open_overlay_for_test();
assert_eq!(
app.overlay_depth_for_test(),
2,
"recursive child nodes should open nested overlays instead of staying inert"
);
{
let overlay = app
.active_overlay_form_state_for_test()
.expect("nested overlay form");
assert!(overlay.field_by_pointer("/name").is_some());
overlay
.field_mut_by_pointer("/name")
.expect("grandchild name")
.seed_value(&json!("grandchild-1"));
}
assert!(
app.save_overlay_stack_to_root(),
"nested recursive overlays should commit back to the root form"
);
let current = app
.form_state_mut_for_test()
.field_by_pointer(RECURSIVE_CHILDREN_PTR)
.expect("recursive children field")
.current_value()
.expect("recursive children value");
assert_eq!(
current,
Some(json!([{
"name": "child-1",
"children": [{
"name": "grandchild-1"
}]
}]))
);
}
#[test]
fn ultra_complex_matrix_overlay_opens_for_rows_and_cells() {
let mut app = build_runtime_app_from_example_fragment(MATRIX_SCHEMA_PTR, "matrix");
{
let form_state = app.form_state_mut_for_test();
focus_field(form_state, MATRIX_PTR);
}
app.open_overlay_for_test();
assert_eq!(
app.overlay_depth_for_test(),
1,
"matrix should open a row overlay instead of ignoring Ctrl+E/Enter"
);
{
let overlay = app
.active_overlay_form_state_for_test()
.expect("matrix row overlay");
assert!(
overlay.field_by_pointer("/__value").is_some(),
"row overlay should expose the wrapped inner array field"
);
focus_field(overlay, "/__value");
}
app.open_overlay_for_test();
assert_eq!(
app.overlay_depth_for_test(),
2,
"matrix row cells should open a second overlay instead of staying inert"
);
let overlay = app
.active_overlay_form_state_for_test()
.expect("matrix cell overlay");
let field = overlay
.field_by_pointer("/__value")
.expect("wrapped null cell field");
match &field.schema.kind {
FieldKind::Enum { values, .. } => {
assert_eq!(values, &vec![Value::Null]);
}
other => panic!("null matrix variant should be a fixed null enum, got {other:?}"),
}
assert_eq!(
field.current_value().expect("null cell current value"),
Some(Value::Null)
);
}
@@ -0,0 +1,71 @@
use std::collections::HashMap;
use crate::tui::{
model::{CompositeField, CompositeMode, CompositeVariant, FieldKind, FieldSchema},
state::{FieldState, field::components::helpers::format_collection_value},
};
use serde_json::json;
#[test]
fn formats_collection_status_with_selection() {
let text = format_collection_value("Map", 2, Some("key=value".to_string()), "(Ctrl+E edit)");
assert_eq!(text, "Map[2] • key=value (Ctrl+E edit)");
}
#[test]
fn formats_collection_status_when_empty() {
let text = format_collection_value("List", 0, None, "(Ctrl+N add)");
assert_eq!(text, "List: empty (Ctrl+N add)");
}
#[test]
fn composite_list_display_value_uses_available_width_for_selected_entry() {
let variants = vec![CompositeVariant {
id: "bar".to_string(),
title: "Bar Item".to_string(),
description: None,
schema: json!({
"type": "object",
"properties": {
"Bar": {"type": "string"},
"Id": {"type": "integer"}
}
}),
is_object: true,
}];
let template = CompositeField {
mode: CompositeMode::AnyOf,
variants,
};
let mut field = FieldState::from_schema(FieldSchema {
name: "blahList".to_string(),
path: vec!["blahList".to_string()],
pointer: "/blahList".to_string(),
title: "Blah List".to_string(),
description: None,
kind: FieldKind::Array(Box::new(FieldKind::Composite(Box::new(template)))),
required: false,
default: None,
metadata: HashMap::new(),
});
field.seed_value(&json!([{
"Bar": "客服热线 400-820-8820 转人工服务",
"Id": 0
}]));
let text = field.display_value_with_limit(64);
assert!(
text.contains("Variants: #1 Bar Item"),
"display text: {text}"
);
assert!(
!text.contains("Bar:"),
"display text should stay compact: {text}"
);
assert!(
!text.contains("Id: 0"),
"display text should stay compact: {text}"
);
}
@@ -0,0 +1,243 @@
use crate::{
tui::model::{FieldKind, FieldSchema},
tui::state::field::components::{
BoolComponent, ComponentPalette, EnumComponent, FieldComponent, TextComponent,
},
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde_json::json;
use std::sync::Arc;
fn integer_schema() -> FieldSchema {
FieldSchema {
name: "count".to_string(),
path: vec!["count".to_string()],
pointer: "/count".to_string(),
title: "count".to_string(),
description: None,
kind: FieldKind::Integer,
required: false,
default: None,
metadata: Default::default(),
}
}
fn string_schema(default: &str) -> FieldSchema {
FieldSchema {
name: "name".to_string(),
path: vec!["name".to_string()],
pointer: "/name".to_string(),
title: "name".to_string(),
description: None,
kind: FieldKind::String,
required: false,
default: Some(json!(default)),
metadata: Default::default(),
}
}
fn number_schema() -> FieldSchema {
FieldSchema {
name: "ratio".to_string(),
path: vec!["ratio".to_string()],
pointer: "/ratio".to_string(),
title: "ratio".to_string(),
description: None,
kind: FieldKind::Number,
required: false,
default: None,
metadata: Default::default(),
}
}
fn bool_schema() -> FieldSchema {
FieldSchema {
name: "enabled".to_string(),
path: vec!["enabled".to_string()],
pointer: "/enabled".to_string(),
title: "enabled".to_string(),
description: None,
kind: FieldKind::Boolean,
required: false,
default: Some(json!(false)),
metadata: Default::default(),
}
}
fn enum_schema() -> FieldSchema {
FieldSchema {
name: "mode".to_string(),
path: vec!["mode".to_string()],
pointer: "/mode".to_string(),
title: "mode".to_string(),
description: None,
kind: FieldKind::Enum {
labels: vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()],
values: vec![json!("alpha"), json!("beta"), json!("gamma")],
},
required: false,
default: Some(json!("alpha")),
metadata: Default::default(),
}
}
#[test]
fn text_component_supports_numeric_stepper() {
let schema = integer_schema();
let mut component = TextComponent::new(&schema, Arc::new(ComponentPalette::default()));
let key = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
assert!(component.handle_key(&schema, &key));
assert_eq!(component.display_value(&schema), "1");
let key = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
assert!(component.handle_key(&schema, &key));
assert_eq!(component.display_value(&schema), "0");
}
#[test]
fn text_component_rejects_control_characters() {
let schema = integer_schema();
let mut component = TextComponent::new(&schema, Arc::new(ComponentPalette::default()));
let ctrl_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
assert!(!component.handle_key(&schema, &ctrl_a));
assert_eq!(component.display_value(&schema), "");
}
#[test]
fn number_input_rejects_non_numeric_chars_but_accepts_decimal_and_exponent_editing() {
let schema = number_schema();
let mut component = TextComponent::new(&schema, Arc::new(ComponentPalette::default()));
assert!(!component.handle_key(
&schema,
&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)
));
assert_eq!(component.display_value(&schema), "");
for key in [
KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE),
] {
assert!(
component.handle_key(&schema, &key),
"expected {key:?} to be accepted"
);
}
assert_eq!(component.display_value(&schema), "-1.2e-3");
assert!(!component.handle_key(
&schema,
&KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)
));
assert_eq!(component.display_value(&schema), "-1.2e-3");
}
#[test]
fn integer_input_rejects_decimal_and_exponent_chars() {
let schema = integer_schema();
let mut component = TextComponent::new(&schema, Arc::new(ComponentPalette::default()));
assert!(component.handle_key(
&schema,
&KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)
));
assert!(!component.handle_key(
&schema,
&KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)
));
assert!(!component.handle_key(
&schema,
&KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)
));
assert!(!component.handle_key(
&schema,
&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)
));
assert_eq!(component.display_value(&schema), "1");
}
#[test]
fn text_component_supports_cursor_editing_delete_undo_redo_and_ctrl_w() {
let schema = string_schema("alpha beta");
let mut component = TextComponent::new(&schema, Arc::new(ComponentPalette::default()));
assert!(component.handle_key(&schema, &KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)));
assert!(component.handle_key(&schema, &KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)));
assert!(component.handle_key(
&schema,
&KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)
));
assert_eq!(component.display_value(&schema), "alpha beXta");
assert!(component.handle_key(&schema, &KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)));
assert_eq!(component.display_value(&schema), "alpha beXa");
assert!(component.handle_key(
&schema,
&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)
));
assert_eq!(component.display_value(&schema), "alpha bea");
assert!(component.handle_key(
&schema,
&KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL)
));
assert_eq!(component.display_value(&schema), "alpha beXa");
assert!(component.handle_key(
&schema,
&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)
));
assert_eq!(component.display_value(&schema), "alpha bea");
let mut component = TextComponent::new(&schema, Arc::new(ComponentPalette::default()));
assert!(component.handle_key(
&schema,
&KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)
));
assert_eq!(component.display_value(&schema), "alpha ");
assert!(component.handle_key(
&schema,
&KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL)
));
assert_eq!(component.display_value(&schema), "alpha beta");
}
#[test]
fn enum_and_bool_components_keep_left_right_toggle_behavior() {
let palette = Arc::new(ComponentPalette::default());
let bool_schema = bool_schema();
let mut bool_component = BoolComponent::new(&bool_schema, Arc::clone(&palette));
assert!(!bool_component.bool_value().expect("bool default"));
assert!(bool_component.handle_key(
&bool_schema,
&KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)
));
assert!(bool_component.bool_value().expect("bool toggled"));
assert!(bool_component.handle_key(
&bool_schema,
&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)
));
assert!(!bool_component.bool_value().expect("bool toggled back"));
let enum_schema = enum_schema();
let FieldKind::Enum { labels, values } = &enum_schema.kind else {
panic!("expected enum field kind");
};
let mut enum_component = EnumComponent::new(labels, values, &enum_schema, Arc::clone(&palette));
assert_eq!(enum_component.display_value(&enum_schema), "alpha");
assert!(enum_component.handle_key(
&enum_schema,
&KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)
));
assert_eq!(enum_component.display_value(&enum_schema), "beta");
assert!(enum_component.handle_key(
&enum_schema,
&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)
));
assert_eq!(enum_component.display_value(&enum_schema), "alpha");
}
@@ -0,0 +1,67 @@
use crate::tui::view::components::fields::{error_lines, meta_lines};
use crate::{
tui::model::{FieldKind, FieldSchema},
tui::state::FieldState,
};
use ratatui::style::{Color, Modifier};
fn make_field() -> FieldState {
FieldState::from_schema(FieldSchema {
name: "test".into(),
path: vec!["test".into()],
pointer: "/test".into(),
title: "Test".into(),
description: None,
kind: FieldKind::String,
required: false,
default: None,
metadata: Default::default(),
})
}
#[test]
fn meta_line_selected_uses_dark_text() {
let field = make_field();
let lines = meta_lines(&field, true, 40);
let span = lines
.first()
.and_then(|line| line.spans.first())
.expect("type span");
assert_eq!(span.style.fg, Some(Color::Blue));
assert!(span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn meta_line_unselected_uses_gray() {
let field = make_field();
let lines = meta_lines(&field, false, 40);
let span = lines
.first()
.and_then(|line| line.spans.first())
.expect("type span");
assert_eq!(span.style.fg, Some(Color::DarkGray));
assert!(!span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn error_lines_wrap_and_cap_at_three_lines() {
let mut field = make_field();
field.set_error(
"this validation error is intentionally long so the renderer must wrap it across multiple lines without letting the panel grow forever".to_string(),
);
let lines = error_lines(&field, 28).expect("error lines");
assert_eq!(lines.len(), 4, "label + at most 3 wrapped lines");
let last = lines
.last()
.expect("last wrapped line")
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(
last.contains('…'),
"truncated final line should end with ellipsis: {last}"
);
}
@@ -0,0 +1,99 @@
use crate::tui::view::{
UiContext,
components::footer::{
FooterActionItem, FooterStatusTone, footer_action_items, footer_status_model,
},
};
#[test]
fn footer_action_items_split_help_snippets_into_pairs() {
let items = footer_action_items(Some(
"Ctrl+S -> validate and save • Ctrl+Q -> quit • Tab -> next field",
));
assert_eq!(
items,
vec![
FooterActionItem {
keys: "Ctrl+S".to_string(),
action: "validate and save".to_string(),
},
FooterActionItem {
keys: "Ctrl+Q".to_string(),
action: "quit".to_string(),
},
FooterActionItem {
keys: "Tab".to_string(),
action: "next field".to_string(),
},
]
);
}
#[test]
fn footer_status_model_prioritizes_error_tone_and_focus_context() {
let ctx = UiContext {
status_message: "3 issue(s) remaining",
dirty: true,
error_count: 3,
help: None,
global_errors: &[String::from(
"matrix[0][2] invalid: expected boolean or null but got string that keeps growing",
)],
focus_label: Some("Level4 › Matrix".to_string()),
session_title: Some("Service Config"),
popup: None,
composite_overlay: None,
help_overlay: None,
};
let status = footer_status_model(&ctx);
assert_eq!(status.tone, FooterStatusTone::Error);
assert_eq!(status.badge, "ERR 3");
assert_eq!(status.message, "3 issue(s) remaining");
assert!(
status.meta.iter().any(|entry| entry == "Unsaved changes"),
"dirty state should still be surfaced when errors exist"
);
assert!(
status
.meta
.iter()
.any(|entry| entry == "Focus Level4 › Matrix"),
"focus label should remain visible in the footer meta line"
);
assert!(
status
.alert
.as_deref()
.is_some_and(|alert| alert.ends_with('…')),
"global alert text should be truncated for readability"
);
assert_eq!(status.session_title.as_deref(), Some("Service Config"));
}
#[test]
fn footer_status_model_compacts_ready_message() {
let ctx = UiContext {
status_message: crate::tui::app::status::READY_STATUS,
dirty: false,
error_count: 0,
help: Some("Ctrl+S -> validate and save"),
global_errors: &[],
focus_label: None,
session_title: Some("SchemaUI Demo"),
popup: None,
composite_overlay: None,
help_overlay: None,
};
let status = footer_status_model(&ctx);
assert_eq!(status.tone, FooterStatusTone::Ready);
assert_eq!(status.badge, "READY");
assert_eq!(status.message, "Ready for input");
assert!(status.meta.is_empty());
assert!(status.alert.is_none());
assert_eq!(status.session_title.as_deref(), Some("SchemaUI Demo"));
}
@@ -0,0 +1,5 @@
mod component_collection_tests;
mod component_text_tests;
mod fields_tests;
mod footer_tests;
mod overlay_tests;
@@ -0,0 +1,44 @@
use serde_json::json;
use crate::tui::model::form_schema_from_ui_ast;
use crate::tui::state::LayoutNavModel;
use crate::tui::view::components::overlay::layout_section_description;
use crate::ui_ast::{build_ui_ast, layout::build_ui_layout};
#[test]
fn layout_section_description_matches_nav_model() {
let schema = json!({
"type": "object",
"properties": {
"service": {
"title": "Service",
"type": "object",
"properties": {
"routes": {
"title": "Routes",
"type": "array",
"items": { "type": "string" }
}
}
}
}
});
let ast = build_ui_ast(&schema).expect("ast");
let form_schema = form_schema_from_ui_ast(&ast);
let mut form_state = crate::tui::state::FormState::from_schema(&form_schema);
let layout = build_ui_layout(&ast);
let layout_nav = LayoutNavModel::from_uilayout(&layout);
form_state.set_layout_nav(layout_nav.clone());
assert!(form_state.focus_first_field_with_layout());
let expected = {
let root = layout_nav.roots.first().expect("root");
let section = root.sections.first().expect("section");
format!("Section: {} - {}", root.title, section.title)
};
let desc = layout_section_description(&form_state).expect("description");
assert_eq!(desc, expected);
}
+4
View File
@@ -0,0 +1,4 @@
pub(crate) mod app;
pub(crate) mod components;
mod session_artifact_tests;
pub(crate) mod state;
@@ -0,0 +1,41 @@
use std::{fs, path::PathBuf};
use serde_json::{Value, json};
use crate::io::{DocumentFormat, input::parse_document_str};
use crate::precompile::build_ui_artifact_bundle;
use crate::tui::session::resolve_tui_artifacts;
fn schema_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("schemas")
.join("test-comprehensive.schema.json")
}
fn schema_value() -> Value {
let path = schema_path();
let contents = fs::read_to_string(path).expect("schema file readable");
parse_document_str(&contents, DocumentFormat::Json).expect("schema parses")
}
#[test]
fn resolve_tui_artifacts_matches_runtime_when_bundle_supplies_tui_derivatives() {
let schema = schema_value();
let defaults = json!({
"simpleTypes": {
"text": "hello from defaults",
"number": 7,
"toggle": true,
"dropdown": "option2"
}
});
let bundle =
build_ui_artifact_bundle(&schema, Some(&defaults)).expect("build UI artifact bundle");
let runtime = resolve_tui_artifacts(&bundle.ui.ui_ast, &bundle.ui.layout, None);
let provided = resolve_tui_artifacts(&bundle.ui.ui_ast, &bundle.ui.layout, Some(bundle.tui));
assert_eq!(runtime, provided);
}
@@ -0,0 +1,137 @@
use std::sync::Arc;
use serde_json::{Map, Value, json};
use crate::tui::model::{CompositeField, CompositeMode, CompositeVariant, FieldKind};
use crate::tui::state::array::ScalarArrayState;
use crate::tui::state::composite::CompositeListState;
use crate::tui::state::field::components::ComponentPalette;
use crate::tui::state::key_value::KeyValueState;
fn scalar_array(values: &[Value]) -> ScalarArrayState {
let kind = FieldKind::String;
let mut state = ScalarArrayState::new(
"/tags",
"Tags".to_string(),
None,
&kind,
None,
Arc::new(ComponentPalette::default()),
);
state.seed_entries_from_array(values);
state
}
fn composite_list(defaults: &[Value]) -> CompositeListState {
let variants = vec![CompositeVariant {
id: "variant_0".to_string(),
title: "Entry".to_string(),
description: None,
schema: json!({"type": "string"}),
is_object: false,
}];
let template = CompositeField {
mode: CompositeMode::OneOf,
variants,
};
CompositeListState::new(
"/items",
&template,
Some(&Value::Array(defaults.to_vec())),
Arc::new(ComponentPalette::default()),
)
}
fn key_value_with_entries(pairs: &[(&str, &str)]) -> KeyValueState {
use crate::tui::model::KeyValueField;
let value_kind = FieldKind::String;
let kv_field = KeyValueField {
key_title: "Key".to_string(),
key_description: None,
key_default: None,
key_schema: json!({"type": "string"}),
value_title: "Value".to_string(),
value_description: None,
value_default: None,
value_schema: json!({"type": "string"}),
value_kind: Box::new(value_kind),
entry_schema: json!({"type": "object"}),
};
let mut state = KeyValueState::new(
"/headers",
&kv_field,
None,
Arc::new(ComponentPalette::default()),
);
// Seed entries manually using seed_entries_from_object so we exercise the
// same code path used for defaults.
let mut map = Map::new();
for (k, v) in pairs {
map.insert((*k).to_string(), Value::String((*v).to_string()));
}
state.seed_entries_from_object(&map);
state
}
#[test]
fn scalar_array_remove_selected_can_delete_all_entries_and_updates_selection() {
let mut state = scalar_array(&[Value::String("a".into()), Value::String("b".into())]);
assert_eq!(state.len(), 2);
assert_eq!(state.selected_index(), Some(0));
// Remove first entry; list should shrink and selection should stay valid.
assert!(state.remove_selected());
assert_eq!(state.len(), 1);
assert_eq!(state.selected_index(), Some(0));
// Remove last remaining entry; list becomes empty and selected_index is None.
assert!(state.remove_selected());
assert_eq!(state.len(), 0);
assert_eq!(state.selected_index(), None);
// Further removes are no-ops.
assert!(!state.remove_selected());
}
#[test]
fn composite_list_remove_selected_can_delete_all_entries_and_updates_selection() {
let mut state = composite_list(&[Value::String("a".into()), Value::String("b".into())]);
assert_eq!(state.len(), 2);
assert_eq!(state.selected_index(), Some(0));
// Remove first entry.
assert!(state.remove_selected());
assert_eq!(state.len(), 1);
assert_eq!(state.selected_index(), Some(0));
// Remove last remaining entry.
assert!(state.remove_selected());
assert_eq!(state.len(), 0);
assert_eq!(state.selected_index(), None);
// Further removes are no-ops.
assert!(!state.remove_selected());
}
#[test]
fn key_value_remove_selected_can_delete_all_entries_and_updates_selection() {
let mut state = key_value_with_entries(&[("a", "1"), ("b", "2")]);
assert_eq!(state.len(), 2);
assert_eq!(state.selected_index(), Some(0));
// Remove first entry.
assert!(state.remove_selected());
assert_eq!(state.len(), 1);
assert_eq!(state.selected_index(), Some(0));
// Remove last remaining entry.
assert!(state.remove_selected());
assert_eq!(state.len(), 0);
assert_eq!(state.selected_index(), None);
// Further removes are no-ops.
assert!(!state.remove_selected());
}
@@ -0,0 +1,80 @@
use crate::{
tui::model::{FieldKind, FieldSchema, KeyValueField},
tui::state::FieldState,
};
use serde_json::json;
use std::collections::HashMap;
fn scalar_array_field() -> FieldState {
FieldState::from_schema(FieldSchema {
name: "routes".to_string(),
path: vec!["routes".to_string()],
pointer: "/routes".to_string(),
title: "Routes".to_string(),
description: Some("HTTP routes".to_string()),
kind: FieldKind::Array(Box::new(FieldKind::String)),
required: false,
default: None,
metadata: HashMap::new(),
})
}
fn key_value_field() -> FieldState {
let template = KeyValueField {
key_title: "Header".to_string(),
key_description: None,
key_default: None,
key_schema: json!({"type": "string"}),
value_title: "Value".to_string(),
value_description: None,
value_default: None,
value_schema: json!({"type": "string"}),
value_kind: Box::new(FieldKind::String),
entry_schema: json!({"type": "object"}),
};
FieldState::from_schema(FieldSchema {
name: "headers".to_string(),
path: vec!["headers".to_string()],
pointer: "/headers".to_string(),
title: "Headers".to_string(),
description: None,
kind: FieldKind::KeyValue(Box::new(template)),
required: false,
default: None,
metadata: HashMap::new(),
})
}
#[test]
fn scalar_array_overlay_context_reports_selected_entry() {
let mut field = scalar_array_field();
field.seed_value(&json!(["/health", "/ready"]));
let ctx = field.overlay_context().expect("context");
let panel = ctx.entry_panel.expect("panel state");
assert_eq!(panel.entries[0], "#1 \"/health\"");
assert_eq!(panel.selected, 0);
assert!(
ctx.instructions
.as_ref()
.expect("instructions")
.contains("Ctrl+N")
);
assert!(ctx.title.unwrap().starts_with("#1"));
}
#[test]
fn key_value_overlay_context_includes_entry_label() {
let mut field = key_value_field();
field.seed_value(&json!({"env": "prod"}));
let ctx = field.overlay_context().expect("context");
assert!(
ctx.instructions
.as_ref()
.expect("instructions")
.contains("Ctrl+D")
);
let panel = ctx.entry_panel.expect("panel state");
assert_eq!(panel.entries.len(), 1);
assert_eq!(panel.selected, 0);
assert!(ctx.title.unwrap().starts_with("env"));
}
@@ -0,0 +1,114 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde_json::{Value, json};
use std::sync::Arc;
use crate::{
tui::app::options::UiOptions,
tui::model::{FieldKind, FieldSchema, KeyValueField},
tui::state::FieldState,
};
fn integer_field_schema() -> FieldSchema {
FieldSchema {
name: "threshold".to_string(),
path: vec!["threshold".to_string()],
pointer: "/threshold".to_string(),
title: "Threshold".to_string(),
description: None,
kind: FieldKind::Integer,
required: false,
default: Some(Value::Number(0.into())),
metadata: Default::default(),
}
}
fn bool_field_schema() -> FieldSchema {
FieldSchema {
name: "enabled".to_string(),
path: vec!["enabled".to_string()],
pointer: "/enabled".to_string(),
title: "Enabled".to_string(),
description: None,
kind: FieldKind::Boolean,
required: false,
default: Some(Value::Bool(false)),
metadata: Default::default(),
}
}
fn key_value_schema() -> FieldSchema {
let template = KeyValueField {
key_title: "Key".to_string(),
key_description: None,
key_default: None,
key_schema: json!({"type": "string"}),
value_title: "Value".to_string(),
value_description: None,
value_default: None,
value_schema: json!({"type": "string"}),
value_kind: Box::new(FieldKind::String),
entry_schema: json!({
"type": "object",
"required": ["key", "value"],
"properties": {
"key": {"type": "string"},
"value": {"type": "string"}
}
}),
};
FieldSchema {
name: "labels".to_string(),
path: vec!["labels".to_string()],
pointer: "/labels".to_string(),
title: "Labels".to_string(),
description: None,
kind: FieldKind::KeyValue(Box::new(template)),
required: false,
default: None,
metadata: Default::default(),
}
}
#[test]
fn integer_step_uses_component_palette() {
let options = UiOptions::default().with_integer_step(5);
let palette = options.component_palette();
let mut field =
FieldState::from_schema_with_palette(integer_field_schema(), Arc::clone(&palette));
let key = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
field.handle_key(&key);
assert_eq!(field.display_value(), "5");
}
#[test]
fn bool_labels_follow_palette_configuration() {
let options = UiOptions::default().with_bool_labels("On", "Off");
let palette = options.component_palette();
let mut field = FieldState::from_schema_with_palette(bool_field_schema(), Arc::clone(&palette));
assert_eq!(field.display_value(), "Off");
let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
field.handle_key(&key);
assert_eq!(field.display_value(), "On");
}
#[test]
fn overlay_context_uses_custom_instructions() {
let options =
UiOptions::default().with_overlay_instructions("Ctrl+X remove • Ctrl+Y duplicate");
let palette = options.component_palette();
let field = FieldState::from_schema_with_palette(key_value_schema(), Arc::clone(&palette));
let context = field
.overlay_context()
.expect("key/value overlay context should exist");
assert_eq!(
context.instructions.as_deref(),
Some("Ctrl+X remove • Ctrl+Y duplicate")
);
}
@@ -0,0 +1,233 @@
use std::collections::HashMap;
use serde_json::json;
use crate::{
tui::model::{CompositeField, CompositeMode, CompositeVariant, FieldKind, FieldSchema},
tui::state::FieldState,
};
fn composite_list_field() -> FieldState {
let variants = vec![
CompositeVariant {
id: "target".to_string(),
title: "Target object".to_string(),
description: None,
schema: json!({
"type": "object",
"properties": {
"url": {"type": "string"}
}
}),
is_object: true,
},
CompositeVariant {
id: "string".to_string(),
title: "String entry".to_string(),
description: None,
schema: json!({"type": "string"}),
is_object: false,
},
CompositeVariant {
id: "integer".to_string(),
title: "Integer entry".to_string(),
description: None,
schema: json!({"type": "integer"}),
is_object: false,
},
];
let template = CompositeField {
mode: CompositeMode::AnyOf,
variants,
};
FieldState::from_schema(FieldSchema {
name: "deepItems".to_string(),
path: vec!["deepItems".to_string()],
pointer: "/deepItems".to_string(),
title: "Deep Items".to_string(),
description: None,
kind: FieldKind::Array(Box::new(FieldKind::Composite(Box::new(template)))),
required: false,
default: None,
metadata: HashMap::new(),
})
}
#[test]
fn composite_list_popup_exposes_entry_variants() {
let mut field = composite_list_field();
assert!(field.composite_popup().is_none(), "no entry yet");
assert!(
field.ensure_composite_list_popup_entry(),
"first popup should seed an entry"
);
let popup = field.composite_popup().expect("popup available");
assert_eq!(popup.options.len(), 3);
assert!(popup.multi, "anyOf should expose multi-select popup");
assert_eq!(popup.options[0], "Target object");
}
#[test]
fn composite_list_selection_updates_summary() {
let mut field = composite_list_field();
field.ensure_composite_list_popup_entry();
let popup = field.composite_popup().expect("popup");
let mut toggles = vec![false; popup.options.len()];
toggles[0] = true;
toggles[2] = true;
field.apply_composite_selection(0, Some(toggles));
let (entries, selected) = field
.composite_list_panel()
.expect("panel state must exist");
assert_eq!(selected, 0, "first entry should be selected");
assert_eq!(entries.len(), 2, "one entry per selected variant");
assert!(
entries[0].contains("Target object"),
"first entry should summarize the 'Target object' variant: {:?}",
entries[0]
);
assert!(
entries[1].contains("Integer entry"),
"second entry should summarize the 'Integer entry' variant: {:?}",
entries[1]
);
}
#[test]
fn composite_list_adds_entries_with_selected_variants() {
let mut field = composite_list_field();
// Add an entry explicitly as the target object variant
assert!(
field.composite_list_add_entry_with_variant(0),
"should add entry for 'Target object' variant"
);
// Add another entry explicitly as the integer variant
assert!(
field.composite_list_add_entry_with_variant(2),
"should add entry for 'Integer entry' variant"
);
let (entries, selected) = field
.composite_list_panel()
.expect("panel state must exist after adding entries");
assert_eq!(entries.len(), 2, "expected two entries after explicit adds");
assert_eq!(selected, 1, "last added entry should be selected");
assert!(
entries[0].contains("Target object"),
"first entry should summarize the 'Target object' variant: {:?}",
entries[0]
);
assert!(
entries[1].contains("Integer entry"),
"second entry should summarize the 'Integer entry' variant: {:?}",
entries[1]
);
}
#[test]
fn composite_list_summary_includes_object_preview() {
let variants = vec![CompositeVariant {
id: "bar".to_string(),
title: "Bar Item".to_string(),
description: None,
schema: json!({
"type": "object",
"properties": {
"Bar": {"type": "string"},
"Id": {"type": "integer"}
}
}),
is_object: true,
}];
let template = CompositeField {
mode: CompositeMode::AnyOf,
variants,
};
let mut field = FieldState::from_schema(FieldSchema {
name: "blahList".to_string(),
path: vec!["blahList".to_string()],
pointer: "/blahList".to_string(),
title: "Blah List".to_string(),
description: None,
kind: FieldKind::Array(Box::new(FieldKind::Composite(Box::new(template)))),
required: false,
default: None,
metadata: HashMap::new(),
});
field.seed_value(&json!([{
"Bar": "on tui",
"Id": 0
}]));
let (entries, selected) = field.composite_list_panel().expect("panel state");
assert_eq!(selected, 0);
assert_eq!(entries.len(), 1);
assert!(
entries[0].contains("Bar Item { Bar: on tui, Id: 0 }"),
"entry summary should include object preview: {:?}",
entries[0]
);
}
#[test]
fn composite_list_summary_with_limit_keeps_bar_and_id_visible() {
let variants = vec![CompositeVariant {
id: "bar".to_string(),
title: "Bar Item".to_string(),
description: None,
schema: json!({
"type": "object",
"properties": {
"Bar": {"type": "string"},
"Id": {"type": "integer"}
}
}),
is_object: true,
}];
let template = CompositeField {
mode: CompositeMode::AnyOf,
variants,
};
let mut field = FieldState::from_schema(FieldSchema {
name: "blahList".to_string(),
path: vec!["blahList".to_string()],
pointer: "/blahList".to_string(),
title: "Blah List".to_string(),
description: None,
kind: FieldKind::Array(Box::new(FieldKind::Composite(Box::new(template)))),
required: false,
default: None,
metadata: HashMap::new(),
});
field.seed_value(&json!([{
"Bar": "客服热线 400-820-8820 转人工服务",
"Id": 0
}]));
let (entries, selected) = field
.composite_list_panel_with_limit(44)
.expect("panel state");
assert_eq!(selected, 0);
assert_eq!(entries.len(), 1);
assert!(
entries[0].contains("Bar:"),
"entry summary: {:?}",
entries[0]
);
assert!(
entries[0].contains("Id: 0"),
"entry summary: {:?}",
entries[0]
);
let compact = field
.composite_list_selected_label_with_limit(44)
.expect("compact selected label");
assert!(!compact.contains("Bar:"), "compact label: {compact}");
assert!(!compact.contains("Id: 0"), "compact label: {compact}");
}
@@ -0,0 +1,177 @@
use std::sync::Arc;
use serde_json::json;
fn defs_schema_path() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("schemas")
.join("test.defs.schema.json")
}
fn runtime_form_state(schema: &serde_json::Value) -> FormState {
let ast = build_ui_ast(schema).expect("ui ast");
let form_schema = form_schema_from_ui_ast(&ast);
FormState::from_schema(&form_schema)
}
fn defs_2020_12_schema_path() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("schemas")
.join("test.defs.2020-12.schema.json")
}
use crate::{
tui::state::field::components::ComponentPalette,
tui::{
model::{CompositeField, CompositeMode, CompositeVariant, form_schema_from_ui_ast},
state::{CompositeState, FormState},
},
ui_ast::build_ui_ast,
};
#[test]
fn seed_from_value_uses_enum_discriminator() {
let variant_one = CompositeVariant {
id: "variant_one".to_string(),
title: "Variant 1".to_string(),
description: None,
schema: json!({
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["alpha"]
}
}
}),
is_object: true,
};
let variant_two = CompositeVariant {
id: "variant_two".to_string(),
title: "Variant 2".to_string(),
description: None,
schema: json!({
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["beta"]
}
}
}),
is_object: true,
};
let field = CompositeField {
mode: CompositeMode::OneOf,
variants: vec![variant_one, variant_two],
};
let mut state = CompositeState::new(
"/preferences",
&field,
Arc::new(ComponentPalette::default()),
);
state
.seed_from_value(&json!({
"kind": "beta"
}))
.expect("seed from value");
assert_eq!(state.active_indices(), vec![1]);
assert_eq!(state.summary(), "Variant: #2 Variant 2");
}
#[test]
fn non_object_variant_round_trips_through_wrapper() {
let variant = CompositeVariant {
id: "list".to_string(),
title: "String List".to_string(),
description: None,
schema: json!({
"type": "array",
"items": {"type": "string"}
}),
is_object: false,
};
let field = CompositeField {
mode: CompositeMode::OneOf,
variants: vec![variant],
};
let palette = Arc::new(ComponentPalette::default());
let mut state = CompositeState::new("/options", &field, Arc::clone(&palette));
state
.seed_from_value(&json!(["a", "b"]))
.expect("seed array value");
assert_eq!(
state.build_value(true).expect("build value"),
Some(json!(["a", "b"]))
);
let mut session = state
.take_editor_session("/options", 0)
.expect("editor session");
assert!(
session
.schema
.get("properties")
.and_then(|props| props.get("__value"))
.is_some(),
"overlay schema should expose wrapped value field"
);
session
.form_state
.seed_from_value(&json!({"__value": ["z"]}));
state.restore_editor_session(session);
assert_eq!(
state.build_value(true).expect("build updated value"),
Some(json!(["z"]))
);
}
#[test]
fn object_variant_overlay_keeps_root_dollar_defs_for_valid_2020_12_refs() {
let schema: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(defs_2020_12_schema_path()).expect("2020-12 defs schema readable"),
)
.expect("2020-12 defs schema json");
let mut state = runtime_form_state(&schema);
let field = state.field_mut_by_pointer("/blah").expect("blah field");
let session = field
.open_composite_editor(0)
.expect("valid 2020-12 $defs schema should build variant session");
assert_eq!(session.schema.get("$schema"), schema.get("$schema"));
assert_eq!(session.schema.get("$defs"), schema.get("$defs"));
assert!(
session.form_state.field_by_pointer("/Bar").is_some(),
"variant form should resolve $defs-backed referenced Bar field"
);
assert!(session.form_state.field_by_pointer("/Id").is_some());
}
#[test]
fn draft_07_schema_with_dollar_defs_builds_overlay_session() {
let schema: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(defs_schema_path()).expect("defs schema readable"),
)
.expect("defs schema json");
let mut state = runtime_form_state(&schema);
let field = state.field_mut_by_pointer("/blah").expect("blah field");
let session = field
.open_composite_editor(0)
.expect("draft-07 + $defs schema should build variant session");
assert_eq!(session.schema.get("$schema"), schema.get("$schema"));
assert_eq!(session.schema.get("$defs"), schema.get("$defs"));
assert!(
session.form_state.field_by_pointer("/Bar").is_some(),
"variant form should resolve $defs-backed referenced Bar field"
);
assert!(session.form_state.field_by_pointer("/Id").is_some());
}
@@ -0,0 +1,69 @@
use crate::{
tui::{
model::{FieldKind, form_schema_from_ui_ast},
state::FormState,
},
ui_ast::build_ui_ast,
};
use jsonschema::validator_for;
use serde_json::json;
fn enum_integer_schema() -> serde_json::Value {
json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"EnumInteger": {
"description": "Select one of a few integers",
"enum": [2, 5, 42]
}
},
"additionalProperties": false
})
}
#[test]
fn enum_integer_selection_preserves_numeric_value_through_runtime_pipeline() {
let schema = enum_integer_schema();
let ast = build_ui_ast(&schema).expect("ui ast");
let form_schema = form_schema_from_ui_ast(&ast);
let mut state = FormState::from_schema(&form_schema);
let field = state
.field_mut_by_pointer("/EnumInteger")
.expect("enum field");
match &field.schema.kind {
FieldKind::Enum { labels, values } => {
assert_eq!(
labels,
&vec!["2".to_string(), "5".to_string(), "42".to_string()]
);
assert_eq!(values, &vec![json!(2), json!(5), json!(42)]);
}
other => panic!("expected enum field, got {:?}", other),
}
field.set_enum_selected(1);
let built = state.try_build_value().expect("form value");
assert_eq!(built, json!({"EnumInteger": 5}));
let validator = validator_for(&schema).expect("validator");
assert!(
validator.validate(&built).is_ok(),
"numeric enum should validate"
);
}
#[test]
fn enum_integer_seed_uses_raw_value_matching() {
let schema = enum_integer_schema();
let ast = build_ui_ast(&schema).expect("ui ast");
let form_schema = form_schema_from_ui_ast(&ast);
let mut state = FormState::from_schema(&form_schema);
state.seed_from_value(&json!({"EnumInteger": 42}));
let field = state.field_by_pointer("/EnumInteger").expect("enum field");
let enum_state = field.enum_state().expect("enum state");
assert_eq!(enum_state.selected, 2);
}
@@ -0,0 +1,39 @@
use crate::tui::state::value_summary::{summarize_value, summarize_value_with_limit};
use serde_json::{Value, json};
#[test]
fn summarize_value_handles_unicode_without_panic() {
let value =
Value::String("非法所得房间 abdf sgfsjadlg sadfas 百度地方是灯红酒绿 啥地方 ".to_string());
let summary = summarize_value(&value);
assert_eq!(summary, "\"非法所得房间 abdf sgfsjadlg sa…\"");
}
#[test]
fn summarize_value_truncates_long_strings_on_char_boundaries() {
let long = "abcdefghijklmnoabcdefghijklmnoabcdefghijklmno";
let value = Value::String(long.to_string());
let summary = summarize_value(&value);
assert_eq!(summary, "\"abcdefghijklmnoabcdefghi…\"");
}
#[test]
fn summarize_value_renders_object_preview() {
let value = json!({
"Bar": "on tui",
"Id": 0
});
let summary = summarize_value(&value);
assert_eq!(summary, "{ Bar: on tui, Id: 0 }");
}
#[test]
fn summarize_value_with_limit_preserves_multiple_object_fields_when_space_allows() {
let value = json!({
"Bar": "客服热线 400-820-8820 转人工服务",
"Id": 0
});
let summary = summarize_value_with_limit(&value, 42);
assert!(summary.contains("Bar:"), "summary: {summary}");
assert!(summary.contains("Id: 0"), "summary: {summary}");
}
@@ -0,0 +1,248 @@
use std::collections::HashMap;
use crate::{
tui::model::{FieldKind, FieldSchema, form_schema_from_ui_ast},
tui::state::{FieldState, FormState, LayoutNavModel, RootSectionState, SectionState},
ui_ast::{
build_ui_ast,
layout::{self, LayoutRoot, LayoutSection, UiLayout},
},
};
use serde_json::json;
fn text_field(pointer: &str, title: &str) -> FieldState {
let path: Vec<String> = pointer
.trim_start_matches('/')
.split('/')
.map(|segment| segment.to_string())
.collect();
let schema = FieldSchema {
name: title.to_string(),
path,
pointer: pointer.to_string(),
title: title.to_string(),
description: None,
kind: FieldKind::String,
required: false,
default: None,
metadata: HashMap::new(),
};
FieldState::from_schema(schema)
}
fn section_with_single_field(
id: &str,
title: &str,
field_pointer: &str,
field_title: &str,
) -> SectionState {
SectionState {
id: id.to_string(),
title: title.to_string(),
description: None,
path: vec![id.to_string()],
depth: 0,
fields: vec![text_field(field_pointer, field_title)],
scroll_offset: 0,
}
}
fn build_form_and_layout() -> (FormState, UiLayout) {
// Form layout:
// runtime/auth -> /auth/user
// runtime/db -> /db/url
// ops/cache -> /cache/ttl
let runtime_auth = section_with_single_field("auth", "Auth", "/auth/user", "user");
let runtime_db = section_with_single_field("db", "DB", "/db/url", "url");
let ops_cache = section_with_single_field("cache", "Cache", "/cache/ttl", "ttl");
let root_runtime = RootSectionState {
id: "runtime".to_string(),
title: "Runtime".to_string(),
description: None,
sections: vec![runtime_auth, runtime_db],
};
let root_ops = RootSectionState {
id: "ops".to_string(),
title: "Ops".to_string(),
description: None,
sections: vec![ops_cache],
};
let form = FormState::from_roots_for_test(vec![root_runtime, root_ops]);
let layout = UiLayout {
roots: vec![
LayoutRoot {
id: "runtime".to_string(),
title: Some("Runtime".to_string()),
description: None,
sections: vec![
LayoutSection {
id: "auth".to_string(),
title: "Auth".to_string(),
description: None,
pointer: "/auth".to_string(),
path: vec!["auth".to_string()],
field_pointers: vec!["/auth/user".to_string()],
children: Vec::new(),
},
LayoutSection {
id: "db".to_string(),
title: "DB".to_string(),
description: None,
pointer: "/db".to_string(),
path: vec!["db".to_string()],
field_pointers: vec!["/db/url".to_string()],
children: Vec::new(),
},
],
},
LayoutRoot {
id: "ops".to_string(),
title: Some("Ops".to_string()),
description: None,
sections: vec![LayoutSection {
id: "cache".to_string(),
title: "Cache".to_string(),
description: None,
pointer: "/cache".to_string(),
path: vec!["cache".to_string()],
field_pointers: vec!["/cache/ttl".to_string()],
children: Vec::new(),
}],
},
],
};
(form, layout)
}
fn focused_pointer(form: &FormState) -> String {
form.focused_field()
.map(|field| field.schema.pointer.clone())
.unwrap_or_else(|| "<none>".to_string())
}
#[test]
fn layout_nav_controls_section_stepping_order() {
let (mut form, layout) = build_form_and_layout();
let nav = LayoutNavModel::from_uilayout(&layout);
form.set_layout_nav(nav);
// Start at the first section (auth).
assert_eq!(focused_pointer(&form), "/auth/user");
form.focus_next_section(1);
assert_eq!(focused_pointer(&form), "/db/url");
form.focus_next_section(1);
assert_eq!(focused_pointer(&form), "/cache/ttl");
// Wrap back to the first section.
form.focus_next_section(1);
assert_eq!(focused_pointer(&form), "/auth/user");
}
#[test]
fn layout_nav_controls_root_stepping_order() {
let (mut form, layout) = build_form_and_layout();
let nav = LayoutNavModel::from_uilayout(&layout);
form.set_layout_nav(nav);
assert_eq!(focused_pointer(&form), "/auth/user");
form.focus_next_root(1);
assert_eq!(focused_pointer(&form), "/cache/ttl");
form.focus_next_root(1);
assert_eq!(focused_pointer(&form), "/auth/user");
}
#[test]
fn layout_nav_first_field_focus_uses_layout() {
let (mut form, layout) = build_form_and_layout();
let nav = LayoutNavModel::from_uilayout(&layout);
form.set_layout_nav(nav);
// Move focus away from the first section.
form.focus_next_section(1);
form.focus_next_section(1);
assert_eq!(focused_pointer(&form), "/cache/ttl");
// layout-driven first-field focus should return to the first section.
assert!(form.focus_first_field_with_layout());
assert_eq!(focused_pointer(&form), "/auth/user");
}
#[test]
fn layout_nav_from_schema_preserves_declared_root_and_section_order() {
let schema = json!({
"type": "object",
"properties": {
"zeta": {
"type": "object",
"title": "Zeta",
"properties": {
"name": {"type": "string"},
"network": {
"type": "object",
"title": "Network",
"properties": {
"port": {"type": "integer"}
}
},
"auth": {
"type": "object",
"title": "Auth",
"properties": {
"user": {"type": "string"}
}
}
}
},
"alpha": {
"type": "object",
"title": "Alpha",
"properties": {
"enabled": {"type": "boolean"}
}
}
}
});
let ast = build_ui_ast(&schema).expect("ui ast");
let form_schema = form_schema_from_ui_ast(&ast);
let layout = layout::build_ui_layout(&ast);
let nav = LayoutNavModel::from_uilayout(&layout);
let root_titles: Vec<_> = nav.roots.iter().map(|root| root.title.as_str()).collect();
assert_eq!(
root_titles,
vec!["Zeta", "Alpha"],
"root nav tabs should follow schema declaration order",
);
let zeta_sections: Vec<_> = nav.roots[0]
.sections
.iter()
.map(|section| section.title.as_str())
.collect();
assert_eq!(
zeta_sections,
vec!["Zeta", "Network", "Auth"],
"nested nav tabs should follow schema declaration order",
);
let mut form = FormState::from_schema(&form_schema).with_layout_nav(nav);
assert_eq!(focused_pointer(&form), "/zeta/name");
form.focus_next_section(1);
assert_eq!(focused_pointer(&form), "/zeta/network/port");
form.focus_next_section(1);
assert_eq!(focused_pointer(&form), "/zeta/auth/user");
form.focus_next_root(1);
assert_eq!(focused_pointer(&form), "/alpha/enabled");
}
+11
View File
@@ -0,0 +1,11 @@
mod collection_remove_tests;
mod component_overlay_context_tests;
mod component_palette_tests;
mod composite_list_tests;
mod composite_tests;
mod enum_tests;
mod key_value_tests;
mod layout_nav_tests;
mod navigation_tests;
mod state_tests;
mod ultra_complex_state_tests;
@@ -0,0 +1,113 @@
use std::collections::HashMap;
use crate::{
tui::model::{FieldKind, FieldSchema},
tui::state::{FieldState, FormState, RootSectionState, SectionState},
};
fn text_field(pointer: &str, title: &str) -> FieldState {
let path: Vec<String> = pointer
.trim_start_matches('/')
.split('/')
.map(|segment| segment.to_string())
.collect();
let schema = FieldSchema {
name: title.to_string(),
path,
pointer: pointer.to_string(),
title: title.to_string(),
description: None,
kind: FieldKind::String,
required: false,
default: None,
metadata: HashMap::new(),
};
FieldState::from_schema(schema)
}
fn section(id: &str, title: &str, depth: usize, fields: Vec<FieldState>) -> SectionState {
SectionState {
id: id.to_string(),
title: title.to_string(),
description: None,
path: vec![id.to_string()],
depth,
fields,
scroll_offset: 0,
}
}
fn sample_form_state() -> FormState {
let general_fields = vec![
text_field("/general/name", "name"),
text_field("/general/url", "url"),
];
let child_fields = vec![text_field("/general/child/address", "address")];
let root_general = RootSectionState {
id: "general".to_string(),
title: "General".to_string(),
description: None,
sections: vec![
section("general_main", "General", 0, general_fields),
section("general_empty", "Empty", 0, Vec::new()),
section("general_child", "Child", 1, child_fields),
],
};
let site_fields = vec![text_field("/site/enabled", "enabled")];
let root_site = RootSectionState {
id: "site".to_string(),
title: "Site".to_string(),
description: None,
sections: vec![section("site_main", "Site", 0, site_fields)],
};
FormState::from_roots_for_test(vec![root_general, root_site])
}
fn focused_pointer(form: &FormState) -> String {
form.focused_field()
.map(|field| field.schema.pointer.clone())
.unwrap_or_else(|| "<none>".to_string())
}
#[test]
fn tab_cycles_sections_and_roots() {
let mut form = sample_form_state();
let mut seen = Vec::new();
for _ in 0..5 {
seen.push(focused_pointer(&form));
form.focus_next_field();
}
seen.push(focused_pointer(&form));
assert_eq!(
seen,
vec![
"/general/name",
"/general/url",
"/general/child/address",
"/site/enabled",
"/general/name",
"/general/url",
]
);
}
#[test]
fn shift_tab_moves_backward_through_hierarchy() {
let mut form = sample_form_state();
form.set_root_index(0);
form.set_section_index(0);
form.set_field_index(0);
// jump near the end
form.focus_prev_field(); // should wrap to site/enabled
let first = focused_pointer(&form);
form.focus_prev_field();
let second = focused_pointer(&form);
form.focus_prev_field();
let third = focused_pointer(&form);
assert_eq!(first, "/site/enabled");
assert_eq!(second, "/general/child/address");
assert_eq!(third, "/general/url");
}
@@ -0,0 +1,134 @@
use crate::{
tui::model::{FieldKind, FieldSchema},
tui::state::{FieldState, FormState, RootSectionState, SectionState},
};
fn mk_field(name: &str) -> FieldState {
FieldState::from_schema(FieldSchema {
name: name.to_string(),
path: vec![name.to_string()],
pointer: format!("/{name}"),
title: name.to_string(),
description: None,
kind: FieldKind::String,
required: false,
default: None,
metadata: Default::default(),
})
}
fn mk_section(id: &str, fields: &[&str]) -> SectionState {
SectionState {
id: id.to_string(),
title: id.to_string(),
description: None,
path: vec![id.to_string()],
depth: 0,
fields: fields.iter().map(|name| mk_field(name)).collect(),
scroll_offset: 0,
}
}
#[test]
fn field_navigation_wraps_across_roots() {
let root_a = RootSectionState {
id: "a".into(),
title: "A".into(),
description: None,
sections: vec![mk_section("a/one", &["a1", "a2"])],
};
let root_b = RootSectionState {
id: "b".into(),
title: "B".into(),
description: None,
sections: vec![mk_section("b/one", &["b1"]), mk_section("b/two", &["b2"])],
};
let mut state = FormState::from_roots_for_test(vec![root_a, root_b]);
state.set_field_index(1);
state.focus_next_field();
assert_eq!(state.root_index(), 1);
assert_eq!(state.section_index(), 0);
assert_eq!(state.field_index(), 0);
state.focus_next_field();
assert_eq!(state.section_index(), 1);
assert_eq!(state.field_index(), 0);
state.focus_next_field();
assert_eq!(state.root_index(), 0);
assert_eq!(state.section_index(), 0);
assert_eq!(state.field_index(), 0);
}
#[test]
fn field_navigation_wraps_backwards_across_roots() {
let root_a = RootSectionState {
id: "a".into(),
title: "A".into(),
description: None,
sections: vec![mk_section("a/one", &["a1"])],
};
let root_b = RootSectionState {
id: "b".into(),
title: "B".into(),
description: None,
sections: vec![mk_section("b/one", &["b1", "b2"])],
};
let mut state = FormState::from_roots_for_test(vec![root_a, root_b]);
state.focus_prev_field();
assert_eq!(state.root_index(), 1);
assert_eq!(state.section_index(), 0);
assert_eq!(state.field_index(), 1);
}
#[test]
fn section_navigation_cycles_ordered_tree() {
let sections_a = vec![mk_section("auth", &["user"]), mk_section("db", &["url"])];
let sections_b = vec![mk_section("cache", &["ttl"])];
let mut state = FormState::from_roots_for_test(vec![
RootSectionState {
id: "runtime".into(),
title: "Runtime".into(),
description: None,
sections: sections_a,
},
RootSectionState {
id: "ops".into(),
title: "Ops".into(),
description: None,
sections: sections_b,
},
]);
state.focus_next_section(1);
assert_eq!(state.root_index(), 0);
assert_eq!(state.section_index(), 1);
state.focus_next_section(1);
assert_eq!(state.root_index(), 1);
assert_eq!(state.section_index(), 0);
state.focus_next_section(1);
assert_eq!(state.root_index(), 0);
assert_eq!(state.section_index(), 0);
}
#[test]
fn skips_empty_sections_when_focusing() {
let empty = mk_section("app", &[]);
let server = mk_section("server", &["host"]);
let storage = mk_section("storage", &["path"]);
let mut state = FormState::from_sections("app", "App", None, vec![empty, server, storage]);
assert_eq!(
state.section_index(),
1,
"should jump to first populated section"
);
state.focus_next_section(1);
assert_eq!(
state.section_index(),
2,
"Ctrl+Tab should skip empty sections"
);
state.focus_next_section(1);
assert_eq!(
state.section_index(),
1,
"wrap keeps focusable sections only"
);
}
@@ -0,0 +1,288 @@
use std::{fs, path::PathBuf};
use jsonschema::validator_for;
use serde_json::{Value, json};
use crate::{
tui::{
model::{FieldKind, form_schema_from_ui_ast},
state::FormState,
},
ui_ast::build_ui_ast,
};
const ULTRA_DEEP_SCHEMA_PTR: &str = "/properties/level1/properties/level2/properties/config/oneOf/1/properties/settings/properties/level3/properties/level4/properties/level5/properties/ultraDeep";
const MATRIX_SCHEMA_PTR: &str = "/properties/level1/properties/level2/properties/config/oneOf/1/properties/settings/properties/level3/properties/level4/properties/matrix";
const ULTRA_DEEP_PTR: &str = "/ultraDeep";
const MATRIX_PTR: &str = "/matrix";
fn ultra_complex_schema_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("examples")
.join("ultra-complex.schema.json")
}
fn load_ultra_complex_schema() -> Value {
let contents = fs::read_to_string(ultra_complex_schema_path()).expect("ultra schema readable");
serde_json::from_str(&contents).expect("ultra schema json")
}
fn wrap_property_schema(root_schema: &Value, field_name: &str, property_schema: Value) -> Value {
let mut wrapped = json!({
"type": "object",
"properties": {
field_name: property_schema
}
});
if let Some(definitions) = root_schema
.get("definitions")
.cloned()
.or_else(|| root_schema.get("$defs").cloned())
&& let Some(object) = wrapped.as_object_mut()
{
object.insert("definitions".to_string(), definitions.clone());
}
wrapped
}
fn wrapped_example_fragment(pointer: &str, field_name: &str) -> Value {
let root_schema = load_ultra_complex_schema();
let fragment = root_schema
.pointer(pointer)
.cloned()
.unwrap_or_else(|| panic!("schema fragment {pointer} not found"));
wrap_property_schema(&root_schema, field_name, fragment)
}
fn runtime_form_state(schema: &Value) -> FormState {
let ast = build_ui_ast(schema).expect("ui ast");
let form_schema = form_schema_from_ui_ast(&ast);
FormState::from_schema(&form_schema)
}
#[test]
fn ultra_complex_const_discriminants_autofill_without_manual_kind_input() {
let schema = wrapped_example_fragment(ULTRA_DEEP_SCHEMA_PTR, "ultraDeep");
let mut state = runtime_form_state(&schema);
let mut entry_ctx = {
let field = state
.field_mut_by_pointer(ULTRA_DEEP_PTR)
.expect("ultraDeep field");
assert!(
field.composite_list_add_entry(),
"should create ultraDeep entry"
);
field
.open_composite_list_editor()
.expect("open ultraDeep entry editor")
};
{
let variant_field = entry_ctx
.session
.form_state
.field_mut_by_pointer("/variant")
.expect("variant field");
variant_field.apply_composite_selection(1, None);
let beta_ctx = variant_field
.open_composite_editor(1)
.expect("open beta variant editor");
let built_beta = beta_ctx
.form_state
.try_build_value()
.expect("beta variant value");
let expected_beta = json!({
"kind": "beta",
"betaValue": 0.0
});
assert_eq!(built_beta, expected_beta);
let validator = validator_for(&beta_ctx.schema).expect("beta validator");
assert!(
validator.validate(&built_beta).is_ok(),
"const discriminant should make the variant valid without manual kind input"
);
variant_field.close_composite_editor(beta_ctx, true);
}
let built_entry = entry_ctx
.session
.form_state
.try_build_value()
.expect("ultraDeep entry value");
let expected_entry = json!({
"id": 0,
"variant": {
"kind": "beta",
"betaValue": 0.0
}
});
assert_eq!(built_entry, expected_entry);
{
let field = state
.field_mut_by_pointer(ULTRA_DEEP_PTR)
.expect("ultraDeep field");
field.close_composite_list_editor(entry_ctx.entry_index, entry_ctx.session, true);
}
let current = state
.field_by_pointer(ULTRA_DEEP_PTR)
.expect("ultraDeep field")
.current_value()
.expect("current ultraDeep value");
assert_eq!(current, Some(json!([expected_entry])));
}
#[test]
fn ultra_complex_matrix_rows_and_cells_roundtrip_through_nested_composite_lists() {
let schema = wrapped_example_fragment(MATRIX_SCHEMA_PTR, "matrix");
let mut state = runtime_form_state(&schema);
let mut row_ctx = {
let field = state
.field_mut_by_pointer(MATRIX_PTR)
.expect("matrix field");
assert!(matches!(field.schema.kind, FieldKind::Array(_)));
assert!(
field.composite_list_add_entry(),
"should create a matrix row"
);
field
.open_composite_list_editor()
.expect("open matrix row editor")
};
{
let row_field = row_ctx
.session
.form_state
.field_mut_by_pointer("/__value")
.expect("wrapped row array field");
match &row_field.schema.kind {
FieldKind::Array(inner) => {
assert!(
matches!(inner.as_ref(), FieldKind::Composite(_)),
"matrix rows should edit cells through composite overlays, got {inner:?}"
);
}
other => panic!("expected wrapped row array field, got {other:?}"),
}
assert!(
row_field.composite_list_add_entry_with_variant(4),
"should create an object cell entry"
);
let mut cell_ctx = row_field
.open_composite_list_editor()
.expect("open matrix cell editor");
{
let cell_form = &mut cell_ctx.session.form_state;
cell_form
.field_mut_by_pointer("/x")
.expect("x field")
.seed_value(&json!(1.5));
cell_form
.field_mut_by_pointer("/y")
.expect("y field")
.seed_value(&json!(2.5));
let data_field = cell_form.field_mut_by_pointer("/data").expect("data field");
data_field.apply_composite_selection(1, None);
let mut data_ctx = data_field
.open_composite_editor(1)
.expect("open vector data editor");
data_ctx
.form_state
.field_mut_by_pointer("/magnitude")
.expect("magnitude field")
.seed_value(&json!(3.0));
data_ctx
.form_state
.field_mut_by_pointer("/direction")
.expect("direction field")
.seed_value(&json!(90.0));
let built_data = data_ctx
.form_state
.try_build_value()
.expect("vector data value");
let expected_data = json!({
"type": "vector",
"magnitude": 3.0,
"direction": 90.0
});
assert_eq!(built_data, expected_data);
let validator = validator_for(&data_ctx.schema).expect("data validator");
assert!(validator.validate(&built_data).is_ok());
data_field.close_composite_editor(data_ctx, true);
}
let expected_cell = json!({
"x": 1.5,
"y": 2.5,
"data": {
"type": "vector",
"magnitude": 3.0,
"direction": 90.0
}
});
let built_cell = cell_ctx
.session
.form_state
.try_build_value()
.expect("matrix cell value");
assert_eq!(built_cell, expected_cell);
row_field.close_composite_list_editor(cell_ctx.entry_index, cell_ctx.session, true);
}
let expected_row = json!({
"__value": [{
"x": 1.5,
"y": 2.5,
"data": {
"type": "vector",
"magnitude": 3.0,
"direction": 90.0
}
}]
});
let built_row = row_ctx
.session
.form_state
.try_build_value()
.expect("matrix row value");
assert_eq!(built_row, expected_row);
{
let field = state
.field_mut_by_pointer(MATRIX_PTR)
.expect("matrix field");
field.close_composite_list_editor(row_ctx.entry_index, row_ctx.session, true);
}
let current = state
.field_by_pointer(MATRIX_PTR)
.expect("matrix field")
.current_value()
.expect("matrix current value");
assert_eq!(
current,
Some(json!([[{
"x": 1.5,
"y": 2.5,
"data": {
"type": "vector",
"magnitude": 3.0,
"direction": 90.0
}
}]]))
);
}
@@ -0,0 +1,95 @@
use serde_json::json;
use crate::ui_ast::{UiNodeKind, build_ui_ast};
fn root_kind<'a>(ast: &'a crate::ui_ast::UiAst, pointer: &str) -> &'a UiNodeKind {
&ast.roots
.iter()
.find(|node| node.pointer == pointer)
.unwrap_or_else(|| panic!("root {pointer} should exist"))
.kind
}
#[test]
fn composite_variant_title_uses_kind_const() {
let schema = json!({
"type": "object",
"properties": {
"item": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": { "const": "simple" },
"label": { "type": "string" }
},
"required": ["kind", "label"]
}
]
}
}
});
let ast = build_ui_ast(&schema).expect("ui ast should build");
let UiNodeKind::Composite { variants, .. } = root_kind(&ast, "/item") else {
panic!("item should render as composite");
};
assert_eq!(variants[0].title.as_deref(), Some("Simple"));
}
#[test]
fn composite_variant_title_for_scalar_array_uses_list_notation() {
let schema = json!({
"type": "object",
"properties": {
"item": {
"oneOf": [
{
"type": "array",
"items": { "type": "string" }
}
]
}
}
});
let ast = build_ui_ast(&schema).expect("ui ast should build");
let UiNodeKind::Composite { variants, .. } = root_kind(&ast, "/item") else {
panic!("item should render as composite");
};
assert_eq!(variants[0].title.as_deref(), Some("List<string>"));
}
#[test]
fn one_of_and_any_of_roots_render_as_composites() {
let schema = json!({
"type": "object",
"properties": {
"oneOfItem": {
"oneOf": [
{ "type": "string" },
{ "type": "integer" }
]
},
"anyOfItem": {
"anyOf": [
{ "type": "boolean" },
{ "type": "string" }
]
}
}
});
let ast = build_ui_ast(&schema).expect("ui ast should build");
assert!(matches!(
root_kind(&ast, "/oneOfItem"),
UiNodeKind::Composite { .. }
));
assert!(matches!(
root_kind(&ast, "/anyOfItem"),
UiNodeKind::Composite { .. }
));
}
@@ -0,0 +1,76 @@
use std::path::PathBuf;
use serde_json::Value;
use crate::io::{DocumentFormat, input::parse_document_str};
use crate::ui_ast::{build_ui_ast, defaults, index};
fn schema_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("schemas")
.join("test-comprehensive.schema.json")
}
#[test]
fn defaults_index_covers_all_pointers() {
let path = schema_path();
let contents = std::fs::read_to_string(&path).expect("schema file readable");
let schema_value: Value =
parse_document_str(&contents, DocumentFormat::Json).expect("schema parses at runtime");
let ast = build_ui_ast(&schema_value).expect("runtime UiAst");
let pointers = index::collect_pointers(&ast);
let defaults = defaults::collect_defaults(&ast);
// The defaults index should only ever contain entries for pointers that
// actually exist in the UiAst. We deliberately do not require it to cover
// every pointer because some pointers (e.g. internal variant nodes) are
// not meant to have independent defaults applied.
for pointer in defaults.keys() {
assert!(
pointers.contains(pointer),
"defaults index contains unknown pointer: {pointer}",
);
}
}
#[test]
fn composite_default_includes_const_and_required_fields() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"channel": {
"oneOf": [
{
"properties": {
"type": {"const": "http"},
"url": {"type": "string"}
},
"required": ["type", "url"]
}
]
}
}
});
let ast = build_ui_ast(&schema).expect("runtime UiAst");
let defaults = defaults::collect_defaults(&ast);
let value = defaults
.get("/channel")
.expect("default for composite channel field");
let obj = value
.as_object()
.expect("composite default must be an object");
assert_eq!(obj.get("type"), Some(&Value::String("http".to_string())));
assert!(
obj.get("url")
.and_then(Value::as_str)
.map(|s| s.is_empty())
.unwrap_or(false),
"required url field should have an inferred empty-string default",
);
}
+64
View File
@@ -0,0 +1,64 @@
use std::collections::BTreeSet;
use std::path::PathBuf;
use serde_json::Value;
use crate::io::{DocumentFormat, input::parse_document_str};
use crate::ui_ast::{
build_ui_ast,
index::{PointerIndex, build_pointer_index, collect_pointers},
};
fn schema_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("schemas")
.join("test-comprehensive.schema.json")
}
#[test]
fn pointer_index_covers_all_pointers() {
let path = schema_path();
let contents = std::fs::read_to_string(&path).expect("schema file readable");
let schema_value: Value =
parse_document_str(&contents, DocumentFormat::Json).expect("schema parses at runtime");
let ast = build_ui_ast(&schema_value).expect("runtime UiAst");
let pointers: BTreeSet<String> = collect_pointers(&ast);
let index: PointerIndex = build_pointer_index(&ast);
assert_eq!(
index.len(),
pointers.len(),
"index size must match pointer set"
);
for p in &pointers {
assert!(index.contains_key(p), "missing pointer in index: {p}");
}
}
#[test]
fn pointer_index_assigns_unique_positions() {
let path = schema_path();
let contents = std::fs::read_to_string(&path).expect("schema file readable");
let schema_value: Value =
parse_document_str(&contents, DocumentFormat::Json).expect("schema parses at runtime");
let ast = build_ui_ast(&schema_value).expect("runtime UiAst");
let index: PointerIndex = build_pointer_index(&ast);
let mut values: Vec<usize> = index.values().copied().collect();
values.sort_unstable();
// Ensure that all assigned positions are unique. We do not require the
// index to be perfectly dense because multiple UiNodes may share the same
// pointer, which would naturally create gaps when later entries override
// earlier ones in the map.
let unique: BTreeSet<usize> = values.iter().copied().collect();
assert_eq!(
values.len(),
unique.len(),
"pointer index positions must be unique"
);
}
+238
View File
@@ -0,0 +1,238 @@
use std::path::PathBuf;
use serde_json::{Value, json};
use crate::io::{DocumentFormat, input::parse_document_str};
use crate::ui_ast::{UiNodeKind, build_ui_ast, index, layout};
fn schema_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("schemas")
.join("test-comprehensive.schema.json")
}
#[test]
fn layout_field_pointers_reference_valid_nodes() {
let path = schema_path();
let contents = std::fs::read_to_string(&path).expect("schema file readable");
let schema_value: Value =
parse_document_str(&contents, DocumentFormat::Json).expect("schema parses at runtime");
let ast = build_ui_ast(&schema_value).expect("runtime UiAst");
let pointers = index::collect_pointers(&ast);
let layout = layout::build_ui_layout(&ast);
assert!(
!layout.roots.is_empty(),
"layout must expose at least one root for the comprehensive schema",
);
for root in &layout.roots {
assert!(
!root.sections.is_empty(),
"layout root '{}' must contain at least one section",
root.id,
);
for section in &root.sections {
for pointer in &section.field_pointers {
assert!(
pointers.contains(pointer),
"layout references unknown field pointer: {pointer}",
);
}
}
}
}
#[test]
fn layout_groups_scalar_roots_into_general_section() {
let schema = json!({
"type": "object",
"properties": {
"metadata": {
"type": "object",
"properties": {
"serviceName": {"type": "string"}
}
},
"generalFlag": {"type": "string"}
}
});
let ast = build_ui_ast(&schema).expect("runtime UiAst");
let layout = layout::build_ui_layout(&ast);
// Expect one object root for "metadata" and a separate "general" root
// that collects non-object top-level fields such as "generalFlag".
let general = layout
.roots
.iter()
.find(|root| root.id == "general")
.expect("general root");
let general_section = general.sections.first().expect("general section");
assert!(
general_section
.field_pointers
.iter()
.any(|p| p == "/generalFlag"),
"general section should include the scalar top-level field",
);
let metadata = layout
.roots
.iter()
.find(|root| root.id == "metadata")
.expect("metadata root");
let metadata_section = metadata.sections.first().expect("metadata section");
assert_eq!(metadata_section.pointer, "/metadata");
assert!(
metadata_section
.field_pointers
.iter()
.any(|p| p == "/metadata/serviceName"),
"metadata section should include its child field pointer",
);
}
#[test]
fn layout_preserves_declared_root_and_nested_property_order() {
let schema = json!({
"type": "object",
"properties": {
"zeta": {
"type": "object",
"title": "Zeta",
"properties": {
"second": {"type": "string"},
"first": {"type": "string"},
"network": {
"type": "object",
"title": "Network",
"properties": {
"port": {"type": "integer"}
}
},
"auth": {
"type": "object",
"title": "Auth",
"properties": {
"user": {"type": "string"}
}
}
}
},
"alpha": {
"type": "object",
"title": "Alpha",
"properties": {
"enabled": {"type": "boolean"}
}
}
}
});
let ast = build_ui_ast(&schema).expect("runtime UiAst");
let layout = layout::build_ui_layout(&ast);
let root_ids: Vec<_> = layout.roots.iter().map(|root| root.id.as_str()).collect();
assert_eq!(
root_ids,
vec!["zeta", "alpha"],
"layout roots should follow schema declaration order",
);
let zeta = layout
.roots
.iter()
.find(|root| root.id == "zeta")
.expect("zeta root");
let zeta_section = zeta.sections.first().expect("zeta section");
assert_eq!(
zeta_section.field_pointers,
vec!["/zeta/second", "/zeta/first"],
"field list should preserve declared property order within an object section",
);
let child_pointers: Vec<_> = zeta_section
.children
.iter()
.map(|section| section.pointer.as_str())
.collect();
assert_eq!(
child_pointers,
vec!["/zeta/network", "/zeta/auth"],
"nested section order should follow schema declaration order",
);
}
#[test]
fn build_ui_ast_accepts_object_only_allof_refs_without_crashing() {
let schema = json!({
"type": "object",
"properties": {
"permissions": {
"allOf": [
{ "$ref": "#/definitions/PermissionsToml" }
]
},
"features": {
"type": "object",
"properties": {
"apps": { "type": "boolean" }
}
}
},
"definitions": {
"PermissionsToml": {
"type": "object"
}
}
});
let ast = build_ui_ast(&schema).expect("runtime UiAst");
assert_eq!(
ast.roots.len(),
2,
"all top-level roots should still be built"
);
let permissions = ast
.roots
.iter()
.find(|node| node.pointer == "/permissions")
.expect("permissions root");
assert_eq!(permissions.default_value, Some(json!({})));
assert!(
matches!(
permissions.kind,
UiNodeKind::Object {
ref children,
ref required
} if children.is_empty() && required.is_empty()
),
"opaque object schemas should degrade to an empty object boundary instead of crashing",
);
let features = ast
.roots
.iter()
.find(|node| node.pointer == "/features")
.expect("features root");
assert!(
matches!(
features.kind,
UiNodeKind::Object { ref children, .. }
if children.iter().any(|child| child.pointer == "/features/apps")
),
"ordinary object schemas should remain fully traversable",
);
let ui_layout = layout::build_ui_layout(&ast);
assert!(
ui_layout.roots.iter().any(|root| root.id == "features"),
"layout should still be buildable when an opaque object field is present",
);
}
+4
View File
@@ -0,0 +1,4 @@
pub(crate) mod builder_tests;
pub(crate) mod defaults_tests;
pub(crate) mod index_tests;
pub(crate) mod layout_tests;
@@ -0,0 +1,142 @@
use std::net::{IpAddr, SocketAddr, TcpListener};
use std::thread;
use std::time::{Duration, Instant};
use serde_json::json;
use ureq::Agent;
use crate::web::session::ServeOptions;
use crate::{FrontendOptions, SchemaUI};
fn reserve_port() -> u16 {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind ephemeral port");
let port = listener.local_addr().expect("listener addr").port();
drop(listener);
port
}
fn test_http_agent() -> Agent {
Agent::new_with_config(
Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(2)))
.proxy(None)
.build(),
)
}
fn wait_until_ready(agent: &Agent, base_url: &str) {
let deadline = Instant::now() + Duration::from_secs(5);
let session_url = format!("{base_url}/api/session");
loop {
let outcome = match agent.get(&session_url).call() {
Ok(response) if response.status().as_u16() == 200 => return,
Ok(response) => format!("unexpected status: {}", response.status()),
Err(err) => err.to_string(),
};
assert!(
Instant::now() < deadline,
"web session did not become ready at {base_url}; last outcome: {}",
outcome
);
thread::sleep(Duration::from_millis(50));
}
}
#[test]
fn web_frontend_exit_does_not_panic_when_runtime_is_dropped() {
let port = reserve_port();
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let base_url = format!("http://{addr}");
let expected = json!({ "name": "alice" });
let schema = json!({
"type": "object",
"properties": {
"name": { "type": "string" }
}
});
let handle = thread::spawn(move || {
SchemaUI::from_schema(schema).run(FrontendOptions::Web(ServeOptions {
host: IpAddr::from([127, 0, 0, 1]),
port,
}))
});
let agent = test_http_agent();
wait_until_ready(&agent, &base_url);
let response = agent
.post(format!("{base_url}/api/exit"))
.content_type("application/json")
.send(
serde_json::to_string(&json!({
"data": expected.clone(),
"commit": true
}))
.expect("serialize exit payload"),
)
.expect("post exit request");
assert_eq!(response.status().as_u16(), 200);
let result = handle
.join()
.expect("web frontend thread should not panic")
.expect("web frontend should return the committed payload");
assert_eq!(result, expected);
}
#[tokio::test(flavor = "multi_thread")]
async fn web_frontend_async_runner_works_inside_existing_runtime() {
let port = reserve_port();
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let base_url = format!("http://{addr}");
let expected = json!({ "name": "bob" });
let schema = json!({
"type": "object",
"properties": {
"name": { "type": "string" }
}
});
let task = tokio::spawn(async move {
SchemaUI::from_schema(schema)
.run_web_async(ServeOptions {
host: IpAddr::from([127, 0, 0, 1]),
port,
})
.await
});
let wait_url = base_url.clone();
tokio::task::spawn_blocking(move || {
let agent = test_http_agent();
wait_until_ready(&agent, &wait_url);
})
.await
.expect("wait task should not panic");
let exit_url = format!("{base_url}/api/exit");
let exit_payload = json!({
"data": expected.clone(),
"commit": true
});
let response = tokio::task::spawn_blocking(move || {
test_http_agent()
.post(exit_url)
.content_type("application/json")
.send(serde_json::to_string(&exit_payload).expect("serialize exit payload"))
})
.await
.expect("exit task should not panic")
.expect("post exit request");
assert_eq!(response.status().as_u16(), 200);
let result = task
.await
.expect("web frontend task should not panic")
.expect("web frontend should return the committed payload");
assert_eq!(result, expected);
}
+5
View File
@@ -0,0 +1,5 @@
#[cfg(feature = "web")]
mod frontend_runtime_tests;
#[cfg(feature = "precompile")]
mod web_snapshot_tests;
@@ -0,0 +1,254 @@
use std::{fs, path::PathBuf};
use crate::io::DocumentFormat;
use crate::io::input::parse_document_str;
use crate::precompile::build_ui_artifact_bundle;
use crate::precompile::web;
use crate::web::session::SessionResponse;
use crate::web::session::WebSessionBuilder;
use serde_json::{Value, json};
fn schema_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("schemas")
.join("test-comprehensive.schema.json")
}
fn defaults_value() -> Value {
json!({
"simpleTypes": {
"text": "hello from defaults",
"number": 7,
"toggle": true,
"dropdown": "option2"
}
})
}
fn opaque_object_schema() -> Value {
json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Opaque Object Schema",
"type": "object",
"properties": {
"permissions": {
"allOf": [
{ "$ref": "#/definitions/PermissionsToml" }
],
"description": "Named permission profiles."
},
"features": {
"type": "object",
"properties": {
"apps": { "type": "boolean" }
}
}
},
"definitions": {
"PermissionsToml": {
"type": "object"
}
}
})
}
fn issue72_schema() -> Value {
json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "My title",
"description": "My description",
"type": "object",
"properties": {
"EnumInteger": {
"title": "integer title",
"description": "Select one of a few integers",
"enum": [2, 5]
},
"comment": {
"type": "string",
"title": "Comment title",
"description": "Comment description"
}
},
"propertyNames": true,
"required": [],
"additionalProperties": false
})
}
#[test]
fn web_session_builder_accepts_opaque_object_fields() {
let snapshot = WebSessionBuilder::new(opaque_object_schema())
.with_initial_data(json!({
"features": {
"apps": true
}
}))
.build()
.expect("web session config")
.session_response();
assert_eq!(snapshot.title.as_deref(), Some("Opaque Object Schema"));
assert_eq!(snapshot.data, json!({ "features": { "apps": true } }));
assert!(
snapshot
.ui_ast
.roots
.iter()
.any(|node| node.pointer == "/permissions"),
"opaque object fields should remain representable in the web session UI AST",
);
}
#[test]
fn build_session_snapshot_for_comprehensive_schema_succeeds() {
let path = schema_path();
let snapshot = web::build_session_snapshot_from_files(&path, DocumentFormat::Json, None)
.expect("session snapshot");
assert!(!snapshot.formats.is_empty());
assert!(!snapshot.ui_ast.roots.is_empty());
}
#[test]
fn web_session_builder_uses_root_schema_metadata_for_header() {
let snapshot = WebSessionBuilder::new(issue72_schema())
.build()
.expect("web session config")
.session_response();
assert_eq!(snapshot.title.as_deref(), Some("My title"));
assert_eq!(snapshot.description.as_deref(), Some("My description"));
}
#[test]
fn web_session_builder_with_title_overrides_only_header_title() {
let snapshot = WebSessionBuilder::new(issue72_schema())
.with_title("CLI override")
.build()
.expect("web session config")
.session_response();
assert_eq!(snapshot.title.as_deref(), Some("CLI override"));
assert_eq!(snapshot.description.as_deref(), Some("My description"));
}
#[test]
fn web_snapshot_builder_uses_root_schema_metadata_for_header() {
let mut schema_path = std::env::temp_dir();
schema_path.push(format!(
"schemaui_issue72_{}_schema.json",
std::process::id()
));
fs::write(
&schema_path,
serde_json::to_vec_pretty(&issue72_schema()).expect("serialize schema"),
)
.expect("write schema file");
let snapshot = web::build_session_snapshot_from_files(&schema_path, DocumentFormat::Json, None)
.expect("session snapshot");
assert_eq!(snapshot.title.as_deref(), Some("My title"));
assert_eq!(snapshot.description.as_deref(), Some("My description"));
let _ = fs::remove_file(&schema_path);
}
#[test]
fn json_snapshot_roundtrip_via_file_is_deserializable() {
let path = schema_path();
let snapshot = web::build_session_snapshot_from_files(&path, DocumentFormat::Json, None)
.expect("session snapshot");
// Write JSON snapshot to a temp-ish file and ensure we can read it back
// into a SessionResponse. This mirrors what the web frontend expects when
// consuming the snapshot as raw JSON.
let mut out_path = std::env::temp_dir();
out_path.push(format!(
"schemaui_web_snapshot_{}_json.json",
std::process::id()
));
web::write_session_snapshot_json(&snapshot, &out_path).expect("write JSON snapshot");
let contents = std::fs::read_to_string(&out_path).expect("read JSON snapshot");
let decoded: SessionResponse =
serde_json::from_str(&contents).expect("deserialize JSON snapshot into SessionResponse");
// Basic structural sanity checks
assert_eq!(decoded.formats, snapshot.formats);
assert!(!decoded.ui_ast.roots.is_empty());
assert!(decoded.layout.is_some());
let _ = std::fs::remove_file(&out_path);
}
#[test]
fn ts_snapshot_module_has_expected_export_shape() {
let path = schema_path();
let snapshot = web::build_session_snapshot_from_files(&path, DocumentFormat::Json, None)
.expect("session snapshot");
let mut out_path = std::env::temp_dir();
out_path.push(format!(
"schemaui_web_snapshot_{}_module.ts",
std::process::id()
));
let export_name = "SessionSnapshot";
web::write_session_snapshot_ts_module(&snapshot, &out_path, export_name)
.expect("write TS snapshot module");
let src = std::fs::read_to_string(&out_path).expect("read TS snapshot module");
// We don't run tsc here (that belongs in the web/ui repo), but we do check
// that the generated module has the expected import and export signature.
assert!(
src.contains("import type { SessionResponse } from '@schemaui/types/SessionResponse';",)
);
assert!(src.contains("export const SessionSnapshot: SessionResponse ="));
let _ = std::fs::remove_file(&out_path);
}
#[test]
fn web_session_builder_with_ui_artifact_bundle_matches_snapshot_builder() {
let path = schema_path();
let schema_raw = fs::read_to_string(&path).expect("schema file readable");
let schema_value: Value =
parse_document_str(&schema_raw, DocumentFormat::Json).expect("schema parses");
let defaults = defaults_value();
let bundle =
build_ui_artifact_bundle(&schema_value, Some(&defaults)).expect("build UI artifact bundle");
let config = WebSessionBuilder::new(schema_value)
.with_initial_data(defaults.clone())
.with_ui_artifact_bundle(bundle)
.build()
.expect("web session config");
let runtime_snapshot = config.session_response();
let mut defaults_path = std::env::temp_dir();
defaults_path.push(format!(
"schemaui_web_defaults_{}_snapshot.json",
std::process::id()
));
fs::write(
&defaults_path,
serde_json::to_vec_pretty(&defaults).expect("serialize defaults"),
)
.expect("write defaults file");
let snapshot_from_builder =
web::build_session_snapshot_from_files(&path, DocumentFormat::Json, Some(&defaults_path))
.expect("build web snapshot from files");
assert_eq!(runtime_snapshot, snapshot_from_builder);
let _ = fs::remove_file(&defaults_path);
}
+295
View File
@@ -0,0 +1,295 @@
use std::collections::HashMap;
use std::sync::Arc;
use crossterm::event::KeyEvent;
use crate::tui::state::FormCommand;
use super::keymap::KeymapStore;
#[derive(Debug, Clone, Copy)]
pub enum KeyAction {
Save,
Quit,
ResetStatus,
TogglePopup,
EditComposite,
ShowHelp,
HelpClose,
HelpPageStep(i32),
HelpShortcutScroll(i32),
HelpShortcutPage(i32),
HelpShortcutHome,
HelpShortcutEnd,
HelpErrorScroll(i32),
FieldStep(i32),
SectionStep(i32),
RootStep(i32),
ListAddEntry,
ListRemoveEntry,
ListMove(i32),
ListSelect(i32),
Input(KeyEvent),
None,
}
#[derive(Debug, Clone, Copy)]
pub enum AppCommand {
Save,
Quit,
ResetStatus,
TogglePopup,
EditComposite,
ShowHelp,
ListAddEntry,
ListRemoveEntry,
ListMove(i32),
ListSelect(i32),
}
#[derive(Debug, Clone)]
pub enum CommandDispatch {
Form(FormCommand),
App(AppCommand),
Input(KeyEvent),
None,
}
#[derive(Debug, Clone)]
pub struct KeyBindingMap {
bindings: HashMap<KeyActionDiscriminant, CommandDispatch>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum KeyActionDiscriminant {
Save,
Quit,
ResetStatus,
TogglePopup,
EditComposite,
ShowHelp,
FieldStepPos,
FieldStepNeg,
SectionStepPos,
SectionStepNeg,
RootStepPos,
RootStepNeg,
ListAdd,
ListRemove,
ListMoveUp,
ListMoveDown,
ListSelectLeft,
ListSelectRight,
}
impl KeyBindingMap {
pub fn builtin() -> Self {
use CommandDispatch::{App, Form};
use FormCommand::*;
let mut bindings = HashMap::new();
bindings.insert(KeyActionDiscriminant::Save, App(AppCommand::Save));
bindings.insert(KeyActionDiscriminant::Quit, App(AppCommand::Quit));
bindings.insert(KeyActionDiscriminant::FieldStepPos, Form(FocusNextField));
bindings.insert(KeyActionDiscriminant::FieldStepNeg, Form(FocusPrevField));
bindings.insert(
KeyActionDiscriminant::SectionStepPos,
Form(FormCommand::FocusNextSection(1)),
);
bindings.insert(
KeyActionDiscriminant::SectionStepNeg,
Form(FormCommand::FocusNextSection(-1)),
);
bindings.insert(
KeyActionDiscriminant::RootStepPos,
Form(FormCommand::FocusNextRoot(1)),
);
bindings.insert(
KeyActionDiscriminant::RootStepNeg,
Form(FormCommand::FocusNextRoot(-1)),
);
bindings.insert(
KeyActionDiscriminant::TogglePopup,
App(AppCommand::TogglePopup),
);
bindings.insert(
KeyActionDiscriminant::EditComposite,
App(AppCommand::EditComposite),
);
bindings.insert(KeyActionDiscriminant::ShowHelp, App(AppCommand::ShowHelp));
bindings.insert(
KeyActionDiscriminant::ResetStatus,
App(AppCommand::ResetStatus),
);
bindings.insert(
KeyActionDiscriminant::ListAdd,
App(AppCommand::ListAddEntry),
);
bindings.insert(
KeyActionDiscriminant::ListRemove,
App(AppCommand::ListRemoveEntry),
);
bindings.insert(
KeyActionDiscriminant::ListMoveUp,
App(AppCommand::ListMove(-1)),
);
bindings.insert(
KeyActionDiscriminant::ListMoveDown,
App(AppCommand::ListMove(1)),
);
bindings.insert(
KeyActionDiscriminant::ListSelectLeft,
App(AppCommand::ListSelect(-1)),
);
bindings.insert(
KeyActionDiscriminant::ListSelectRight,
App(AppCommand::ListSelect(1)),
);
Self { bindings }
}
pub fn resolve(&self, action: KeyAction) -> CommandDispatch {
match action {
KeyAction::Save => self
.bindings
.get(&KeyActionDiscriminant::Save)
.cloned()
.unwrap_or(CommandDispatch::App(AppCommand::Save)),
KeyAction::Quit => self
.bindings
.get(&KeyActionDiscriminant::Quit)
.cloned()
.unwrap_or(CommandDispatch::App(AppCommand::Quit)),
KeyAction::ResetStatus => self
.bindings
.get(&KeyActionDiscriminant::ResetStatus)
.cloned()
.unwrap_or(CommandDispatch::App(AppCommand::ResetStatus)),
KeyAction::ShowHelp => self
.bindings
.get(&KeyActionDiscriminant::ShowHelp)
.cloned()
.unwrap_or(CommandDispatch::App(AppCommand::ShowHelp)),
KeyAction::TogglePopup => self
.bindings
.get(&KeyActionDiscriminant::TogglePopup)
.cloned()
.unwrap_or(CommandDispatch::App(AppCommand::TogglePopup)),
KeyAction::EditComposite => self
.bindings
.get(&KeyActionDiscriminant::EditComposite)
.cloned()
.unwrap_or(CommandDispatch::App(AppCommand::EditComposite)),
KeyAction::FieldStep(delta) => {
let key = if delta >= 0 {
KeyActionDiscriminant::FieldStepPos
} else {
KeyActionDiscriminant::FieldStepNeg
};
self.bindings.get(&key).cloned().unwrap_or({
if delta >= 0 {
CommandDispatch::Form(FormCommand::FocusNextField)
} else {
CommandDispatch::Form(FormCommand::FocusPrevField)
}
})
}
KeyAction::SectionStep(delta) => {
let key = if delta >= 0 {
KeyActionDiscriminant::SectionStepPos
} else {
KeyActionDiscriminant::SectionStepNeg
};
self.bindings
.get(&key)
.cloned()
.unwrap_or(CommandDispatch::Form(FormCommand::FocusNextSection(delta)))
}
KeyAction::RootStep(delta) => {
let key = if delta >= 0 {
KeyActionDiscriminant::RootStepPos
} else {
KeyActionDiscriminant::RootStepNeg
};
self.bindings
.get(&key)
.cloned()
.unwrap_or(CommandDispatch::Form(FormCommand::FocusNextRoot(delta)))
}
KeyAction::ListAddEntry => self
.bindings
.get(&KeyActionDiscriminant::ListAdd)
.cloned()
.unwrap_or(CommandDispatch::App(AppCommand::ListAddEntry)),
KeyAction::ListRemoveEntry => self
.bindings
.get(&KeyActionDiscriminant::ListRemove)
.cloned()
.unwrap_or(CommandDispatch::App(AppCommand::ListRemoveEntry)),
KeyAction::ListMove(delta) => {
let key = if delta >= 0 {
KeyActionDiscriminant::ListMoveDown
} else {
KeyActionDiscriminant::ListMoveUp
};
self.bindings
.get(&key)
.cloned()
.unwrap_or(CommandDispatch::App(AppCommand::ListMove(delta)))
}
KeyAction::ListSelect(delta) => {
let key = if delta >= 0 {
KeyActionDiscriminant::ListSelectRight
} else {
KeyActionDiscriminant::ListSelectLeft
};
self.bindings
.get(&key)
.cloned()
.unwrap_or(CommandDispatch::App(AppCommand::ListSelect(delta)))
}
KeyAction::HelpClose
| KeyAction::HelpPageStep(_)
| KeyAction::HelpShortcutScroll(_)
| KeyAction::HelpShortcutPage(_)
| KeyAction::HelpShortcutHome
| KeyAction::HelpShortcutEnd
| KeyAction::HelpErrorScroll(_) => CommandDispatch::None,
KeyAction::Input(event) => CommandDispatch::Input(event),
KeyAction::None => CommandDispatch::None,
}
}
}
impl Default for KeyBindingMap {
fn default() -> Self {
Self::builtin()
}
}
pub struct InputRouter {
store: Arc<KeymapStore>,
}
impl InputRouter {
pub(crate) fn new(store: Arc<KeymapStore>) -> Self {
Self { store }
}
pub fn classify(&self, key: &KeyEvent) -> KeyAction {
#[cfg(feature = "debug")]
println!("{key:?}");
self.store.classify(key).unwrap_or(KeyAction::Input(*key))
}
pub(crate) fn classify_for_contexts(
&self,
key: &KeyEvent,
contexts: &[super::keymap::KeymapContext],
) -> KeyAction {
#[cfg(feature = "debug")]
println!("{key:?} @ {contexts:?}");
self.store
.classify_for_contexts(key, contexts)
.unwrap_or(KeyAction::Input(*key))
}
}
+408
View File
@@ -0,0 +1,408 @@
use anyhow::{Result, anyhow};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::Deserialize;
use std::sync::{Arc, LazyLock};
use super::input::KeyAction;
macro_rules! keymap_source {
() => {
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/keymap/default.keymap.json"
))
};
}
pub(super) use keymap_source;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum KeymapContext {
Default,
Collection,
Overlay,
Help,
TextInput,
NumericInput,
}
impl KeymapContext {
fn from_str(raw: &str) -> Option<Self> {
match raw {
"default" => Some(KeymapContext::Default),
"collection" => Some(KeymapContext::Collection),
"overlay" => Some(KeymapContext::Overlay),
"help" => Some(KeymapContext::Help),
"text" | "textInput" | "text-input" => Some(KeymapContext::TextInput),
"numeric" | "numericInput" | "numeric-input" => Some(KeymapContext::NumericInput),
_ => None,
}
}
}
#[derive(Deserialize)]
struct RawEntry {
id: String,
description: String,
contexts: Vec<String>,
#[serde(default = "default_dispatch")]
dispatch: bool,
action: RawAction,
combos: Vec<String>,
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
enum RawAction {
Save,
Quit,
ResetStatus,
TogglePopup,
EditComposite,
ShowHelp,
HelpClose,
HelpPageStep { delta: i32 },
HelpShortcutScroll { delta: i32 },
HelpShortcutPage { delta: i32 },
HelpShortcutHome,
HelpShortcutEnd,
HelpErrorScroll { delta: i32 },
FieldStep { delta: i32 },
SectionStep { delta: i32 },
RootStep { delta: i32 },
ListAddEntry,
ListRemoveEntry,
ListMove { delta: i32 },
ListSelect { delta: i32 },
None,
}
#[derive(Clone, Debug)]
struct KeyBinding {
action: KeyAction,
contexts: Vec<KeymapContext>,
combos: Vec<KeyPattern>,
snippet: String,
dispatch: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HelpEntry {
pub(crate) keys: String,
pub(crate) action: String,
}
impl KeyBinding {
fn from_raw(raw: RawEntry) -> Result<Self> {
let contexts = raw
.contexts
.iter()
.filter_map(|ctx| KeymapContext::from_str(ctx))
.collect::<Vec<_>>();
if contexts.is_empty() {
return Err(anyhow!("keymap entry {} must declare contexts", raw.id));
}
let action = raw.action.into_action();
let mut combos = Vec::with_capacity(raw.combos.len());
for combo in raw.combos {
let pattern = KeyPattern::parse(&combo)
.map_err(|err| anyhow!("failed to parse combo '{combo}' for {}: {err}", raw.id))?;
combos.push(pattern);
}
if combos.is_empty() {
return Err(anyhow!("keymap entry {} must declare combos", raw.id));
}
let combos_display = combos
.iter()
.map(|pattern| pattern.display.clone())
.collect::<Vec<_>>()
.join("/");
let snippet = format!("{combos_display} -> {}", raw.description);
Ok(Self {
action,
contexts,
combos,
snippet,
dispatch: raw.dispatch,
})
}
fn classify(&self, key: &KeyEvent) -> Option<KeyAction> {
if !self.dispatch {
return None;
}
if self.contexts.contains(&KeymapContext::Help) {
return None;
}
self.combos
.iter()
.find(|pattern| pattern.matches(key))
.map(|_| self.action)
}
fn classify_for_contexts(
&self,
key: &KeyEvent,
contexts: &[KeymapContext],
) -> Option<KeyAction> {
if !self.dispatch
|| !self
.contexts
.iter()
.any(|context| contexts.contains(context))
{
return None;
}
self.combos
.iter()
.find(|pattern| pattern.matches(key))
.map(|_| self.action)
}
}
#[derive(Clone, Debug)]
struct KeyPattern {
matcher: CodeMatcher,
required: KeyModifiers,
allow_shift: bool,
display: String,
}
impl KeyPattern {
fn parse(spec: &str) -> Result<Self, String> {
let display = spec.trim().to_string();
if display.is_empty() {
return Err("combo cannot be empty".into());
}
let mut tokens = display
.split('+')
.map(|t| t.trim())
.filter(|t| !t.is_empty())
.collect::<Vec<_>>();
if tokens.is_empty() {
return Err("combo must contain key".into());
}
let key_token = tokens.pop().unwrap();
let matcher = CodeMatcher::from_token(key_token)?;
let mut required = KeyModifiers::empty();
for token in tokens {
match token.to_lowercase().as_str() {
"ctrl" | "control" => required |= KeyModifiers::CONTROL,
"shift" => required |= KeyModifiers::SHIFT,
"alt" => required |= KeyModifiers::ALT,
other => {
return Err(format!("unsupported modifier '{other}'"));
}
}
}
let allow_shift = matcher.allows_extra_shift() && !required.contains(KeyModifiers::SHIFT);
Ok(Self {
matcher,
required,
allow_shift,
display,
})
}
fn matches(&self, key: &KeyEvent) -> bool {
if !self.matcher.matches(&key.code) {
return false;
}
if !modifiers_include(key.modifiers, self.required) {
return false;
}
let extra = remove_modifiers(key.modifiers, self.required);
if self.allow_shift {
let tolerated = extra & !KeyModifiers::SHIFT;
tolerated.is_empty()
} else {
extra.is_empty()
}
}
}
#[derive(Clone, Debug)]
enum CodeMatcher {
Literal(KeyCode),
Alpha(char),
}
impl CodeMatcher {
fn from_token(token: &str) -> Result<Self, String> {
let normalized = token.to_lowercase();
let matcher = match normalized.as_str() {
"tab" => CodeMatcher::Literal(KeyCode::Tab),
"backtab" | "shift+tab" => CodeMatcher::Literal(KeyCode::BackTab),
"enter" => CodeMatcher::Literal(KeyCode::Enter),
"esc" | "escape" => CodeMatcher::Literal(KeyCode::Esc),
"left" => CodeMatcher::Literal(KeyCode::Left),
"right" => CodeMatcher::Literal(KeyCode::Right),
"up" => CodeMatcher::Literal(KeyCode::Up),
"down" => CodeMatcher::Literal(KeyCode::Down),
"home" => CodeMatcher::Literal(KeyCode::Home),
"end" => CodeMatcher::Literal(KeyCode::End),
"pageup" => CodeMatcher::Literal(KeyCode::PageUp),
"pagedown" => CodeMatcher::Literal(KeyCode::PageDown),
"delete" | "del" => CodeMatcher::Literal(KeyCode::Delete),
"backspace" => CodeMatcher::Literal(KeyCode::Backspace),
other => {
if other.len() == 1 {
CodeMatcher::Alpha(other.chars().next().unwrap())
} else {
return Err(format!("unsupported key '{token}'"));
}
}
};
Ok(matcher)
}
fn matches(&self, code: &KeyCode) -> bool {
match (self, code) {
(CodeMatcher::Literal(expected), actual) => actual == expected,
(CodeMatcher::Alpha(expected), KeyCode::Char(actual)) => {
actual.to_ascii_lowercase() == *expected
}
_ => false,
}
}
fn allows_extra_shift(&self) -> bool {
matches!(
self,
CodeMatcher::Alpha(_) | CodeMatcher::Literal(KeyCode::BackTab)
)
}
}
impl RawAction {
fn into_action(self) -> KeyAction {
match self {
RawAction::Save => KeyAction::Save,
RawAction::Quit => KeyAction::Quit,
RawAction::ResetStatus => KeyAction::ResetStatus,
RawAction::TogglePopup => KeyAction::TogglePopup,
RawAction::EditComposite => KeyAction::EditComposite,
RawAction::ShowHelp => KeyAction::ShowHelp,
RawAction::HelpClose => KeyAction::HelpClose,
RawAction::HelpPageStep { delta } => KeyAction::HelpPageStep(delta),
RawAction::HelpShortcutScroll { delta } => KeyAction::HelpShortcutScroll(delta),
RawAction::HelpShortcutPage { delta } => KeyAction::HelpShortcutPage(delta),
RawAction::HelpShortcutHome => KeyAction::HelpShortcutHome,
RawAction::HelpShortcutEnd => KeyAction::HelpShortcutEnd,
RawAction::HelpErrorScroll { delta } => KeyAction::HelpErrorScroll(delta),
RawAction::FieldStep { delta } => KeyAction::FieldStep(delta),
RawAction::SectionStep { delta } => KeyAction::SectionStep(delta),
RawAction::RootStep { delta } => KeyAction::RootStep(delta),
RawAction::ListAddEntry => KeyAction::ListAddEntry,
RawAction::ListRemoveEntry => KeyAction::ListRemoveEntry,
RawAction::ListMove { delta } => KeyAction::ListMove(delta),
RawAction::ListSelect { delta } => KeyAction::ListSelect(delta),
RawAction::None => KeyAction::None,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct KeymapStore {
bindings: Arc<Vec<KeyBinding>>,
}
impl KeymapStore {
pub fn from_json(raw: &str) -> Result<Self> {
let entries: Vec<RawEntry> = serde_json::from_str(raw)?;
Self::from_entries(entries)
}
pub fn builtin() -> Self {
Self::from_json(keymap_source!()).expect("invalid keymap/default.keymap.json")
}
fn from_entries(entries: Vec<RawEntry>) -> Result<Self> {
let mut bindings = Vec::with_capacity(entries.len());
for entry in entries {
bindings.push(KeyBinding::from_raw(entry)?);
}
Ok(Self {
bindings: Arc::new(bindings),
})
}
pub fn classify(&self, key: &KeyEvent) -> Option<KeyAction> {
self.bindings
.iter()
.find_map(|binding| binding.classify(key))
}
pub fn classify_for_contexts(
&self,
key: &KeyEvent,
contexts: &[KeymapContext],
) -> Option<KeyAction> {
self.bindings
.iter()
.find_map(|binding| binding.classify_for_contexts(key, contexts))
}
pub fn help_text(&self, context: KeymapContext) -> Option<String> {
self.help_text_for_contexts(&[context])
}
pub fn help_text_for_contexts(&self, contexts: &[KeymapContext]) -> Option<String> {
let snippets = self
.bindings
.iter()
.filter(|binding| {
contexts
.iter()
.any(|context| binding.contexts.contains(context))
})
.map(|binding| binding.snippet.clone())
.collect::<Vec<_>>();
if snippets.is_empty() {
None
} else {
Some(snippets.join(" • "))
}
}
pub(crate) fn help_entries(&self, context: KeymapContext) -> Vec<HelpEntry> {
self.help_entries_for_contexts(&[context])
}
pub(crate) fn help_entries_for_contexts(&self, contexts: &[KeymapContext]) -> Vec<HelpEntry> {
self.bindings
.iter()
.filter(|binding| {
contexts
.iter()
.any(|context| binding.contexts.contains(context))
})
.map(|binding| {
let (keys, action) = match binding.snippet.split_once("->") {
Some((keys, action)) => (keys.trim().to_string(), action.trim().to_string()),
None => (binding.snippet.clone(), String::new()),
};
HelpEntry { keys, action }
})
.collect()
}
}
static DEFAULT_STORE: LazyLock<Arc<KeymapStore>> =
LazyLock::new(|| Arc::new(KeymapStore::builtin()));
pub(crate) fn default_store() -> Arc<KeymapStore> {
DEFAULT_STORE.clone()
}
fn modifiers_include(actual: KeyModifiers, required: KeyModifiers) -> bool {
actual.contains(required)
}
fn remove_modifiers(actual: KeyModifiers, required: KeyModifiers) -> KeyModifiers {
KeyModifiers::from_bits_truncate(actual.bits() & !required.bits())
}
fn default_dispatch() -> bool {
true
}
+11
View File
@@ -0,0 +1,11 @@
pub mod input;
pub mod keymap;
pub mod options;
pub mod popup;
pub mod runtime;
pub mod status;
pub mod terminal;
pub mod validation;
pub use options::UiOptions;
pub(crate) use runtime::App;
+221
View File
@@ -0,0 +1,221 @@
use std::{borrow::Cow, sync::Arc, time::Duration};
use anyhow::Result;
use super::{
input::KeyBindingMap,
keymap::{self, KeymapStore},
};
use crate::tui::state::field::components::{
BoolTogglePresentation, CollectionHints, ComponentPalette, CompositeHints, EnumBehaviour,
NumericTuning,
};
#[derive(Debug, Clone)]
pub struct UiOptions {
pub tick_rate: Duration,
pub auto_validate: bool,
pub confirm_exit: bool,
pub show_help: bool,
pub keymap: KeyBindingMap,
pub(crate) keymap_store: Arc<KeymapStore>,
pub(crate) component_palette: Arc<ComponentPalette>,
}
impl Default for UiOptions {
fn default() -> Self {
Self {
tick_rate: Duration::from_millis(250),
auto_validate: true,
confirm_exit: true,
show_help: true,
keymap: KeyBindingMap::default(),
keymap_store: keymap::default_store(),
component_palette: Arc::new(ComponentPalette::default()),
}
}
}
impl UiOptions {
pub fn with_keymap(mut self, keymap: KeyBindingMap) -> Self {
self.keymap = keymap;
self
}
pub fn with_keymap_json(self, raw: &str) -> Result<Self> {
let keymap_store = Arc::new(KeymapStore::from_json(raw)?);
Ok(self.with_keymap_store(keymap_store))
}
pub(crate) fn with_keymap_store(mut self, keymap_store: Arc<KeymapStore>) -> Self {
self.keymap_store = keymap_store;
self
}
pub fn with_auto_validate(mut self, enabled: bool) -> Self {
self.auto_validate = enabled;
self
}
pub fn with_help(mut self, show: bool) -> Self {
self.show_help = show;
self
}
pub fn with_confirm_exit(mut self, confirm: bool) -> Self {
self.confirm_exit = confirm;
self
}
pub fn with_tick_rate(mut self, tick_rate: Duration) -> Self {
self.tick_rate = tick_rate;
self
}
pub fn with_component_palette(mut self, palette: ComponentPalette) -> Self {
self.component_palette = Arc::new(palette);
self
}
pub fn with_integer_step(self, step: i64) -> Self {
self.map_palette(|mut palette| {
palette.numeric = palette.numeric.with_integer_step(step);
palette
})
}
pub fn with_integer_fast_step(self, step: i64) -> Self {
self.map_palette(|mut palette| {
palette.numeric = palette.numeric.with_integer_fast_step(step);
palette
})
}
pub fn with_float_step(self, step: f64) -> Self {
self.map_palette(|mut palette| {
palette.numeric = palette.numeric.with_float_step(step);
palette
})
}
pub fn with_float_fast_step(self, step: f64) -> Self {
self.map_palette(|mut palette| {
palette.numeric = palette.numeric.with_float_fast_step(step);
palette
})
}
pub fn with_numeric_tuning(mut self, tuning: NumericTuning) -> Self {
let mut palette = (*self.component_palette).clone();
palette.numeric = tuning;
self.component_palette = Arc::new(palette);
self
}
pub fn with_bool_presentation(mut self, presentation: BoolTogglePresentation) -> Self {
let mut palette = (*self.component_palette).clone();
palette.bools = presentation;
self.component_palette = Arc::new(palette);
self
}
pub fn with_bool_labels(
self,
true_label: impl Into<Cow<'static, str>>,
false_label: impl Into<Cow<'static, str>>,
) -> Self {
let true_label = true_label.into();
let false_label = false_label.into();
self.map_palette(|mut palette| {
palette.bools = palette.bools.clone().with_labels(true_label, false_label);
palette
})
}
pub fn with_bool_toggle_arrows(self, enabled: bool) -> Self {
self.map_palette(|mut palette| {
palette.bools = palette.bools.clone().with_toggle_with_arrows(enabled);
palette
})
}
pub fn with_bool_toggle_space(self, enabled: bool) -> Self {
self.map_palette(|mut palette| {
palette.bools = palette.bools.clone().with_toggle_with_space(enabled);
palette
})
}
pub fn with_enum_behaviour(mut self, behaviour: EnumBehaviour) -> Self {
let mut palette = (*self.component_palette).clone();
palette.enums = behaviour;
self.component_palette = Arc::new(palette);
self
}
pub fn with_enum_wrap(self, wrap: bool) -> Self {
self.map_palette(|mut palette| {
palette.enums = palette.enums.clone().with_wrap(wrap);
palette
})
}
pub fn with_collection_hints(mut self, hints: CollectionHints) -> Self {
let mut palette = (*self.component_palette).clone();
palette.collection = hints;
self.component_palette = Arc::new(palette);
self
}
pub fn with_overlay_instructions(self, instructions: impl Into<Cow<'static, str>>) -> Self {
let instructions = instructions.into();
self.map_palette(|mut palette| {
palette.collection = palette
.collection
.clone()
.with_overlay_instructions(instructions);
palette
})
}
pub fn with_list_hint(self, hint: impl Into<Cow<'static, str>>) -> Self {
let hint = hint.into();
self.map_palette(|mut palette| {
palette.collection = palette.collection.clone().with_list_hint(hint);
palette
})
}
pub fn with_composite_hints(mut self, hints: CompositeHints) -> Self {
let mut palette = (*self.component_palette).clone();
palette.composite = hints;
self.component_palette = Arc::new(palette);
self
}
pub fn with_composite_single_hint(self, hint: impl Into<Cow<'static, str>>) -> Self {
let hint = hint.into();
self.map_palette(|mut palette| {
palette.composite = palette.composite.clone().with_single_hint(hint);
palette
})
}
pub fn with_composite_multi_hint(self, hint: impl Into<Cow<'static, str>>) -> Self {
let hint = hint.into();
self.map_palette(|mut palette| {
palette.composite = palette.composite.clone().with_multi_hint(hint);
palette
})
}
pub fn component_palette(&self) -> Arc<ComponentPalette> {
Arc::clone(&self.component_palette)
}
fn map_palette(mut self, map: impl FnOnce(ComponentPalette) -> ComponentPalette) -> Self {
let updated = map((*self.component_palette).clone());
self.component_palette = Arc::new(updated);
self
}
}
+139
View File
@@ -0,0 +1,139 @@
use crate::{
tui::state::{CompositePopupData, FieldState},
tui::view::PopupRender,
};
pub(crate) struct PopupState {
field_pointer: String,
title: String,
options: Vec<String>,
selected: usize,
multi: bool,
toggles: Vec<bool>,
}
impl PopupState {
pub(crate) fn from_field(field: &FieldState) -> Option<Self> {
if let (Some(options), Some(states)) = (field.multi_options(), field.multi_states()) {
return Some(Self {
field_pointer: field.schema.pointer.clone(),
title: field.schema.display_label(),
options: options.to_vec(),
selected: 0,
multi: true,
toggles: states.to_vec(),
});
}
if let Some(value) = field.bool_value() {
return Some(Self {
field_pointer: field.schema.pointer.clone(),
title: field.schema.display_label(),
options: vec!["true".to_string(), "false".to_string()],
selected: if value { 0 } else { 1 },
multi: false,
toggles: Vec::new(),
});
}
if let Some(state) = field.enum_state() {
return Some(Self {
field_pointer: field.schema.pointer.clone(),
title: field.schema.display_label(),
options: state.options.to_vec(),
selected: state.selected,
multi: false,
toggles: Vec::new(),
});
}
field
.composite_popup()
.map(|data| Self::from_composite(field, data))
}
fn from_composite(field: &FieldState, data: CompositePopupData) -> Self {
Self {
field_pointer: field.schema.pointer.clone(),
title: field.schema.display_label(),
options: data.options,
selected: data.selected,
multi: data.multi,
toggles: data.active,
}
}
/// Create a popup for variant selector
pub(crate) fn for_variant_selector(
field_pointer: String,
title: String,
options: Vec<String>,
selected: usize,
) -> Self {
Self {
field_pointer,
title,
options,
selected,
multi: false,
toggles: Vec::new(),
}
}
pub(crate) fn select_previous(&mut self) {
if self.options.is_empty() {
return;
}
if self.selected == 0 {
self.selected = self.options.len().saturating_sub(1);
} else {
self.selected -= 1;
}
}
pub(crate) fn select_next(&mut self) {
if self.options.is_empty() {
return;
}
self.selected = (self.selected + 1) % self.options.len();
}
pub(crate) fn selection(&self) -> usize {
self.selected
}
pub(crate) fn pointer(&self) -> &str {
&self.field_pointer
}
pub(crate) fn is_multi(&self) -> bool {
self.multi
}
pub(crate) fn toggle_current(&mut self) {
if !self.multi {
return;
}
if let Some(flag) = self.toggles.get_mut(self.selected) {
*flag = !*flag;
}
}
pub(crate) fn active(&self) -> Option<&[bool]> {
if self.multi {
Some(&self.toggles)
} else {
None
}
}
pub(crate) fn as_render(&self) -> PopupRender<'_> {
PopupRender {
title: &self.title,
options: &self.options,
selected: self.selected,
multi: self.multi,
active: self.active(),
}
}
}
+319
View File
@@ -0,0 +1,319 @@
use super::{App, overlay::OverlayHost};
use crate::tui::state::{CompositePopupData, FieldState};
impl App {
pub(super) fn list_field_pointer(&self) -> Option<String> {
if let Some(editor) = self.active_overlay()
&& matches!(
editor.target(),
super::overlay::CompositeOverlayTarget::ListEntry { .. }
| super::overlay::CompositeOverlayTarget::KeyValueEntry { .. }
| super::overlay::CompositeOverlayTarget::ArrayEntry { .. }
)
{
return Some(editor.field_pointer().to_string());
}
self.form_state
.focused_field()
.filter(|field| field.is_composite_list())
.map(|field| field.schema.pointer.clone())
}
pub(super) fn handle_list_add_entry(&mut self) -> bool {
let Some(pointer) = self.list_field_pointer() else {
self.status
.set_raw("Focus a repeatable field before Ctrl+N add");
return false;
};
let overlay_host = self
.active_overlay()
.filter(|editor| editor.field_pointer() == pointer)
.map(|editor| editor.host());
let targeted_overlay = overlay_host.is_some();
if targeted_overlay && !self.save_active_overlay() {
return false;
}
// Check if we need to show variant selector
let field_ref = self.list_field_ref_for_host(&pointer, overlay_host);
if let Some(field) = field_ref {
let variant_count = field.composite_list_variant_count();
if variant_count > 1 {
// Multiple variants available, show selector popup
if let Some(popup_data) = field.composite_list_variant_selector_popup() {
self.show_variant_selector_popup(pointer.clone(), popup_data, overlay_host);
return true;
}
}
}
// Only one variant or no popup data, add entry directly
let selection_label = {
let Some(field) = self.list_field_mut_for_host(&pointer, overlay_host) else {
return false;
};
if field.composite_list_add_entry() {
field.composite_list_selected_label()
} else {
return false;
}
};
let status_message = selection_label
.map(|label| format!("Added entry {label}"))
.unwrap_or_else(|| "Added entry".to_string());
self.exit_armed = false;
self.status.value_updated();
if self.options.auto_validate {
self.run_validation(false);
}
if targeted_overlay {
self.close_active_overlay(false);
self.status.set_raw(status_message);
self.try_open_composite_editor();
return true;
}
self.status.set_raw(status_message);
self.refresh_list_overlay_panel();
self.run_overlay_validation();
true
}
fn show_variant_selector_popup(
&mut self,
field_pointer: String,
popup_data: CompositePopupData,
overlay_host: Option<OverlayHost>,
) {
use super::super::popup::PopupState;
use super::{AppPopup, PopupOwner};
let popup_state = PopupState::for_variant_selector(
field_pointer.clone(),
"Select variant type".to_string(),
popup_data.options,
popup_data.selected,
);
self.popup = Some(AppPopup {
owner: PopupOwner::VariantSelector {
field_pointer,
overlay_host,
},
state: popup_state,
});
self.status
.set_raw("Select variant type (↑/↓ to move, Enter to add, Esc to cancel)");
}
pub(super) fn handle_list_remove_entry(&mut self) -> bool {
let Some(pointer) = self.list_field_pointer() else {
self.status
.set_raw("Focus a repeatable field before Ctrl+D remove");
return false;
};
let overlay_host = self
.active_overlay()
.filter(|editor| editor.field_pointer() == pointer)
.map(|editor| editor.host());
let targeted_overlay = overlay_host.is_some();
if targeted_overlay && !self.save_active_overlay() {
return false;
}
let removed = {
let Some(field) = self.list_field_mut_for_host(&pointer, overlay_host) else {
return false;
};
if field.composite_list_remove_entry() {
field.composite_list_selected_label()
} else {
self.status.set_raw("No entry to remove");
return false;
}
};
// Determine whether the list is now empty. We treat the absence of a
// collection panel as meaning there are no entries left.
let list_is_empty_after = self
.list_field_ref_for_host(&pointer, overlay_host)
.map(|field| field.composite_list_panel().is_none())
.unwrap_or(true);
let status_message = removed
.map(|label| format!("Removed entry • now at {label}"))
.unwrap_or_else(|| "List is now empty".to_string());
self.exit_armed = false;
self.status.value_updated();
if self.options.auto_validate {
self.run_validation(false);
}
if targeted_overlay {
self.close_active_overlay(false);
self.status.set_raw(status_message);
// If there are still entries, reopen the editor on the updated
// selection. If the list is empty, stay at the parent level so the
// empty state is preserved instead of auto-creating a new entry.
if !list_is_empty_after {
self.try_open_composite_editor();
}
return true;
}
self.status.set_raw(status_message);
self.refresh_list_overlay_panel();
self.run_overlay_validation();
true
}
pub(super) fn handle_list_move_entry(&mut self, delta: i32) -> bool {
let Some(pointer) = self.list_field_pointer() else {
self.status
.set_raw("Focus a repeatable field before Ctrl+↑/↓ move");
return false;
};
let overlay_host = self
.active_overlay()
.filter(|editor| editor.field_pointer() == pointer)
.map(|editor| editor.host());
if overlay_host.is_some() && !self.save_active_overlay() {
return false;
}
let moved_label = {
let Some(field) = self.list_field_mut_for_host(&pointer, overlay_host) else {
return false;
};
if field.composite_list_move_entry(delta) {
field.composite_list_selected_label()
} else {
self.status.set_raw("Cannot move entry further");
return false;
}
};
self.exit_armed = false;
self.status.value_updated();
if let Some(label) = moved_label {
self.status.set_raw(format!("Moved entry to {}", label));
}
if self.options.auto_validate {
self.run_validation(false);
}
self.refresh_list_overlay_panel();
self.run_overlay_validation();
true
}
pub(super) fn handle_list_select_entry(&mut self, delta: i32) -> bool {
let Some(pointer) = self.list_field_pointer() else {
self.status
.set_raw("Focus a repeatable field before Ctrl+←/→ select");
return false;
};
let overlay_host = self
.active_overlay()
.filter(|editor| editor.field_pointer() == pointer)
.map(|editor| editor.host());
let targeted_overlay = overlay_host.is_some();
if targeted_overlay && !self.save_active_overlay() {
return false;
}
let changed = {
let Some(field) = self.list_field_mut_for_host(&pointer, overlay_host) else {
return false;
};
field.composite_list_select_entry(delta)
};
if !changed {
return false;
}
let status_message = if let Some(field) =
self.list_field_ref_for_host(&pointer, overlay_host)
&& let Some(label) = field.composite_list_selected_label()
{
format!("Selected entry {}", label)
} else {
"Selected entry".to_string()
};
self.exit_armed = false;
self.status.value_updated();
if targeted_overlay {
self.close_active_overlay(false);
self.status.set_raw(status_message);
self.try_open_composite_editor();
return true;
}
self.status.set_raw(status_message);
self.refresh_list_overlay_panel();
self.run_overlay_validation();
true
}
fn list_field_mut_for_host(
&mut self,
pointer: &str,
host: Option<OverlayHost>,
) -> Option<&mut FieldState> {
match host {
Some(target_host) => self
.host_form_state_mut(target_host)
.field_mut_by_pointer(pointer),
None => self.form_state.field_mut_by_pointer(pointer),
}
}
fn list_field_ref_for_host(
&self,
pointer: &str,
host: Option<OverlayHost>,
) -> Option<&FieldState> {
match host {
Some(target_host) => self.host_form_state(target_host).field_by_pointer(pointer),
None => self.form_state.field_by_pointer(pointer),
}
}
pub(super) fn handle_variant_selector_result(
&mut self,
field_pointer: &str,
overlay_host: Option<OverlayHost>,
variant_index: usize,
) {
let targeted_overlay = overlay_host.is_some();
let selection_label = {
let Some(field) = self.list_field_mut_for_host(field_pointer, overlay_host) else {
return;
};
if field.composite_list_add_entry_with_variant(variant_index) {
field.composite_list_selected_label()
} else {
return;
}
};
let status_message = selection_label
.map(|label| format!("Added entry {label}"))
.unwrap_or_else(|| "Added entry".to_string());
self.exit_armed = false;
self.status.value_updated();
if self.options.auto_validate {
self.run_validation(false);
}
if targeted_overlay {
self.close_active_overlay(false);
self.status.set_raw(status_message);
self.try_open_composite_editor();
return;
}
self.status.set_raw(status_message);
self.refresh_list_overlay_panel();
self.run_overlay_validation();
}
}
+903
View File
@@ -0,0 +1,903 @@
use crate::tui::model::FieldKind;
use crate::tui::state::{FormCommand, FormEngine, FormState};
use crate::tui::view::{
self, CompositeOverlay, HelpErrorRender, HelpOverlayPage, HelpOverlayRender,
HelpShortcutRender, UiContext,
};
use anyhow::{Result, anyhow};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use jsonschema::Validator;
use ratatui::layout::Rect;
use serde_json::Value;
use std::{collections::HashMap, sync::Arc};
use super::{
input::{AppCommand, CommandDispatch, InputRouter},
keymap::{KeymapContext, KeymapStore},
options::UiOptions,
popup::PopupState,
status::StatusLine,
terminal::TerminalGuard,
validation::{ValidationOutcome, validate_form},
};
mod list_ops;
mod overlay;
use overlay::CompositeEditorOverlay;
#[derive(Clone)]
pub(crate) enum PopupOwner {
Root,
Composite,
/// Variant selector for composite list add_entry
VariantSelector {
field_pointer: String,
overlay_host: Option<overlay::OverlayHost>,
},
}
struct AppPopup {
owner: PopupOwner,
state: PopupState,
}
pub(crate) struct App {
form_state: FormState,
validator: Validator,
options: UiOptions,
session_title: Option<String>,
status: StatusLine,
global_errors: Vec<String>,
validation_errors: usize,
exit_armed: bool,
should_quit: bool,
result: Option<Value>,
popup: Option<AppPopup>,
overlay_stack: Vec<CompositeEditorOverlay>,
overlay_validator_cache: HashMap<String, Arc<Validator>>,
input_router: InputRouter,
keymap_store: Arc<KeymapStore>,
help_overlay: Option<HelpOverlayState>,
}
struct HelpOverlayState {
pages: Vec<HelpOverlayPage>,
page: usize,
viewport: Rect,
shortcut_offset: usize,
error_offset: usize,
}
#[cfg(all(test, feature = "json"))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HelpOverlaySnapshot {
pub total_shortcuts: usize,
pub visible_shortcuts: usize,
pub shortcut_offset: usize,
pub error_offset: usize,
pub visible_errors: usize,
pub total_errors: usize,
pub current_page: usize,
pub total_pages: usize,
pub summary: String,
}
impl App {
fn current_help_text(&self) -> Option<String> {
if !self.options.show_help {
return None;
}
let contexts = self.current_help_contexts();
match contexts.as_slice() {
[context] => self.keymap_store.help_text(*context),
_ => self.keymap_store.help_text_for_contexts(&contexts),
}
}
fn current_help_contexts(&self) -> Vec<KeymapContext> {
if self.overlay_depth() > 0 {
let mut contexts = vec![KeymapContext::Overlay];
if let Some(field) = self
.active_overlay()
.and_then(|editor| editor.form_state().focused_field())
&& let Some(field_context) = field_help_context(&field.schema.kind)
{
contexts.push(field_context);
}
return contexts;
}
let Some(field) = self.form_state.focused_field() else {
return vec![KeymapContext::Default];
};
if field.is_composite_list() {
return vec![KeymapContext::Collection];
}
let mut contexts = vec![KeymapContext::Default];
if let Some(field_context) = field_help_context(&field.schema.kind) {
contexts.push(field_context);
}
contexts
}
fn help_overlay_sections(&self) -> Vec<(String, Vec<KeymapContext>)> {
let mut sections = vec![
("Form".to_string(), vec![KeymapContext::Default]),
("List".to_string(), vec![KeymapContext::Collection]),
("Overlay".to_string(), vec![KeymapContext::Overlay]),
("Help".to_string(), vec![KeymapContext::Help]),
];
if let Some(context) = self.current_help_contexts().into_iter().find(|context| {
matches!(
context,
KeymapContext::TextInput | KeymapContext::NumericInput
)
}) {
sections.push(("Field".to_string(), vec![context]));
}
sections
}
fn handle_popup_key(&mut self, key: KeyEvent) -> Result<bool> {
if let Some(app_popup) = &mut self.popup {
let popup = &mut app_popup.state;
match key.code {
KeyCode::Esc => {
self.popup = None;
self.status.ready();
}
KeyCode::Up => popup.select_previous(),
KeyCode::Down => popup.select_next(),
KeyCode::Char(' ') if popup.is_multi() => {
popup.toggle_current();
return Ok(true);
}
KeyCode::Enter => {
let (pointer, selection, multi_flags) = {
let pointer = popup.pointer().to_string();
let selection = popup.selection();
let multi_flags = popup.active().map(|flags| flags.to_vec());
(pointer, selection, multi_flags)
};
let owner = app_popup.owner.clone();
self.popup = None;
self.apply_popup_selection_data(owner, &pointer, selection, multi_flags);
if self.options.auto_validate {
self.run_validation(false);
}
self.status.value_updated();
}
_ => {}
}
return Ok(true);
}
Ok(false)
}
fn dispatch_form_command(&mut self, command: FormCommand) {
let mut engine = FormEngine::new(&mut self.form_state, &self.validator);
if let Err(message) = engine.dispatch(command) {
self.status.set_raw(&message);
}
self.validation_errors = self.form_state.error_count();
}
fn handle_app_command(&mut self, command: AppCommand) -> bool {
match command {
AppCommand::Save => {
self.exit_armed = false;
if self.overlay_depth() > 0 {
let _ = self.save_active_overlay();
} else {
self.on_save();
}
}
AppCommand::Quit => {
if self.overlay_depth() > 0 {
self.request_overlay_exit();
return true;
}
self.on_exit();
}
AppCommand::ResetStatus => {
self.exit_armed = false;
self.status.ready();
}
AppCommand::ShowHelp => {
self.toggle_help_overlay();
return true;
}
AppCommand::TogglePopup => {
if self.try_open_popup(PopupOwner::Root) {
return true;
}
}
AppCommand::EditComposite => {
self.try_open_composite_editor();
}
AppCommand::ListAddEntry => {
if self.handle_list_add_entry() {
return true;
}
}
AppCommand::ListRemoveEntry => {
if self.handle_list_remove_entry() {
return true;
}
}
AppCommand::ListMove(delta) => {
if self.handle_list_move_entry(delta) {
return true;
}
}
AppCommand::ListSelect(delta) => {
if self.handle_list_select_entry(delta) {
return true;
}
}
}
false
}
fn toggle_help_overlay(&mut self) {
if self.help_overlay.is_some() {
self.help_overlay = None;
return;
}
let viewport = Rect::new(0, 0, 0, 0);
let pages = self.build_help_overlay_pages(viewport);
if pages.is_empty() {
return;
}
// Help overlay is global and should not compete with other popups.
self.popup = None;
self.help_overlay = Some(HelpOverlayState {
pages,
page: 0,
viewport,
shortcut_offset: 0,
error_offset: 0,
});
}
fn handle_help_overlay_key(&mut self, key: &KeyEvent) -> bool {
let action = self
.input_router
.classify_for_contexts(key, &[KeymapContext::Help]);
if matches!(action, super::input::KeyAction::HelpClose) {
if self.help_overlay.is_none() {
return false;
}
self.help_overlay = None;
return true;
}
let Some(state) = self.help_overlay.as_mut() else {
return false;
};
let shortcut_capacity = view::help_overlay_panel_capacities(state.viewport).shortcuts;
let shortcut_max_offset = state
.pages
.first()
.map(|page| page.shortcuts.len().saturating_sub(shortcut_capacity))
.unwrap_or(0);
let error_message_width = view::help_overlay_error_message_capacity(state.viewport);
let current_page = state.page.min(state.pages.len().saturating_sub(1));
let error_max_offset = state
.pages
.get(current_page)
.map(|page| help_page_error_max_offset(page, error_message_width))
.unwrap_or(0);
match action {
super::input::KeyAction::HelpPageStep(delta) => {
let total = state.pages.len();
if total > 0 {
let next = (state.page as i32 + delta).rem_euclid(total as i32) as usize;
state.page = next;
}
let page = state.page.min(state.pages.len().saturating_sub(1));
state.error_offset = state
.pages
.get(page)
.map(|entry| {
state
.error_offset
.min(help_page_error_max_offset(entry, error_message_width))
})
.unwrap_or(0);
}
super::input::KeyAction::HelpShortcutScroll(delta) => {
if delta < 0 {
state.shortcut_offset = state
.shortcut_offset
.saturating_sub(delta.unsigned_abs() as usize);
} else {
state.shortcut_offset =
(state.shortcut_offset + delta as usize).min(shortcut_max_offset);
}
}
super::input::KeyAction::HelpShortcutPage(delta) => {
let step = shortcut_capacity.saturating_mul(delta.unsigned_abs() as usize);
if delta < 0 {
state.shortcut_offset = state.shortcut_offset.saturating_sub(step);
} else {
state.shortcut_offset = (state.shortcut_offset + step).min(shortcut_max_offset);
}
}
super::input::KeyAction::HelpShortcutHome => {
state.shortcut_offset = 0;
}
super::input::KeyAction::HelpShortcutEnd => {
state.shortcut_offset = shortcut_max_offset;
}
super::input::KeyAction::HelpErrorScroll(delta) => {
if delta < 0 {
state.error_offset = state
.error_offset
.saturating_sub(delta.unsigned_abs() as usize);
} else {
state.error_offset =
(state.error_offset + delta as usize).min(error_max_offset);
}
}
_ => {}
}
true
}
fn build_help_overlay_pages(&self, viewport: Rect) -> Vec<HelpOverlayPage> {
let errors_per_page = view::help_overlay_error_page_capacity(viewport);
let mut shortcuts: Vec<HelpShortcutRender> = Vec::new();
let sections = self.help_overlay_sections();
for (scope, contexts) in sections {
let entries = match contexts.as_slice() {
[context] => self.keymap_store.help_entries(*context),
_ => self.keymap_store.help_entries_for_contexts(&contexts),
};
for entry in entries {
shortcuts.push(HelpShortcutRender {
scope: scope.clone(),
keys: entry.keys,
action: entry.action,
});
}
}
let mut errors: Vec<HelpErrorRender> = Vec::new();
for (pointer, message) in self.form_state.error_entries() {
errors.push(HelpErrorRender {
index: errors.len() + 1,
pointer,
message,
});
}
for msg in &self.global_errors {
errors.push(HelpErrorRender {
index: errors.len() + 1,
pointer: "<global>".to_string(),
message: msg.clone(),
});
}
let total_errors = errors.len();
if total_errors == 0 && shortcuts.is_empty() {
return Vec::new();
}
let total_pages = if total_errors == 0 {
1
} else {
total_errors.div_ceil(errors_per_page)
};
let mut pages: Vec<HelpOverlayPage> = Vec::with_capacity(total_pages.max(1));
for page_idx in 0..total_pages {
let start = page_idx * errors_per_page;
let end = (start + errors_per_page).min(total_errors);
let page_errors = if total_errors == 0 {
Vec::new()
} else {
errors[start..end].to_vec()
};
pages.push(HelpOverlayPage {
summary:
"All shortcuts stay visible here; only the error list paginates to fit terminal height."
.to_string(),
current_page: page_idx + 1,
total_pages,
shortcuts: shortcuts.clone(),
errors: page_errors,
total_errors,
});
}
pages
}
fn refresh_help_overlay_pages(&mut self, viewport: Rect) {
let Some(current_page) = self.help_overlay.as_ref().map(|overlay| overlay.page) else {
return;
};
let pages = self.build_help_overlay_pages(viewport);
let shortcut_capacity = view::help_overlay_panel_capacities(viewport).shortcuts;
let error_message_width = view::help_overlay_error_message_capacity(viewport);
let shortcut_offset = self
.help_overlay
.as_ref()
.map(|overlay| overlay.shortcut_offset)
.unwrap_or(0);
let error_offset = self
.help_overlay
.as_ref()
.map(|overlay| overlay.error_offset)
.unwrap_or(0);
let next_page = if pages.is_empty() {
0
} else {
current_page.min(pages.len().saturating_sub(1))
};
let next_shortcut_offset = pages
.first()
.map(|page| shortcut_offset.min(page.shortcuts.len().saturating_sub(shortcut_capacity)))
.unwrap_or(0);
let next_error_offset = pages
.get(next_page)
.map(|page| error_offset.min(help_page_error_max_offset(page, error_message_width)))
.unwrap_or(0);
if let Some(overlay) = self.help_overlay.as_mut() {
overlay.viewport = viewport;
overlay.pages = pages;
overlay.page = next_page;
overlay.shortcut_offset = next_shortcut_offset;
overlay.error_offset = next_error_offset;
}
}
fn handle_field_input(&mut self, event: &KeyEvent) {
if let Some(field) = self.form_state.focused_field_mut()
&& field.handle_key(event)
{
let pointer = field.schema.pointer.clone();
self.exit_armed = false;
self.status.editing(&field.schema.display_label());
if self.options.auto_validate {
self.dispatch_form_command(FormCommand::FieldEdited { pointer });
}
}
}
pub fn new(form_state: FormState, validator: Validator, options: UiOptions) -> Self {
let keymap_store = options.keymap_store.clone();
Self {
form_state,
validator,
options,
session_title: None,
status: StatusLine::new(),
global_errors: Vec::new(),
validation_errors: 0,
exit_armed: false,
should_quit: false,
result: None,
popup: None,
overlay_stack: Vec::new(),
overlay_validator_cache: HashMap::new(),
input_router: InputRouter::new(keymap_store.clone()),
keymap_store,
help_overlay: None,
}
}
pub fn set_session_title(&mut self, title: Option<String>) {
self.session_title = title;
}
pub fn run(&mut self) -> Result<Value> {
let mut terminal = TerminalGuard::new()?;
while !self.should_quit {
terminal.autoresize()?;
terminal.draw(|frame| self.draw(frame))?;
if !event::poll(self.options.tick_rate)? {
continue;
}
match event::read()? {
Event::Key(key) => self.handle_key(key)?,
Event::Resize(width, height) => {
terminal.resize(Rect::new(0, 0, width, height))?;
continue;
}
Event::Mouse(_) => {}
Event::FocusGained | Event::FocusLost | Event::Paste(_) => {}
}
}
if let Some(value) = self.result.take() {
Ok(value)
} else {
Err(anyhow!("user exited without saving"))
}
}
fn draw(&mut self, frame: &mut ratatui::Frame<'_>) {
let help = self.current_help_text();
let form_dirty = self.form_state.is_dirty();
self.refresh_help_overlay_pages(frame.area());
let help_overlay_render = self.help_overlay.as_ref().and_then(|state| {
if state.pages.is_empty() {
return None;
}
let total = state.pages.len();
let idx = state.page.min(total.saturating_sub(1));
let page = &state.pages[idx];
Some(HelpOverlayRender {
page,
shortcut_offset: state.shortcut_offset,
error_offset: state.error_offset,
})
});
if let Some(editor) = self.overlay_stack.last_mut() {
let child = editor
.form_state()
.focused_field()
.map(|field| field.schema.display_label())
.unwrap_or_else(|| "<none>".to_string());
let focus_label = Some(format!("{} › {}", editor.field_label(), child));
let overlay_meta = CompositeOverlay {
title: editor.title().to_string(),
description: editor.description().cloned(),
dirty: editor.form_state().is_dirty(),
instructions: editor.instructions().to_string(),
list_entries: editor.entry_tabs_entries().map(|entries| entries.to_vec()),
list_selected: editor.entry_tabs_selected(),
entry_label: editor.entry_tabs_label().map(|label| label.to_string()),
level: editor.level(),
};
let overlay_form_state = editor.form_state_mut();
view::draw(
frame,
&mut self.form_state,
Some(overlay_form_state),
UiContext {
status_message: self.status.message(),
dirty: form_dirty,
error_count: self.validation_errors,
help: help.as_deref(),
global_errors: &self.global_errors,
focus_label,
session_title: self.session_title.as_deref(),
popup: self.popup.as_ref().map(|popup| popup.state.as_render()),
composite_overlay: Some(overlay_meta),
help_overlay: help_overlay_render,
},
);
return;
}
let focus_label = self
.form_state
.focused_field()
.map(|field| field.schema.display_label());
view::draw(
frame,
&mut self.form_state,
None,
UiContext {
status_message: self.status.message(),
dirty: form_dirty,
error_count: self.validation_errors,
help: help.as_deref(),
global_errors: &self.global_errors,
focus_label,
session_title: self.session_title.as_deref(),
popup: self.popup.as_ref().map(|popup| popup.state.as_render()),
composite_overlay: None,
help_overlay: help_overlay_render,
},
);
}
fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
if key.kind != KeyEventKind::Press {
return Ok(());
}
if self.handle_help_overlay_key(&key) {
return Ok(());
}
if self.handle_popup_key(key)? {
return Ok(());
}
if self.overlay_depth() > 0 {
self.handle_composite_editor_key(key)?;
return Ok(());
}
let dispatch = self
.options
.keymap
.resolve(self.input_router.classify(&key));
match dispatch {
CommandDispatch::Form(command) => {
self.dispatch_form_command(command);
self.exit_armed = false;
}
CommandDispatch::App(command) => {
if self.handle_app_command(command) {
return Ok(());
}
}
CommandDispatch::Input(event) => {
self.handle_field_input(&event);
}
CommandDispatch::None => {}
}
Ok(())
}
fn try_open_popup(&mut self, owner: PopupOwner) -> bool {
if self.popup.is_some() {
return true;
}
match &owner {
PopupOwner::Root => {
if let Some(field) = self.form_state.focused_field_mut() {
field.ensure_composite_list_popup_entry();
}
}
PopupOwner::Composite => {
if let Some(editor) = self.active_overlay_mut()
&& let Some(field) = editor.form_state_mut().focused_field_mut()
{
field.ensure_composite_list_popup_entry();
}
}
PopupOwner::VariantSelector { .. } => {
// Variant selector popup is created directly, no field preparation needed
}
}
let field_opt = match &owner {
PopupOwner::Root => self.form_state.focused_field(),
PopupOwner::Composite => self
.active_overlay()
.and_then(|editor| editor.form_state().focused_field()),
PopupOwner::VariantSelector { .. } => {
// Variant selector popup doesn't use focused field
return false;
}
};
let Some(field) = field_opt else {
return false;
};
if let Some(popup) = PopupState::from_field(field) {
let message = if popup.is_multi() {
"Use ↑/↓ to move, Space to toggle, Enter to apply"
} else {
"Use ↑/↓ and Enter to choose"
};
self.status.set_raw(message);
self.popup = Some(AppPopup {
owner,
state: popup,
});
return true;
}
false
}
fn on_save(&mut self) {
if let Some(value) = self.run_validation(true) {
self.status
.set_raw("Configuration saved. Press Ctrl+Q to exit.");
self.result = Some(value);
self.form_state.mark_clean();
self.exit_armed = false;
}
}
fn on_exit(&mut self) {
// First attempt: validate the full form. If it passes, treat Ctrl+Q
// as a final save-and-exit, even if the user never pressed Ctrl+S.
if !self.exit_armed {
if let Some(value) = self.run_validation(true) {
self.result = Some(value);
self.form_state.mark_clean();
self.should_quit = true;
self.exit_armed = false;
return;
}
// Validation failed. When confirm_exit is enabled, require a
// second Ctrl+Q to force exit without saving the current
// (invalid) state. The last successfully validated result, if
// any, is preserved.
if self.options.confirm_exit {
self.exit_armed = true;
self.status.pending_exit();
return;
}
// confirm_exit disabled: exit immediately without updating
// result; callers will see "user exited without saving" unless a
// previous successful save populated result.
self.should_quit = true;
return;
}
// Second Ctrl+Q after a failed validation: force exit without
// changing result. This mirrors the behavior of quitting without a
// successful save.
self.should_quit = true;
}
fn run_validation(&mut self, announce: bool) -> Option<Value> {
match validate_form(&mut self.form_state, &self.validator) {
ValidationOutcome::Valid(value) => {
self.global_errors.clear();
self.validation_errors = 0;
if announce {
self.status.validation_passed();
}
Some(value)
}
ValidationOutcome::Invalid {
issues,
global_errors,
} => {
self.global_errors = global_errors;
self.validation_errors = issues;
if announce {
self.status.issues_remaining(issues);
}
None
}
ValidationOutcome::BuildError { message } => {
self.global_errors = vec![message.clone()];
self.validation_errors = 1;
self.status.set_raw(message);
None
}
}
}
}
#[cfg(all(test, feature = "json"))]
impl App {
pub(crate) fn form_state_mut_for_test(&mut self) -> &mut FormState {
&mut self.form_state
}
pub(crate) fn handle_key_for_test(&mut self, key: KeyEvent) -> Result<()> {
self.handle_key(key)
}
pub(crate) fn toggle_help_overlay_for_test(&mut self, viewport: Rect) {
self.toggle_help_overlay();
self.refresh_help_overlay_pages(viewport);
}
pub(crate) fn current_help_text_for_test(&self) -> Option<String> {
self.current_help_text()
}
pub(crate) fn help_overlay_snapshot_for_test(
&mut self,
viewport: Rect,
) -> Option<HelpOverlaySnapshot> {
self.refresh_help_overlay_pages(viewport);
let capacities = view::help_overlay_panel_capacities(viewport);
let overlay = self.help_overlay.as_ref()?;
let idx = overlay.page.min(overlay.pages.len().saturating_sub(1));
let page = overlay.pages.get(idx)?;
let visible_shortcuts = page
.shortcuts
.len()
.saturating_sub(overlay.shortcut_offset)
.min(capacities.shortcuts);
Some(HelpOverlaySnapshot {
total_shortcuts: page.shortcuts.len(),
visible_shortcuts,
shortcut_offset: overlay.shortcut_offset,
error_offset: overlay.error_offset,
visible_errors: page.errors.len(),
total_errors: page.total_errors,
current_page: page.current_page,
total_pages: page.total_pages,
summary: page.summary.clone(),
})
}
}
fn field_help_context(kind: &FieldKind) -> Option<KeymapContext> {
match kind {
FieldKind::String | FieldKind::Json | FieldKind::Nullable(_) => {
Some(KeymapContext::TextInput)
}
FieldKind::Integer | FieldKind::Number => Some(KeymapContext::NumericInput),
_ => None,
}
}
fn help_page_error_max_offset(page: &HelpOverlayPage, message_width: usize) -> usize {
if message_width == 0 {
return 0;
}
page.errors
.iter()
.map(|entry| entry.message.chars().count().saturating_sub(message_width))
.max()
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
tui::{app::options::UiOptions, model::form_schema_from_ui_ast},
ui_ast::build_ui_ast,
};
use jsonschema::validator_for;
use serde_json::json;
fn app_with_single_field() -> App {
let schema = json!({
"type": "object",
"properties": {
"name": {"type": "string"}
}
});
let ast = build_ui_ast(&schema).expect("ui ast");
let form_schema = form_schema_from_ui_ast(&ast);
let form_state = FormState::from_schema(&form_schema);
let validator = validator_for(&schema).expect("validator");
App::new(form_state, validator, UiOptions::default())
}
#[test]
fn exit_after_save_keeps_last_saved_result() {
let mut app = app_with_single_field();
app.on_save();
assert!(app.result.is_some(), "save should populate result");
assert_eq!(
app.status.message(),
"Configuration saved. Press Ctrl+Q to exit."
);
assert!(
!app.form_state.is_dirty(),
"successful save should clear dirty flags"
);
assert!(!app.exit_armed, "save should reset exit confirmation");
app.on_exit();
assert!(app.result.is_some(), "exiting should keep last saved value");
assert!(app.should_quit, "exit flag should be set");
}
#[test]
fn exit_without_explicit_save_validates_and_sets_result() {
let mut app = app_with_single_field();
app.on_exit();
assert!(
app.result.is_some(),
"Ctrl+Q should validate and produce a result for a valid form"
);
}
}
@@ -0,0 +1,76 @@
use crate::tui::app::runtime::App;
use crate::tui::app::runtime::overlay::editor::CompositeEditorOverlay;
use crate::tui::app::runtime::overlay::state::OverlayHost;
use crate::tui::state::FormState;
impl App {
pub(crate) fn overlay_depth(&self) -> usize {
self.overlay_stack.len()
}
pub(crate) fn active_overlay(&self) -> Option<&CompositeEditorOverlay> {
self.overlay_stack.last()
}
pub(crate) fn active_overlay_mut(&mut self) -> Option<&mut CompositeEditorOverlay> {
self.overlay_stack.last_mut()
}
pub(super) fn overlay_help_text(&self) -> String {
let base = self
.current_help_text()
.unwrap_or_else(|| "Ctrl+S save • Esc/Ctrl+Q exit overlay".to_string());
if let Some(editor) = self.active_overlay() {
format!("L{} • {}", editor.level(), base)
} else {
base
}
}
pub(super) fn set_overlay_status_message(&mut self) {
if let Some(editor) = self.active_overlay() {
let help = self.overlay_help_text();
self.status
.set_raw(format!("Overlay {}: {}", editor.level(), help));
}
}
pub(crate) fn host_form_state(&self, host: OverlayHost) -> &FormState {
match host {
OverlayHost::RootForm => &self.form_state,
OverlayHost::Overlay { parent_level } => {
let idx = parent_level.saturating_sub(1);
self.overlay_stack[idx].form_state()
}
}
}
pub(crate) fn host_form_state_mut(&mut self, host: OverlayHost) -> &mut FormState {
match host {
OverlayHost::RootForm => &mut self.form_state,
OverlayHost::Overlay { parent_level } => {
let idx = parent_level.saturating_sub(1);
self.overlay_stack
.get_mut(idx)
.expect("overlay host should exist")
.form_state_mut()
}
}
}
pub(super) fn initialize_active_overlay(&mut self) {
self.set_overlay_status_message();
self.refresh_list_overlay_panel();
self.setup_overlay_validator();
self.run_overlay_validation();
self.reset_overlay_focus_mode();
}
pub(super) fn reset_overlay_focus_mode(&mut self) {
if let Some(editor) = self.active_overlay_mut()
&& !editor.focus_entries()
{
editor.focus_form_first();
}
}
}
@@ -0,0 +1,118 @@
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
use crate::tui::app::input::{AppCommand, CommandDispatch};
use crate::tui::app::runtime::overlay::app::open::overlay_field_input_result;
use crate::tui::app::runtime::{App, PopupOwner};
use crate::tui::state::apply_command;
impl App {
pub(crate) fn handle_composite_editor_key(&mut self, key: KeyEvent) -> Result<()> {
if key.code == KeyCode::Esc {
if !self.request_overlay_exit() {
return Ok(());
}
return Ok(());
}
let dispatch = self
.options
.keymap
.resolve(self.input_router.classify(&key));
match dispatch {
CommandDispatch::Form(command) => {
if self.handle_overlay_focus_command(&command) {
return Ok(());
}
if let Some(editor) = self.active_overlay_mut() {
editor.set_exit_armed(false);
apply_command(editor.form_state_mut(), command.clone());
self.run_overlay_validation();
}
}
CommandDispatch::App(command) => {
if self.handle_overlay_app_command(command)? {
return Ok(());
}
}
CommandDispatch::Input(event) => {
self.handle_overlay_field_input(&event);
}
CommandDispatch::None => {}
}
Ok(())
}
fn handle_overlay_field_input(&mut self, event: &KeyEvent) {
let Some(result) = ({
let editor = match self.active_overlay_mut() {
Some(editor) => editor,
None => return,
};
editor.set_exit_armed(false);
overlay_field_input_result(editor, event)
}) else {
return;
};
let (parent_label, child_label, pointer) = result;
self.status
.editing(&format!("{parent_label} › {child_label}"));
self.validate_overlay_field(pointer);
}
pub(crate) fn handle_overlay_app_command(&mut self, command: AppCommand) -> Result<bool> {
match command {
AppCommand::Save => {
self.save_overlay_stack_to_root();
return Ok(true);
}
AppCommand::Quit => {
self.request_overlay_exit();
return Ok(true);
}
AppCommand::EditComposite => {
self.try_open_composite_editor();
return Ok(true);
}
AppCommand::ShowHelp => {
self.toggle_help_overlay();
return Ok(true);
}
AppCommand::TogglePopup => {
if self.try_open_popup(PopupOwner::Composite) {
return Ok(true);
}
}
AppCommand::ResetStatus => {
self.status.ready();
if let Some(editor) = self.active_overlay_mut() {
editor.set_exit_armed(false);
}
return Ok(true);
}
AppCommand::ListAddEntry => {
if self.handle_list_add_entry() {
return Ok(true);
}
}
AppCommand::ListRemoveEntry => {
if self.handle_list_remove_entry() {
return Ok(true);
}
}
AppCommand::ListMove(delta) => {
if self.handle_list_move_entry(delta) {
return Ok(true);
}
}
AppCommand::ListSelect(delta) => {
if self.handle_list_select_entry(delta) {
return Ok(true);
}
}
}
Ok(false)
}
}
@@ -0,0 +1,190 @@
use crate::tui::app::runtime::App;
use crate::tui::app::runtime::overlay::state::{
CompositeOverlayTarget, EntryAdvance, OverlaySession,
};
impl App {
pub(super) fn advance_overlay_entry(&mut self, delta: i32) -> bool {
let (action, field_pointer, host) = {
let editor = match self.active_overlay_mut() {
Some(editor) => editor,
None => return false,
};
if !editor.can_focus_entries() {
return false;
}
if editor.entry_tabs_snapshot().is_none() {
return false;
}
let pointer = editor.field_pointer().to_string();
let host = editor.host();
let Some(next) = editor.advance_entry_tab(delta) else {
editor.set_exit_armed(false);
if !editor.focus_entries() {
editor.focus_form_first();
}
return true;
};
(next, pointer, host)
};
match action {
EntryAdvance::Variant { variant_index } => self.switch_overlay_variant(variant_index),
EntryAdvance::Collection {
selected: next_index,
} => {
let previous_depth = self.overlay_depth();
self.close_active_overlay(true);
if self.overlay_depth() != previous_depth.saturating_sub(1) {
return false;
}
let (changed, label) = {
let host_state = self.host_form_state_mut(host);
let Some(field) = host_state.field_mut_by_pointer(&field_pointer) else {
return false;
};
let changed = field.collection_set_selected(next_index);
let label = field.collection_selected_label();
(changed, label)
};
self.exit_armed = false;
self.status.value_updated();
if let Some(label) = label {
self.status.set_raw(format!("Selected entry {}", label));
} else if !changed {
self.status.ready();
}
let expected_depth = previous_depth;
self.try_open_composite_editor();
if self.overlay_depth() != expected_depth {
return false;
}
if let Some(editor) = self.active_overlay_mut() {
editor.set_exit_armed(false);
if !editor.focus_entries() {
editor.focus_form_first();
}
}
true
}
}
}
pub(super) fn switch_overlay_variant(&mut self, variant_index: usize) -> bool {
let (field_pointer, host) = {
let editor = match self.active_overlay() {
Some(editor) => editor,
None => return false,
};
match editor.session() {
OverlaySession::Composite(_) => {}
_ => return false,
}
let current = editor.current_variant_index().unwrap_or(variant_index);
if current == variant_index {
return true;
}
(editor.field_pointer().to_string(), editor.host())
};
let old_session = {
let editor = match self.active_overlay_mut() {
Some(editor) => editor,
None => return false,
};
match editor.take_composite_session() {
Some(session) => session,
None => return false,
}
};
let host_state = self.host_form_state_mut(host);
let Some(field) = host_state.field_mut_by_pointer(&field_pointer) else {
if let Some(editor) = self.active_overlay_mut() {
editor.replace_composite_session(old_session);
}
return false;
};
let old_index = old_session.variant_index;
field.close_composite_editor(old_session, false);
let new_session = match field.open_composite_editor(variant_index) {
Ok(session) => session,
Err(err) => {
if let Ok(restored) = field.open_composite_editor(old_index)
&& let Some(editor) = self.active_overlay_mut()
{
editor.replace_composite_session(restored);
editor.sync_variant_selection(old_index);
}
self.status.set_raw(&err.message);
return false;
}
};
if let Some(editor) = self.active_overlay_mut() {
editor.replace_composite_session(new_session);
editor.sync_variant_selection(variant_index);
editor.set_exit_armed(false);
if !editor.focus_entries() {
editor.focus_form_first();
}
}
self.exit_armed = false;
self.status
.set_raw(format!("Switched to variant #{}", variant_index + 1));
self.setup_overlay_validator();
self.run_overlay_validation();
true
}
pub(crate) fn refresh_list_overlay_panel(&mut self) {
let Some(mut overlay) = self.overlay_stack.pop() else {
return;
};
if !overlay.needs_list_panel() {
self.overlay_stack.push(overlay);
return;
}
let data = {
let host_state = self.host_form_state(overlay.host());
host_state
.field_by_pointer(overlay.field_pointer())
.map(|field| {
(
field.composite_list_panel(),
field.composite_list_selected_label(),
field.composite_list_selected_index(),
)
})
};
if let Some((panel, label, idx)) = data {
if let Some((entries, selected)) = panel {
overlay.set_entry_tabs(entries, selected);
}
if let Some(label) = label {
let field_label = overlay.field_label().to_string();
overlay.store.update_title(&field_label, &label);
overlay.store.set_description(Some(label));
}
if let Some(index) = idx {
match overlay.target_mut() {
CompositeOverlayTarget::ListEntry { entry_index }
| CompositeOverlayTarget::KeyValueEntry { entry_index }
| CompositeOverlayTarget::ArrayEntry { entry_index } => {
*entry_index = index;
}
_ => {}
}
}
}
self.overlay_stack.push(overlay);
}
}
@@ -0,0 +1,246 @@
use anyhow::Result;
#[cfg(all(test, feature = "json"))]
use crate::tui::app::runtime::overlay::state::OverlayFocusMode;
use crate::tui::app::runtime::App;
use crate::tui::app::validation::{ValidationOutcome, validate_form};
use crate::tui::state::FormCommand;
use super::editor::CompositeEditorOverlay;
use super::state::{FocusDirection, FocusOutcome};
mod core;
mod input;
mod list_ops;
mod open;
mod popup_ops;
mod validation;
const MSG_NO_FIELD_SELECTED: &str = "No field selected";
const MSG_SELECT_VARIANT_BEFORE_EDIT: &str =
"Select a variant via Enter before editing (oneOf/anyOf)";
const MSG_UNABLE_AUTO_CREATE_ENTRY: &str = "Unable to auto-create the first entry; use Ctrl+N";
const MSG_FOCUS_COMPOSITE_BEFORE_EDITING: &str =
"Focus a composite or composite list field before editing";
const MSG_OVERLAY_DIRTY_CONFIRM_EXIT: &str = "Overlay dirty. Press Esc again to discard changes.";
impl App {
fn validate_overlay_before_commit(
overlay: &mut CompositeEditorOverlay,
) -> std::result::Result<(), String> {
let Some(validator) = overlay.validator_clone() else {
return Ok(());
};
match validate_form(overlay.form_state_mut(), &validator) {
ValidationOutcome::Valid(_) => Ok(()),
ValidationOutcome::Invalid {
issues,
global_errors,
} => Err(global_errors
.into_iter()
.next()
.unwrap_or_else(|| format!("{issues} issue(s) remaining"))),
ValidationOutcome::BuildError { message } => Err(message),
}
}
pub(crate) fn close_active_overlay(&mut self, commit: bool) {
let Some(mut overlay) = self.overlay_stack.pop() else {
return;
};
self.overlay_validator_cache
.remove(&overlay.validator_cache_key());
self.popup = None;
if commit {
match self.apply_overlay_commit(&overlay) {
Ok(()) => {
overlay.form_state_mut().mark_clean();
overlay.set_exit_armed(false);
self.exit_armed = false;
self.status.value_updated();
if overlay.level() == 1 && self.options.auto_validate {
self.run_validation(false);
} else {
self.run_overlay_validation();
}
}
Err(message) => {
self.status.set_raw(&message);
self.overlay_stack.push(overlay);
return;
}
}
} else {
self.status.ready();
}
if let Some(parent) = self.active_overlay_mut() {
parent.set_exit_armed(false);
self.set_overlay_status_message();
self.refresh_list_overlay_panel();
self.run_overlay_validation();
}
}
fn apply_overlay_commit(&mut self, overlay: &CompositeEditorOverlay) -> Result<(), String> {
let payload = overlay.build_commit_payload();
let host_state = self.host_form_state_mut(payload.host());
payload.apply(host_state)
}
fn handle_overlay_focus_command(&mut self, command: &FormCommand) -> bool {
if !matches!(
command,
FormCommand::FocusNextField | FormCommand::FocusPrevField
) {
return false;
}
let direction = match command {
FormCommand::FocusNextField => FocusDirection::Forward,
FormCommand::FocusPrevField => FocusDirection::Backward,
_ => return false,
};
let outcome = {
let editor = match self.active_overlay_mut() {
Some(editor) => editor,
None => return false,
};
editor.set_exit_armed(false);
editor.advance_focus(direction)
};
match outcome {
FocusOutcome::Consumed => true,
FocusOutcome::RequestEntryDelta(delta) => self.advance_overlay_entry(delta),
FocusOutcome::PassThrough => false,
}
}
pub(crate) fn request_overlay_exit(&mut self) -> bool {
if let Some(editor) = self.active_overlay_mut()
&& editor.dirty()
&& !editor.exit_armed()
{
editor.set_exit_armed(true);
self.status.set_raw(MSG_OVERLAY_DIRTY_CONFIRM_EXIT);
return false;
}
self.close_active_overlay(false);
true
}
pub(crate) fn save_active_overlay(&mut self) -> bool {
let Some(mut overlay) = self.overlay_stack.pop() else {
return false;
};
if let Err(message) = Self::validate_overlay_before_commit(&mut overlay) {
self.status.set_raw(&message);
self.overlay_stack.push(overlay);
return false;
}
match self.apply_overlay_commit(&overlay) {
Ok(()) => {
overlay.form_state_mut().mark_clean();
overlay.set_exit_armed(false);
self.status
.set_raw(format!("Overlay {} saved.", overlay.level()));
if overlay.level() == 1 && self.options.auto_validate {
self.run_validation(false);
} else {
self.run_overlay_validation();
}
self.overlay_stack.push(overlay);
self.set_overlay_status_message();
self.refresh_list_overlay_panel();
true
}
Err(message) => {
self.status.set_raw(&message);
self.overlay_stack.push(overlay);
false
}
}
}
/// Save the entire overlay stack into the root form state.
///
/// This is used for user-initiated saves (Ctrl+S) while editing inside
/// one or more overlays. It walks from the deepest overlay up to the
/// top-level overlay, committing each overlay's session into its host
/// form state. This ensures that changes made in nested overlays are
/// persisted even if intermediate overlays are later closed without an
/// additional save.
pub(crate) fn save_overlay_stack_to_root(&mut self) -> bool {
let depth = self.overlay_stack.len();
if depth == 0 {
return true;
}
// Commit overlays from deepest (highest level) back towards the root.
for idx in (0..depth).rev() {
if let Err(message) = Self::validate_overlay_before_commit(&mut self.overlay_stack[idx])
{
self.status.set_raw(&message);
return false;
}
// Build commit payload without borrowing the overlay mutably while
// we also mutate the host form state.
let payload = {
let overlay = &self.overlay_stack[idx];
overlay.build_commit_payload()
};
let host = payload.host();
if let Err(message) = payload.apply(self.host_form_state_mut(host)) {
self.status.set_raw(&message);
return false;
}
// Mark the overlay itself as clean so that subsequent exits do
// not discard already-committed changes.
let overlay = &mut self.overlay_stack[idx];
overlay.form_state_mut().mark_clean();
overlay.set_exit_armed(false);
}
// After committing, keep using overlay-scoped validation so we don't
// surprise the user with full-form validation on every overlay save.
self.run_overlay_validation();
self.set_overlay_status_message();
self.refresh_list_overlay_panel();
true
}
}
#[cfg(all(test, feature = "json"))]
impl App {
pub(crate) fn overlay_depth_for_test(&self) -> usize {
self.overlay_depth()
}
pub(crate) fn open_overlay_for_test(&mut self) {
self.try_open_composite_editor();
}
pub(crate) fn active_overlay_form_state_for_test(
&mut self,
) -> Option<&mut crate::tui::state::FormState> {
self.active_overlay_mut()
.map(|overlay| overlay.form_state_mut())
}
pub(crate) fn overlay_entry_focus_for_test(&self) -> Option<bool> {
self.active_overlay()
.map(|overlay| overlay.focus_mode() == OverlayFocusMode::EntryTabs)
}
pub(crate) fn overlay_selected_entry_for_test(&self) -> Option<usize> {
self.active_overlay()
.and_then(|overlay| overlay.entry_tabs_selected())
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,269 @@
use crate::tui::app::runtime::App;
use crate::tui::app::runtime::overlay::editor::CompositeEditorOverlay;
use crate::tui::app::runtime::overlay::state::{
CompositeOverlayTarget, OverlayHost, OverlaySession,
};
use crate::tui::model::{CompositeMode, FieldKind};
use crate::tui::state::FieldState;
use crate::tui::state::field::components::CompositeSelectorView;
use crossterm::event::KeyEvent;
use super::{
MSG_FOCUS_COMPOSITE_BEFORE_EDITING, MSG_NO_FIELD_SELECTED, MSG_SELECT_VARIANT_BEFORE_EDIT,
MSG_UNABLE_AUTO_CREATE_ENTRY,
};
fn ensure_variant_selected_for_anyof(
field: &mut FieldState,
is_anyof: bool,
variant_count: usize,
) -> std::result::Result<usize, &'static str> {
let mut active = field.active_composite_variants();
if active.is_empty() && is_anyof && variant_count > 0 {
let mut flags = vec![false; variant_count];
flags[0] = true;
field.apply_composite_selection(0, Some(flags));
active = field.active_composite_variants();
}
if let Some(&index) = active.first() {
Ok(index)
} else {
Err(MSG_SELECT_VARIANT_BEFORE_EDIT)
}
}
fn ensure_composite_list_entry(field: &mut FieldState) -> std::result::Result<(), &'static str> {
if field.composite_list_selected_index().is_none() && !field.composite_list_add_entry() {
Err(MSG_UNABLE_AUTO_CREATE_ENTRY)
} else {
Ok(())
}
}
pub(super) fn overlay_field_input_result(
editor: &mut CompositeEditorOverlay,
event: &KeyEvent,
) -> Option<(String, String, String)> {
let field_label = editor.field_label().to_string();
if let Some(field) = editor.form_state_mut().focused_field_mut()
&& field.handle_key(event)
{
Some((
field_label,
field.schema.display_label(),
field.schema.pointer.clone(),
))
} else {
None
}
}
impl App {
pub(crate) fn try_open_composite_editor(&mut self) {
let overlay_help_text = self.overlay_help_text().to_string();
let level = self.overlay_depth() + 1;
let host = if level == 1 {
OverlayHost::RootForm
} else {
OverlayHost::Overlay {
parent_level: level - 1,
}
};
let previous_depth = self.overlay_depth();
let field_result = if let Some(editor) = self.active_overlay_mut() {
editor.form_state_mut().focused_field_mut()
} else {
self.form_state.focused_field_mut()
};
let Some(field) = field_result else {
self.status.set_raw(MSG_NO_FIELD_SELECTED);
return;
};
let component_context = field.overlay_context();
match &field.schema.kind {
FieldKind::Composite(template) => {
let schema = template.as_ref();
let is_anyof = matches!(schema.mode, CompositeMode::AnyOf);
let variant_index =
match ensure_variant_selected_for_anyof(field, is_anyof, schema.variants.len())
{
Ok(idx) => idx,
Err(msg) => {
self.status.set_raw(msg);
return;
}
};
let pointer = field.schema.pointer.clone();
let label = field.schema.display_label();
match field.open_composite_editor(variant_index) {
Ok(session) => {
let mut overlay = CompositeEditorOverlay::new(
pointer,
label,
level,
host,
OverlaySession::Composite(session),
overlay_help_text.clone(),
);
if let Some((labels, indices)) =
Self::variant_tab_entries_for_field(field, overlay.target())
{
let selected = overlay.current_variant_index().unwrap_or(0);
overlay.set_variant_tabs(labels, indices, selected);
}
let _ = field;
self.popup = None;
self.overlay_stack.push(overlay);
self.initialize_active_overlay();
}
Err(err) => self.status.set_raw(&err.message),
}
}
FieldKind::Array(inner) if matches!(inner.as_ref(), FieldKind::Composite(_)) => {
let pointer = field.schema.pointer.clone();
let label = field.schema.display_label();
if let Err(msg) = ensure_composite_list_entry(field) {
self.status.set_raw(msg);
return;
}
match field.open_composite_list_editor() {
Ok(context) => {
let mut overlay = CompositeEditorOverlay::new(
pointer,
label,
level,
host,
OverlaySession::Composite(context.session),
overlay_help_text.clone(),
);
overlay.set_target(CompositeOverlayTarget::ListEntry {
entry_index: context.entry_index,
});
if let Some((labels, indices)) =
Self::variant_tab_entries_for_field(field, overlay.target())
{
let selected = overlay.current_variant_index().unwrap_or(0);
overlay.set_variant_tabs(labels, indices, selected);
}
let _ = field;
self.popup = None;
self.overlay_stack.push(overlay);
self.initialize_active_overlay();
}
Err(err) => self.status.set_raw(&err.message),
}
}
FieldKind::KeyValue(_) => {
let pointer = field.schema.pointer.clone();
let label = field.schema.display_label();
match field.open_key_value_editor() {
Ok(context) => {
self.popup = None;
let mut overlay = CompositeEditorOverlay::new(
pointer,
label,
level,
host,
OverlaySession::KeyValue(context.session),
self.overlay_help_text(),
);
overlay.set_target(CompositeOverlayTarget::KeyValueEntry {
entry_index: context.entry_index,
});
self.overlay_stack.push(overlay);
self.initialize_active_overlay();
}
Err(err) => self.status.set_raw(&err.message),
}
}
FieldKind::Array(inner)
if matches!(
inner.as_ref(),
FieldKind::String
| FieldKind::Integer
| FieldKind::Number
| FieldKind::Boolean
| FieldKind::Nullable(_)
) =>
{
let pointer = field.schema.pointer.clone();
let label = field.schema.display_label();
if let Err(msg) = ensure_composite_list_entry(field) {
self.status.set_raw(msg);
return;
}
match field.open_scalar_array_editor() {
Ok(context) => {
self.popup = None;
let mut overlay = CompositeEditorOverlay::new(
pointer,
label,
level,
host,
OverlaySession::Array(context.session),
self.overlay_help_text(),
);
overlay.set_target(CompositeOverlayTarget::ArrayEntry {
entry_index: context.entry_index,
});
self.overlay_stack.push(overlay);
self.initialize_active_overlay();
}
Err(err) => self.status.set_raw(&err.message),
}
}
_ => {
self.status.set_raw(MSG_FOCUS_COMPOSITE_BEFORE_EDITING);
}
}
if self.overlay_depth() > previous_depth
&& let Some(ctx) = component_context
&& let Some(editor) = self.active_overlay_mut()
{
editor.apply_component_context(ctx);
}
}
fn variant_tab_entries_for_field(
field: &FieldState,
target: &CompositeOverlayTarget,
) -> Option<(Vec<String>, Vec<usize>)> {
match (target, &field.schema.kind) {
(CompositeOverlayTarget::Field, FieldKind::Composite(meta))
if matches!(meta.mode, CompositeMode::AnyOf) =>
{
let view = field.composite_selector_view()?;
Self::variant_entries_from_view(&view)
}
(CompositeOverlayTarget::ListEntry { .. }, FieldKind::Array(inner)) if matches!(inner.as_ref(), FieldKind::Composite(meta) if matches!(meta.mode, CompositeMode::AnyOf)) =>
{
let view = field.composite_entry_selector_view()?;
Self::variant_entries_from_view(&view)
}
_ => None,
}
}
fn variant_entries_from_view(
view: &CompositeSelectorView,
) -> Option<(Vec<String>, Vec<usize>)> {
let mut labels = Vec::new();
let mut indices = Vec::new();
for (idx, option) in view.options.iter().enumerate() {
if view.active.get(idx).copied().unwrap_or(false) {
labels.push(format!("#{} {}", idx + 1, option));
indices.push(idx);
}
}
if labels.is_empty() {
None
} else {
Some((labels, indices))
}
}
}
@@ -0,0 +1,68 @@
use crate::tui::app::runtime::{App, PopupOwner};
use crate::tui::model::FieldKind;
use crate::tui::state::FieldState;
fn apply_selection_to_field(field: &mut FieldState, selection: usize, multi: Option<Vec<bool>>) {
if let Some(flags) = multi {
match &field.schema.kind {
FieldKind::Composite(_) => {
field.apply_composite_selection(selection, Some(flags));
}
FieldKind::Array(inner) if matches!(inner.as_ref(), FieldKind::Composite(_)) => {
// Array of composite: delegate to composite list state
field.apply_composite_selection(selection, Some(flags));
}
FieldKind::Array(inner) if matches!(inner.as_ref(), FieldKind::Enum { .. }) => {
field.set_multi_selection(&flags);
}
_ => {}
}
return;
}
match &field.schema.kind {
FieldKind::Composite(_) => {
field.apply_composite_selection(selection, None);
}
FieldKind::Array(inner) if matches!(inner.as_ref(), FieldKind::Composite(_)) => {
// Single-choice selection for array-of-composite entries
field.apply_composite_selection(selection, None);
}
FieldKind::Boolean => field.set_bool(selection == 0),
FieldKind::Enum { .. } => field.set_enum_selected(selection),
_ => {}
}
}
impl App {
pub(crate) fn apply_popup_selection_data(
&mut self,
owner: PopupOwner,
pointer: &str,
selection: usize,
multi: Option<Vec<bool>>,
) {
match owner {
PopupOwner::Root => {
if let Some(field) = self.form_state.field_mut_by_pointer(pointer) {
apply_selection_to_field(field, selection, multi);
}
}
PopupOwner::Composite => {
if let Some(editor) = self.active_overlay_mut()
&& let Some(field) = editor.form_state_mut().field_mut_by_pointer(pointer)
{
apply_selection_to_field(field, selection, multi);
self.run_overlay_validation();
}
}
PopupOwner::VariantSelector {
field_pointer,
overlay_host,
} => {
// Handle variant selector: add entry with selected variant
self.handle_variant_selector_result(&field_pointer, overlay_host, selection);
}
}
}
}
@@ -0,0 +1,54 @@
use super::*;
use crate::{
tui::app::options::UiOptions,
tui::app::runtime::overlay::CompositeOverlayTarget,
tui::model::{FieldKind, FieldSchema},
tui::state::{FieldState, FormState, SectionState},
};
use jsonschema::validator_for;
use serde_json::json;
use std::collections::HashMap;
fn scalar_array_field_state() -> FieldState {
let schema = FieldSchema {
name: "allowed_methods".to_string(),
path: vec!["allowed_methods".to_string()],
pointer: "/allowed_methods".to_string(),
title: "Allowed Methods".to_string(),
description: None,
kind: FieldKind::Array(Box::new(FieldKind::String)),
required: false,
default: Some(json!(["GET"])),
metadata: HashMap::new(),
};
FieldState::from_schema(schema)
}
fn build_app_with_scalar_array() -> App {
let section = SectionState {
id: "section".to_string(),
title: "Section".to_string(),
description: None,
path: vec!["app".to_string()],
depth: 0,
fields: vec![scalar_array_field_state()],
scroll_offset: 0,
};
let form_state = FormState::from_sections("app", "App", None, vec![section]);
let validator = validator_for(&json!({"type": "object"})).expect("validator");
App::new(form_state, validator, UiOptions::default())
}
#[test]
fn ctrl_e_opens_scalar_array_overlay() {
let mut app = build_app_with_scalar_array();
app.try_open_composite_editor();
assert!(
matches!(
app.active_overlay().map(|overlay| overlay.target()),
Some(CompositeOverlayTarget::ArrayEntry { .. })
),
"scalar arrays should open overlay via Ctrl+E"
);
assert_eq!(app.overlay_depth(), 1);
}
@@ -0,0 +1,75 @@
use jsonschema::validator_for;
use std::sync::Arc;
use crate::tui::app::runtime::App;
use crate::tui::app::runtime::overlay::state::OverlaySession;
use crate::tui::state::{FormCommand, FormEngine};
impl App {
pub(crate) fn validate_overlay_field(&mut self, pointer: String) {
let Some(editor) = self.active_overlay_mut() else {
return;
};
let Some(validator) = editor.validator_clone() else {
return;
};
let mut engine = FormEngine::new(editor.form_state_mut(), &validator);
if let Err(message) = engine.dispatch(FormCommand::FieldEdited { pointer }) {
self.status.set_raw(&message);
}
}
pub(crate) fn setup_overlay_validator(&mut self) {
let Some(cache_key) = self
.active_overlay()
.map(|editor| editor.validator_cache_key())
else {
return;
};
if let Some(cached) = self.overlay_validator_cache.get(&cache_key).cloned() {
if let Some(editor) = self.active_overlay_mut() {
editor.set_validator(Some(cached));
}
self.run_overlay_validation();
return;
}
let validator = {
let Some(editor) = self.active_overlay() else {
return;
};
match editor.session() {
OverlaySession::Composite(session) => {
validator_for(&session.schema).ok().map(Arc::new)
}
OverlaySession::KeyValue(session) => {
validator_for(&session.schema).ok().map(Arc::new)
}
OverlaySession::Array(session) => validator_for(&session.schema).ok().map(Arc::new),
OverlaySession::Detached => return,
}
};
if let Some(valid) = &validator {
self.overlay_validator_cache
.insert(cache_key, valid.clone());
}
if let Some(editor) = self.active_overlay_mut() {
editor.set_validator(validator);
}
self.run_overlay_validation();
}
pub(crate) fn run_overlay_validation(&mut self) {
let pointer = {
let Some(editor) = self.active_overlay() else {
return;
};
editor
.form_state()
.focused_field()
.map(|field| field.schema.pointer.clone())
};
if let Some(pointer) = pointer {
self.validate_overlay_field(pointer);
}
}
}
@@ -0,0 +1,345 @@
use std::sync::Arc;
use jsonschema::Validator;
use crate::tui::state::FormState;
use crate::tui::state::field::components::helpers::OverlayContext;
use super::state::{
CompositeOverlayTarget, EntryAdvance, EntryTabsKind, OverlayFocusMode, OverlayHost,
OverlaySession, OverlayState, OverlayStore,
};
#[derive(Clone)]
pub(crate) struct CompositeEditorOverlay {
pub(super) state: OverlayState,
pub(super) store: OverlayStore,
}
impl CompositeEditorOverlay {
pub(super) fn new(
field_pointer: String,
field_label: String,
level: usize,
host: OverlayHost,
session: OverlaySession,
instructions: String,
) -> Self {
let display_title = format!("Edit {} – {}", field_label, session.title());
let display_description = session.description();
let store = OverlayStore::new(display_title, display_description, instructions);
let state = OverlayState::new(field_pointer, field_label, host, level, session);
Self { state, store }
}
pub(super) fn build_commit_payload(&self) -> super::state::OverlayResult {
self.state.build_result()
}
pub(super) fn needs_list_panel(&self) -> bool {
matches!(
self.state.target(),
CompositeOverlayTarget::ListEntry { .. }
| CompositeOverlayTarget::KeyValueEntry { .. }
| CompositeOverlayTarget::ArrayEntry { .. }
)
}
pub(crate) fn form_state(&self) -> &FormState {
self.state.form_state()
}
pub(crate) fn form_state_mut(&mut self) -> &mut FormState {
self.state.form_state_mut()
}
pub(super) fn dirty(&self) -> bool {
self.state.dirty()
}
pub(super) fn can_focus_entries(&self) -> bool {
self.store.can_focus_entries()
}
pub(super) fn focus_entries(&mut self) -> bool {
if self.store.can_focus_entries() {
self.store.focus_entries();
true
} else {
false
}
}
pub(super) fn focus_form_default(&mut self) -> bool {
if !self.form_state().has_focusable_fields() {
return false;
}
self.store.focus_form();
true
}
pub(super) fn focus_form_first(&mut self) -> bool {
if !self.focus_form_default() {
return false;
}
if !self.form_state_mut().focus_first_field_with_layout() {
self.form_state_mut().focus_first_field();
}
true
}
pub(super) fn focus_form_last(&mut self) -> bool {
if !self.focus_form_default() {
return false;
}
self.form_state_mut().focus_last_field();
true
}
pub(super) fn apply_component_context(&mut self, ctx: OverlayContext) {
let field_label = self.field_label().to_string();
self.store.apply_component_context(&field_label, ctx);
}
pub(super) fn entry_tabs_len(&self) -> usize {
self.store.entry_tabs_len()
}
pub(crate) fn entry_tabs_selected(&self) -> Option<usize> {
self.store.entry_tabs_selected()
}
pub(crate) fn entry_tabs_entries(&self) -> Option<&[String]> {
self.store.entry_tabs_entries()
}
pub(crate) fn entry_tabs_label(&self) -> Option<&str> {
self.store.entry_tabs_label().map(|label| label.as_str())
}
pub(super) fn entry_tabs_snapshot(&self) -> Option<(usize, usize)> {
self.store.entry_tabs_snapshot()
}
pub(super) fn set_entry_tabs(&mut self, entries: Vec<String>, selected: usize) {
self.store.set_entry_tabs("Entries", entries, selected);
}
pub(super) fn set_variant_tabs(
&mut self,
entries: Vec<String>,
ids: Vec<usize>,
selected: usize,
) {
self.store
.set_variant_tabs("Variants", entries, ids, selected);
self.store.append_instructions(
"Ctrl+←/→ switches variants; Tab focuses variant tabs".to_string(),
);
}
pub(crate) fn instructions(&self) -> &str {
self.store.instructions()
}
pub(crate) fn title(&self) -> &str {
self.store.title()
}
pub(crate) fn description(&self) -> Option<&String> {
self.store.description()
}
pub(super) fn focus_mode(&self) -> OverlayFocusMode {
self.store.focus()
}
pub(crate) fn field_label(&self) -> &str {
self.state.field_label()
}
pub(crate) fn level(&self) -> usize {
self.state.level()
}
pub(crate) fn host(&self) -> OverlayHost {
self.state.host()
}
pub(crate) fn field_pointer(&self) -> &str {
self.state.field_pointer()
}
pub(crate) fn target(&self) -> &CompositeOverlayTarget {
self.state.target()
}
pub(super) fn target_mut(&mut self) -> &mut CompositeOverlayTarget {
self.state.target_mut()
}
pub(super) fn set_target(&mut self, target: CompositeOverlayTarget) {
self.state.set_target(target);
}
pub(super) fn exit_armed(&self) -> bool {
self.state.exit_armed()
}
pub(super) fn set_exit_armed(&mut self, armed: bool) {
self.state.set_exit_armed(armed);
}
pub(super) fn validator_clone(&self) -> Option<Arc<Validator>> {
self.state.validator_clone()
}
pub(super) fn set_validator(&mut self, validator: Option<Arc<Validator>>) {
self.state.set_validator(validator);
}
pub(super) fn session(&self) -> &OverlaySession {
self.state.session()
}
pub(super) fn current_variant_index(&self) -> Option<usize> {
self.state.current_variant_index()
}
pub(super) fn take_composite_session(
&mut self,
) -> Option<crate::tui::state::CompositeEditorSession> {
self.state.take_composite_session()
}
pub(super) fn replace_composite_session(
&mut self,
session: crate::tui::state::CompositeEditorSession,
) {
self.state.replace_composite_session(session);
}
pub(super) fn sync_variant_selection(&mut self, variant_index: usize) {
if let Some(store) = self.store.entry_tabs.as_mut() {
store.select_by_id(variant_index);
}
}
pub(super) fn advance_entry_tab(&mut self, delta: i32) -> Option<EntryAdvance> {
let store = self.store.entry_tabs.as_mut()?;
if !store.advance(delta) {
return None;
}
match store.kind() {
EntryTabsKind::Entries => Some(EntryAdvance::Collection {
selected: store.selected(),
}),
EntryTabsKind::Variants => store
.selected_id()
.map(|variant_index| EntryAdvance::Variant { variant_index }),
}
}
pub(super) fn session_kind(&self) -> &'static str {
match self.session() {
OverlaySession::Composite(_) => "composite",
OverlaySession::KeyValue(_) => "keyvalue",
OverlaySession::Array(_) => "array",
OverlaySession::Detached => "detached",
}
}
pub(super) fn validator_cache_key(&self) -> String {
let target = match self.target() {
CompositeOverlayTarget::Field => "field",
CompositeOverlayTarget::ListEntry { .. } => "list",
CompositeOverlayTarget::KeyValueEntry { .. } => "keyvalue-entry",
CompositeOverlayTarget::ArrayEntry { .. } => "array-entry",
};
format!(
"{}::{}::{}",
self.field_pointer(),
self.session_kind(),
target
)
}
pub(super) fn advance_focus(
&mut self,
direction: super::state::FocusDirection,
) -> super::state::FocusOutcome {
use super::state::{FocusOutcome, OverlayFocusMode};
let has_fields = self.form_state().has_focusable_fields();
let entries_len = self.entry_tabs_len();
let focus_mode = self.focus_mode();
match direction {
super::state::FocusDirection::Forward => {
if focus_mode == OverlayFocusMode::EntryTabs {
if has_fields && self.focus_form_first() {
return FocusOutcome::Consumed;
}
if entries_len > 0 {
return FocusOutcome::RequestEntryDelta(1);
}
if self.focus_entries() {
return FocusOutcome::Consumed;
}
return FocusOutcome::Consumed;
}
if !has_fields {
if entries_len > 0 {
return FocusOutcome::RequestEntryDelta(1);
}
if self.focus_entries() {
return FocusOutcome::Consumed;
}
return FocusOutcome::Consumed;
}
if self.form_state().focus_is_last() {
if entries_len > 0 {
return FocusOutcome::RequestEntryDelta(1);
}
if self.focus_entries() {
return FocusOutcome::Consumed;
}
return FocusOutcome::Consumed;
}
FocusOutcome::PassThrough
}
super::state::FocusDirection::Backward => {
if focus_mode == OverlayFocusMode::EntryTabs {
if has_fields && self.focus_form_last() {
return FocusOutcome::Consumed;
}
if entries_len > 0 {
return FocusOutcome::RequestEntryDelta(-1);
}
if self.focus_entries() {
return FocusOutcome::Consumed;
}
return FocusOutcome::Consumed;
}
if !has_fields {
if entries_len > 0 {
return FocusOutcome::RequestEntryDelta(-1);
}
if self.focus_entries() {
return FocusOutcome::Consumed;
}
return FocusOutcome::Consumed;
}
if self.form_state().focus_is_first() {
if entries_len > 0 {
return FocusOutcome::RequestEntryDelta(-1);
}
if self.focus_entries() {
return FocusOutcome::Consumed;
}
return FocusOutcome::Consumed;
}
FocusOutcome::PassThrough
}
}
}
}
@@ -0,0 +1,6 @@
mod app;
mod editor;
mod state;
pub(crate) use editor::CompositeEditorOverlay;
pub(crate) use state::{CompositeOverlayTarget, OverlayHost};
@@ -0,0 +1,544 @@
use std::sync::Arc;
use jsonschema::Validator;
use crate::tui::state::field::components::helpers::OverlayContext;
use crate::tui::state::{
ArrayEditorSession, CompositeEditorSession, FormState, KeyValueEditorSession,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum EntryTabsKind {
Entries,
Variants,
}
#[derive(Debug, Clone)]
pub(super) struct EntryTabsStore {
entries: Vec<String>,
ids: Vec<usize>,
selected: usize,
kind: EntryTabsKind,
}
impl EntryTabsStore {
pub(super) fn new(entries: Vec<String>, selected: usize) -> Self {
let ids = (0..entries.len()).collect();
Self::with_kind(entries, ids, selected, EntryTabsKind::Entries)
}
pub(super) fn with_kind(
entries: Vec<String>,
ids: Vec<usize>,
selected_id: usize,
kind: EntryTabsKind,
) -> Self {
let mut store = Self {
entries,
ids,
selected: 0,
kind,
};
store.select_by_id(selected_id);
store
}
pub(super) fn len(&self) -> usize {
self.entries.len()
}
pub(super) fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub(super) fn entries(&self) -> &[String] {
&self.entries
}
pub(super) fn selected(&self) -> usize {
self.selected
}
pub(super) fn selected_id(&self) -> Option<usize> {
self.ids.get(self.selected).copied()
}
pub(super) fn kind(&self) -> EntryTabsKind {
self.kind
}
pub(super) fn select(&mut self, index: usize) {
if self.entries.is_empty() {
self.selected = 0;
} else {
self.selected = index.min(self.entries.len().saturating_sub(1));
}
}
pub(super) fn set_entries(&mut self, entries: Vec<String>, selected: usize) {
self.ids = (0..entries.len()).collect();
self.entries = entries;
self.kind = EntryTabsKind::Entries;
self.select(selected);
}
pub(super) fn set_entries_with_ids(
&mut self,
entries: Vec<String>,
ids: Vec<usize>,
selected_id: usize,
) {
self.entries = entries;
self.ids = ids;
self.kind = EntryTabsKind::Variants;
self.select_by_id(selected_id);
}
pub(super) fn select_by_id(&mut self, id: usize) {
if let Some(pos) = self.ids.iter().position(|candidate| *candidate == id) {
self.select(pos);
} else {
self.select(0);
}
}
pub(super) fn advance(&mut self, delta: i32) -> bool {
if self.entries.is_empty() {
return false;
}
let len = self.entries.len() as i32;
let mut next = self.selected as i32 + delta;
next = ((next % len) + len) % len;
let next = next as usize;
if next == self.selected {
false
} else {
self.selected = next;
true
}
}
}
#[derive(Debug, Clone)]
pub(super) struct OverlayStore {
pub(super) display_title: String,
pub(super) display_description: Option<String>,
pub(super) instructions: String,
pub(super) entry_tabs: Option<EntryTabsStore>,
pub(super) entry_label: Option<String>,
pub(super) focus: OverlayFocusMode,
}
impl OverlayStore {
pub(super) fn new(
display_title: String,
display_description: Option<String>,
instructions: String,
) -> Self {
Self {
display_title,
display_description,
instructions,
entry_tabs: None,
entry_label: None,
focus: OverlayFocusMode::FormFields,
}
}
pub(super) fn entry_tabs_len(&self) -> usize {
self.entry_tabs.as_ref().map(|tabs| tabs.len()).unwrap_or(0)
}
pub(super) fn entry_tabs_selected(&self) -> Option<usize> {
self.entry_tabs.as_ref().map(|tabs| tabs.selected())
}
pub(super) fn entry_tabs_entries(&self) -> Option<&[String]> {
self.entry_tabs.as_ref().map(|tabs| tabs.entries())
}
pub(super) fn entry_tabs_label(&self) -> Option<&String> {
self.entry_label.as_ref()
}
pub(super) fn entry_tabs_snapshot(&self) -> Option<(usize, usize)> {
self.entry_tabs
.as_ref()
.map(|tabs| (tabs.len(), tabs.selected()))
}
pub(super) fn can_focus_entries(&self) -> bool {
self.entry_tabs
.as_ref()
.map(|tabs| !tabs.is_empty())
.unwrap_or(false)
}
pub(super) fn focus_entries(&mut self) {
self.focus = OverlayFocusMode::EntryTabs;
}
pub(super) fn focus_form(&mut self) {
self.focus = OverlayFocusMode::FormFields;
}
pub(super) fn focus(&self) -> OverlayFocusMode {
self.focus
}
pub(super) fn set_entry_tabs(
&mut self,
label: impl Into<String>,
entries: Vec<String>,
selected: usize,
) {
if let Some(store) = self.entry_tabs.as_mut() {
store.set_entries(entries, selected);
} else {
self.entry_tabs = Some(EntryTabsStore::new(entries, selected));
}
self.entry_label = Some(label.into());
}
pub(super) fn set_variant_tabs(
&mut self,
label: impl Into<String>,
entries: Vec<String>,
ids: Vec<usize>,
selected_id: usize,
) {
let label = label.into();
if let Some(store) = self.entry_tabs.as_mut() {
store.set_entries_with_ids(entries, ids, selected_id);
} else {
self.entry_tabs = Some(EntryTabsStore::with_kind(
entries,
ids,
selected_id,
EntryTabsKind::Variants,
));
}
self.entry_label = Some(label);
}
pub(super) fn update_title(&mut self, field_label: &str, title: &str) {
self.display_title = format!("Edit {} – {}", field_label, title);
}
pub(super) fn set_description(&mut self, description: Option<String>) {
self.display_description = description;
}
pub(super) fn append_instructions(&mut self, extra: String) {
if self.instructions.trim().is_empty() {
self.instructions = extra;
} else {
self.instructions = format!("{} • {}", self.instructions, extra);
}
}
pub(super) fn apply_component_context(&mut self, field_label: &str, ctx: OverlayContext) {
if let Some(title) = ctx.title {
self.update_title(field_label, &title);
}
if let Some(description) = ctx.description {
self.display_description = Some(description);
}
if let Some(panel) = ctx.entry_panel {
self.set_entry_tabs("Entries", panel.entries, panel.selected);
}
if let Some(extra) = ctx.instructions {
self.append_instructions(extra);
}
}
pub(super) fn title(&self) -> &str {
&self.display_title
}
pub(super) fn description(&self) -> Option<&String> {
self.display_description.as_ref()
}
pub(super) fn instructions(&self) -> &str {
&self.instructions
}
}
#[derive(Clone)]
pub(super) struct OverlayResult {
pub(super) field_pointer: String,
pub(super) host: OverlayHost,
pub(super) target: CompositeOverlayTarget,
pub(super) session: OverlaySession,
}
impl OverlayResult {
pub(super) fn host(&self) -> OverlayHost {
self.host
}
pub(super) fn apply(self, host_state: &mut FormState) -> Result<(), String> {
match self.target {
CompositeOverlayTarget::Field => {
let OverlaySession::Composite(session) = self.session else {
return Err("Invalid overlay session".to_string());
};
let Some(field) = host_state.field_mut_by_pointer(&self.field_pointer) else {
return Err("Overlay target no longer exists".to_string());
};
field.close_composite_editor(session, true);
Ok(())
}
CompositeOverlayTarget::ListEntry { entry_index } => {
let OverlaySession::Composite(session) = self.session else {
return Err("Invalid overlay session".to_string());
};
let Some(field) = host_state.field_mut_by_pointer(&self.field_pointer) else {
return Err("Overlay target no longer exists".to_string());
};
field.close_composite_list_editor(entry_index, session, true);
Ok(())
}
CompositeOverlayTarget::KeyValueEntry { entry_index } => {
let OverlaySession::KeyValue(session) = self.session else {
return Err("Invalid overlay session".to_string());
};
let Some(field) = host_state.field_mut_by_pointer(&self.field_pointer) else {
return Err("Overlay target no longer exists".to_string());
};
field
.close_key_value_editor(entry_index, &session, true)
.map_err(|err| err.message)
.map(|_| ())
}
CompositeOverlayTarget::ArrayEntry { entry_index } => {
let OverlaySession::Array(session) = self.session else {
return Err("Invalid overlay session".to_string());
};
let Some(field) = host_state.field_mut_by_pointer(&self.field_pointer) else {
return Err("Overlay target no longer exists".to_string());
};
field
.close_scalar_array_editor(entry_index, &session, true)
.map_err(|err| err.message)
.map(|_| ())
}
}
}
}
#[derive(Debug, Clone)]
pub(super) struct OverlayState {
pub(super) field_pointer: String,
pub(super) field_label: String,
pub(super) host: OverlayHost,
pub(super) level: usize,
pub(super) target: CompositeOverlayTarget,
pub(super) session: OverlaySession,
pub(super) exit_armed: bool,
pub(super) validator: Option<Arc<Validator>>,
}
impl OverlayState {
pub(super) fn new(
field_pointer: String,
field_label: String,
host: OverlayHost,
level: usize,
session: OverlaySession,
) -> Self {
Self {
field_pointer,
field_label,
host,
level,
target: CompositeOverlayTarget::Field,
session,
exit_armed: false,
validator: None,
}
}
pub(super) fn dirty(&self) -> bool {
self.session.is_dirty()
}
pub(super) fn form_state(&self) -> &FormState {
self.session.form_state()
}
pub(super) fn form_state_mut(&mut self) -> &mut FormState {
self.session.form_state_mut()
}
pub(super) fn session(&self) -> &OverlaySession {
if matches!(self.session, OverlaySession::Detached) {
panic!("overlay session detached");
}
&self.session
}
pub(super) fn take_composite_session(&mut self) -> Option<CompositeEditorSession> {
match std::mem::replace(&mut self.session, OverlaySession::Detached) {
OverlaySession::Composite(session) => Some(session),
other => {
self.session = other;
None
}
}
}
pub(super) fn replace_composite_session(&mut self, session: CompositeEditorSession) {
self.session = OverlaySession::Composite(session);
}
pub(super) fn current_variant_index(&self) -> Option<usize> {
match &self.session {
OverlaySession::Composite(session) => Some(session.variant_index),
_ => None,
}
}
pub(super) fn field_pointer(&self) -> &str {
&self.field_pointer
}
pub(super) fn field_label(&self) -> &str {
&self.field_label
}
pub(super) fn level(&self) -> usize {
self.level
}
pub(super) fn host(&self) -> OverlayHost {
self.host
}
pub(super) fn target(&self) -> &CompositeOverlayTarget {
&self.target
}
pub(super) fn target_mut(&mut self) -> &mut CompositeOverlayTarget {
&mut self.target
}
pub(super) fn set_target(&mut self, target: CompositeOverlayTarget) {
self.target = target;
}
pub(super) fn exit_armed(&self) -> bool {
self.exit_armed
}
pub(super) fn set_exit_armed(&mut self, armed: bool) {
self.exit_armed = armed;
}
pub(super) fn set_validator(&mut self, validator: Option<Arc<Validator>>) {
self.validator = validator;
}
pub(super) fn validator_clone(&self) -> Option<Arc<Validator>> {
self.validator.clone()
}
pub(super) fn build_result(&self) -> OverlayResult {
OverlayResult {
field_pointer: self.field_pointer.clone(),
host: self.host,
target: self.target.clone(),
session: self.session.clone(),
}
}
}
#[derive(Debug, Clone)]
pub(super) enum OverlaySession {
Composite(CompositeEditorSession),
KeyValue(KeyValueEditorSession),
Array(ArrayEditorSession),
Detached,
}
impl OverlaySession {
pub(super) fn form_state(&self) -> &FormState {
match self {
OverlaySession::Composite(session) => &session.form_state,
OverlaySession::KeyValue(session) => &session.form_state,
OverlaySession::Array(session) => &session.form_state,
OverlaySession::Detached => panic!("overlay session detached"),
}
}
pub(super) fn form_state_mut(&mut self) -> &mut FormState {
match self {
OverlaySession::Composite(session) => &mut session.form_state,
OverlaySession::KeyValue(session) => &mut session.form_state,
OverlaySession::Array(session) => &mut session.form_state,
OverlaySession::Detached => panic!("overlay session detached"),
}
}
pub(super) fn is_dirty(&self) -> bool {
self.form_state().is_dirty()
}
pub(super) fn title(&self) -> &str {
match self {
OverlaySession::Composite(session) => &session.title,
OverlaySession::KeyValue(_) => "Entry",
OverlaySession::Array(session) => &session.title,
OverlaySession::Detached => panic!("overlay session detached"),
}
}
pub(super) fn description(&self) -> Option<String> {
match self {
OverlaySession::Composite(session) => session.description.clone(),
OverlaySession::KeyValue(_) => None,
OverlaySession::Array(session) => session.description.clone(),
OverlaySession::Detached => None,
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum OverlayHost {
RootForm,
Overlay { parent_level: usize },
}
#[derive(Debug, Clone)]
pub(crate) enum CompositeOverlayTarget {
Field,
ListEntry { entry_index: usize },
KeyValueEntry { entry_index: usize },
ArrayEntry { entry_index: usize },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum OverlayFocusMode {
FormFields,
EntryTabs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FocusDirection {
Forward,
Backward,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FocusOutcome {
Consumed,
RequestEntryDelta(i32),
PassThrough,
}
#[derive(Debug, Clone, Copy)]
pub(super) enum EntryAdvance {
Collection { selected: usize },
Variant { variant_index: usize },
}
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
#[derive(Debug, Clone)]
pub struct StatusLine {
message: String,
}
pub const READY_STATUS: &str = "Ready. Press Ctrl+S to validate and save.";
impl Default for StatusLine {
fn default() -> Self {
Self {
message: READY_STATUS.to_string(),
}
}
}
impl StatusLine {
pub fn new() -> Self {
Self::default()
}
pub fn set_raw(&mut self, msg: impl Into<String>) {
self.message = msg.into();
}
pub fn ready(&mut self) {
self.message = READY_STATUS.to_string();
}
pub fn editing(&mut self, label: &str) {
self.message = format!("Editing {label}");
}
pub fn value_updated(&mut self) {
self.message = "Value updated".to_string();
}
pub fn validation_passed(&mut self) {
self.message = "Validation passed".to_string();
}
pub fn issues_remaining(&mut self, count: usize) {
self.message = format!("{count} issue(s) remaining");
}
pub fn pending_exit(&mut self) {
self.message = "Unsaved changes. Press Ctrl+Q again to quit without saving.".to_string();
}
pub fn message(&self) -> &str {
&self.message
}
}
+68
View File
@@ -0,0 +1,68 @@
use std::{
io::{self, Stdout},
ops::{Deref, DerefMut},
sync::Once,
};
use anyhow::{Context, Result};
use crossterm::{
cursor::Show,
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
static PANIC_HOOK: Once = Once::new();
pub struct TerminalGuard {
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl TerminalGuard {
pub fn new() -> Result<Self> {
enable_raw_mode().context("failed to enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend).context("failed to initialize terminal")?;
install_panic_hook();
Ok(Self { terminal })
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = self.terminal.show_cursor();
restore_terminal();
}
}
impl Deref for TerminalGuard {
type Target = Terminal<CrosstermBackend<Stdout>>;
fn deref(&self) -> &Self::Target {
&self.terminal
}
}
impl DerefMut for TerminalGuard {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.terminal
}
}
fn install_panic_hook() {
PANIC_HOOK.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
restore_terminal();
previous(panic_info);
}));
});
}
fn restore_terminal() {
let _ = disable_raw_mode();
let mut stdout = io::stdout();
let _ = execute!(stdout, LeaveAlternateScreen, Show);
}
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff. Show more