mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 03:21:33 -10:00
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:
1 parent
4bdc23f9d1
commit
ed597ccc9e
171 files changed
+28913
-3
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"v":1}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"git": {
|
||||
"sha1": "5d591ae751a0b910704795f534e5c331a56641fc",
|
||||
"dirty": true
|
||||
},
|
||||
"path_in_vcs": ""
|
||||
}
|
||||
Vendored
+204
@@ -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
@@ -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"
|
||||
Vendored
+18
@@ -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
@@ -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.
|
||||
Vendored
+575
@@ -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>
|
||||
|
||||
[](https://crates.io/crates/schemaui)
|
||||
[](https://docs.rs/schemaui)
|
||||
[](https://github.com/yuniqueunic/schemaui#license)
|
||||

|
||||
|
||||
<!--  -->
|
||||
|
||||
<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>
|
||||
Vendored
+668
@@ -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>
|
||||
|
||||
[](https://crates.io/crates/schemaui)
|
||||
[](https://docs.rs/schemaui)
|
||||
[](https://github.com/yuniqueunic/schemaui#license)
|
||||

|
||||
|
||||
<!--  -->
|
||||
|
||||
<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>
|
||||
Vendored
+280
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
pub mod input;
|
||||
pub mod output;
|
||||
|
||||
mod format;
|
||||
|
||||
pub use format::{DocumentFormat, DocumentFormatProbe};
|
||||
+178
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+60
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
||||
pub mod dialect;
|
||||
pub mod loader;
|
||||
pub mod metadata;
|
||||
pub mod model;
|
||||
pub mod resolver;
|
||||
+281
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
mod schema_ui_tests;
|
||||
@@ -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"]));
|
||||
}
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
pub(crate) mod format_tests;
|
||||
+11
@@ -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"))
|
||||
);
|
||||
}
|
||||
@@ -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(§ion.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 §ion.fields {
|
||||
if predicate(field) {
|
||||
return Some(field);
|
||||
}
|
||||
}
|
||||
if let Some(found) = find_in_sections(§ion.children, predicate) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
@@ -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 §ion.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)
|
||||
);
|
||||
}
|
||||
+71
@@ -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
@@ -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());
|
||||
}
|
||||
+80
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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 §ion.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",
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user