mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 20:02:16 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37a586dd61 | |||
| 79214b6b49 | |||
| 49c0faa7da | |||
| 2cdfe050db | |||
| 303fddb20e | |||
| 03606ec97b | |||
| c4fb42f665 | |||
| 00e31c24fd | |||
| 03b8f4138b | |||
| 613b468ef5 | |||
| c0a0da225a |
@@ -1,3 +1,6 @@
|
||||
# Preserve canonical source receipts and the runtime icon-discovery snapshot.
|
||||
.gitattributes text eol=lf
|
||||
/assets/icons/ui/design/requested_icons.txt text eol=lf
|
||||
# SolidCraft designs (.sc): a JSON document or a ZIP container, never Scala source, so they
|
||||
# stay out of GitHub's language statistics. Git's own text/binary detection handles both kinds.
|
||||
*.sc -linguist-detectable
|
||||
|
||||
@@ -19,7 +19,7 @@ body:
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: |
|
||||
What you clicked or typed, in order. For a problem on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
|
||||
What you clicked or typed, in order. For a problem on a particular design, `solidcraft-cli script design.sc --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
|
||||
Using an AI agent with SolidCraft's MCP server? The `solidcraft-reporting` skill in [`skills/`](https://github.com/tangency-labs/solidcraft/tree/main/skills) has it reproduce the problem in a fresh design and write these steps for you.
|
||||
placeholder: |
|
||||
1. Sketch a circle on the XY plane
|
||||
|
||||
@@ -136,6 +136,7 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
|
||||
[Adding a command](docs/book/src/dev/commands.md#adding-a-command); a test warns about a
|
||||
command without them, and will fail it once required), engine tests that measure the result (volume, area,
|
||||
face/edge/vertex counts against a value you can derive), and for UI work a scenario (below).
|
||||
A command on the toolbar also ships with its help tooltip (step 6 of Adding a command).
|
||||
6. **One set of camera controls.** Every page or canvas that shows a view (the modelling
|
||||
viewport, the Render preview, and any new page such as a flat pattern or drawing) calls
|
||||
`navigation::handle` (`crates/ui-egui/src/navigation.rs`) for its camera input, with
|
||||
@@ -283,6 +284,9 @@ and a fresh CI run:
|
||||
- **Commands without schemas** (`crates/engine/src/schema/unconverted.txt`): sorted, one id per
|
||||
line, and it only shrinks. Converting a command removes its line; a new command is never added
|
||||
to it.
|
||||
- **Toolbar commands without help** (`BACKLOG` in `crates/ui-egui/src/command_help.rs`): sorted,
|
||||
one id per line, and it only shrinks. Writing a command's help removes its line; a new command
|
||||
is never added to it.
|
||||
- **Languages** (`crates/ui-egui/src/i18n.rs`): one per line, English first, then by id (a test
|
||||
checks it).
|
||||
- **Generated files**: `THIRD-PARTY-LICENSES.txt` and `docs/features.md` are rewritten on `main`
|
||||
|
||||
Generated
+17
-17
@@ -4208,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4242,11 +4242,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4258,7 +4258,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4267,7 +4267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4284,7 +4284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4305,7 +4305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4314,7 +4314,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4334,7 +4334,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4358,7 +4358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4378,7 +4378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4387,7 +4387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4401,7 +4401,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4412,14 +4412,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4449,7 +4449,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5994,7 +5994,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"serde",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -74,7 +74,7 @@ other is a tracked bug.
|
||||
- **Parameters and configurations** with unit-aware expressions.
|
||||
- **Assemblies** with joints, motion studies, contact sets, interference and mass properties.
|
||||
- **Sheet metal** with flanges, hems, fold/unfold and flat patterns, and plastic enclosure features.
|
||||
- **Files:** STEP, IGES, 3MF, STL, OBJ and glTF in and out where supported, URDF export, `.solidcraft` designs.
|
||||
- **Files:** STEP, IGES, 3MF, STL, OBJ and glTF in and out where supported, URDF export, `.sc` designs (`.solidcraft` files from earlier releases still open).
|
||||
- **Desktop app, browser app, headless CLI and MCP server**, all running the same commands.
|
||||
|
||||
The full list is in [docs/capabilities.md](docs/capabilities.md) and [docs/features.md](docs/features.md);
|
||||
|
||||
@@ -68,7 +68,7 @@ fn run() -> Result<(), String> {
|
||||
let generation_start = Instant::now();
|
||||
let (commands, command_timings) = session.run_script_timed(&recipe, false).map_err(|e| e.to_string())?;
|
||||
let generation_seconds = generation_start.elapsed().as_secs_f64();
|
||||
let design_path = output.with_extension("solidcraft");
|
||||
let design_path = output.with_extension(solidcraft_engine::io::DESIGN_EXT);
|
||||
session.execute("file.save_as", &json!({"path":design_path})).map_err(|e| e.to_string())?;
|
||||
let scene = solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?;
|
||||
let bodies: Vec<Value> = scene
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! no fixture.
|
||||
//!
|
||||
//! ```text
|
||||
//! solidcraft-cli bench-agent [--scale F] [--script-out script.json] [--save design.solidcraft]
|
||||
//! solidcraft-cli bench-agent [--scale F] [--script-out script.json] [--save design.sc]
|
||||
//! ```
|
||||
//! prints one JSON line per hundred commands (mean and max milliseconds, bodies so far), then the
|
||||
//! build total, then the phases `bench-open` prints for the saved design.
|
||||
@@ -205,7 +205,10 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
);
|
||||
let path = match flag("--save") {
|
||||
Some(p) => p.clone(),
|
||||
None => std::env::temp_dir().join(format!("bench-agent-{}.solidcraft", std::process::id())).to_string_lossy().into_owned(),
|
||||
None => std::env::temp_dir()
|
||||
.join(format!("bench-agent-{}.{}", std::process::id(), solidcraft_engine::io::DESIGN_EXT))
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
};
|
||||
s.execute("file.save", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
let r = crate::cmd_bench_open(std::slice::from_ref(&path));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! SolidCraft headless command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
|
||||
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--json-out FILE] (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
|
||||
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] [--ops script.json] loading timings as JSON lines
|
||||
//! solidcraft-cli run <script.json|design.sc|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
|
||||
//! solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli script <design.sc> [--out script.json] the design's history as a command script
|
||||
//! solidcraft-cli bench-open <design.sc | part.step> [--parse-only] [--save OUT.sc] [--display LEVEL [--edit NAME EXPR]] [--ops script.json] loading timings as JSON lines
|
||||
//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed
|
||||
//! solidcraft-cli bench-assistant --connect 127.0.0.1:PORT [--token T] --base-url URL --model NAME [--tasks 1,2,5] [--repeat N]
|
||||
//! [--timeout-s 300] [--json out.json] [--markdown out.md]
|
||||
@@ -56,19 +56,19 @@ struct Command {
|
||||
}
|
||||
|
||||
/// `run`'s lines in the usage.
|
||||
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
|
||||
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
|
||||
/// `eval`'s lines in the usage.
|
||||
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--json-out FILE] (alias: inspect)\n solidcraft-cli eval --in <script.json|design.solidcraft|part.step> [--json-out FILE]\n (with both inputs, runs the positional script on --in before measuring; does not automatically save the modified design)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n";
|
||||
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect)\n solidcraft-cli eval --in <script.json|design.sc|part.step> [--json-out FILE]\n (with both inputs, runs the positional script on --in before measuring; does not automatically save the modified design)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n";
|
||||
/// `script`'s lines in the usage.
|
||||
const USAGE_SCRIPT: &str = " solidcraft-cli script <design.solidcraft|script.json> [--out script.json]\n (the design's history as a command script that eval and run replay)\n";
|
||||
const USAGE_SCRIPT: &str = " solidcraft-cli script <design.sc|script.json> [--out script.json]\n (the design's history as a command script that eval and run replay)\n";
|
||||
/// `bench-open`'s lines in the usage.
|
||||
const USAGE_BENCH_OPEN: &str = " solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] [--ops script.json]\n (--ops: after opening, run each command of the script and undo it, timing the command, the\n evaluations it caused and the view it leaves: placing and meshing what changed)\n";
|
||||
const USAGE_BENCH_OPEN: &str = " solidcraft-cli bench-open <design.sc | part.step> [--parse-only] [--save OUT.sc] [--display LEVEL [--edit NAME EXPR]] [--ops script.json]\n (--ops: after opening, run each command of the script and undo it, timing the command, the\n evaluations it caused and the view it leaves: placing and meshing what changed)\n";
|
||||
/// `bench-agent`'s lines in the usage.
|
||||
const USAGE_BENCH_AGENT: &str = " solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]\n";
|
||||
const USAGE_BENCH_AGENT: &str = " solidcraft-cli bench-agent [--scale F] [--save design.sc] [--script-out script.json]\n";
|
||||
/// `bench-assistant`'s lines in the usage.
|
||||
const USAGE_BENCH_ASSISTANT: &str = " solidcraft-cli bench-assistant --connect 127.0.0.1:PORT [--token T] --base-url URL --model NAME [--tasks 1,2,5] [--repeat N] [--timeout-s 300] [--json out.json] [--markdown out.md]\n";
|
||||
/// `snapshot`'s lines in the usage.
|
||||
const USAGE_SNAPSHOT: &str = " solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]\n [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]\n solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...\n (as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;\n --view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;\n --camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);\n --no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;\n --body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside\n it shows) and color #rrggbb, either or both, once per body;\n --tool: draw this body solid in the tool colour and every other body see-through, so a cut's or a join's tool body\n is visible inside the material it meets; a later --body overrides it;\n --section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world\n coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON)\n";
|
||||
const USAGE_SNAPSHOT: &str = " solidcraft-cli snapshot <script|design> [--in design.sc|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]\n [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]\n solidcraft-cli snapshot --in <design.sc|part.step> --out shot.png ...\n (as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;\n --view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;\n --camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);\n --no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;\n --body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside\n it shows) and color #rrggbb, either or both, once per body;\n --tool: draw this body solid in the tool colour and every other body see-through, so a cut's or a join's tool body\n is visible inside the material it meets; a later --body overrides it;\n --section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world\n coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON)\n";
|
||||
/// `render`'s lines in the usage.
|
||||
const USAGE_RENDER: &str = " solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]\n [--progressive] [--samples N] [--max-depth N] [--seed N] [--exposure EV] [--lighting FILE.json] [--environment FILE.json] [--tone-map reinhard|aces_fitted]\n";
|
||||
/// `exec`'s lines in the usage.
|
||||
@@ -83,7 +83,7 @@ const USAGE_ORACLE: &str = " solidcraft-cli oracle <case-dir>... [--json]\n";
|
||||
const USAGE_FUZZ: &str =
|
||||
" solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]\n";
|
||||
/// `mcp`'s lines in the usage.
|
||||
const USAGE_MCP: &str = " solidcraft-cli mcp [--headless] [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]\n [--param-check off|warn|strict] [--render-gpu]\n solidcraft-cli mcp setup [--dry-run] [--client opencode|claude-desktop|claude-code|cursor] [--live]\n (adds a `solidcraft` server, with this program's path, to each installed AI client; --live adds `solidcraft-live` for the running app; alias: mcp install)\n";
|
||||
const USAGE_MCP: &str = " solidcraft-cli mcp [--headless] [--in design.sc|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]\n [--param-check off|warn|strict] [--render-gpu]\n solidcraft-cli mcp setup [--dry-run] [--client opencode|claude-desktop|claude-code|cursor] [--live]\n (adds a `solidcraft` server, with this program's path, to each installed AI client; --live adds `solidcraft-live` for the running app; alias: mcp install)\n";
|
||||
/// `crashes`'s lines in the usage.
|
||||
const USAGE_CRASHES: &str = " solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]\n";
|
||||
/// `licences`'s lines in the usage.
|
||||
@@ -817,7 +817,7 @@ fn bench_ops(mut s: Session, script: &str) -> Result<Session, String> {
|
||||
|
||||
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
|
||||
pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]")?;
|
||||
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.sc | part.step> [--parse-only]")?;
|
||||
let t = std::time::Instant::now();
|
||||
let step = solidcraft_engine::io::is_step_path(path);
|
||||
let limit = if step { solidcraft_engine::io::MAX_STEP_BYTES } else { solidcraft_engine::io::MAX_DESIGN_BYTES };
|
||||
@@ -863,13 +863,13 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "place_ms": place_ms, "bodies": st.bodies.len(), "triangles": triangles})
|
||||
);
|
||||
let flag = |name: &str| args.iter().position(|a| a == name);
|
||||
// `--save PATH`: save the opened design without its model cache (to PATH.plain.solidcraft) and
|
||||
// `--save PATH`: save the opened design without its model cache (to PATH.plain.sc) and
|
||||
// as the app does, with it, then read the cache back.
|
||||
let s = match flag("--save").and_then(|i| args.get(i + 1)) {
|
||||
Some(out) => {
|
||||
let mut s = s;
|
||||
// Without the cache first, for comparison.
|
||||
let plain = format!("{out}.plain.solidcraft");
|
||||
let plain = format!("{out}.plain.{}", solidcraft_engine::io::DESIGN_EXT);
|
||||
let t = std::time::Instant::now();
|
||||
s.execute("file.save_as", &json!({"path": plain, "cache": false})).map_err(|e| e.to_string())?;
|
||||
let plain_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
@@ -92,7 +92,7 @@ fn designs() -> Vec<(String, PathBuf)> {
|
||||
add("regressions", root.join("examples/regressions"), "json");
|
||||
add("demo", root.join("examples/demo"), "json");
|
||||
add("typical", root.join("tests/action_matrix/designs"), "json");
|
||||
add("demo", root.join("tests/action_matrix/designs"), "solidcraft");
|
||||
add("demo", root.join("tests/action_matrix/designs"), solidcraft_engine::io::DESIGN_EXT);
|
||||
let mut cases: Vec<PathBuf> =
|
||||
std::fs::read_dir(root.join("tests/oracle")).unwrap().flatten().map(|e| e.path()).filter(|p| p.join("recipe.json").is_file()).collect();
|
||||
cases.sort();
|
||||
@@ -105,7 +105,7 @@ fn designs() -> Vec<(String, PathBuf)> {
|
||||
/// Build a design from its file.
|
||||
fn load(path: &Path) -> Result<Session, String> {
|
||||
let mut s = Session::default();
|
||||
if path.extension().is_some_and(|x| x == "solidcraft") {
|
||||
if solidcraft_engine::io::is_design_container_path(path) {
|
||||
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).map_err(|e| e.to_string())?;
|
||||
return Ok(s);
|
||||
}
|
||||
@@ -1558,7 +1558,7 @@ fn sketches_on_loft_and_sweep_faces() {
|
||||
#[test]
|
||||
fn press_pull_on_the_pi_case_floor() {
|
||||
reproduces(
|
||||
Some("tests/action_matrix/designs/pi-case.solidcraft"),
|
||||
Some("tests/action_matrix/designs/pi-case.sc"),
|
||||
json!([{"command": "solid.press_pull", "params": {"face": [30, 20, 0], "distance": 1}}]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,12 +75,15 @@ fn designs() -> Vec<(String, Session)> {
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "solidcraft" || x == "json") && !p.to_string_lossy().ends_with(".expected.json"))
|
||||
.filter(|p| {
|
||||
(solidcraft_engine::io::is_design_container_path(p) || p.extension().is_some_and(|x| x == "json"))
|
||||
&& !p.to_string_lossy().ends_with(".expected.json")
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
for p in files {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let s = if p.extension().is_some_and(|x| x == "solidcraft") {
|
||||
let s = if solidcraft_engine::io::is_design_container_path(&p) {
|
||||
open(&p)
|
||||
} else {
|
||||
let mut s = Session::default();
|
||||
@@ -150,7 +153,7 @@ fn pi_case_faces_per_step_at_both_heights() {
|
||||
const FACES: [(usize, usize); 13] =
|
||||
[(2, 6), (3, 10), (4, 19), (5, 19), (6, 25), (7, 43), (8, 43), (9, 55), (10, 55), (11, 59), (12, 59), (13, 63), (14, 79)];
|
||||
let dir = scratch_dir("pi");
|
||||
let design: Value = serde_json::from_slice(&std::fs::read(root().join("tests/action_matrix/designs/pi-case.solidcraft")).unwrap()).unwrap();
|
||||
let design: Value = serde_json::from_slice(&std::fs::read(root().join("tests/action_matrix/designs/pi-case.sc")).unwrap()).unwrap();
|
||||
let at = |height: &str, file: &str| {
|
||||
let mut d = design.clone();
|
||||
for p in d["params"].as_array_mut().unwrap() {
|
||||
|
||||
@@ -145,7 +145,7 @@ fn eval_and_inspect_reject_invalid_options_before_script_execution_or_reporting(
|
||||
#[test]
|
||||
fn cli_loads_both_design_encodings_by_contents() {
|
||||
let dir = Scratch::new();
|
||||
for extension in ["solidcraft", "solvecraft", "json"] {
|
||||
for extension in ["sc", "solidcraft", "solvecraft", "json"] {
|
||||
let saved = dir.0.join(format!("box.{extension}"));
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap());
|
||||
let bytes = std::fs::read(&saved).unwrap();
|
||||
@@ -412,6 +412,11 @@ fn legacy_extension_and_format_tag_still_open() {
|
||||
assert_eq!(body_count(&resaved), 2);
|
||||
let reopened = solidcraft_engine::io::read_design(&std::fs::read(&resaved).unwrap()).unwrap();
|
||||
assert!(reopened.to_json().contains("\"solidcraft/"));
|
||||
// `.solidcraft` (before `.sc`) opens as `--in` and as the design to evaluate; `.sc` too.
|
||||
let current = dir.0.join("current.sc");
|
||||
json_output(cli().args(["run", "--in"]).arg(&resaved).arg(dir.script()).arg("--save").arg(¤t).output().unwrap());
|
||||
assert_eq!(body_count(¤t), 3);
|
||||
assert!(solidcraft_engine::io::is_design_container(&std::fs::read(¤t).unwrap()), "a .sc file is a design container");
|
||||
}
|
||||
|
||||
/// `snapshot` starts from `--in` as `run` does (its script runs on that design), and refuses an
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Straight edges stay one edge after many cuts (#1049). The owner's design
|
||||
//! (`tests/action_matrix/designs/crazy-geom-cuts.solidcraft`, saved by 0.10.0): five extrudes,
|
||||
//! (`tests/action_matrix/designs/crazy-geom-cuts.sc`, saved by 0.10.0): five extrudes,
|
||||
//! joins and cuts, over four sketches, three of them on faces of the body with the faces'
|
||||
//! edges projected. Cuts used to leave the straight edges of the walls they sliced split at
|
||||
//! vertices nothing else used, so an edge that is one straight line was drawn (and lit on hover)
|
||||
@@ -14,7 +14,7 @@ use solidcraft_engine::doc::kernel::{FaceSurface, face_surface};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
fn design() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/action_matrix/designs/crazy-geom-cuts.solidcraft")
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/action_matrix/designs/crazy-geom-cuts.sc")
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Session {
|
||||
|
||||
@@ -169,10 +169,15 @@ pub fn upload_limit(extensions: &[&str]) -> Option<usize> {
|
||||
(!extensions.is_empty() && extensions.iter().all(image)).then_some(solidcraft_engine::doc::canvas::MAX_CANVAS_BYTES)
|
||||
}
|
||||
|
||||
/// The file type a Save As dialog offers for `name`: (description, MIME type, extension).
|
||||
pub fn save_type(name: &str) -> (&'static str, &'static str, &'static str) {
|
||||
let json = std::path::Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case("json"));
|
||||
if json { ("Plain JSON design", "application/json", ".json") } else { ("SolidCraft design", "application/octet-stream", ".solidcraft") }
|
||||
/// The file type a Save As dialog offers for `name`: (description, MIME type, extension). A
|
||||
/// design is `.sc` unless the name already has an older design extension, which it keeps.
|
||||
pub fn save_type(name: &str) -> (&'static str, &'static str, String) {
|
||||
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
|
||||
match ext.as_deref() {
|
||||
Some("json") => ("Plain JSON design", "application/json", ".json".into()),
|
||||
Some(e) if solidcraft_engine::io::is_design_ext(e) => ("SolidCraft design", "application/octet-stream", format!(".{e}")),
|
||||
_ => ("SolidCraft design", "application/octet-stream", format!(".{}", solidcraft_engine::io::DESIGN_EXT)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this a cancelled dialog (the browser's `AbortError`)? Not an error to show.
|
||||
@@ -264,7 +269,7 @@ mod web {
|
||||
let options = Object::new();
|
||||
let _ = Reflect::set(&options, &"suggestedName".into(), &name.into());
|
||||
let (description, mime, extension) = save_type(name);
|
||||
let _ = Reflect::set(&options, &"types".into(), &types(description, mime, extension));
|
||||
let _ = Reflect::set(&options, &"types".into(), &types(description, mime, &extension));
|
||||
options
|
||||
}
|
||||
|
||||
@@ -349,7 +354,9 @@ mod web {
|
||||
let options = Object::new();
|
||||
let _ = Reflect::set(&options, &"multiple".into(), &JsValue::FALSE);
|
||||
let extensions = Array::new();
|
||||
for e in [".solidcraft", ".solvecraft", ".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf", ".fcstd", ".3dm", ".dxf", ".svg"] {
|
||||
let designs = solidcraft_engine::io::DESIGN_EXTS.iter().map(|e| format!(".{e}"));
|
||||
let others = [".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf", ".fcstd", ".3dm", ".dxf", ".svg"].map(String::from);
|
||||
for e in designs.chain(others) {
|
||||
extensions.push(&e.into());
|
||||
}
|
||||
let accept = Object::new();
|
||||
@@ -667,6 +674,7 @@ mod tests {
|
||||
#[test]
|
||||
fn only_design_opens_use_the_disk_dialog() {
|
||||
assert!(opens_design(&[]));
|
||||
assert!(opens_design(&["sc"]));
|
||||
assert!(opens_design(&["solidcraft"]));
|
||||
assert!(opens_design(&["solvecraft"]));
|
||||
assert!(!opens_design(&["step", "stp"]));
|
||||
@@ -710,9 +718,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_save_dialog_offers_the_type_of_the_name() {
|
||||
assert_eq!(save_type("Plate.solidcraft").2, ".solidcraft");
|
||||
assert_eq!(save_type("Plate.sc").2, ".sc");
|
||||
assert_eq!(save_type("Plate.JSON").2, ".json");
|
||||
assert_eq!(save_type("Plate").2, ".solidcraft");
|
||||
assert_eq!(save_type("Plate").2, ".sc");
|
||||
// A design opened under an older extension keeps it.
|
||||
assert_eq!(save_type("Plate.solidcraft").2, ".solidcraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,11 +24,13 @@ pub const OPFS: &str = disk::OPFS_PREFIX;
|
||||
pub const DOWNLOADS: &str = disk::DOWNLOADS_PREFIX;
|
||||
/// Files brought in from the computer.
|
||||
pub const UPLOAD: &str = "/upload/";
|
||||
/// The autosave of unsaved work (in OPFS: `recovery.solidcraft` and `recovery.json`).
|
||||
const RECOVERY: &str = "recovery.solidcraft";
|
||||
/// The autosave earlier releases kept (compatibility shim, docs/compatibility.md): recovered
|
||||
/// when there is no current one.
|
||||
const LEGACY_RECOVERY: &str = "recovery.solvecraft";
|
||||
/// The autosave of unsaved work (in OPFS: `recovery.sc` and `recovery.json`), named with
|
||||
/// `solidcraft_engine::io::DESIGN_EXT`.
|
||||
const RECOVERY: &str = "recovery.sc";
|
||||
/// The autosaves earlier releases kept, newest first, one per entry of
|
||||
/// `solidcraft_engine::io::LEGACY_DESIGN_EXTS` (compatibility shim, docs/compatibility.md): the
|
||||
/// newest is recovered when there is no current one.
|
||||
const LEGACY_RECOVERY: [&str; 2] = ["recovery.solidcraft", "recovery.solvecraft"];
|
||||
const RECOVERY_META: &str = "recovery.json";
|
||||
|
||||
/// What to do with a file brought in from the computer.
|
||||
@@ -282,9 +284,10 @@ pub fn start(files: &Rc<Files>) {
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let has_recovery = all.iter().any(|(n, _, _)| n == RECOVERY);
|
||||
let legacy = (!has_recovery).then(|| LEGACY_RECOVERY.into_iter().find(|l| all.iter().any(|(n, _, _)| n == l))).flatten();
|
||||
for (name, bytes, modified) in all {
|
||||
if name == LEGACY_RECOVERY {
|
||||
if !has_recovery {
|
||||
if LEGACY_RECOVERY.contains(&name.as_str()) {
|
||||
if legacy == Some(name.as_str()) {
|
||||
vfs::insert(&format!("{OPFS}{RECOVERY}"), bytes);
|
||||
}
|
||||
continue;
|
||||
@@ -424,7 +427,9 @@ pub fn autosave(files: &Rc<Files>, doc: Option<(&solidcraft_engine::doc::Documen
|
||||
}
|
||||
None => spawn_local(async move {
|
||||
let _ = remove_file(RECOVERY).await;
|
||||
let _ = remove_file(LEGACY_RECOVERY).await;
|
||||
for legacy in LEGACY_RECOVERY {
|
||||
let _ = remove_file(legacy).await;
|
||||
}
|
||||
let _ = remove_file(RECOVERY_META).await;
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -72,10 +72,10 @@ impl Download {
|
||||
}
|
||||
let Some((name, bytes)) = self.pending.take() else { return };
|
||||
// The extension selects the design loader even when the URL has no extension.
|
||||
let path = "/linked/design.solidcraft";
|
||||
let path = &format!("/linked/design.{}", solidcraft_engine::io::DESIGN_EXT);
|
||||
vfs::insert(path, bytes);
|
||||
app.open_path(path);
|
||||
if app.session.path.as_deref() == Some(path) {
|
||||
if app.session.path.as_deref() == Some(path.as_str()) {
|
||||
app.session.path = None;
|
||||
app.session.doc_mut().name = name;
|
||||
app.session.mark_unsaved();
|
||||
@@ -90,9 +90,14 @@ async fn fetch(value: &str) -> Reply {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let page_origin = window.location().origin().map_err(|_| "Cannot read page origin")?;
|
||||
let url = design_url(value, &page_origin)?;
|
||||
let name = url.path_segments().and_then(|mut segments| segments.next_back()).filter(|name| !name.is_empty()).unwrap_or("Design.solidcraft");
|
||||
let name = url.path_segments().and_then(|mut segments| segments.next_back()).filter(|name| !name.is_empty()).unwrap_or("Design");
|
||||
let decoded = js_sys::decode_uri_component(name).ok().and_then(|name| name.as_string()).unwrap_or_else(|| name.to_owned());
|
||||
let name: String = decoded.strip_suffix(".solidcraft").unwrap_or(&decoded).chars().take(255).collect();
|
||||
// `arm.sc` (or a legacy `arm.solidcraft`) is named `arm`.
|
||||
let stem = match decoded.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.is_empty() && solidcraft_engine::io::is_design_ext(ext) => stem,
|
||||
_ => decoded.as_str(),
|
||||
};
|
||||
let name: String = stem.chars().take(255).collect();
|
||||
let response = JsFuture::from(window.fetch_with_str(url.as_str())).await.map_err(|_| "Network request failed or the host refused CORS access")?;
|
||||
let response: web_sys::Response = response.dyn_into().map_err(|_| "Invalid download response")?;
|
||||
design_url(&response.url(), &page_origin).map_err(|_| "Design redirected to a URL that does not use HTTPS or the page's own origin")?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! SolidCraft desktop app.
|
||||
//!
|
||||
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]`
|
||||
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [--safe-mode] [design.sc | part.step]`
|
||||
//!
|
||||
//! `--safe-mode` (or `SOLIDCRAFT_SAFE_MODE=1`) starts with default settings, no recovery offer and
|
||||
//! the software renderer; see `startup` and docs/book/src/guide/troubleshooting.md.
|
||||
@@ -778,7 +778,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"usage: solidcraft [--control PORT] [--sample] [--theme-file THEME.json] [--crash-log] [--safe-mode] [design.solidcraft | part.step]"
|
||||
"usage: solidcraft [--control PORT] [--sample] [--theme-file THEME.json] [--crash-log] [--safe-mode] [design.sc | part.step]"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1203,28 +1203,44 @@
|
||||
"from the profile": "من المقطع",
|
||||
"from the rule": "من القاعدة",
|
||||
"help.apache": "رخصة Apache 2.0",
|
||||
"help.appearance.assign": "يمنح الأجسام أو الأوجه أو المكونات المحددة لونًا أو مظهر مادة، من دون تغيير وزنها. استخدمه للتمييز بين القطع أو لمعاينة التشطيب.",
|
||||
"help.attributions": "الإسنادات (ATTRIBUTION.md)",
|
||||
"help.build": "الإصدار {version} · الإيداع {commit} · بُني في {date}",
|
||||
"help.connect_note": "أعد تشغيل كل مساعد لتحميل خادم SolidCraft. نُسخ كل ملف احتياطيًا بامتداد .bak قبل تغييره.",
|
||||
"help.connect_title": "ربط مساعدي الذكاء الاصطناعي",
|
||||
"help.face.delete": "يحذف الأوجه المحددة ويترك الأوجه المجاورة تسد الفجوة، أو يترك الفتحة مفتوحة كسطح. استخدمه لإزالة حافة مقوّسة أو ثقب أو نتوء يعترض الطريق.",
|
||||
"help.font_details": "تستخدم الواجهة خطوط egui المدمجة (Ubuntu Light وHack، بموجب رخصة Ubuntu Font ورخصة MIT/Bitstream Vera، وNoto Emoji وخط أيقونات الرموز التعبيرية بموجب OFL وMIT). يستخدم نص الرسم خط Liberation Sans (SIL OFL 1.1). حزمة الخطوط الاختيارية craft-fonts لتطبيقات Crafting (BIZ UDPGothic وShippori Mincho وBIZ UDMincho وNoto Sans CJK SC وNoto Sans Arabic، بموجب SIL OFL 1.1) غير مضمّنة في إصدارات SolidCraft.",
|
||||
"help.fonts": "الخطوط",
|
||||
"help.licences": "الرخص",
|
||||
"help.material.assign": "يحدد المادة التي صُنعت منها الأجسام المحددة، مثل الفولاذ أو الألومنيوم أو ABS. استخدمه لتطابق الكتلة والوزن القطعة الحقيقية.",
|
||||
"help.mit": "رخصة MIT",
|
||||
"help.notice": "NOTICE",
|
||||
"help.parameters.change": "يفتح جدول القيم المسماة والتعبيرات التي تتحكم في أبعاد التصميم. استخدمه لتغيير مقاس في مكان واحد فتتبعه كل الميزات التي تستخدمه.",
|
||||
"help.solid.align": "يحرّك الأجسام المحددة بحيث تلتقي نقطة أو وجه أو مستوى منها بنقطة أو وجه أو مستوى في مكان آخر. استخدمه لوضع قطعة ملاصقة لأخرى دون قياس المسافة.",
|
||||
"help.solid.chamfer": "يقطع الحواف المحددة بشطفة مستوية بالمسافة التي تحددها. استخدمه لكسر الحواف الحادة أو لتسهيل إدخال مسمار أو برغي.",
|
||||
"help.solid.combine": "يضم الأجسام، أو يقتطعها من جسم هدف، أو يُبقي الحجم المشترك بينها فقط، ويمكنه الاحتفاظ بأجسام الأداة. استخدمه لدمج القطع، أو لقطع شكل بشكل آخر، أو لمعرفة موضع تداخل جسمين.",
|
||||
"help.solid.draft": "يميل الأوجه المحددة بالزاوية التي تحددها، مقيسةً من مستوى محايد. استخدمه في القطع المقولبة أو المصبوبة كي تخرج من القالب بسهولة.",
|
||||
"help.solid.extrude": "يدفع مقطع رسم إلى ثلاثي الأبعاد على مسافة محددة، بإضافة مادة أو إزالتها. استخدمه لتحويل مخطط مسطح إلى كتلة أو نتوء أو تجويف.",
|
||||
"help.solid.fillet": "يقوّس الحواف المحددة بانحناء دائري ناعم بنصف القطر الذي تحدده. استخدمه لتنعيم الحواف الحادة من أجل المتانة أو الراحة أو المظهر.",
|
||||
"help.solid.hole": "يحفر ثقبًا دائريًا بالقطر الذي تحدده عند نقطة مختارة على وجه. استخدمه لفتحات البراغي والمسامير والفتحات الدائرية البسيطة الأخرى.",
|
||||
"help.solid.loft": "يصنع جسمًا صلبًا ناعمًا ينتقل بين مقطعي رسم أو أكثر، أو من مقطع إلى نقطة. استخدمه للانتقالات والأقماع والمقابض وأي شكل يتغير مقطعه على طوله.",
|
||||
"help.solid.mirror": "يصنع نسخة معكوسة من الأجسام أو الميزات المحددة عبر مستوى، ويمكنه دمجها مع الأصل. استخدمه للقطع المتماثلة، فتنمذج نصفًا واحدًا وتحصل على الاثنين.",
|
||||
"help.solid.move": "يحرّك الأجسام المحددة أو يدوّرها، أو نسخة منها، بالمسافة والزاوية اللتين تحددهما. استخدمه لتغيير موضع جسم أو لإنشاء جسم ثانٍ في مكان آخر.",
|
||||
"help.solid.offset_face": "يدفع الأوجه المحددة إلى الداخل أو الخارج بالمسافة التي تحددها، وتتبعها الأوجه المحيطة. استخدمه لزيادة سماكة جدار أو عمق جيب أو ارتفاع نتوء دون تعديل الرسم.",
|
||||
"help.solid.pattern.circular": "يكرر العناصر المحددة حول محور، بالعدد والزاوية الكلية اللذين تحددهما. استخدمه لدوائر البراغي والأذرع والزعانف والأسنان.",
|
||||
"help.solid.pattern.rectangular": "يكرر العناصر المحددة في اتجاه واحد أو اتجاهين بالعدد والتباعد اللذين تحددهما. استخدمه لصفوف الثقوب والأضلاع والمسامير والأرجل وشبكاتها.",
|
||||
"help.solid.press_pull": "يضيف المادة أو يزيلها حين تسحب وجهًا، أو يقوّس الحواف حين تسحبها. استخدمه لإجراء تعديلات سريعة على النموذج مباشرة.",
|
||||
"help.solid.remove": "يُخرج الأجسام المحددة من التصميم بدءًا من هذه النقطة في السجل، وتحتفظ بها الخطوات السابقة. استخدمه للتخلص من جسم مساعد، مثل أداة قطع لم تعد تحتاجها.",
|
||||
"help.solid.replace_face": "ينقل الأوجه المستوية المحددة إلى مستوى أو إلى وجه موازٍ، فيطول الجسم أو يقصر ليبلغه. استخدمه لجعل وجه مستويًا مع قطعة أخرى أو مع مستوى إنشائي.",
|
||||
"help.solid.revolve": "يدير مقطع رسم حول محور لصنع جسم دائري. استخدمه للأعمدة والمقابض والأوعية وأي قطعة دائرية حول خط مركزي.",
|
||||
"help.solid.scale": "يكبّر الأجسام المحددة أو يصغّرها بمعامل، متساوٍ في كل الاتجاهات أو مختلف لكل محور. استخدمه لتغيير حجم شكل مستورد أو لحساب الانكماش.",
|
||||
"help.solid.shell": "يفرّغ الجسم الصلب تاركًا جدرانًا بالسماكة التي تحددها، ويمكنه فتح الأوجه التي تختارها. استخدمه للصناديق والأغطية والقطع الأخرى رقيقة الجدران.",
|
||||
"help.solid.split_body": "يقسم جسمًا إلى أجسام منفصلة بمستوى أو بوجه جسم آخر أو بخط من رسم. استخدمه لصنع قطع تُطبع أو تُشغَّل أو تُلوَّن كلٌّ على حدة.",
|
||||
"help.solid.split_face": "يقسم الأوجه المحددة على طول مستوى أو وجه جسم آخر أو منحنيات رسم، دون قطع الجسم. استخدمه لإعطاء جزء من وجه ميلًا أو لونًا أو إزاحة خاصة به.",
|
||||
"help.solid.sweep": "يسحب مقطع رسم على طول مسار لصنع جسم صلب. استخدمه للأنابيب والقضبان والمقابض وأي قطعة ثابتة المقطع تتبع منحنى.",
|
||||
"help.third_party": "رخص الأطراف الثالثة",
|
||||
"help.timeline.compute_all": "يعيد بناء كل ميزة في السجل من البداية. استخدمه لتحديث النموذج ولمعرفة الميزات التي تفشل.",
|
||||
"help.timeline.delete": "يحذف الميزات المحددة من السجل، وحذف رسمٍ يحذف معه الميزات المبنية عليه. استخدمه لإزالة خطوة لم تعد مطلوبة.",
|
||||
"hole_presets.confirm_delete": "تأكيد الحذف",
|
||||
"hole_presets.delete": "حذف",
|
||||
"hole_presets.delete_prompt": "هل تحذف هذا الإعداد المسبق الشخصي؟ الثقوب الموجودة لا تتغير.",
|
||||
@@ -1462,6 +1478,10 @@
|
||||
"prefs.assistant": "المساعد",
|
||||
"prefs.axes": "المحاور",
|
||||
"prefs.axis": "المحور",
|
||||
"prefs.bodies_folders": "مجلدات الأجسام",
|
||||
"prefs.bodies_folders_always": "دائمًا",
|
||||
"prefs.bodies_folders_never": "أبدًا",
|
||||
"prefs.bodies_folders_several": "عند تعدد الأجسام فقط",
|
||||
"prefs.bottom": "أسفل",
|
||||
"prefs.browser": "المستعرض",
|
||||
"prefs.built_in": "مدمج",
|
||||
@@ -1564,6 +1584,8 @@
|
||||
"prefs.saving": "الحفظ التلقائي والاستعادة",
|
||||
"prefs.scale_note": "أو Ctrl + / Ctrl − في النافذة. الأزرار 28 px عند 100%.",
|
||||
"prefs.scale_text": "الحجم والنص",
|
||||
"prefs.search": "البحث في الإعدادات",
|
||||
"prefs.search_empty": "لا توجد إعدادات مطابقة",
|
||||
"prefs.select": "تحديد",
|
||||
"prefs.select_one_item": "تحديد عنصر واحد",
|
||||
"prefs.selecting": "التحديد",
|
||||
|
||||
@@ -903,7 +903,7 @@
|
||||
"home.new_mm": "Nový návrh",
|
||||
"home.new_mm_hint": "Milimetry · otevře se v pracovním prostoru Návrh",
|
||||
"home.open": "Otevřít…",
|
||||
"home.open_hint": "Návrh ve formátu .solidcraft ({shortcut})",
|
||||
"home.open_hint": "Návrh ve formátu .sc ({shortcut})",
|
||||
"home.open_now": "Otevřít nyní",
|
||||
"home.opened_days_ago": "Před {count} dny",
|
||||
"home.opened_hours_ago": "Před {count} h",
|
||||
|
||||
@@ -1106,7 +1106,7 @@
|
||||
"home.new_mm": "Nyt design",
|
||||
"home.new_mm_hint": "Millimeter · åbner i Design",
|
||||
"home.open": "Åbn…",
|
||||
"home.open_hint": "Et .solidcraft-design ({shortcut})",
|
||||
"home.open_hint": "Et .sc-design ({shortcut})",
|
||||
"home.open_now": "Åbn nu",
|
||||
"home.opened_days_ago": "For {count} dage siden",
|
||||
"home.opened_hours_ago": "For {count} t siden",
|
||||
|
||||
@@ -1229,7 +1229,7 @@
|
||||
"home.new_mm": "Neuer Entwurf",
|
||||
"home.new_mm_hint": "Millimeter · öffnet sich im Arbeitsbereich Entwurf",
|
||||
"home.open": "Öffnen…",
|
||||
"home.open_hint": "Ein .solidcraft-Entwurf ({shortcut})",
|
||||
"home.open_hint": "Ein .sc-Entwurf ({shortcut})",
|
||||
"home.open_now": "Jetzt öffnen",
|
||||
"home.opened_days_ago": "Vor {count} Tagen",
|
||||
"home.opened_hours_ago": "Vor {count} Std.",
|
||||
@@ -1393,6 +1393,10 @@
|
||||
"pattern.flip_direction": "Richtung umkehren",
|
||||
"pick a hole to seat it, or OK places it at the origin": "Wählen Sie eine Bohrung zum Einsetzen, oder OK platziert es im Ursprung",
|
||||
"pick one or two vertices, edges or faces": "Wählen Sie ein oder zwei Ecken, Kanten oder Flächen",
|
||||
"prefs.bodies_folders": "Körperordner",
|
||||
"prefs.bodies_folders_always": "Immer",
|
||||
"prefs.bodies_folders_never": "Nie",
|
||||
"prefs.bodies_folders_several": "Nur bei mehreren Körpern",
|
||||
"prefs.cpu": "CPU (Software)",
|
||||
"prefs.dialogs": "Dialoge",
|
||||
"prefs.dialogs_edge": "Am Rand der Ansicht",
|
||||
@@ -1403,6 +1407,8 @@
|
||||
"prefs.minutes_suffix": " min",
|
||||
"prefs.origin_plane_names": "Namen der Ursprungsebenen",
|
||||
"prefs.origin_plane_names_note": "Namen in Browser, Dialogen und Listen. {top} ist XY, {front} ist XZ und {right} ist YZ, mit Z nach oben. Befehle, Skripte und gespeicherte Konstruktionen behalten XY, XZ und YZ.",
|
||||
"prefs.search": "Einstellungen suchen",
|
||||
"prefs.search_empty": "Keine passenden Einstellungen",
|
||||
"prefs.theme_follow": "Einer Designdatei folgen",
|
||||
"prefs.theme_follow_error": "Nicht angewendet: {error}. Das letzte gültige Design bleibt aktiv.",
|
||||
"prefs.theme_follow_launch": "Nur für diesen Start (--theme-file); wird neu geladen, sobald ein anderes Werkzeug die Datei überschreibt.",
|
||||
|
||||
+23
-1
@@ -1203,28 +1203,44 @@
|
||||
"from the profile": "from the profile",
|
||||
"from the rule": "from the rule",
|
||||
"help.apache": "Apache licence 2.0",
|
||||
"help.appearance.assign": "Gives the selected bodies, faces or components a colour or a material look, without changing their weight. Use it to tell parts apart or to preview a finish.",
|
||||
"help.attributions": "Attributions (ATTRIBUTION.md)",
|
||||
"help.build": "Version {version} · commit {commit} · built {date}",
|
||||
"help.connect_note": "Restart each assistant to load the SolidCraft server. Each file was backed up as .bak before it changed.",
|
||||
"help.connect_title": "Connect AI assistants",
|
||||
"help.face.delete": "Removes the selected faces and lets the neighbouring faces close the gap, or leaves the gap open as a surface. Use it to take off a rounded edge, a hole or a boss that is in the way.",
|
||||
"help.font_details": "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and the MIT/Bitstream Vera licence; Noto Emoji and the emoji icon font, under the OFL and MIT). Sketch text uses Liberation Sans (SIL OFL 1.1). The Crafting Apps' optional font pack, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), is not bundled in SolidCraft builds.",
|
||||
"help.fonts": "Fonts",
|
||||
"help.licences": "Licences",
|
||||
"help.material.assign": "Sets what the selected bodies are made of, such as steel, aluminium or ABS. Use it so that the mass and weight match the real part.",
|
||||
"help.mit": "MIT licence",
|
||||
"help.notice": "NOTICE",
|
||||
"help.parameters.change": "Opens the table of named values and expressions that drive the dimensions of the design. Use it to change a size in one place and let every feature that uses it follow.",
|
||||
"help.solid.align": "Moves the selected bodies so that a point, face or plane on them meets a point, face or plane elsewhere. Use it to place a part against another one without measuring the offset.",
|
||||
"help.solid.chamfer": "Cuts the selected edges with a flat bevel of the distance you set. Use it to break sharp edges or to ease the entry of a pin or screw.",
|
||||
"help.solid.combine": "Joins bodies, cuts them out of a target body, or keeps only the volume they share, and can keep the tool bodies. Use it to merge parts, to cut one shape with another or to find where two bodies overlap.",
|
||||
"help.solid.draft": "Tilts the selected faces by the angle you set, measured from a neutral plane. Use it on moulded or cast parts so that they slide out of the mould.",
|
||||
"help.solid.extrude": "Pushes a sketch profile into 3D along a distance, adding or removing material. Use it to turn a flat outline into a block, a boss or a pocket.",
|
||||
"help.solid.fillet": "Rounds the selected edges with a smooth circular blend of the radius you set. Use it to soften sharp edges for strength, comfort or looks.",
|
||||
"help.solid.hole": "Drills a round hole of the diameter you set at a chosen point on a face. Use it for screw clearances, pins and other simple round openings.",
|
||||
"help.solid.loft": "Blends a smooth solid between two or more sketch profiles, or from a profile to a point. Use it for transitions, funnels, handles and any shape that changes section along its length.",
|
||||
"help.solid.mirror": "Makes a mirrored copy of the selected bodies or features across a plane, and can merge it with the original. Use it for symmetric parts, so that you model one half and get both.",
|
||||
"help.solid.move": "Moves or rotates the selected bodies, or a copy of them, by the distance and angle you set. Use it to reposition a body or to make a second one in another place.",
|
||||
"help.solid.offset_face": "Pushes the selected faces in or out by the distance you set, and the faces around them follow. Use it to make a wall thicker, a pocket deeper or a boss taller without editing the sketch.",
|
||||
"help.solid.pattern.circular": "Repeats the selected objects around an axis, for the count and total angle you set. Use it for bolt circles, spokes, fins and teeth.",
|
||||
"help.solid.pattern.rectangular": "Repeats the selected objects in one or two directions with the count and spacing you set. Use it for rows and grids of holes, ribs, pins or feet.",
|
||||
"help.solid.press_pull": "Adds or removes material as you drag a face, or rounds edges as you drag them. Use it for quick changes straight on the model.",
|
||||
"help.solid.remove": "Takes the selected bodies out of the design from this point in the timeline on; earlier steps keep them. Use it to drop a helper body, such as a cutting tool you no longer need.",
|
||||
"help.solid.replace_face": "Moves the selected flat faces onto a plane or onto a parallel face, and the body grows or shrinks to meet it. Use it to bring a face flush with another part or a construction plane.",
|
||||
"help.solid.revolve": "Spins a sketch profile around an axis to make a round solid. Use it for shafts, knobs, bowls and any part that is round about a centre line.",
|
||||
"help.solid.scale": "Makes the selected bodies larger or smaller by a factor, the same in every direction or one per axis. Use it to resize an imported shape or to allow for shrinkage.",
|
||||
"help.solid.shell": "Hollows out a solid, leaving walls of the thickness you set, and can open the faces you pick. Use it for boxes, covers and other thin-walled parts.",
|
||||
"help.solid.split_body": "Cuts a body into separate bodies with a plane, the face of another body or a sketch line. Use it to make parts that are printed, machined or coloured on their own.",
|
||||
"help.solid.split_face": "Divides the selected faces along a plane, the face of another body or sketch curves, without cutting the body. Use it to give part of a face its own draft, colour or offset.",
|
||||
"help.solid.sweep": "Drags a sketch profile along a path to make a solid. Use it for pipes, rails, handles and any part of constant section that follows a curve.",
|
||||
"help.third_party": "Third-party licences",
|
||||
"help.timeline.compute_all": "Rebuilds every feature in the timeline from the start. Use it to refresh the model and to see which features fail.",
|
||||
"help.timeline.delete": "Deletes the selected features from the timeline; deleting a sketch also deletes the features built from it. Use it to remove a step that is no longer wanted.",
|
||||
"hole_presets.confirm_delete": "Confirm delete",
|
||||
"hole_presets.delete": "Delete",
|
||||
"hole_presets.delete_prompt": "Delete this personal preset? Existing holes are unchanged.",
|
||||
@@ -1275,7 +1291,7 @@
|
||||
"home.new_mm": "New design",
|
||||
"home.new_mm_hint": "Millimetres · opens in Design",
|
||||
"home.open": "Open…",
|
||||
"home.open_hint": "A .solidcraft design ({shortcut})",
|
||||
"home.open_hint": "A .sc design ({shortcut})",
|
||||
"home.open_now": "Open now",
|
||||
"home.opened_days_ago": "{count} days ago",
|
||||
"home.opened_hours_ago": "{count} h ago",
|
||||
@@ -1462,6 +1478,10 @@
|
||||
"prefs.assistant": "Assistant",
|
||||
"prefs.axes": "Axes",
|
||||
"prefs.axis": "Axis",
|
||||
"prefs.bodies_folders": "Bodies folders",
|
||||
"prefs.bodies_folders_always": "Always",
|
||||
"prefs.bodies_folders_never": "Never",
|
||||
"prefs.bodies_folders_several": "Several bodies only",
|
||||
"prefs.bottom": "Bottom",
|
||||
"prefs.browser": "Browser",
|
||||
"prefs.built_in": "Built in",
|
||||
@@ -1564,6 +1584,8 @@
|
||||
"prefs.saving": "Autosave and recovery",
|
||||
"prefs.scale_note": "Also Ctrl + / Ctrl − in the window. Buttons are 28 px at 100%.",
|
||||
"prefs.scale_text": "Scale and text",
|
||||
"prefs.search": "Search settings",
|
||||
"prefs.search_empty": "No matching settings",
|
||||
"prefs.select": "Select",
|
||||
"prefs.select_one_item": "Select one item",
|
||||
"prefs.selecting": "Selecting",
|
||||
|
||||
@@ -944,7 +944,7 @@
|
||||
"home.new_mm": "Nuevo diseño",
|
||||
"home.new_mm_hint": "Milímetros · se abre en Diseño",
|
||||
"home.open": "Abrir…",
|
||||
"home.open_hint": "Un diseño .solidcraft ({shortcut})",
|
||||
"home.open_hint": "Un diseño .sc ({shortcut})",
|
||||
"home.open_now": "Abrir ahora",
|
||||
"home.opened_days_ago": "Hace {count} días",
|
||||
"home.opened_hours_ago": "Hace {count} h",
|
||||
|
||||
+17
-1
@@ -1159,28 +1159,44 @@
|
||||
"from the profile": "depuis le profil",
|
||||
"from the rule": "selon la règle",
|
||||
"help.apache": "Licence Apache 2.0",
|
||||
"help.appearance.assign": "Donne aux corps, faces ou composants sélectionnés une couleur ou l'apparence d'un matériau, sans changer leur poids. À utiliser pour distinguer des pièces ou prévisualiser une finition.",
|
||||
"help.attributions": "Attributions (ATTRIBUTION.md)",
|
||||
"help.build": "Version {version} · commit {commit} · compilée le {date}",
|
||||
"help.connect_note": "Redémarrez chaque assistant pour charger le serveur SolidCraft. Chaque fichier a été sauvegardé en .bak avant d'être modifié.",
|
||||
"help.connect_title": "Connecter les assistants IA",
|
||||
"help.face.delete": "Supprime les faces sélectionnées et laisse les faces voisines refermer le vide, ou laisse le trou ouvert. À utiliser pour retirer un arrondi, un trou ou un bossage qui gêne.",
|
||||
"help.font_details": "L’interface utilise les polices intégrées d’egui (Ubuntu Light et Hack, sous les licences Ubuntu Font Licence et MIT/Bitstream Vera ; Noto Emoji et la police d’icônes emoji, sous OFL et MIT). Le texte des esquisses utilise Liberation Sans (SIL OFL 1.1). Le pack de polices facultatif de The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic ; SIL OFL 1.1), n’est pas inclus dans les versions de SolidCraft.",
|
||||
"help.fonts": "Polices",
|
||||
"help.licences": "Licences",
|
||||
"help.material.assign": "Applique le matériau choisi aux corps sélectionnés, par exemple acier, aluminium ou ABS. À utiliser pour que la masse et le poids correspondent à la pièce réelle.",
|
||||
"help.mit": "Licence MIT",
|
||||
"help.notice": "NOTICE",
|
||||
"help.parameters.change": "Ouvre le tableau des valeurs nommées et des expressions qui pilotent les cotes de la conception. À utiliser pour changer une dimension à un seul endroit et faire suivre toutes les fonctions qui l'utilisent.",
|
||||
"help.solid.align": "Déplace les corps sélectionnés pour qu'un de leurs points, faces ou plans rejoigne un point, une face ou un plan ailleurs. À utiliser pour poser une pièce contre une autre sans mesurer le décalage.",
|
||||
"help.solid.chamfer": "Coupe les arêtes sélectionnées par un biseau plat de la distance indiquée. À utiliser pour casser des arêtes vives ou faciliter l'entrée d'une goupille ou d'une vis.",
|
||||
"help.solid.combine": "Joint des corps, les retire d'un corps cible ou ne garde que le volume qu'ils ont en commun, et peut conserver les corps outils. À utiliser pour fusionner des pièces, tailler une forme avec une autre ou repérer où deux corps se recouvrent.",
|
||||
"help.solid.draft": "Incline les faces sélectionnées de l'angle indiqué, mesuré depuis un plan neutre. À utiliser sur les pièces moulées ou coulées pour qu'elles sortent du moule.",
|
||||
"help.solid.extrude": "Pousse le profil d'une esquisse en 3D sur une distance, en ajoutant ou en retirant de la matière. À utiliser pour transformer un contour plat en bloc, en bossage ou en poche.",
|
||||
"help.solid.fillet": "Arrondit les arêtes sélectionnées par un raccord circulaire lisse du rayon indiqué. À utiliser pour adoucir des arêtes vives par souci de solidité, de confort ou d'aspect.",
|
||||
"help.solid.hole": "Perce un trou rond du diamètre indiqué en un point choisi d'une face. À utiliser pour les passages de vis, les goupilles et autres ouvertures rondes simples.",
|
||||
"help.solid.loft": "Raccorde en douceur deux profils d'esquisse ou plus, ou un profil et un point, par un solide lissé. À utiliser pour les transitions, entonnoirs, poignées et toute forme dont la section change sur sa longueur.",
|
||||
"help.solid.mirror": "Crée une copie symétrique des corps ou fonctions sélectionnés par rapport à un plan, et peut la fusionner avec l'original. À utiliser pour les pièces symétriques : on modélise une moitié et on obtient les deux.",
|
||||
"help.solid.move": "Déplace ou fait tourner les corps sélectionnés, ou une copie, selon la distance et l'angle indiqués. À utiliser pour repositionner un corps ou en créer un second ailleurs.",
|
||||
"help.solid.offset_face": "Pousse les faces sélectionnées vers l'intérieur ou l'extérieur de la distance indiquée, et les faces voisines suivent. À utiliser pour épaissir une paroi, creuser une poche ou rehausser un bossage sans modifier l'esquisse.",
|
||||
"help.solid.pattern.circular": "Répète les objets sélectionnés autour d'un axe, selon le nombre et l'angle total indiqués. À utiliser pour les cercles de perçages, rayons, ailettes et dents.",
|
||||
"help.solid.pattern.rectangular": "Répète les objets sélectionnés dans une ou deux directions, selon le nombre et l'espacement indiqués. À utiliser pour les rangées et grilles de trous, nervures, goupilles ou pieds.",
|
||||
"help.solid.press_pull": "Ajoute ou retire de la matière quand on fait glisser une face, ou arrondit des arêtes quand on les fait glisser. À utiliser pour des modifications rapides directement sur le modèle.",
|
||||
"help.solid.remove": "Retire les corps sélectionnés de la conception à partir de ce point de la chronologie ; les étapes précédentes les gardent. À utiliser pour écarter un corps d'aide, comme un outil de découpe devenu inutile.",
|
||||
"help.solid.replace_face": "Amène les faces planes sélectionnées sur un plan ou une face parallèle, et le corps s'allonge ou se raccourcit pour la rejoindre. À utiliser pour mettre une face au ras d'une autre pièce ou d'un plan de construction.",
|
||||
"help.solid.revolve": "Fait tourner le profil d'une esquisse autour d'un axe pour obtenir un solide de révolution. À utiliser pour les arbres, boutons, bols et toute pièce ronde autour d'une ligne centrale.",
|
||||
"help.solid.scale": "Agrandit ou réduit les corps sélectionnés d'un facteur, identique dans toutes les directions ou propre à chaque axe. À utiliser pour redimensionner une forme importée ou compenser un retrait.",
|
||||
"help.solid.shell": "Évide un solide en laissant des parois de l'épaisseur indiquée, et peut ouvrir les faces choisies. À utiliser pour les boîtes, couvercles et autres pièces à parois minces.",
|
||||
"help.solid.split_body": "Scinde un corps en corps séparés avec un plan, la face d'un autre corps ou une ligne d'esquisse. À utiliser pour obtenir des pièces imprimées, usinées ou colorées séparément.",
|
||||
"help.solid.split_face": "Scinde les faces sélectionnées le long d'un plan, de la face d'un autre corps ou de courbes d'esquisse, sans scinder le corps. À utiliser pour donner à une partie de face sa propre dépouille, couleur ou décalage.",
|
||||
"help.solid.sweep": "Entraîne le profil d'une esquisse le long d'une trajectoire pour obtenir un solide. À utiliser pour les tuyaux, rails, poignées et toute pièce de section constante qui suit une courbe.",
|
||||
"help.third_party": "Licences tierces",
|
||||
"help.timeline.compute_all": "Recalcule toutes les fonctions de la chronologie depuis le début. À utiliser pour rafraîchir le modèle et voir quelles fonctions échouent.",
|
||||
"help.timeline.delete": "Supprime les fonctions sélectionnées de la chronologie ; supprimer une esquisse supprime aussi les fonctions construites à partir d'elle. À utiliser pour retirer une étape devenue inutile.",
|
||||
"home.back": "Revenir à {name}",
|
||||
"home.crashes": "Rapports de plantage",
|
||||
"home.design_workspace": "Conception",
|
||||
@@ -1211,7 +1227,7 @@
|
||||
"home.new_mm": "Nouvelle conception",
|
||||
"home.new_mm_hint": "Millimètres · ouvre l’espace Conception",
|
||||
"home.open": "Ouvrir…",
|
||||
"home.open_hint": "Une conception .solidcraft ({shortcut})",
|
||||
"home.open_hint": "Une conception .sc ({shortcut})",
|
||||
"home.open_now": "Ouvrir maintenant",
|
||||
"home.opened_days_ago": "Il y a {count} j",
|
||||
"home.opened_hours_ago": "Il y a {count} h",
|
||||
|
||||
@@ -900,7 +900,7 @@
|
||||
"home.new_mm": "Novo deseño",
|
||||
"home.new_mm_hint": "Milímetros · ábrese en Deseño",
|
||||
"home.open": "Abrir…",
|
||||
"home.open_hint": "Un deseño .solidcraft ({shortcut})",
|
||||
"home.open_hint": "Un deseño .sc ({shortcut})",
|
||||
"home.open_now": "Abrir agora",
|
||||
"home.opened_days_ago": "Hai {count} días",
|
||||
"home.opened_hours_ago": "Hai {count} h",
|
||||
|
||||
@@ -1203,28 +1203,44 @@
|
||||
"from the profile": "מהפרופיל",
|
||||
"from the rule": "לפי הכלל",
|
||||
"help.apache": "רישיון Apache 2.0",
|
||||
"help.appearance.assign": "מתן צבע או מראה של חומר לגופים, לפאות או לרכיבים שנבחרו, בלי לשנות את משקלם. מתאים להבחנה בין חלקים או לתצוגה מקדימה של גימור.",
|
||||
"help.attributions": "ייחוסים (ATTRIBUTION.md)",
|
||||
"help.build": "גרסה {version} · קומיט {commit} · נבנתה ב־{date}",
|
||||
"help.connect_note": "יש להפעיל מחדש כל עוזר כדי לטעון את שרת SolidCraft. לכל קובץ נשמר גיבוי בסיומת .bak לפני השינוי.",
|
||||
"help.connect_title": "חיבור עוזרי בינה מלאכותית",
|
||||
"help.face.delete": "מחיקת הפאות שנבחרו, כשהפאות השכנות סוגרות את הפער, או השארת הפתח פתוח כמשטח. מתאים להסרת עיגול, חור או בליטה שמפריעים.",
|
||||
"help.font_details": "הממשק משתמש בגופנים המובנים של egui (Ubuntu Light ו־Hack, תחת Ubuntu Font Licence ותחת רישיון MIT/Bitstream Vera; Noto Emoji וגופן סמלי האימוג'י, תחת OFL ו־MIT). טקסט בסקיצות משתמש ב־Liberation Sans (SIL OFL 1.1). חבילת הגופנים האופציונלית של Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), אינה כלולה בגרסאות של SolidCraft.",
|
||||
"help.fonts": "גופנים",
|
||||
"help.licences": "רישיונות",
|
||||
"help.material.assign": "קביעת החומר שממנו עשויים הגופים שנבחרו, למשל פלדה, אלומיניום או ABS. כך המסה והמשקל תואמים את החלק האמיתי.",
|
||||
"help.mit": "רישיון MIT",
|
||||
"help.notice": "NOTICE",
|
||||
"help.parameters.change": "פתיחת הטבלה של ערכים בעלי שם וביטויים שקובעים את מידות התכנון. כך משנים מידה במקום אחד וכל הפיצ'רים שמשתמשים בה מתעדכנים.",
|
||||
"help.solid.align": "הזזת הגופים שנבחרו כך שנקודה, פאה או מישור שלהם יפגשו נקודה, פאה או מישור במקום אחר. מתאים להצמדת חלק לחלק אחר בלי למדוד את המרחק.",
|
||||
"help.solid.chamfer": "חיתוך המקצועות שנבחרו בשיפוע ישר במרחק שנקבע. כך שוברים מקצועות חדים או מקלים על הכנסה של פין או בורג.",
|
||||
"help.solid.combine": "איחוד גופים, חיסורם מגוף יעד או השארת הנפח המשותף להם בלבד, עם אפשרות לשמור את גופי הכלי. מתאים למיזוג חלקים, לחיתוך צורה אחת בעזרת אחרת או למציאת החפיפה בין שני גופים.",
|
||||
"help.solid.draft": "הטיית הפאות שנבחרו בזווית שנקבעה, הנמדדת ממישור ניטרלי. מתאים לחלקים יצוקים או מוזרקים, כדי שישתחררו מהתבנית.",
|
||||
"help.solid.extrude": "דחיפת פרופיל של סקיצה לתלת־ממד לאורך מרחק, להוספה או להסרה של חומר. כך הופכים קו מתאר שטוח לגוש, לבליטה או לכיס.",
|
||||
"help.solid.fillet": "עיגול המקצועות שנבחרו במעבר מעגלי חלק ברדיוס שנקבע. כך מרככים מקצועות חדים לטובת חוזק, נוחות או מראה.",
|
||||
"help.solid.hole": "קידוח חור עגול בקוטר שנקבע בנקודה שנבחרה על פאה. מתאים לחורי מעבר לברגים, לפינים ולפתחים עגולים פשוטים אחרים.",
|
||||
"help.solid.loft": "יצירת גוף מוצק חלק במעבר בין שני פרופילים של סקיצה או יותר, או מפרופיל לנקודה. מתאים למעברים, למשפכים, לידיות ולכל צורה שהחתך שלה משתנה לאורכה.",
|
||||
"help.solid.mirror": "יצירת עותק משוקף של הגופים או הפיצ'רים שנבחרו ביחס למישור, עם אפשרות למזג אותו עם המקור. מתאים לחלקים סימטריים: ממדלים חצי אחד ומקבלים את שניהם.",
|
||||
"help.solid.move": "הזזה או סיבוב של הגופים שנבחרו, או של עותק שלהם, במרחק ובזווית שנקבעו. מתאים לשינוי מיקום של גוף או ליצירת גוף נוסף במקום אחר.",
|
||||
"help.solid.offset_face": "דחיפת הפאות שנבחרו פנימה או החוצה במרחק שנקבע, והפאות שסביבן מתאימות את עצמן. כך מעבים דופן, מעמיקים כיס או מגביהים בליטה בלי לערוך את הסקיצה.",
|
||||
"help.solid.pattern.circular": "שכפול הפריטים שנבחרו סביב ציר, לפי המספר והזווית הכוללת שנקבעו. מתאים למעגלי ברגים, לחישורים, לצלעות קירור ולשיניים.",
|
||||
"help.solid.pattern.rectangular": "שכפול הפריטים שנבחרו בכיוון אחד או בשניים, לפי המספר והמרווח שנקבעו. מתאים לשורות ולרשתות של חורים, צלעות, פינים או רגליות.",
|
||||
"help.solid.press_pull": "הוספה או הסרה של חומר בגרירת פאה, או עיגול מקצועות בגרירתם. מתאים לשינויים מהירים ישירות על המודל.",
|
||||
"help.solid.remove": "הוצאת הגופים שנבחרו מהתכנון מנקודה זו בציר הזמן והלאה; השלבים הקודמים שומרים אותם. מתאים להסרת גוף עזר, כמו כלי חיתוך שכבר אין בו צורך.",
|
||||
"help.solid.replace_face": "העברת הפאות השטוחות שנבחרו אל מישור או אל פאה מקבילה, והגוף מתארך או מתקצר כדי להגיע אליהם. מתאים ליישור פאה בקו אחד עם חלק אחר או עם מישור עזר.",
|
||||
"help.solid.revolve": "סיבוב פרופיל של סקיצה סביב ציר ליצירת גוף מוצק עגול. מתאים לגלים, לכפתורים, לקערות ולכל חלק שעגול סביב קו מרכז.",
|
||||
"help.solid.scale": "הגדלה או הקטנה של הגופים שנבחרו לפי מקדם, זהה בכל הכיוונים או שונה לכל ציר. מתאים לשינוי גודל של צורה מיובאת או להתחשבות בהתכווצות.",
|
||||
"help.solid.shell": "חלילת גוף מוצק תוך השארת דפנות בעובי שנקבע, עם אפשרות לפתוח את הפאות שנבחרו. מתאים לקופסאות, למכסים ולחלקים אחרים בעלי דופן דקה.",
|
||||
"help.solid.split_body": "פיצול גוף לגופים נפרדים בעזרת מישור, פאה של גוף אחר או קו בסקיצה. מתאים ליצירת חלקים שמודפסים, מעובדים או נצבעים כל אחד בנפרד.",
|
||||
"help.solid.split_face": "פיצול הפאות שנבחרו לאורך מישור, פאה של גוף אחר או עקומות של סקיצה, בלי לחתוך את הגוף. כך נותנים לחלק מפאה שיפוע, צבע או היסט משלו.",
|
||||
"help.solid.sweep": "גרירת פרופיל של סקיצה לאורך מסלול ליצירת גוף מוצק. מתאים לצינורות, למסילות, לידיות ולכל חלק בעל חתך קבוע שעוקב אחרי עקומה.",
|
||||
"help.third_party": "רישיונות צד שלישי",
|
||||
"help.timeline.compute_all": "בנייה מחדש של כל הפיצ'רים בציר הזמן מההתחלה. מתאים לרענון המודל ולבדיקה אילו פיצ'רים נכשלים.",
|
||||
"help.timeline.delete": "מחיקת הפיצ'רים שנבחרו מציר הזמן; מחיקת סקיצה מוחקת גם את הפיצ'רים שנבנו ממנה. מתאים להסרת שלב שכבר אין בו צורך.",
|
||||
"hole_presets.confirm_delete": "אישור מחיקה",
|
||||
"hole_presets.delete": "מחיקה",
|
||||
"hole_presets.delete_prompt": "למחוק את ההגדרה המוכנה האישית הזו? חורים קיימים לא ישתנו.",
|
||||
@@ -1462,6 +1478,10 @@
|
||||
"prefs.assistant": "עוזר",
|
||||
"prefs.axes": "צירים",
|
||||
"prefs.axis": "ציר",
|
||||
"prefs.bodies_folders": "תיקיות גופים",
|
||||
"prefs.bodies_folders_always": "תמיד",
|
||||
"prefs.bodies_folders_never": "אף פעם",
|
||||
"prefs.bodies_folders_several": "רק כשיש כמה גופים",
|
||||
"prefs.bottom": "למטה",
|
||||
"prefs.browser": "עץ התכן",
|
||||
"prefs.built_in": "מובנה",
|
||||
@@ -1564,6 +1584,8 @@
|
||||
"prefs.saving": "שמירה אוטומטית ושחזור",
|
||||
"prefs.scale_note": "גם Ctrl + / Ctrl − בחלון. הכפתורים בגודל 28 px ב־100%.",
|
||||
"prefs.scale_text": "קנה מידה וטקסט",
|
||||
"prefs.search": "חיפוש הגדרות",
|
||||
"prefs.search_empty": "אין הגדרות תואמות",
|
||||
"prefs.select": "בחירה",
|
||||
"prefs.select_one_item": "בחירת פריט אחד",
|
||||
"prefs.selecting": "בחירה",
|
||||
|
||||
@@ -899,7 +899,7 @@
|
||||
"home.new_mm": "Új terv",
|
||||
"home.new_mm_hint": "Milliméter · a Tervezés munkaterületen nyílik meg",
|
||||
"home.open": "Megnyitás…",
|
||||
"home.open_hint": "Egy .solidcraft terv ({shortcut})",
|
||||
"home.open_hint": "Egy .sc terv ({shortcut})",
|
||||
"home.open_now": "Jelenleg megnyitva",
|
||||
"home.opened_days_ago": "{count} napja",
|
||||
"home.opened_hours_ago": "{count} órája",
|
||||
|
||||
@@ -899,7 +899,7 @@
|
||||
"home.new_mm": "Desain baru",
|
||||
"home.new_mm_hint": "Milimeter · terbuka di Desain",
|
||||
"home.open": "Buka…",
|
||||
"home.open_hint": "Desain .solidcraft ({shortcut})",
|
||||
"home.open_hint": "Desain .sc ({shortcut})",
|
||||
"home.open_now": "Sedang terbuka",
|
||||
"home.opened_days_ago": "{count} hari lalu",
|
||||
"home.opened_hours_ago": "{count} jam lalu",
|
||||
|
||||
@@ -925,7 +925,7 @@
|
||||
"home.new_mm": "Nuovo progetto",
|
||||
"home.new_mm_hint": "Millimetri · si apre in Progettazione",
|
||||
"home.open": "Apri…",
|
||||
"home.open_hint": "Un progetto .solidcraft ({shortcut})",
|
||||
"home.open_hint": "Un progetto .sc ({shortcut})",
|
||||
"home.open_now": "Apri ora",
|
||||
"home.opened_days_ago": "{count} giorni fa",
|
||||
"home.opened_hours_ago": "{count} h fa",
|
||||
|
||||
+23
-1
@@ -1203,28 +1203,44 @@
|
||||
"from the profile": "形材から",
|
||||
"from the rule": "ルールから",
|
||||
"help.apache": "Apacheライセンス2.0",
|
||||
"help.appearance.assign": "選択した立体、面、コンポーネントに、重さを変えずに色や材質の見た目を付けます。部品を見分けるときや仕上げを確認するときに使います。",
|
||||
"help.attributions": "帰属表示 (ATTRIBUTION.md)",
|
||||
"help.build": "バージョン {version} · コミット {commit} · ビルド日 {date}",
|
||||
"help.connect_note": "各アシスタントを再起動すると SolidCraft サーバーが読み込まれます。変更したファイルは .bak としてバックアップされています。",
|
||||
"help.connect_title": "AI アシスタントを接続",
|
||||
"help.face.delete": "選択した面を削除し、隣の面でその穴をふさぎます。穴を開いたまま面として残すこともできます。じゃまな丸み、穴、突起を取り除くときに使います。",
|
||||
"help.font_details": "インターフェースにはegui組み込みのフォントを使用しています(Ubuntu Font LicenceおよびMIT/Bitstream VeraライセンスのUbuntu LightとHack、OFLおよびMITのNoto Emojiと絵文字アイコンフォント)。スケッチのテキストにはLiberation Sans(SIL OFL 1.1)を使用しています。The Crafting Appsのオプションフォントパックcraft-fonts(BIZ UDPGothic、Shippori Mincho、BIZ UDMincho、Noto Sans CJK SC、Noto Sans Arabic、SIL OFL 1.1)はSolidCraftのビルドに同梱されていません。",
|
||||
"help.fonts": "フォント",
|
||||
"help.licences": "ライセンス",
|
||||
"help.material.assign": "選択した立体の材料を、鋼、アルミニウム、ABS などに設定します。質量と重さを実際の部品に合わせるときに使います。",
|
||||
"help.mit": "MITライセンス",
|
||||
"help.notice": "NOTICE",
|
||||
"help.parameters.change": "デザインの寸法を決める名前付きの値と式の表を開きます。1か所で寸法を変え、それを使うすべてのフィーチャに反映させるときに使います。",
|
||||
"help.solid.align": "選択した立体を動かし、その点、面、平面を別の場所の点、面、平面に合わせます。距離を測らずに部品を別の部品に当てて配置するときに使います。",
|
||||
"help.solid.chamfer": "選択した辺を、指定した距離で平らに斜めに削ります。鋭い角を落とすときや、ピンやねじを入れやすくするときに使います。",
|
||||
"help.solid.combine": "複数の立体を結合したり、対象の立体から切り取ったり、共通する体積だけを残したりでき、工具の立体を残すこともできます。部品の結合、ある形での切り抜き、2つの立体が重なる範囲の確認に使います。",
|
||||
"help.solid.draft": "選択した面を、中立面から測った指定の角度だけ傾けます。成形品や鋳物の部品を型から抜けるようにするときに使います。",
|
||||
"help.solid.extrude": "スケッチの輪郭を指定した距離だけ立体方向に押し出し、材料を足したり削ったりします。平面の図形をブロック、突起、ポケットにするときに使います。",
|
||||
"help.solid.fillet": "選択した辺を、指定した半径のなめらかな円弧で丸めます。強度や持ちやすさ、見た目のために鋭い角を和らげるときに使います。",
|
||||
"help.solid.hole": "面上の指定した位置に、指定した直径の丸い穴をあけます。ねじのクリアランスやピン穴など、単純な丸い開口に使います。",
|
||||
"help.solid.loft": "2つ以上のスケッチの輪郭の間、または輪郭から点までを、なめらかな立体でつなぎます。移行部、漏斗、ハンドルなど、長さに沿って断面が変わる形に使います。",
|
||||
"help.solid.mirror": "選択した立体またはフィーチャを平面に対して鏡像でコピーし、元の立体と結合することもできます。左右対称の部品で、半分だけモデリングして両側を得るときに使います。",
|
||||
"help.solid.move": "選択した立体、またはそのコピーを、指定した距離と角度で移動・回転します。立体の位置を変えるときや、別の場所にもう1つ作るときに使います。",
|
||||
"help.solid.offset_face": "選択した面を指定した距離だけ内側または外側へ動かし、周りの面もそれに合わせます。スケッチを編集せずに、壁を厚く、ポケットを深く、突起を高くするときに使います。",
|
||||
"help.solid.pattern.circular": "選択したオブジェクトを、指定した数と全体の角度で軸のまわりに繰り返し配置します。ボルト円、スポーク、フィン、歯などに使います。",
|
||||
"help.solid.pattern.rectangular": "選択したオブジェクトを、指定した数と間隔で1方向または2方向に繰り返し配置します。穴、リブ、ピン、脚の列や格子状の配置に使います。",
|
||||
"help.solid.press_pull": "面をドラッグして材料を足したり削ったりし、辺をドラッグして丸めます。モデル上で直接すばやく変更するときに使います。",
|
||||
"help.solid.remove": "タイムラインのこの位置から先で、選択した立体をデザインから取り除きます。それより前のステップには残ります。不要になった切り取り用の立体など、補助の立体を外すときに使います。",
|
||||
"help.solid.replace_face": "選択した平らな面を平面または平行な面の位置へ動かし、立体はそこまで伸び縮みします。面を別の部品や作業平面と面一にするときに使います。",
|
||||
"help.solid.revolve": "スケッチの輪郭を軸のまわりに回転させ、丸い立体を作ります。軸、つまみ、ボウルなど、中心線まわりに丸い部品に使います。",
|
||||
"help.solid.scale": "選択した立体を、全方向に同じ倍率、または軸ごとの倍率で拡大・縮小します。読み込んだ形状の大きさを変えるときや、収縮分を見込むときに使います。",
|
||||
"help.solid.shell": "立体の中をくり抜き、指定した厚さの壁を残します。選んだ面を開口にすることもできます。箱、カバーなどの薄肉の部品に使います。",
|
||||
"help.solid.split_body": "平面、別の立体の面、またはスケッチの線で、立体を別々の立体に分割します。別々に印刷、加工、着色する部品を作るときに使います。",
|
||||
"help.solid.split_face": "選択した面を、平面、別の立体の面、またはスケッチの曲線に沿って分割します。立体そのものは切りません。面の一部にだけ抜き勾配、色、オフセットを付けるときに使います。",
|
||||
"help.solid.sweep": "スケッチの輪郭をパスに沿って動かし、立体を作ります。パイプ、レール、ハンドルなど、一定の断面で曲線をたどる部品に使います。",
|
||||
"help.third_party": "サードパーティライセンス",
|
||||
"help.timeline.compute_all": "タイムラインのすべてのフィーチャを最初から作り直します。モデルを更新し、どのフィーチャが失敗するかを確認するときに使います。",
|
||||
"help.timeline.delete": "選択したフィーチャをタイムラインから削除します。スケッチを削除すると、そこから作ったフィーチャも削除されます。不要になったステップを取り除くときに使います。",
|
||||
"hole_presets.confirm_delete": "削除を確認",
|
||||
"hole_presets.delete": "削除",
|
||||
"hole_presets.delete_prompt": "この個人用プリセットを削除しますか?既存の穴は変更されません。",
|
||||
@@ -1275,7 +1291,7 @@
|
||||
"home.new_mm": "新しいデザイン",
|
||||
"home.new_mm_hint": "ミリメートル · デザインで開きます",
|
||||
"home.open": "開く…",
|
||||
"home.open_hint": ".solidcraft 形式のデザイン ({shortcut})",
|
||||
"home.open_hint": ".sc 形式のデザイン ({shortcut})",
|
||||
"home.open_now": "今すぐ開く",
|
||||
"home.opened_days_ago": "{count} 日前",
|
||||
"home.opened_hours_ago": "{count} 時間前",
|
||||
@@ -1462,6 +1478,10 @@
|
||||
"prefs.assistant": "アシスタント",
|
||||
"prefs.axes": "軸",
|
||||
"prefs.axis": "軸",
|
||||
"prefs.bodies_folders": "ボディフォルダー",
|
||||
"prefs.bodies_folders_always": "常に表示",
|
||||
"prefs.bodies_folders_never": "表示しない",
|
||||
"prefs.bodies_folders_several": "複数ボディのみ",
|
||||
"prefs.bottom": "下",
|
||||
"prefs.browser": "ブラウザー",
|
||||
"prefs.built_in": "組み込み",
|
||||
@@ -1564,6 +1584,8 @@
|
||||
"prefs.saving": "自動保存と復元",
|
||||
"prefs.scale_note": "ウィンドウ内では Ctrl + / Ctrl − でも変更できます。100% のボタンは 28 px です。",
|
||||
"prefs.scale_text": "拡大率と文字",
|
||||
"prefs.search": "設定を検索",
|
||||
"prefs.search_empty": "一致する設定がありません",
|
||||
"prefs.select": "選択",
|
||||
"prefs.select_one_item": "項目を 1 つ選択",
|
||||
"prefs.selecting": "選択",
|
||||
|
||||
@@ -1092,7 +1092,7 @@
|
||||
"home.new_mm": "새 설계",
|
||||
"home.new_mm_hint": "밀리미터 · 설계 작업 공간에서 열기",
|
||||
"home.open": "열기…",
|
||||
"home.open_hint": ".solidcraft 설계 ({shortcut})",
|
||||
"home.open_hint": ".sc 설계 ({shortcut})",
|
||||
"home.open_now": "지금 열기",
|
||||
"home.opened_days_ago": "{count}일 전",
|
||||
"home.opened_hours_ago": "{count}시간 전",
|
||||
@@ -1215,6 +1215,10 @@
|
||||
"prefs.assistant": "어시스턴트",
|
||||
"prefs.axes": "축",
|
||||
"prefs.axis": "축",
|
||||
"prefs.bodies_folders": "바디 폴더",
|
||||
"prefs.bodies_folders_always": "항상 표시",
|
||||
"prefs.bodies_folders_never": "표시 안 함",
|
||||
"prefs.bodies_folders_several": "바디가 여러 개일 때만",
|
||||
"prefs.bottom": "아래쪽",
|
||||
"prefs.browser": "브라우저",
|
||||
"prefs.built_in": "기본 제공",
|
||||
@@ -1303,6 +1307,8 @@
|
||||
"prefs.saving": "자동 저장 및 복구",
|
||||
"prefs.scale_note": "창에서 Ctrl + / Ctrl −로도 조절합니다. 버튼은 100%에서 28 px입니다.",
|
||||
"prefs.scale_text": "배율 및 텍스트",
|
||||
"prefs.search": "설정 검색",
|
||||
"prefs.search_empty": "일치하는 설정이 없습니다",
|
||||
"prefs.select": "선택",
|
||||
"prefs.select_one_item": "항목 하나 선택",
|
||||
"prefs.selecting": "선택",
|
||||
|
||||
@@ -900,7 +900,7 @@
|
||||
"home.new_mm": "Reka bentuk baharu",
|
||||
"home.new_mm_hint": "Milimeter · dibuka dalam Reka Bentuk",
|
||||
"home.open": "Buka…",
|
||||
"home.open_hint": "Reka bentuk .solidcraft ({shortcut})",
|
||||
"home.open_hint": "Reka bentuk .sc ({shortcut})",
|
||||
"home.open_now": "Buka sekarang",
|
||||
"home.opened_days_ago": "{count} hari lalu",
|
||||
"home.opened_hours_ago": "{count} jam lalu",
|
||||
|
||||
@@ -1053,7 +1053,7 @@
|
||||
"home.new_mm": "Nytt design",
|
||||
"home.new_mm_hint": "Millimeter · åpnes i Design",
|
||||
"home.open": "Åpne…",
|
||||
"home.open_hint": "Et .solidcraft-design ({shortcut})",
|
||||
"home.open_hint": "Et .sc-design ({shortcut})",
|
||||
"home.open_now": "Åpne nå",
|
||||
"home.opened_days_ago": "for {count} dager siden",
|
||||
"home.opened_hours_ago": "for {count} t siden",
|
||||
|
||||
@@ -899,7 +899,7 @@
|
||||
"home.new_mm": "Nieuw ontwerp",
|
||||
"home.new_mm_hint": "Millimeters · opent in Ontwerp",
|
||||
"home.open": "Openen…",
|
||||
"home.open_hint": "Een .solidcraft-ontwerp ({shortcut})",
|
||||
"home.open_hint": "Een .sc-ontwerp ({shortcut})",
|
||||
"home.open_now": "Momenteel geopend",
|
||||
"home.opened_days_ago": "{count} dagen geleden",
|
||||
"home.opened_hours_ago": "{count} u geleden",
|
||||
|
||||
@@ -961,7 +961,7 @@
|
||||
"home.new_mm": "Nowy projekt",
|
||||
"home.new_mm_hint": "Milimetry · otwiera w obszarze Projektowanie",
|
||||
"home.open": "Otwórz…",
|
||||
"home.open_hint": "Projekt .solidcraft ({shortcut})",
|
||||
"home.open_hint": "Projekt .sc ({shortcut})",
|
||||
"home.open_now": "Otwórz teraz",
|
||||
"home.opened_days_ago": "{count} dni temu",
|
||||
"home.opened_hours_ago": "{count} godz. temu",
|
||||
|
||||
@@ -1074,7 +1074,7 @@
|
||||
"home.new_mm": "Novo projeto",
|
||||
"home.new_mm_hint": "Milímetros · abre em Projeto",
|
||||
"home.open": "Abrir…",
|
||||
"home.open_hint": "Um projeto .solidcraft ({shortcut})",
|
||||
"home.open_hint": "Um projeto .sc ({shortcut})",
|
||||
"home.open_now": "Abrir agora",
|
||||
"home.opened_days_ago": "Há {count} dias",
|
||||
"home.opened_hours_ago": "Há {count} h",
|
||||
|
||||
@@ -899,7 +899,7 @@
|
||||
"home.new_mm": "Proiect nou",
|
||||
"home.new_mm_hint": "Milimetri · se deschide în Proiectare",
|
||||
"home.open": "Deschide…",
|
||||
"home.open_hint": "Un proiect .solidcraft ({shortcut})",
|
||||
"home.open_hint": "Un proiect .sc ({shortcut})",
|
||||
"home.open_now": "Deschis acum",
|
||||
"home.opened_days_ago": "Acum {count} zile",
|
||||
"home.opened_hours_ago": "Acum {count} h",
|
||||
|
||||
@@ -899,7 +899,7 @@
|
||||
"home.new_mm": "Новый проект",
|
||||
"home.new_mm_hint": "Миллиметры · откроется в рабочей области «Проектирование»",
|
||||
"home.open": "Открыть…",
|
||||
"home.open_hint": "Проект .solidcraft ({shortcut})",
|
||||
"home.open_hint": "Проект .sc ({shortcut})",
|
||||
"home.open_now": "Открыть сейчас",
|
||||
"home.opened_days_ago": "{count} дн. назад",
|
||||
"home.opened_hours_ago": "{count} ч назад",
|
||||
|
||||
@@ -899,7 +899,7 @@
|
||||
"home.new_mm": "Ny konstruktion",
|
||||
"home.new_mm_hint": "Millimeter · öppnas i Konstruktion",
|
||||
"home.open": "Öppna…",
|
||||
"home.open_hint": "En .solidcraft-konstruktion ({shortcut})",
|
||||
"home.open_hint": "En .sc-konstruktion ({shortcut})",
|
||||
"home.open_now": "Öppen nu",
|
||||
"home.opened_days_ago": "För {count} dagar sedan",
|
||||
"home.opened_hours_ago": "För {count} h sedan",
|
||||
|
||||
@@ -1252,7 +1252,7 @@
|
||||
"home.new_mm": "Yeni tasarım",
|
||||
"home.new_mm_hint": "Milimetre · Tasarım alanında açılır",
|
||||
"home.open": "Aç…",
|
||||
"home.open_hint": "Bir .solidcraft tasarımı ({shortcut})",
|
||||
"home.open_hint": "Bir .sc tasarımı ({shortcut})",
|
||||
"home.open_now": "Şimdi aç",
|
||||
"home.opened_days_ago": "{count} gün önce",
|
||||
"home.opened_hours_ago": "{count} sa. önce",
|
||||
|
||||
@@ -1252,7 +1252,7 @@
|
||||
"home.new_mm": "Новий проєкт",
|
||||
"home.new_mm_hint": "Міліметри · відкриється в робочому просторі «Проєктування»",
|
||||
"home.open": "Відкрити…",
|
||||
"home.open_hint": "Проєкт .solidcraft ({shortcut})",
|
||||
"home.open_hint": "Проєкт .sc ({shortcut})",
|
||||
"home.open_now": "Відкрити зараз",
|
||||
"home.opened_days_ago": "{count} дн. тому",
|
||||
"home.opened_hours_ago": "{count} год тому",
|
||||
|
||||
+23
-1
@@ -1203,28 +1203,44 @@
|
||||
"from the profile": "từ biên dạng",
|
||||
"from the rule": "từ quy tắc",
|
||||
"help.apache": "Giấy phép Apache 2.0",
|
||||
"help.appearance.assign": "Gán màu hoặc vẻ ngoài vật liệu cho các khối, mặt hoặc thành phần đã chọn mà không thay đổi khối lượng. Dùng để phân biệt các chi tiết hoặc xem trước lớp hoàn thiện.",
|
||||
"help.attributions": "Ghi công (ATTRIBUTION.md)",
|
||||
"help.build": "Phiên bản {version} · commit {commit} · dựng ngày {date}",
|
||||
"help.connect_note": "Khởi động lại từng trợ lý ảo để nạp SolidCraft server. Mỗi tập tin cấu hình đều đã được sao lưu thành .bak trước khi thay đổi.",
|
||||
"help.connect_title": "Kết nối trợ lý ảo AI",
|
||||
"help.face.delete": "Xóa các mặt đã chọn và để các mặt lân cận khép kín chỗ trống, hoặc giữ lỗ hở dưới dạng bề mặt. Dùng để bỏ một cạnh bo tròn, một lỗ hoặc một phần lồi đang vướng.",
|
||||
"help.font_details": "Giao diện sử dụng các phông chữ tích hợp của egui (Ubuntu Light và Hack, theo Ubuntu Font Licence và giấy phép MIT/Bitstream Vera; Noto Emoji và phông biểu tượng emoji, theo OFL và MIT). Văn bản phác thảo dùng Liberation Sans (SIL OFL 1.1). Gói phông chữ tùy chọn của Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), không được đóng gói kèm trong các bản dựng SolidCraft.",
|
||||
"help.fonts": "Phông chữ",
|
||||
"help.licences": "Giấy phép",
|
||||
"help.material.assign": "Đặt vật liệu cho các khối đã chọn, chẳng hạn thép, nhôm hoặc ABS. Dùng để khối lượng và trọng lượng khớp với chi tiết thật.",
|
||||
"help.mit": "Giấy phép MIT",
|
||||
"help.notice": "NOTICE",
|
||||
"help.parameters.change": "Mở bảng các giá trị có tên và biểu thức điều khiển kích thước của thiết kế. Dùng để sửa một kích thước ở một chỗ và mọi tính năng dùng nó sẽ cập nhật theo.",
|
||||
"help.solid.align": "Di chuyển các khối đã chọn để một điểm, mặt hoặc mặt phẳng trên chúng gặp một điểm, mặt hoặc mặt phẳng ở nơi khác. Dùng để đặt chi tiết áp vào chi tiết khác mà không cần đo khoảng cách.",
|
||||
"help.solid.chamfer": "Vát các cạnh đã chọn theo khoảng cách bạn thiết lập. Dùng để triệt tiêu cạnh sắc hoặc giúp lắp chốt, bu lông dễ dàng hơn.",
|
||||
"help.solid.combine": "Hợp nhất các khối, cắt trừ khỏi khối đích, hoặc chỉ giữ lại phần thể tích giao nhau, đồng thời có thể giữ lại các khối công cụ. Dùng để gộp chi tiết, dùng hình này cắt hình kia hoặc tìm vị trí hai khối giao nhau.",
|
||||
"help.solid.draft": "Làm nghiêng các mặt đã chọn theo góc thiết lập, đo từ một mặt phẳng trung hòa. Dùng cho chi tiết đúc hoặc ép phun để chúng tháo ra khỏi khuôn được.",
|
||||
"help.solid.extrude": "Đùn biên dạng phác thảo thành khối 3D theo khoảng cách, thêm hoặc bớt vật liệu. Dùng để biến phác thảo phẳng thành khối, vấu nổi hoặc hốc lõm.",
|
||||
"help.solid.fillet": "Bo tròn các cạnh đã chọn bằng cung cong mượt mà theo bán kính thiết lập. Dùng để làm mềm cạnh sắc nhằm tăng độ bền, an toàn khi chạm hoặc tăng thẩm mỹ.",
|
||||
"help.solid.hole": "Khoan lỗ tròn theo đường kính thiết lập tại vị trí chọn trên mặt. Dùng cho lỗ xỏ bu lông, chốt định vị và các lỗ tròn đơn giản.",
|
||||
"help.solid.loft": "Tạo khối đặc chuyển tiếp mượt mà giữa hai hay nhiều biên dạng phác thảo, hoặc từ một biên dạng đến một điểm. Dùng cho đoạn chuyển tiếp, phễu, tay nắm và bất kỳ hình dạng nào có tiết diện biến thiên dọc theo chiều dài.",
|
||||
"help.solid.mirror": "Tạo bản sao đối xứng của các khối hoặc tính năng được chọn qua một mặt phẳng, có thể hợp nhất với bản gốc. Dùng cho các chi tiết đối xứng, chỉ cần dựng một nửa để có toàn bộ.",
|
||||
"help.solid.move": "Di chuyển hoặc xoay các khối đã chọn, hoặc một bản sao của chúng, theo khoảng cách và góc thiết lập. Dùng để đổi vị trí một khối hoặc tạo thêm một khối ở chỗ khác.",
|
||||
"help.solid.offset_face": "Đẩy các mặt đã chọn vào trong hoặc ra ngoài theo khoảng cách thiết lập, các mặt xung quanh tự điều chỉnh theo. Dùng để làm thành dày hơn, hốc sâu hơn hoặc phần lồi cao hơn mà không sửa bản phác thảo.",
|
||||
"help.solid.pattern.circular": "Lặp lại các đối tượng được chọn quanh một trục theo số lượng và tổng góc thiết lập. Dùng cho bố trí vòng bu lông, nan hoa, cánh tản nhiệt và răng bánh răng.",
|
||||
"help.solid.pattern.rectangular": "Lặp lại các đối tượng được chọn theo một hoặc hai hướng với số lượng và khoảng cách thiết lập. Dùng cho dãy hoặc mảng lỗ, gân tăng cứng, chốt định vị hoặc chân đế.",
|
||||
"help.solid.press_pull": "Thêm hoặc bớt vật liệu khi kéo một mặt, hoặc bo tròn các cạnh khi kéo chúng. Dùng để sửa nhanh trực tiếp trên mô hình.",
|
||||
"help.solid.remove": "Loại các khối đã chọn khỏi thiết kế từ điểm này trong lịch sử trở đi; các bước trước vẫn giữ chúng. Dùng để bỏ một khối phụ trợ, như một công cụ cắt không còn cần.",
|
||||
"help.solid.replace_face": "Đưa các mặt phẳng đã chọn tới một mặt phẳng hoặc một mặt song song, khối sẽ dài ra hoặc ngắn lại để chạm tới đó. Dùng để làm một mặt phẳng hàng với chi tiết khác hoặc với mặt phẳng dựng hình.",
|
||||
"help.solid.revolve": "Quay biên dạng phác thảo quanh một trục để tạo khối tròn xoay. Dùng cho trục, núm xoay, chén bát và bất kỳ chi tiết nào tròn xoay quanh tâm.",
|
||||
"help.solid.scale": "Phóng to hoặc thu nhỏ các khối đã chọn theo một hệ số, như nhau theo mọi hướng hoặc riêng cho từng trục. Dùng để đổi kích thước hình nhập vào hoặc bù độ co ngót.",
|
||||
"help.solid.shell": "Khoét rỗng khối đặc, để lại thành mỏng theo độ dày thiết lập, và có thể mở các mặt bạn chọn. Dùng cho hộp, nắp che và các chi tiết thành mỏng.",
|
||||
"help.solid.split_body": "Tách một khối thành các khối riêng bằng một mặt phẳng, mặt của khối khác hoặc một đường thẳng trong bản phác thảo. Dùng để tạo các chi tiết được in, gia công hoặc tô màu riêng.",
|
||||
"help.solid.split_face": "Chia các mặt đã chọn theo một mặt phẳng, mặt của khối khác hoặc các đường cong phác thảo, mà không tách khối. Dùng để cho một phần của mặt có độ nghiêng, màu hoặc độ dịch riêng.",
|
||||
"help.solid.sweep": "Quét một biên dạng phác thảo dọc theo đường dẫn để tạo khối đặc. Dùng cho đường ống, thanh ray, tay vịn và bất kỳ chi tiết nào có tiết diện không đổi chạy dọc theo đường cong.",
|
||||
"help.third_party": "Giấy phép của bên thứ ba",
|
||||
"help.timeline.compute_all": "Dựng lại mọi tính năng trong lịch sử từ đầu. Dùng để làm mới mô hình và xem tính năng nào bị lỗi.",
|
||||
"help.timeline.delete": "Xóa các tính năng đã chọn khỏi lịch sử; xóa một bản phác thảo cũng xóa các tính năng dựng từ nó. Dùng để bỏ một bước không còn cần.",
|
||||
"hole_presets.confirm_delete": "Xác nhận xóa",
|
||||
"hole_presets.delete": "Xóa",
|
||||
"hole_presets.delete_prompt": "Xóa mẫu thiết lập sẵn cá nhân này? Các lỗ ren/khoan hiện có sẽ không thay đổi.",
|
||||
@@ -1275,7 +1291,7 @@
|
||||
"home.new_mm": "Thiết kế mới",
|
||||
"home.new_mm_hint": "Milimét · mở trong Thiết kế",
|
||||
"home.open": "Mở…",
|
||||
"home.open_hint": "Một thiết kế .solidcraft ({shortcut})",
|
||||
"home.open_hint": "Một thiết kế .sc ({shortcut})",
|
||||
"home.open_now": "Mở ngay",
|
||||
"home.opened_days_ago": "{count} ngày trước",
|
||||
"home.opened_hours_ago": "{count} giờ trước",
|
||||
@@ -1462,6 +1478,10 @@
|
||||
"prefs.assistant": "Trợ lý",
|
||||
"prefs.axes": "Các trục tọa độ",
|
||||
"prefs.axis": "Trục tọa độ",
|
||||
"prefs.bodies_folders": "Thư mục thân khối",
|
||||
"prefs.bodies_folders_always": "Luôn luôn",
|
||||
"prefs.bodies_folders_never": "Không bao giờ",
|
||||
"prefs.bodies_folders_several": "Chỉ khi có nhiều thân khối",
|
||||
"prefs.bottom": "Dưới cùng",
|
||||
"prefs.browser": "Cây thư mục",
|
||||
"prefs.built_in": "Mặc định tích hợp",
|
||||
@@ -1564,6 +1584,8 @@
|
||||
"prefs.saving": "Tự động lưu và khôi phục",
|
||||
"prefs.scale_note": "Cũng có thể dùng Ctrl + / Ctrl − trong cửa sổ. Các nút có kích thước 28 px ở mức 100%.",
|
||||
"prefs.scale_text": "Tỉ lệ và văn bản",
|
||||
"prefs.search": "Tìm kiếm cài đặt",
|
||||
"prefs.search_empty": "Không có cài đặt nào khớp",
|
||||
"prefs.select": "Chọn",
|
||||
"prefs.select_one_item": "Chọn một mục",
|
||||
"prefs.selecting": "Đang chọn",
|
||||
|
||||
@@ -900,7 +900,7 @@
|
||||
"home.new_mm": "新增設計",
|
||||
"home.new_mm_hint": "公釐 · 在設計工作區中開啟",
|
||||
"home.open": "開啟…",
|
||||
"home.open_hint": ".solidcraft 設計檔案 ({shortcut})",
|
||||
"home.open_hint": ".sc 設計檔案 ({shortcut})",
|
||||
"home.open_now": "目前開啟中",
|
||||
"home.opened_days_ago": "{count} 天前",
|
||||
"home.opened_hours_ago": "{count} 小時前",
|
||||
|
||||
+23
-1
@@ -1203,28 +1203,44 @@
|
||||
"from the profile": "取自型材",
|
||||
"from the rule": "来自规则",
|
||||
"help.apache": "Apache 许可证 2.0",
|
||||
"help.appearance.assign": "为所选的实体、面或组件设置颜色或材质外观,不改变其重量。用于区分零件或预览表面效果。",
|
||||
"help.attributions": "署名信息 (ATTRIBUTION.md)",
|
||||
"help.build": "版本 {version} · 提交 {commit} · 构建日期 {date}",
|
||||
"help.connect_note": "请重启各个助手以加载 SolidCraft 服务器。每个被修改的文件都已备份为 .bak。",
|
||||
"help.connect_title": "连接 AI 助手",
|
||||
"help.face.delete": "删除所选的面,并由相邻的面封闭留下的缺口,也可以让缺口保持开放、成为曲面。用于去掉碍事的圆角、孔或凸台。",
|
||||
"help.font_details": "界面使用 egui 的内置字体(Ubuntu Light 和 Hack,采用 Ubuntu Font Licence 和 MIT/Bitstream Vera 许可证;Noto Emoji 和表情图标字体,采用 OFL 和 MIT 许可证)。草图文本使用 Liberation Sans(SIL OFL 1.1)。The Crafting Apps 的可选字体包 craft-fonts(BIZ UDPGothic、Shippori Mincho、BIZ UDMincho、Noto Sans CJK SC、Noto Sans Arabic;SIL OFL 1.1)未随 SolidCraft 构建版本捆绑提供。",
|
||||
"help.fonts": "字体",
|
||||
"help.licences": "许可证",
|
||||
"help.material.assign": "设置所选实体的材料,例如钢、铝或 ABS。用于让质量和重量与实际零件一致。",
|
||||
"help.mit": "MIT 许可证",
|
||||
"help.notice": "NOTICE",
|
||||
"help.parameters.change": "打开驱动设计尺寸的命名数值和表达式表。用于在一处修改尺寸,让所有引用它的特征随之更新。",
|
||||
"help.solid.align": "移动所选实体,使其上的点、面或平面与别处的点、面或平面对齐。用于把一个零件贴靠到另一个零件上,而无需测量距离。",
|
||||
"help.solid.chamfer": "用指定的距离在所选边上切出一个平直的斜面。用于去除锐边,或使销钉、螺钉更容易插入。",
|
||||
"help.solid.combine": "将多个实体合并、从目标实体中切除,或只保留它们共有的体积,还可以保留工具实体。用于合并零件、用一个形体切割另一个形体,或找出两个实体重叠的位置。",
|
||||
"help.solid.draft": "将所选的面按指定角度倾斜,角度从中性面量起。用于注塑件或铸件,使零件能够脱模。",
|
||||
"help.solid.extrude": "将草图轮廓按指定距离拉伸为三维形体,可以增加或去除材料。用于把平面轮廓变成方块、凸台或凹槽。",
|
||||
"help.solid.fillet": "用指定半径的平滑圆弧将所选的边倒圆。用于出于强度、手感或外观的考虑而柔化锐边。",
|
||||
"help.solid.hole": "在面上的指定位置钻出指定直径的圆孔。用于螺钉过孔、销孔等简单的圆形开口。",
|
||||
"help.solid.loft": "在两个或更多草图轮廓之间,或从轮廓到一个点,生成平滑过渡的实体。用于过渡段、漏斗、手柄等截面沿长度变化的形体。",
|
||||
"help.solid.mirror": "将所选实体或特征相对于一个平面生成镜像副本,还可以与原件合并。用于对称零件,只需建一半即可得到两侧。",
|
||||
"help.solid.move": "按指定的距离和角度移动或旋转所选实体,或其副本。用于调整实体位置,或在别处再生成一个。",
|
||||
"help.solid.offset_face": "将所选的面按指定距离向内或向外推移,周围的面随之调整。用于在不编辑草图的情况下加厚壁、加深凹槽或加高凸台。",
|
||||
"help.solid.pattern.circular": "将所选对象按指定的数量和总角度绕轴重复排列。用于螺栓圆周分布、辐条、散热片和轮齿。",
|
||||
"help.solid.pattern.rectangular": "将所选对象按指定的数量和间距沿一个或两个方向重复排列。用于成排或成网格的孔、加强筋、销钉或支脚。",
|
||||
"help.solid.press_pull": "拖动面来增加或去除材料,或拖动边将其倒圆。用于直接在模型上快速修改。",
|
||||
"help.solid.remove": "从时间线的这一位置起,将所选实体从设计中移除;之前的步骤仍保留它们。用于去掉辅助实体,例如不再需要的切割工具。",
|
||||
"help.solid.replace_face": "将所选的平面移到一个平面或平行的面上,实体随之伸长或缩短。用于让一个面与另一个零件或构造平面齐平。",
|
||||
"help.solid.revolve": "将草图轮廓绕一条轴旋转,生成圆形的实体。用于轴、旋钮、碗等围绕中心线呈圆形的零件。",
|
||||
"help.solid.scale": "按比例放大或缩小所选实体,可在各方向相同,也可按轴分别设置。用于调整导入形体的大小,或预留收缩量。",
|
||||
"help.solid.shell": "将实体掏空,留下指定厚度的壁,还可以打开所选的面。用于盒子、盖子等薄壁零件。",
|
||||
"help.solid.split_body": "用平面、另一实体的面或草图直线,将实体切分为多个独立的实体。用于制作分别打印、加工或上色的零件。",
|
||||
"help.solid.split_face": "沿平面、另一实体的面或草图曲线分割所选的面,而不切开实体。用于给面的一部分单独设置拔模、颜色或偏移。",
|
||||
"help.solid.sweep": "沿路径拖动草图轮廓生成实体。用于管道、导轨、手柄等截面恒定且沿曲线延伸的零件。",
|
||||
"help.third_party": "第三方许可证",
|
||||
"help.timeline.compute_all": "从头重新计算时间线中的所有特征。用于刷新模型,并查看哪些特征失败。",
|
||||
"help.timeline.delete": "从时间线中删除所选特征;删除草图时,由它生成的特征也会一起删除。用于去掉不再需要的步骤。",
|
||||
"hole_presets.confirm_delete": "确认删除",
|
||||
"hole_presets.delete": "删除",
|
||||
"hole_presets.delete_prompt": "删除此个人预设?现有孔不会改变。",
|
||||
@@ -1275,7 +1291,7 @@
|
||||
"home.new_mm": "新建设计",
|
||||
"home.new_mm_hint": "毫米 · 在设计工作区中打开",
|
||||
"home.open": "打开…",
|
||||
"home.open_hint": ".solidcraft 设计文件 ({shortcut})",
|
||||
"home.open_hint": ".sc 设计文件 ({shortcut})",
|
||||
"home.open_now": "立即打开",
|
||||
"home.opened_days_ago": "{count} 天前",
|
||||
"home.opened_hours_ago": "{count} 小时前",
|
||||
@@ -1462,6 +1478,10 @@
|
||||
"prefs.assistant": "助手",
|
||||
"prefs.axes": "坐标轴",
|
||||
"prefs.axis": "轴",
|
||||
"prefs.bodies_folders": "实体文件夹",
|
||||
"prefs.bodies_folders_always": "始终显示",
|
||||
"prefs.bodies_folders_never": "不显示",
|
||||
"prefs.bodies_folders_several": "仅限多个实体",
|
||||
"prefs.bottom": "底部",
|
||||
"prefs.browser": "浏览器",
|
||||
"prefs.built_in": "内置",
|
||||
@@ -1564,6 +1584,8 @@
|
||||
"prefs.saving": "自动保存和恢复",
|
||||
"prefs.scale_note": "也可在窗口中使用 Ctrl + / Ctrl −。100% 时按钮高度为 28 像素。",
|
||||
"prefs.scale_text": "缩放与文字",
|
||||
"prefs.search": "搜索设置",
|
||||
"prefs.search_empty": "没有匹配的设置",
|
||||
"prefs.select": "选择",
|
||||
"prefs.select_one_item": "选择一个项目",
|
||||
"prefs.selecting": "选择",
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
<comment>SolidCraft design</comment>
|
||||
<icon name="io.tangencylabs.solidcraft"/>
|
||||
<sub-class-of type="application/json"/>
|
||||
<!-- shared-mime-info also gives *.sc to Scala scripts (text/x-scala, weight 50); with
|
||||
SolidCraft installed its designs win. -->
|
||||
<glob pattern="*.sc" weight="60"/>
|
||||
<!-- Compatibility shim (docs/compatibility.md): designs saved before .sc, and before the rename. -->
|
||||
<glob pattern="*.solidcraft"/>
|
||||
<!-- Compatibility shim (docs/compatibility.md): designs saved before the rename. -->
|
||||
<glob pattern="*.solvecraft"/>
|
||||
<alias type="application/x-solvecraft"/>
|
||||
</mime-type>
|
||||
|
||||
@@ -45,8 +45,11 @@
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>SolidCraft design</string>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<!-- solvecraft: designs saved before the rename (compatibility shim, docs/compatibility.md). -->
|
||||
<array><string>solidcraft</string><string>solvecraft</string></array>
|
||||
<!-- solidcraft: designs saved before .sc; solvecraft: before the rename (compatibility
|
||||
shims, docs/compatibility.md). -->
|
||||
<array><string>sc</string><string>solidcraft</string><string>solvecraft</string></array>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array><string>io.tangencylabs.solidcraft.design</string></array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSHandlerRank</key>
|
||||
@@ -63,5 +66,24 @@
|
||||
<string>Alternate</string>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- The design type: .sc, and the older .solidcraft and .solvecraft (docs/compatibility.md). -->
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>io.tangencylabs.solidcraft.design</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>SolidCraft design</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array><string>public.data</string><string>public.content</string></array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array><string>sc</string><string>solidcraft</string><string>solvecraft</string></array>
|
||||
<key>public.mime-type</key>
|
||||
<array><string>application/x-solidcraft</string></array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,6 +11,6 @@ Some earlier releases were installed as SolveCraft.app. Move that one to the Tra
|
||||
once SolidCraft.app is in Applications, and replace any Dock shortcut with the
|
||||
old name (open SolidCraft.app, then choose Options > Keep in Dock).
|
||||
|
||||
Designs saved as .solvecraft still open, and SolidCraft copies your settings
|
||||
and recovery files from the old SolveCraft folder on its first launch.
|
||||
New designs are saved as .solidcraft.
|
||||
Designs saved as .solidcraft or .solvecraft still open, and SolidCraft copies
|
||||
your settings and recovery files from the old SolveCraft folder on its first
|
||||
launch. New designs are saved as .sc.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
Per-machine install with a setup wizard: Welcome, install folder (Program Files\SolidCraft by
|
||||
default, remembered for upgrades), Ready, progress, and a Finish page with a ticked "Launch
|
||||
SolidCraft" box. Installs a Start Menu shortcut, solidcraft-cli (and adds its folder to the system PATH), the licence files (including
|
||||
THIRD-PARTY-LICENSES.txt), makes SolidCraft the owner of `.solidcraft` (and of the pre-rename `.solvecraft`), and offers it under
|
||||
THIRD-PARTY-LICENSES.txt), makes SolidCraft the owner of `.sc` (and of the older `.solidcraft` and pre-rename `.solvecraft`), and offers it under
|
||||
"Open with" for STEP, IGES, 3MF and STL. `msiexec /qn` installs silently.
|
||||
Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
-->
|
||||
@@ -84,15 +84,17 @@
|
||||
Icon="SolidcraftIcon.exe" IconIndex="0" />
|
||||
</File>
|
||||
|
||||
<!-- .solidcraft: SolidCraft's own format; SolidCraft is its default app. -->
|
||||
<!-- .sc: SolidCraft's own format; SolidCraft is its default app. -->
|
||||
<ProgId Id="Solidcraft.Design" Description="SolidCraft Design" Icon="SolidcraftExe" IconIndex="0">
|
||||
<Extension Id="solidcraft" ContentType="application/x-solidcraft">
|
||||
<Extension Id="sc" ContentType="application/x-solidcraft">
|
||||
<Verb Id="open" Command="Open" TargetFile="SolidcraftExe" Argument=""%1"" />
|
||||
</Extension>
|
||||
</ProgId>
|
||||
<!-- Compatibility shim (docs/compatibility.md): designs saved before the rename open as
|
||||
the same ProgId. Registered by hand: a second Extension under one ProgId would write
|
||||
its open verb twice (WIX0091). -->
|
||||
<!-- Compatibility shim (docs/compatibility.md): designs saved as .solidcraft (before .sc)
|
||||
and as .solvecraft (before the rename) open as the same ProgId. Registered by hand: a
|
||||
second Extension under one ProgId would write its open verb twice (WIX0091). -->
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.solidcraft" Value="Solidcraft.Design" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.solidcraft" Name="Content Type" Value="application/x-solidcraft" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.solvecraft" Value="Solidcraft.Design" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.solvecraft" Name="Content Type" Value="application/x-solvecraft" Type="string" />
|
||||
<!-- STEP, IGES and meshes: offered under "Open with", never forced as the default. -->
|
||||
@@ -108,6 +110,7 @@
|
||||
<!-- "Open with" app list and the Run dialog (Win+R "solidcraft"). -->
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solidcraft.exe" Name="FriendlyAppName" Value="SolidCraft" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solidcraft.exe\shell\open\command" Value=""[#SolidcraftExe]" "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solidcraft.exe\SupportedTypes" Name=".sc" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solidcraft.exe\SupportedTypes" Name=".solidcraft" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solidcraft.exe\SupportedTypes" Name=".solvecraft" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solidcraft.exe\SupportedTypes" Name=".step" Value="" Type="string" />
|
||||
|
||||
@@ -2982,9 +2982,11 @@ fn apply_traced_with(
|
||||
v
|
||||
};
|
||||
// Bodies that take part besides the feature's own targets: the body a Join extrude runs to
|
||||
// joins with the tool (#6424).
|
||||
// joins with the tool (#6424), when it is in the feature's own component. A body of another
|
||||
// component (the shell a connector's boss runs up to) joins only when the feature names it:
|
||||
// an extent's end face never pulls a part across a component boundary.
|
||||
for name in extra {
|
||||
if let Some(i) = state.bodies.iter().position(|b| &b.name == name)
|
||||
if let Some(i) = state.bodies.iter().position(|b| &b.name == name && b.component == f.component)
|
||||
&& !target_idx.contains(&i)
|
||||
{
|
||||
target_idx.push(i);
|
||||
|
||||
@@ -555,8 +555,8 @@ pub(super) fn extract(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sc = scope(s, comp, CMD)?;
|
||||
let given = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(CMD, "`path` is required"))?;
|
||||
let file = absolute(Path::new(given));
|
||||
if !file.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("solidcraft")) {
|
||||
return Err(bad(CMD, "the new design must be a .solidcraft file"));
|
||||
if !solidcraft_io::is_design_container_path(&file) {
|
||||
return Err(bad(CMD, "the new design must be a .sc file"));
|
||||
}
|
||||
if s.path.as_deref().is_some_and(|own| absolute(Path::new(own)) == file) {
|
||||
return Err(bad(CMD, "choose another file than this design's own"));
|
||||
@@ -853,7 +853,7 @@ mod tests {
|
||||
};
|
||||
refuse(&mut s, json!({"component": 0, "path": file}), "not the design");
|
||||
refuse(&mut s, json!({"component": "Plate"}), "`path` is required");
|
||||
refuse(&mut s, json!({"component": "Plate", "path": dir.join("plate.step")}), ".solidcraft");
|
||||
refuse(&mut s, json!({"component": "Plate", "path": dir.join("plate.step")}), ".sc file");
|
||||
|
||||
// A joint from Plate's subcomponent to the rest of the design
|
||||
let pin_occ = s.doc.occurrences.iter().find(|o| o.name.starts_with("Pin")).map(|o| o.id).unwrap();
|
||||
|
||||
@@ -30,7 +30,7 @@ mod relink_tests;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.insert_design", "Insert Design", insert_design).at("SOLID", "ASSEMBLE").icon("component").params(
|
||||
"path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design and a copy of its design properties; \
|
||||
"path: a .sc design → a new component linked to it, holding its solid bodies as placed in that design and a copy of its design properties; \
|
||||
name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]; \
|
||||
reuse?: bool (default false): insert a free occurrence, reusing an existing link to the same file when available",
|
||||
),
|
||||
@@ -39,13 +39,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
→ updated, unchanged, warnings (also joints whose faces are gone or that no longer solve)",
|
||||
),
|
||||
CommandSpec::new("component.relink", "Relink Source", relink).icon("update").params(
|
||||
"component: id or name of a linked component; path: a saved .solidcraft design — replaces the source and linked bodies for all its occurrences, preserving identities and placements; returns path, previous_path, warnings, joint_errors and joint_conflicts",
|
||||
"component: id or name of a linked component; path: a saved .sc design — replaces the source and linked bodies for all its occurrences, preserving identities and placements; returns path, previous_path, warnings, joint_errors and joint_conflicts",
|
||||
),
|
||||
CommandSpec::new("component.break_link", "Break Link", break_link)
|
||||
.icon("break")
|
||||
.params("component: id or name of a linked component — keeps its bodies as they are and forgets the design file"),
|
||||
CommandSpec::new("component.extract", "Extract to File", extract::extract).icon("export").params(
|
||||
"component: id or name of a component that is not linked; path: the .solidcraft design to write → its history, subcomponents and their joints \
|
||||
"component: id or name of a component that is not linked; path: the .sc design to write → its history, subcomponents and their joints \
|
||||
move to that design, and the component becomes linked to it (as component.insert_design makes it); returns path, file, feature, bodies, \
|
||||
notices (what stays behind), warnings and copies (components that build the same bodies: see component.replace_with_link)",
|
||||
),
|
||||
@@ -53,7 +53,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"component: id or name of a linked component → copies: [{component, name}], the components not linked whose own history builds the same bodies",
|
||||
),
|
||||
CommandSpec::new("component.replace_with_link", "Replace with Link", extract::replace_with_link).icon("chain").params(
|
||||
"component or components: [ids or names] of components that are not linked; with: a linked component, or path: a .solidcraft design \
|
||||
"component or components: [ids or names] of components that are not linked; with: a linked component, or path: a .sc design \
|
||||
(its link here, else a new linked component) → each component's history goes and its placements become placements of the linked \
|
||||
component; refused when its bodies are not the same as the link's; returns component, name, replaced and warnings",
|
||||
),
|
||||
@@ -367,8 +367,8 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let given = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))?;
|
||||
let file = absolute(Path::new(given));
|
||||
if !file.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("solidcraft")) {
|
||||
return Err(bad(cmd, "choose a saved .solidcraft design"));
|
||||
if !solidcraft_io::is_design_container_path(&file) {
|
||||
return Err(bad(cmd, "choose a saved .sc design"));
|
||||
}
|
||||
// A network or device path is judged from its text before the disk is asked anything.
|
||||
link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
|
||||
@@ -114,6 +114,28 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => faces.push((body.clone(), vec![*point])),
|
||||
},
|
||||
Sel::Edge { .. } | Sel::Vertex { .. } => skip("edges and vertices are deleted with their body or face".into()),
|
||||
// In the sketch being edited, a profile stands for the curves around it (#7107);
|
||||
// its holes are shapes of their own and stay. A letter's outline stands for its
|
||||
// whole text, so no counters are left behind.
|
||||
Sel::Profile { sketch, index } if s.active_sketch == Some(*sketch) => {
|
||||
let st = s.model.state();
|
||||
let (Some(ss), Ok(sk)) = (st.sketch(*sketch), s.doc.sketch(*sketch)) else {
|
||||
skip("nothing around that profile can be deleted".into());
|
||||
continue;
|
||||
};
|
||||
let outer = ss.profiles.get(*index).map(|pr| solidcraft_sketch::distinct_curves(&pr.outer_curves)).unwrap_or_default();
|
||||
let before = entities.len();
|
||||
for c in outer.iter().filter_map(|id| sk.curves.iter().find(|c| &c.id == id)) {
|
||||
let text = c.link.as_ref().filter(|l| sk.link(l).is_some_and(|l| l.kind == solidcraft_sketch::LinkKind::Text));
|
||||
let id = text.unwrap_or(&c.id);
|
||||
if !entities.contains(id) {
|
||||
entities.push(id.clone());
|
||||
}
|
||||
}
|
||||
if entities.len() == before {
|
||||
skip("nothing around that profile can be deleted".into());
|
||||
}
|
||||
}
|
||||
Sel::Profile { .. } => skip("a profile goes with its sketch".into()),
|
||||
Sel::ExternalSketchCurve { .. } | Sel::ExternalSketchPoint { .. } => skip("edit the source sketch to delete its geometry".into()),
|
||||
}
|
||||
@@ -494,4 +516,55 @@ mod tests {
|
||||
run(&mut s, "selection.delete", json!({"items": [{"type": "sketch_curve", "id": id}]}));
|
||||
assert!(s.model.state().sketch(sk).unwrap().sketch.curves.is_empty());
|
||||
}
|
||||
|
||||
/// A rectangle picked by clicking inside it (a profile) goes with Delete while sketching
|
||||
/// (#7107): its four lines go, the circle inside it (its hole) stays, and undo brings them
|
||||
/// back. Outside the sketch a profile still goes only with its sketch.
|
||||
#[test]
|
||||
fn a_profile_deletes_the_curves_around_it_while_sketching() {
|
||||
let mut s = Session::default();
|
||||
let sk = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 3, "id": "hole"}));
|
||||
let index = {
|
||||
let st = s.model.state();
|
||||
solidcraft_sketch::profile_at_point(&st.sketch(sk).unwrap().profiles, solidcraft_geom::Vec2::new(30.0, 10.0)).unwrap()
|
||||
};
|
||||
let before = curves(&s, sk);
|
||||
assert_eq!(before.len(), 5, "four lines and a circle: {before:?}");
|
||||
let item = json!({"items": [{"type": "profile", "sketch": sk, "index": index}]});
|
||||
run(&mut s, "selection.delete", item.clone());
|
||||
assert_eq!(curves(&s, sk), vec!["hole".to_string()], "only the rectangle goes");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(curves(&s, sk), before);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let err = s.execute("selection.delete", &item).unwrap_err().to_string();
|
||||
assert!(err.contains("a profile goes with its sketch"), "{err}");
|
||||
assert_eq!(curves(&s, sk), before);
|
||||
}
|
||||
|
||||
/// A picked letter goes with its whole text, counters too (an O's inside is a profile of
|
||||
/// its own), and the rectangle beside it stays.
|
||||
#[test]
|
||||
fn a_letter_profile_deletes_its_whole_text_while_sketching() {
|
||||
let mut s = Session::default();
|
||||
let sk = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
|
||||
run(&mut s, "sketch.text", json!({"text": "OB", "at": [60, 0], "height": 10}));
|
||||
let (index, rect) = {
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(sk).unwrap();
|
||||
let text = |id: &String| ss.sketch.curves.iter().any(|c| &c.id == id && c.link.is_some());
|
||||
let index = ss.profiles.iter().position(|p| p.outer_curves.iter().all(text)).unwrap();
|
||||
(index, ss.sketch.curves.iter().filter(|c| c.link.is_none()).map(|c| c.id.clone()).collect::<Vec<_>>())
|
||||
};
|
||||
assert_eq!(rect.len(), 4, "{rect:?}");
|
||||
assert!(curves(&s, sk).len() > 4, "the text has outlines");
|
||||
run(&mut s, "selection.delete", json!({"items": [{"type": "profile", "sketch": sk, "index": index}]}));
|
||||
assert_eq!(curves(&s, sk), rect, "the whole text goes, the rectangle stays");
|
||||
}
|
||||
|
||||
fn curves(s: &Session, sk: u64) -> Vec<String> {
|
||||
s.model.state().sketch(sk).unwrap().sketch.curves.iter().map(|c| c.id.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("open")
|
||||
.key("Ctrl+O")
|
||||
.noundo()
|
||||
.params("path: .solidcraft design, .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh)"),
|
||||
.params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh)"),
|
||||
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
@@ -340,7 +340,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The name a design never given one by its user is shown under.
|
||||
const DEFAULT_NAME: &str = "Untitled";
|
||||
|
||||
/// A design still under the default name takes its file's name (`bracket.solidcraft` →
|
||||
/// A design still under the default name takes its file's name (`bracket.sc` →
|
||||
/// "bracket"); a name the user chose is kept. `None` when nothing changes.
|
||||
fn default_name_for(name: &str, path: &str) -> Option<String> {
|
||||
let name = name.trim();
|
||||
@@ -953,6 +953,39 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A `.solidcraft` design from an earlier release opens, takes its file's name, and `file.save`
|
||||
/// writes it back to that same file (container format), never to a new `.sc` beside it.
|
||||
#[test]
|
||||
fn saving_an_opened_legacy_design_keeps_its_name() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-legacy-ext-save-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let old = dir.join("Bracket.solidcraft");
|
||||
let mut first = Session::default();
|
||||
first.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
first.execute("file.save_as", &json!({"path": old.to_string_lossy()})).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("doc.open", &json!({"path": old.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.doc.name, "Bracket");
|
||||
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5})).unwrap();
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
assert_eq!(s.path.as_deref(), old.to_str());
|
||||
let names: Vec<String> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| !n.ends_with(".bak") && !n.ends_with(".lock"))
|
||||
.collect();
|
||||
assert_eq!(names, ["Bracket.solidcraft"]);
|
||||
let bytes = std::fs::read(&old).unwrap();
|
||||
assert!(solidcraft_io::is_design_container(&bytes));
|
||||
assert_eq!(solidcraft_io::read_design(&bytes).unwrap().features.len(), 2);
|
||||
// Save As to the new extension leaves the old file as it was.
|
||||
s.execute("file.save_as", &json!({"path": dir.join("Bracket.sc").to_string_lossy()})).unwrap();
|
||||
assert!(old.is_file() && dir.join("Bracket.sc").is_file());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #5467: a script, the CLI or the control channel never gets a prompt. A Save over a file
|
||||
/// another program or computer changed since it was opened is refused with an error naming
|
||||
/// `overwrite`, which replaces it; a design opened read-only only saves to another file.
|
||||
|
||||
@@ -1447,3 +1447,37 @@ fn occurrence_bodies_cache_retires_one_answer_when_full() {
|
||||
drop(guard);
|
||||
assert_eq!(s.occurrence_bodies(&root, true).unwrap().len(), 131);
|
||||
}
|
||||
|
||||
/// A connector's boss extruded To Object up to the shell of another component joins the
|
||||
/// connector only: the shell it runs to stays its own body in its own component, and the PCB
|
||||
/// sketched on the shell afterwards is still made (a Join extrude to an object joined the body
|
||||
/// it ran to across components, 0.24.1).
|
||||
#[test]
|
||||
fn join_extrude_to_a_body_of_another_component_leaves_that_body_alone() {
|
||||
let mut s = Session::default();
|
||||
let (shell, _) = created(&mut s, "Shell");
|
||||
run(&mut s, "solid.box", json!({"corner":[-20,-20,20],"length":40,"width":40,"height":5,"body_name":"Shell"}));
|
||||
let (connector, _) = created(&mut s, "Connector");
|
||||
run(&mut s, "solid.cylinder", json!({"radius":6,"height":10,"body_name":"Connector"}));
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,10]},"project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
let plane = s.model.state().sketch(sketch).unwrap().plane;
|
||||
let centre = plane.to_local(solidcraft_geom::Vec3::new(0.0, 0.0, 10.0));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[centre.x, centre.y],"radius":4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// The boss runs up to the shell's underside and joins the connector it was sketched on.
|
||||
run(&mut s, "solid.extrude", json!({"sketch":sketch,"to":[0,0,20],"operation":"join","targets":["Connector"]}));
|
||||
let (pcb, _) = created(&mut s, "PCB");
|
||||
run(&mut s, "solid.box", json!({"corner":[-15,-15,25],"length":30,"width":30,"height":1.6,"body_name":"PCB"}));
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
let st = s.model.state();
|
||||
let mut names: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
names.sort_unstable();
|
||||
assert_eq!(names, ["Connector", "PCB", "Shell"], "three bodies, none merged into another");
|
||||
for (name, component) in [("Shell", shell), ("Connector", connector), ("PCB", pcb)] {
|
||||
assert_eq!(s.doc.model_body_component(st.body(name).unwrap()), component, "{name} stays in its component");
|
||||
}
|
||||
let volume = |name: &str| solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume;
|
||||
let pi = std::f64::consts::PI;
|
||||
assert!((volume("Shell") - 8000.0).abs() < 1e-3, "the shell is unchanged: {}", volume("Shell"));
|
||||
assert!((volume("Connector") - (pi * 36.0 * 10.0 + pi * 16.0 * 10.0)).abs() < 1.0, "the connector took the boss: {}", volume("Connector"));
|
||||
}
|
||||
|
||||
@@ -679,7 +679,7 @@ fn every_flat_face_of_one_sketch_extruded_up_and_down_takes_an_attached_sketch()
|
||||
assert_eq!(sketch_and_extrude_on_every_flat_face(&reopened, &dir, "synthetic-reopened"), 12);
|
||||
// The logo design as it was reported (format 17, with a removed shelled body before it).
|
||||
let mut logo = Session::default();
|
||||
run(&mut logo, "doc.open", json!({"path": root.join("logo-up-down-join.solidcraft").to_string_lossy()}));
|
||||
run(&mut logo, "doc.open", json!({"path": root.join("logo-up-down-join.sc").to_string_lossy()}));
|
||||
assert_eq!(sketch_and_extrude_on_every_flat_face(&logo, &dir, "logo"), 12);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//!
|
||||
//! - **Panics** are caught by a hook. One inside a command becomes a report with the design as
|
||||
//! it was before the command and the command itself, so `solidcraft-cli run replay.json --in
|
||||
//! design.solidcraft` reproduces it. Panics the third-party kernel raises (and
|
||||
//! design.sc` reproduces it. Panics the third-party kernel raises (and
|
||||
//! `solidcraft_kernel::guard` turns into errors) are reported the same way, marked recovered.
|
||||
//! - **Crashes no hook sees** (an abort, a stack overflow, a segfault, the process being killed)
|
||||
//! are found at the next start: every running app keeps a small, locked command journal; a
|
||||
@@ -26,7 +26,7 @@
|
||||
//! into the unclean exit's report.
|
||||
//!
|
||||
//! Reports are folders `crashes/<time>-<signature>/` holding `report.json`, and when known
|
||||
//! `design.solidcraft`, `replay.json` and `minidump.dmp`. Nothing is ever sent anywhere.
|
||||
//! `design.sc` (`design.solidcraft` in reports from earlier releases), `replay.json` and `minidump.dmp`. Nothing is ever sent anywhere.
|
||||
|
||||
use std::backtrace::Backtrace;
|
||||
use std::cell::{Cell, RefCell};
|
||||
@@ -88,7 +88,7 @@ pub struct Summary {
|
||||
pub version: String,
|
||||
/// The report's folder.
|
||||
pub path: String,
|
||||
/// `replay.json` and `design.solidcraft` are there: the crash can be replayed.
|
||||
/// `replay.json` and the design (`design.sc`) are there: the crash can be replayed.
|
||||
pub replayable: bool,
|
||||
}
|
||||
|
||||
@@ -1013,8 +1013,9 @@ fn write_report(state: &State, kind: &str, p: Option<&Panic>, extra: Extra) -> O
|
||||
let mut notes = extra.notes;
|
||||
if let Some(d) = &extra.design {
|
||||
if d.len() <= MAX_DESIGN_BYTES {
|
||||
if solidcraft_io::write_atomic(&folder.join("design.solidcraft"), d, false).is_ok() {
|
||||
files.insert("design".into(), json!("design.solidcraft"));
|
||||
let name = format!("design.{}", solidcraft_io::DESIGN_EXT);
|
||||
if solidcraft_io::write_atomic(&folder.join(&name), d, false).is_ok() {
|
||||
files.insert("design".into(), json!(name));
|
||||
}
|
||||
} else {
|
||||
notes.push(format!("the design is larger than {} MB and was not copied", MAX_DESIGN_BYTES >> 20));
|
||||
@@ -1189,7 +1190,11 @@ pub fn read(dir: &Path, id: &str) -> std::result::Result<Value, String> {
|
||||
let folder = dir.join(id);
|
||||
let mut v = read_report_file(&folder.join("report.json")).ok_or_else(|| format!("no crash report `{id}`"))?;
|
||||
v["path"] = json!(folder.to_string_lossy());
|
||||
for (k, f) in [("design_path", "design.solidcraft"), ("replay_path", "replay.json"), ("minidump_path", "minidump.dmp")] {
|
||||
// Reports from earlier releases hold `design.solidcraft`.
|
||||
if let Some(design) = solidcraft_io::DESIGN_EXTS.iter().map(|ext| folder.join(format!("design.{ext}"))).find(|p| p.is_file()) {
|
||||
v["design_path"] = json!(design.to_string_lossy());
|
||||
}
|
||||
for (k, f) in [("replay_path", "replay.json"), ("minidump_path", "minidump.dmp")] {
|
||||
if folder.join(f).is_file() {
|
||||
v[k] = json!(folder.join(f).to_string_lossy());
|
||||
}
|
||||
|
||||
@@ -232,6 +232,7 @@ fn a_command_panic_is_reported_once_with_the_design_and_the_command() {
|
||||
assert!(!bt.iter().any(|f| f.as_str().unwrap().contains("crash::hook")), "the hook's own frames are dropped: {bt:?}");
|
||||
assert_eq!(v["recent_commands"][0]["command"], "solid.box");
|
||||
// The design from before the command, and the command: together they reproduce the crash.
|
||||
assert!(v["design_path"].as_str().unwrap().ends_with("design.sc"), "{v}");
|
||||
let design = solidcraft_io::read_design(&fs::read(v["design_path"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert_eq!(design.features.len(), 1);
|
||||
let replay: Value = serde_json::from_slice(&fs::read(v["replay_path"].as_str().unwrap()).unwrap()).unwrap();
|
||||
@@ -504,6 +505,17 @@ fn fake_report(dir: &Path, secs: u64, sig: u64, kind: &str) -> String {
|
||||
id
|
||||
}
|
||||
|
||||
/// A report folder an earlier release wrote holds `design.solidcraft`: still found.
|
||||
#[test]
|
||||
fn a_report_from_an_earlier_release_still_finds_its_design() {
|
||||
let d = fresh_dir("legacy-design");
|
||||
let id = fake_report(&d, 1_700_000_000, 7, "command_panic");
|
||||
fs::write(d.join(&id).join("design.solidcraft"), solidcraft_io::write_design(&solidcraft_doc::Document::default())).unwrap();
|
||||
let v = read(&d, &id).unwrap();
|
||||
assert!(v["design_path"].as_str().unwrap().ends_with("design.solidcraft"), "{v}");
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_stay_within_their_limits() {
|
||||
let d = fresh_dir("limits");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Autosave and crash recovery.
|
||||
//!
|
||||
//! Each open design keeps its own recovery entry in the recovery folder: `<id>.solidcraft` (the design
|
||||
//! Each open design keeps its own recovery entry in the recovery folder: `<id>.sc` (the design
|
||||
//! as of the last autosave), `<id>.json` (its file path, name and time) and `<id>.lock`, which
|
||||
//! the app holds locked while it runs. Autosaves are written atomically, only when the design
|
||||
//! has unsaved changes it hasn't written yet; a design with nothing unsaved has no entry. An
|
||||
@@ -114,12 +114,10 @@ fn read_entry(path: &Path) -> Option<Entry> {
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
/// An entry's design: `<id>.solidcraft`, or `<id>.solvecraft` for an autosave copied from a
|
||||
/// pre-rename recovery folder (compatibility shim, docs/compatibility.md).
|
||||
/// An entry's design: `<id>.sc`, or `<id>.solidcraft` / `<id>.solvecraft` for an autosave an
|
||||
/// earlier release wrote (compatibility shim, docs/compatibility.md).
|
||||
fn design_file(dir: &Path, id: &str) -> PathBuf {
|
||||
let current = file(dir, id, solidcraft_io::DESIGN_EXT);
|
||||
let legacy = file(dir, id, solidcraft_io::LEGACY_DESIGN_EXT);
|
||||
if !current.is_file() && legacy.is_file() { legacy } else { current }
|
||||
solidcraft_io::DESIGN_EXTS.iter().map(|ext| file(dir, id, ext)).find(|p| p.is_file()).unwrap_or_else(|| file(dir, id, solidcraft_io::DESIGN_EXT))
|
||||
}
|
||||
|
||||
fn file(dir: &Path, id: &str, ext: &str) -> PathBuf {
|
||||
@@ -170,13 +168,13 @@ pub fn load(dir: &Path, id: &str) -> Result<(Document, Entry)> {
|
||||
}
|
||||
|
||||
/// Delete an entry. Only a real one: `<id>.json` must describe entry `id` and its design must
|
||||
/// be there, so a folder and id that merely name someone's `part.solidcraft` delete nothing.
|
||||
/// be there, so a folder and id that merely name someone's `part.sc` delete nothing.
|
||||
pub fn remove(dir: &Path, id: &str) -> Result<()> {
|
||||
let is_entry = valid_id(id) && read_entry(&file(dir, id, "json")).is_some_and(|e| e.id == id) && design_file(dir, id).is_file();
|
||||
if !is_entry {
|
||||
return Err(EngineError::Other(format!("no recovered design `{id}`")));
|
||||
}
|
||||
for ext in ["json", solidcraft_io::DESIGN_EXT, solidcraft_io::LEGACY_DESIGN_EXT, "lock"] {
|
||||
for ext in ["json", "lock"].into_iter().chain(solidcraft_io::DESIGN_EXTS) {
|
||||
let _ = fs::remove_file(file(dir, id, ext));
|
||||
}
|
||||
Ok(())
|
||||
@@ -331,7 +329,7 @@ impl Autosaver {
|
||||
self.last_at = Instant::now();
|
||||
if !s.is_dirty() {
|
||||
if self.has_entry {
|
||||
for ext in ["json", "solidcraft"] {
|
||||
for ext in ["json"].into_iter().chain(solidcraft_io::DESIGN_EXTS) {
|
||||
let _ = fs::remove_file(file(&self.dir, &self.id, ext));
|
||||
}
|
||||
self.has_entry = false;
|
||||
@@ -387,7 +385,7 @@ impl Autosaver {
|
||||
if let Some(w) = self.writing.take() {
|
||||
let _ = w.done.recv();
|
||||
}
|
||||
for ext in ["json", solidcraft_io::DESIGN_EXT] {
|
||||
for ext in ["json"].into_iter().chain(solidcraft_io::DESIGN_EXTS) {
|
||||
let _ = fs::remove_file(file(&self.dir, &self.id, ext));
|
||||
}
|
||||
self.lock = None;
|
||||
@@ -409,19 +407,37 @@ impl Autosaver {
|
||||
mod tests {
|
||||
#[test]
|
||||
fn autosave_from_a_legacy_folder_is_listed_and_loads() {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-recovery-legacy-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
let id = "1-2-3";
|
||||
let doc = Document::default();
|
||||
fs::write(d.join(format!("{id}.solvecraft")), solidcraft_io::write_design(&doc)).unwrap();
|
||||
let entry = Entry { id: id.into(), path: Some("/d/part.solvecraft".into()), name: "part".into(), saved_at: 1, features: 0 };
|
||||
fs::write(d.join(format!("{id}.json")), serde_json::to_vec(&entry).unwrap()).unwrap();
|
||||
assert_eq!(orphans(&d).iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), [id]);
|
||||
let (_, loaded) = load(&d, id).unwrap();
|
||||
assert_eq!(loaded, entry);
|
||||
remove(&d, id).unwrap();
|
||||
assert!(orphans(&d).is_empty() && !d.join(format!("{id}.solvecraft")).exists());
|
||||
// Autosaves written by earlier releases: `.solvecraft` (before the rename) and
|
||||
// `.solidcraft` (before designs were saved as `.sc`).
|
||||
for ext in solidcraft_io::LEGACY_DESIGN_EXTS {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-recovery-legacy-{ext}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
let id = "1-2-3";
|
||||
let doc = Document::default();
|
||||
fs::write(d.join(format!("{id}.{ext}")), solidcraft_io::write_design(&doc)).unwrap();
|
||||
let entry = Entry { id: id.into(), path: Some(format!("/d/part.{ext}")), name: "part".into(), saved_at: 1, features: 0 };
|
||||
fs::write(d.join(format!("{id}.json")), serde_json::to_vec(&entry).unwrap()).unwrap();
|
||||
assert_eq!(orphans(&d).iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), [id], ".{ext}");
|
||||
let (_, loaded) = load(&d, id).unwrap();
|
||||
assert_eq!(loaded, entry);
|
||||
remove(&d, id).unwrap();
|
||||
assert!(orphans(&d).is_empty() && !d.join(format!("{id}.{ext}")).exists(), ".{ext}");
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
/// New autosaves are written as `<id>.sc`.
|
||||
#[test]
|
||||
fn autosaves_are_written_as_sc() {
|
||||
let d = dir("sc-ext");
|
||||
let mut s = Session::default();
|
||||
let mut a = Autosaver::new(&d, Duration::ZERO).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
assert!(a.save(&s).unwrap());
|
||||
assert!(d.join(format!("{}.sc", a.id)).is_file());
|
||||
assert!(!d.join(format!("{}.solidcraft", a.id)).exists());
|
||||
a.discard();
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
@@ -551,7 +567,7 @@ mod tests {
|
||||
let mut t = Session::default();
|
||||
let r = t.execute("doc.recover", &json!({"dir": d.to_string_lossy(), "id": id, "discard": true})).unwrap();
|
||||
assert_eq!(r["features"], 1, "{r}");
|
||||
assert!(orphans(&d).is_empty() && !file(&d, &id, "json").exists() && !file(&d, &id, "solidcraft").exists());
|
||||
assert!(orphans(&d).is_empty() && !file(&d, &id, "json").exists() && !file(&d, &id, solidcraft_io::DESIGN_EXT).exists());
|
||||
let part = d.join("part.solidcraft");
|
||||
fs::write(&part, solidcraft_io::write_design(&Document::default())).unwrap();
|
||||
assert!(t.execute("doc.recover", &json!({"dir": d.to_string_lossy(), "id": "part", "discard": true})).is_err());
|
||||
@@ -568,7 +584,7 @@ mod tests {
|
||||
assert!(a.tick(&s, false).unwrap());
|
||||
s.mark_saved();
|
||||
a.tick(&s, false).unwrap();
|
||||
assert!(!file(&d, &a.id, "solidcraft").exists());
|
||||
assert!(!file(&d, &a.id, solidcraft_io::DESIGN_EXT).exists());
|
||||
a.close(&s);
|
||||
assert_eq!(fs::read_dir(&d).unwrap().count(), 0);
|
||||
// Closing with unsaved changes keeps them for the next launch.
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::{run, volume};
|
||||
use crate::Session;
|
||||
|
||||
fn open_fixture() -> Session {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/tests/fixtures/cubeish-chamfer-too-large.solidcraft");
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/tests/fixtures/cubeish-chamfer-too-large.sc");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "doc.open", json!({"path": path.to_string_lossy()}));
|
||||
s
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! #1490: the owner's shelled battery case (`battery-tpu-case-v2.solidcraft`, format 21, saved
|
||||
//! #1490: the owner's shelled battery case (`battery-tpu-case-v2.sc`, format 21, saved
|
||||
//! at `wall = 2.5 mm`) with a pleat loft joined into a window in one end wall. The loft stands on
|
||||
//! the case's open rim and its end strips lie in the wall's inner face. At walls of 2.4–2.6 mm
|
||||
//! the join failed with "no result": every exact route was refused, and the last resort's
|
||||
@@ -12,7 +12,7 @@ use super::run;
|
||||
use crate::Session;
|
||||
|
||||
fn open() -> Session {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/tests/fixtures/battery-tpu-case-v2.solidcraft");
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/tests/fixtures/battery-tpu-case-v2.sc");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "doc.open", json!({"path": path.to_string_lossy()}));
|
||||
s
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! cut) was never meshed: its boundary was projected onto its surface starting from a corner
|
||||
//! that a search without a hint misses, so the face was dropped. Every boolean with the body
|
||||
//! then failed (truck's meshes both operands to classify faces), and the exact fallback refused
|
||||
//! it for the same face. The owner's design, `battery-tpu-case.solidcraft` (format 18).
|
||||
//! it for the same face. The owner's design, `battery-tpu-case.sc` (format 18).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -13,7 +13,7 @@ use super::run;
|
||||
use crate::Session;
|
||||
|
||||
fn open_at(feature: &str) -> Session {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/tests/fixtures/battery-tpu-case.solidcraft");
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/tests/fixtures/battery-tpu-case.sc");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "doc.open", json!({"path": path.to_string_lossy()}));
|
||||
run(&mut s, "timeline.roll_to", json!({"feature": feature}));
|
||||
|
||||
@@ -178,7 +178,7 @@ pub static FORMATS: &[FormatSpec] = &[
|
||||
FormatSpec {
|
||||
id: "design",
|
||||
label: "SolidCraft design",
|
||||
extensions: &[crate::DESIGN_EXT, crate::LEGACY_DESIGN_EXT, "json"],
|
||||
extensions: &[crate::DESIGN_EXTS[0], crate::DESIGN_EXTS[1], crate::DESIGN_EXTS[2], "json"],
|
||||
role: Role::Design,
|
||||
import: Some(ImportSpec { target: ImportTarget::Design, options: &[], native_only: false }),
|
||||
export: None,
|
||||
@@ -470,7 +470,7 @@ mod tests {
|
||||
fn import_filter_groups_hold_the_expected_extensions() {
|
||||
let native = import_filters(true);
|
||||
let find = |label: &str| native.iter().find(|(l, _)| *l == label).map(|(_, e)| e.clone()).unwrap_or_default();
|
||||
assert_eq!(find("SolidCraft design (container or JSON)"), vec![crate::DESIGN_EXT, crate::LEGACY_DESIGN_EXT, "json"]);
|
||||
assert_eq!(find("SolidCraft design (container or JSON)"), vec!["sc", "solidcraft", "solvecraft", "json"]);
|
||||
assert_eq!(find("STEP"), vec!["step", "stp"]);
|
||||
assert_eq!(find("IGES"), vec!["igs", "iges"]);
|
||||
assert_eq!(find("FreeCAD and Rhino"), vec!["fcstd", "3dm"]);
|
||||
|
||||
+30
-18
@@ -1,4 +1,4 @@
|
||||
//! File formats. Designs use JSON or versioned containers (`.solidcraft`); the authoritative
|
||||
//! File formats. Designs use JSON or versioned containers (`.sc`; `.solidcraft` and `.solvecraft` still open); the authoritative
|
||||
//! document rebuilds on load and optional display caches remain provisional. Bodies export
|
||||
//! as STL (binary or ASCII), OBJ, STEP and 3MF, and assemblies as URDF ([`urdf()`]); STEP, IGES,
|
||||
//! FreeCAD and Rhino files import as a base feature (see [`step_import_feature`]), 3MF and
|
||||
@@ -46,27 +46,35 @@ pub use urdf::{UrdfOptions, UrdfPackage, urdf};
|
||||
use solidcraft_doc::{Document, Metadata, ModelState};
|
||||
use solidcraft_geom::{Mesh, Vec3};
|
||||
|
||||
/// Design file extension.
|
||||
pub const DESIGN_EXT: &str = "solidcraft";
|
||||
/// The design extension of earlier releases. Compatibility shim (docs/compatibility.md): such
|
||||
/// files still open everywhere a design opens; new designs are saved as [`DESIGN_EXT`].
|
||||
pub const LEGACY_DESIGN_EXT: &str = "solvecraft";
|
||||
/// Design file extension: new designs, autosaves and recovery files are written as `.sc`.
|
||||
pub const DESIGN_EXT: &str = "sc";
|
||||
/// The design extensions of earlier releases, newest first. Compatibility shim
|
||||
/// (docs/compatibility.md): such files still open everywhere a design opens, and Save keeps
|
||||
/// writing an opened file under its own name; new designs are saved as [`DESIGN_EXT`].
|
||||
pub const LEGACY_DESIGN_EXTS: [&str; 2] = ["solidcraft", "solvecraft"];
|
||||
/// Every extension a design file may have, current first.
|
||||
pub const DESIGN_EXTS: [&str; 2] = [DESIGN_EXT, LEGACY_DESIGN_EXT];
|
||||
pub const DESIGN_EXTS: [&str; 3] = [DESIGN_EXT, LEGACY_DESIGN_EXTS[0], LEGACY_DESIGN_EXTS[1]];
|
||||
|
||||
/// Is this a design file name (`.solidcraft`, or the legacy extension), any case?
|
||||
/// Is this extension (without the dot) one a design file may have (`sc` or a legacy one), any case?
|
||||
pub fn is_design_ext(ext: &str) -> bool {
|
||||
DESIGN_EXTS.iter().any(|x| ext.eq_ignore_ascii_case(x))
|
||||
}
|
||||
|
||||
/// Is this a design file name (`.sc`, a legacy extension, or plain `.json`), any case?
|
||||
pub fn is_design_path(path: &str) -> bool {
|
||||
std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| DESIGN_EXTS.iter().chain(["json"].iter()).any(|x| e.eq_ignore_ascii_case(x)))
|
||||
std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| is_design_ext(e) || e.eq_ignore_ascii_case("json"))
|
||||
}
|
||||
|
||||
/// Is this a design container file (`.sc` or a legacy extension, not plain JSON), any case?
|
||||
pub fn is_design_container_path(path: &std::path::Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(is_design_ext)
|
||||
}
|
||||
/// Largest design file we read.
|
||||
pub const MAX_DESIGN_BYTES: usize = 256 << 20;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IoError {
|
||||
#[error("unsupported format `{0}` (use stl, obj, step, 3mf, urdf or solidcraft)")]
|
||||
#[error("unsupported format `{0}` (use stl, obj, step, 3mf, urdf or sc)")]
|
||||
Format(String),
|
||||
#[error("nothing to export: the design has no bodies")]
|
||||
Empty,
|
||||
@@ -346,7 +354,7 @@ impl Format {
|
||||
"3mf" => Format::ThreeMf,
|
||||
"3mf-tagged" => Format::ThreeMfTagged,
|
||||
"urdf" => Format::Urdf,
|
||||
"solidcraft" | "solvecraft" | "json" => Format::Design,
|
||||
e if e == "json" || is_design_ext(e) => Format::Design,
|
||||
_ => return Err(IoError::Format(ext)),
|
||||
})
|
||||
}
|
||||
@@ -577,7 +585,7 @@ pub fn export_with_quality(
|
||||
let objs: Vec<MeshObject> = sel.iter().map(|b| mesh_object(b, format == Format::ThreeMfTagged, quality)).collect::<Result<_>>()?;
|
||||
write_3mf_with(&objs, &threemf_metadata(meta))?
|
||||
}
|
||||
Format::Design => return Err(IoError::Format("solidcraft (save the document instead)".into())),
|
||||
Format::Design => return Err(IoError::Format("sc (save the document instead)".into())),
|
||||
Format::Urdf => return Err(IoError::Format("urdf (an assembly package; use file.export)".into())),
|
||||
})
|
||||
}
|
||||
@@ -944,10 +952,14 @@ mod tests {
|
||||
assert!(step_import_feature(b"ISO-10303-21;", "x.step").is_err());
|
||||
assert!(step_import_feature(&[0xff, 0xfe, 0x00], "x.step").is_err());
|
||||
assert!(Format::from_name("a.dwg").is_err());
|
||||
// Compatibility shim: the legacy design extension is still a design.
|
||||
// Compatibility shim: the legacy design extensions are still designs.
|
||||
assert_eq!(Format::from_name("old.solvecraft").unwrap(), Format::Design);
|
||||
assert_eq!(Format::from_name("new.solidcraft").unwrap(), Format::Design);
|
||||
assert!(is_design_path("/d/old.SOLVECRAFT") && is_design_path("new.solidcraft") && !is_design_path("a.step"));
|
||||
assert_eq!(Format::from_name("older.solidcraft").unwrap(), Format::Design);
|
||||
assert_eq!(Format::from_name("new.sc").unwrap(), Format::Design);
|
||||
assert_eq!(Format::from_name("NEW.SC").unwrap(), Format::Design);
|
||||
assert!(is_design_path("/d/old.SOLVECRAFT") && is_design_path("older.solidcraft") && is_design_path("new.sc") && !is_design_path("a.step"));
|
||||
assert_eq!(DESIGN_EXT, "sc");
|
||||
assert_eq!(DESIGN_EXTS, ["sc", "solidcraft", "solvecraft"], "new extension first, then every older one");
|
||||
assert!(read_stl(&[0u8; 10]).is_err());
|
||||
let mut fake = vec![0u8; 84];
|
||||
fake[80..84].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
|
||||
@@ -826,6 +826,28 @@ fn legacy_design_files_and_resource_uris() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let d = call(&mut s, 1, "resources/read", json!({"uri": "solvecraft://design"}));
|
||||
assert!(d["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline"));
|
||||
|
||||
// `.sc` is what new designs are saved as; a `.solidcraft` file opens and `save {}` writes it
|
||||
// back under its own name.
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-ext-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (new, old) = (dir.join("part.sc"), dir.join("part.solidcraft"));
|
||||
let mut s = server();
|
||||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}));
|
||||
tool(&mut s, "save", json!({"path": new.to_string_lossy()}));
|
||||
assert!(new.is_file());
|
||||
std::fs::rename(&new, &old).unwrap();
|
||||
tool(&mut s, "open", json!({"path": old.to_string_lossy()}));
|
||||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5}}));
|
||||
tool(&mut s, "save", json!({}));
|
||||
assert!(old.is_file() && !new.exists(), "save keeps the opened file's name");
|
||||
assert_eq!(solidcraft_engine::io::read_design(&std::fs::read(&old).unwrap()).unwrap().features.len(), 2);
|
||||
tool(&mut s, "open", json!({"path": old.to_string_lossy()}));
|
||||
tool(&mut s, "save", json!({"path": new.to_string_lossy()}));
|
||||
tool(&mut s, "open", json!({"path": new.to_string_lossy()}));
|
||||
assert!(old.is_file() && new.is_file(), "save as leaves the old file");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The default folders (#205): the recovery folder works out of the box, `screenshot` without a
|
||||
|
||||
@@ -296,7 +296,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"open",
|
||||
"Open a design",
|
||||
"Open a .solidcraft design file, a STEP file (.step/.stp: a new design whose bodies come from the file, as an \
|
||||
"Open a .sc design file (or a .solidcraft one from an earlier release), a STEP file (.step/.stp: a new design whose bodies come from the file, as an \
|
||||
Import base feature in a component named after the file; an assembly's parts become nested components) or a \
|
||||
mesh (.3mf/.stl/.obj/.glb/.gltf: mesh bodies in such a component; a glTF file's nodes become nested components and its \
|
||||
materials appearances); the result reports the component (id, component_name). Replaces the current design; insert into it \
|
||||
@@ -305,7 +305,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
so it is turned by default (`up: \"z\"` keeps its axes). `names` picks what the bodies are named after when the solids' own names say nothing useful.",
|
||||
obj(
|
||||
json!({
|
||||
"path": string("Path to a .solidcraft, .step/.stp, .3mf, .stl, .obj, .glb or .gltf file"),
|
||||
"path": string("Path to a .sc (or .solidcraft), .step/.stp, .3mf, .stl, .obj, .glb or .gltf file"),
|
||||
"up": {"type": "string", "enum": ["z", "y"], "description": "STEP and glTF only. z keeps the file's axes; y: the file has Y up, so its bodies are turned to stand Z up. Default: z for STEP, y for glTF"},
|
||||
"names": {"type": "string", "enum": ["auto", "solid", "product", "instance"], "description": "STEP, IGES, FreeCAD and Rhino. auto (default): keep meaningful solid names, replace placeholders with the nearest named product, else instance; solid: the solid's own name; product: the part's name; instance: the occurrence's name, else the product's"},
|
||||
}),
|
||||
@@ -316,9 +316,9 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"save",
|
||||
"Save the design",
|
||||
"Save the design to `path` or its current file. The default is a design container; a .json path selects plain JSON. Set format to container or json to override the path.",
|
||||
"Save the design to `path` or its current file (a design opened as .solidcraft keeps that name). New designs are .sc files. The default is a design container; a .json path selects plain JSON. Set format to container or json to override the path.",
|
||||
obj(
|
||||
json!({"path": string("Path to save to (default: the current file)"), "format": {"type": "string", "enum": ["container", "json"], "description": "Design encoding (default: JSON for .json, container otherwise)"}}),
|
||||
json!({"path": string("Path to save to, e.g. part.sc (default: the current file)"), "format": {"type": "string", "enum": ["container", "json"], "description": "Design encoding (default: JSON for .json, container otherwise)"}}),
|
||||
&[],
|
||||
),
|
||||
Effect::Files,
|
||||
|
||||
@@ -157,7 +157,7 @@ fn main() {
|
||||
};
|
||||
// "save": only the save (the design is there already).
|
||||
if text.contains("save") {
|
||||
let path = std::env::var_os("HOME").map(std::path::PathBuf::from).unwrap_or_default().join("Documents/block.solidcraft");
|
||||
let path = std::env::var_os("HOME").map(std::path::PathBuf::from).unwrap_or_default().join("Documents/block.sc");
|
||||
let title = format!("save {}", path.display());
|
||||
say(session, "I'll save the design to your Documents folder.");
|
||||
tool(session, "t4", &title, "edit", "in_progress", None);
|
||||
|
||||
@@ -273,7 +273,8 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if !cfg!(target_arch = "wasm32") && app.preferences.ai_features {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
|
||||
let browsing = !crate::prefs_search::querying(ui) || app.prefs_window.section == crate::prefs::PAGE_AI;
|
||||
if browsing && app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
|
||||
refresh(app, &ui.ctx().clone());
|
||||
}
|
||||
agents_section(app, ui);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! A tool call in words, for its card (#1457): "Extrude Sketch1 by 10 mm", "Box 40 × 30 × 20 mm,
|
||||
//! then Fillet 4 edges (r 4 mm)", "Saved block.solidcraft".
|
||||
//! then Fillet 4 edges (r 4 mm)", "Saved block.sc".
|
||||
//!
|
||||
//! A first pass built from the command's translated label, its main parameters (lengths in the
|
||||
//! design's units) and the bodies the result names. Anything it doesn't know (another agent's own
|
||||
|
||||
@@ -360,6 +360,26 @@ pub fn folder_items(app: &SolveApp, component: u64, folder: &str, scope: &str) -
|
||||
v
|
||||
}
|
||||
|
||||
/// A component's or placement's menu, while its Bodies folder is folded away (#4217), takes the
|
||||
/// folder's items it lacks: Create Components from Bodies and Group Selected (New Group and Show
|
||||
/// All are on every component's menu).
|
||||
pub fn folded_folder_items(app: &SolveApp, target: &Target) -> Vec<Item> {
|
||||
let (comp, scope) = match target {
|
||||
Target::Component { id } => (*id, format!("c{id}")),
|
||||
Target::Occurrence { path } => match app.session.occurrence_placement(path) {
|
||||
Ok(placement) => (placement.component, occurrence_key(path)),
|
||||
Err(_) => return Vec::new(),
|
||||
},
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
if !bodies_folded(app, comp, &scope, entries(app, comp, "bodies").len()) {
|
||||
return Vec::new();
|
||||
}
|
||||
let items =
|
||||
folder_items(app, comp, "bodies", &scope).into_iter().filter(|i| matches!(i.id.as_str(), "component.from_bodies" | "ui.groupSelected"));
|
||||
std::iter::once(Item::sep()).chain(items).collect()
|
||||
}
|
||||
|
||||
/// The menu of the Origin folder.
|
||||
pub fn origin_items(app: &SolveApp) -> Vec<Item> {
|
||||
let planes = ["XY", "XZ", "YZ"].iter().all(|p| !app.session.visibility.hidden_origin.iter().any(|h| h == p));
|
||||
@@ -2275,6 +2295,22 @@ fn folder_static(folder: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a component's Bodies folder is folded away, its bodies (and groups) listed straight
|
||||
/// under the component or placement row (Preferences › Interface › Bodies folders, #4217).
|
||||
/// `scope` is that row's tree key (`c{id}`, `o:{path}`) and `bodies` how many items the folder
|
||||
/// holds. The design's own folder always stays, and so does a hidden one: its eye is the way back.
|
||||
fn bodies_folded(app: &SolveApp, comp: u64, scope: &str, bodies: usize) -> bool {
|
||||
use crate::prefs::BodiesFolders;
|
||||
if comp == 0 || bodies == 0 || app.session.folder_hidden(&format!("{scope}/bodies")) {
|
||||
return false;
|
||||
}
|
||||
match app.preferences.browser_bodies_folders {
|
||||
BodiesFolders::Always => false,
|
||||
BodiesFolders::Several => bodies == 1 && !app.session.doc.browser_groups.iter().any(|g| g.component == comp && g.folder == "bodies"),
|
||||
BodiesFolders::Never => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// A component's folder: its row, its groups and its items. Rows are drag sources; items,
|
||||
/// groups and the folder row are drop targets (reorder, into a group, out of groups).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -2304,26 +2340,34 @@ fn folder_rows_at(
|
||||
app.tree.collapsed.remove(&format!("g{}", g.id));
|
||||
}
|
||||
}
|
||||
let open = is_open(app, key, true);
|
||||
// A folded folder draws no row: its groups and items move up into its place, and their own
|
||||
// eyes, hover and drops do its jobs. It is always open, as nothing could open it again.
|
||||
let folded = folder == "bodies" && bodies_folded(app, comp, prefix, data.entries().count());
|
||||
let open = folded || is_open(app, key, true);
|
||||
let inner = if folded { depth } else { depth + 1 };
|
||||
// The folder's eye is its own: hiding it hides its items without changing theirs, and its
|
||||
// items never change it. It and everything in it fade while it or a placement above is
|
||||
// hidden, whatever their own eyes say.
|
||||
let shown = !app.session.folder_hidden(key);
|
||||
let folder_faded = !shown || path.is_some_and(|p| !app.session.occurrence_visible(p));
|
||||
let r = draw_row(
|
||||
ui,
|
||||
ui.id().with(key),
|
||||
&Row {
|
||||
depth,
|
||||
fold: Some(open),
|
||||
eye: Some(shown),
|
||||
icon: "folder",
|
||||
label: folder_label(folder),
|
||||
dim: folder_faded,
|
||||
mark: acts.lit.branch(app, data.entries()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let r = if folded {
|
||||
RowResp::default()
|
||||
} else {
|
||||
draw_row(
|
||||
ui,
|
||||
ui.id().with(key),
|
||||
&Row {
|
||||
depth,
|
||||
fold: Some(open),
|
||||
eye: Some(shown),
|
||||
icon: "folder",
|
||||
label: folder_label(folder),
|
||||
dim: folder_faded,
|
||||
mark: acts.lit.branch(app, data.entries()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
};
|
||||
if r.fold {
|
||||
toggle(app, key, true);
|
||||
} else if r.clicked {
|
||||
@@ -2368,7 +2412,7 @@ fn folder_rows_at(
|
||||
ui,
|
||||
ui.id().with(gkey),
|
||||
&Row {
|
||||
depth: depth + 1,
|
||||
depth: inner,
|
||||
fold: Some(gopen),
|
||||
eye: Some(gshown),
|
||||
icon: "folder",
|
||||
@@ -2403,12 +2447,12 @@ fn folder_rows_at(
|
||||
}
|
||||
if gopen {
|
||||
for e in items {
|
||||
entry_row(app, ui, comp, path, fstatic, e, depth + 2, Some(id), loose_keys, group_faded, acts);
|
||||
entry_row(app, ui, comp, path, fstatic, e, inner + 1, Some(id), loose_keys, group_faded, acts);
|
||||
}
|
||||
}
|
||||
}
|
||||
for e in &data.loose {
|
||||
entry_row(app, ui, comp, path, fstatic, e, depth + 1, None, loose_keys, folder_faded, acts);
|
||||
entry_row(app, ui, comp, path, fstatic, e, inner, None, loose_keys, folder_faded, acts);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,41 @@
|
||||
//! Written help for the ribbon tooltips (#2758): two short sentences per command, shown under the
|
||||
//! tool name when the pointer rests on its button. The names stay as they are; the help is a
|
||||
//! separate message `help.<command id>` in the catalogs, so a tool without help keeps its plain
|
||||
//! tooltip. Text only for now; pictures and animations are a later step.
|
||||
//! tooltip. Text only for now; pictures and animations are a later step. A new toolbar command
|
||||
//! ships with its help: `every_toolbar_command_has_help_or_is_on_the_backlog` fails otherwise.
|
||||
|
||||
use crate::i18n;
|
||||
|
||||
/// The commands that have help text. Each one needs a `help.<id>` message in the catalogs.
|
||||
pub const DOCUMENTED: &[&str] = &[
|
||||
"appearance.assign",
|
||||
"face.delete",
|
||||
"material.assign",
|
||||
"parameters.change",
|
||||
"solid.align",
|
||||
"solid.chamfer",
|
||||
"solid.combine",
|
||||
"solid.draft",
|
||||
"solid.extrude",
|
||||
"solid.fillet",
|
||||
"solid.hole",
|
||||
"solid.loft",
|
||||
"solid.mirror",
|
||||
"solid.move",
|
||||
"solid.offset_face",
|
||||
"solid.pattern.circular",
|
||||
"solid.pattern.rectangular",
|
||||
"solid.press_pull",
|
||||
"solid.remove",
|
||||
"solid.replace_face",
|
||||
"solid.revolve",
|
||||
"solid.scale",
|
||||
"solid.shell",
|
||||
"solid.split_body",
|
||||
"solid.split_face",
|
||||
"solid.sweep",
|
||||
"timeline.compute_all",
|
||||
"timeline.delete",
|
||||
];
|
||||
|
||||
/// The help for `command` in the current language, when it has any.
|
||||
@@ -71,6 +88,285 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Toolbar commands written before every tool needed help (#2758). The list only shrinks: write
|
||||
/// the help for one of these and take it off; a new toolbar command never goes on it.
|
||||
const BACKLOG: &[&str] = &[
|
||||
"assembly.bom",
|
||||
"canvas.decal",
|
||||
"canvas.edit",
|
||||
"canvas.insert",
|
||||
"component.break_link",
|
||||
"component.capture_position",
|
||||
"component.create",
|
||||
"component.from_bodies",
|
||||
"component.insert_design",
|
||||
"component.properties",
|
||||
"component.revert_position",
|
||||
"component.update_links",
|
||||
"config.configure",
|
||||
"config.preview",
|
||||
"config.rules",
|
||||
"config.table",
|
||||
"construct.axis.edge",
|
||||
"construct.axis.normal_to_face",
|
||||
"construct.axis.through_cylinder",
|
||||
"construct.axis.two_planes",
|
||||
"construct.axis.two_points",
|
||||
"construct.plane.along_path",
|
||||
"construct.plane.at_angle",
|
||||
"construct.plane.midplane",
|
||||
"construct.plane.offset",
|
||||
"construct.plane.perpendicular",
|
||||
"construct.plane.tangent",
|
||||
"construct.plane.three_points",
|
||||
"construct.plane.two_edges",
|
||||
"construct.point.along_path",
|
||||
"construct.point.center",
|
||||
"construct.point.edge_plane",
|
||||
"construct.point.three_planes",
|
||||
"construct.point.two_edges",
|
||||
"construct.point.vertex",
|
||||
"contact.check",
|
||||
"contact.create",
|
||||
"contact.disable_all",
|
||||
"contact.edit",
|
||||
"contact.enable_all",
|
||||
"contact.enable_sets",
|
||||
"contact.list",
|
||||
"document.inspect",
|
||||
"document.units",
|
||||
"edit.checkpoint",
|
||||
"edit.restore_checkpoint",
|
||||
"explode.create",
|
||||
"file.insert_mesh",
|
||||
"file.insert_step",
|
||||
"file.save_mesh",
|
||||
"file.send_to_slicer",
|
||||
"frame.member",
|
||||
"inspect.accessibility",
|
||||
"inspect.center_of_mass",
|
||||
"inspect.colored_components",
|
||||
"inspect.compare",
|
||||
"inspect.continuity",
|
||||
"inspect.curvature_comb",
|
||||
"inspect.curvature_map",
|
||||
"inspect.draft",
|
||||
"inspect.environment_map",
|
||||
"inspect.interference",
|
||||
"inspect.isocurve",
|
||||
"inspect.measure",
|
||||
"inspect.minimum_radius",
|
||||
"inspect.section",
|
||||
"inspect.zebra",
|
||||
"joint.as_built",
|
||||
"joint.cam",
|
||||
"joint.create",
|
||||
"joint.drive",
|
||||
"joint.gear",
|
||||
"joint.limits",
|
||||
"joint.motion_link",
|
||||
"joint.origin",
|
||||
"joint.rack_pinion",
|
||||
"joint.reach",
|
||||
"joint.rigid_group",
|
||||
"joint.screw",
|
||||
"library.apply",
|
||||
"library.export",
|
||||
"library.inspect",
|
||||
"material.properties.validate",
|
||||
"mesh.convert",
|
||||
"motion.study",
|
||||
"motion.trace",
|
||||
"occurrence.ground",
|
||||
"parameters.export",
|
||||
"parameters.graph",
|
||||
"parameters.import",
|
||||
"parameters.move",
|
||||
"parts.fastener",
|
||||
"parts.insert",
|
||||
"parts.library",
|
||||
"parts.resize",
|
||||
"plastic.assign_rule",
|
||||
"plastic.boss",
|
||||
"plastic.lip",
|
||||
"plastic.manage_rules",
|
||||
"plastic.rest",
|
||||
"plastic.snap_fit",
|
||||
"sheet.convert",
|
||||
"sheet.export_bend_table",
|
||||
"sheet.export_dxf",
|
||||
"sheet.flange",
|
||||
"sheet.flat_pattern",
|
||||
"sheet.fold",
|
||||
"sheet.hem",
|
||||
"sheet.manage_rules",
|
||||
"sheet.path_flange",
|
||||
"sheet.perforate",
|
||||
"sheet.refold",
|
||||
"sheet.unfold",
|
||||
"sketch.arc.center_point",
|
||||
"sketch.arc.tangent",
|
||||
"sketch.arc.three_point",
|
||||
"sketch.arc3d",
|
||||
"sketch.auto_constrain",
|
||||
"sketch.auto_constrain_from_datum",
|
||||
"sketch.blend_curve",
|
||||
"sketch.break",
|
||||
"sketch.cancel",
|
||||
"sketch.centerline",
|
||||
"sketch.chamfer.distance_angle",
|
||||
"sketch.chamfer.equal_distance",
|
||||
"sketch.chamfer.two_distance",
|
||||
"sketch.circle.center",
|
||||
"sketch.circle.three_point",
|
||||
"sketch.circle.three_tangent",
|
||||
"sketch.circle.two_point",
|
||||
"sketch.circle.two_tangent",
|
||||
"sketch.conic",
|
||||
"sketch.constrainer",
|
||||
"sketch.constraint.coincident",
|
||||
"sketch.constraint.collinear",
|
||||
"sketch.constraint.concentric",
|
||||
"sketch.constraint.curvature",
|
||||
"sketch.constraint.equal",
|
||||
"sketch.constraint.fix",
|
||||
"sketch.constraint.horizontal_vertical",
|
||||
"sketch.constraint.midpoint",
|
||||
"sketch.constraint.parallel",
|
||||
"sketch.constraint.perpendicular",
|
||||
"sketch.constraint.polygon",
|
||||
"sketch.constraint.symmetry",
|
||||
"sketch.constraint.tangent",
|
||||
"sketch.construction",
|
||||
"sketch.copy",
|
||||
"sketch.create",
|
||||
"sketch.cut",
|
||||
"sketch.dimension",
|
||||
"sketch.edit",
|
||||
"sketch.ellipse",
|
||||
"sketch.extend",
|
||||
"sketch.fillet",
|
||||
"sketch.finish",
|
||||
"sketch.finish_auto_constrain",
|
||||
"sketch.fit_curves_to_section",
|
||||
"sketch.include_3d",
|
||||
"sketch.infinite",
|
||||
"sketch.insert_dxf",
|
||||
"sketch.insert_svg",
|
||||
"sketch.intersect",
|
||||
"sketch.intersection_curve",
|
||||
"sketch.isoparametric_curve",
|
||||
"sketch.line",
|
||||
"sketch.line.midpoint",
|
||||
"sketch.line3d",
|
||||
"sketch.mirror",
|
||||
"sketch.move",
|
||||
"sketch.move3d",
|
||||
"sketch.offset",
|
||||
"sketch.paste",
|
||||
"sketch.pattern.circular",
|
||||
"sketch.pattern.rectangular",
|
||||
"sketch.point",
|
||||
"sketch.point3d",
|
||||
"sketch.polygon.circumscribed",
|
||||
"sketch.polygon.edge",
|
||||
"sketch.polygon.inscribed",
|
||||
"sketch.project",
|
||||
"sketch.project_to_surface",
|
||||
"sketch.rectangle.center",
|
||||
"sketch.rectangle.rounded",
|
||||
"sketch.rectangle.three_point",
|
||||
"sketch.rectangle.two_point",
|
||||
"sketch.scale",
|
||||
"sketch.slot.arc_center",
|
||||
"sketch.slot.arc_three_point",
|
||||
"sketch.slot.center_point",
|
||||
"sketch.slot.center_to_center",
|
||||
"sketch.slot.overall",
|
||||
"sketch.spline.control_point",
|
||||
"sketch.spline.control_point_5",
|
||||
"sketch.spline.fit_point",
|
||||
"sketch.spline3d",
|
||||
"sketch.spun_profile",
|
||||
"sketch.text",
|
||||
"sketch.transform",
|
||||
"sketch.trim",
|
||||
"solid.boundary_fill",
|
||||
"solid.bounding_solid",
|
||||
"solid.box",
|
||||
"solid.coil",
|
||||
"solid.cone",
|
||||
"solid.cylinder",
|
||||
"solid.emboss",
|
||||
"solid.pattern.path",
|
||||
"solid.pipe",
|
||||
"solid.rib",
|
||||
"solid.sphere",
|
||||
"solid.thread",
|
||||
"solid.torus",
|
||||
"solid.web",
|
||||
"surface.blend",
|
||||
"surface.boundary",
|
||||
"surface.extend",
|
||||
"surface.extrude",
|
||||
"surface.fair",
|
||||
"surface.loft",
|
||||
"surface.match",
|
||||
"surface.offset",
|
||||
"surface.patch",
|
||||
"surface.reverse_normal",
|
||||
"surface.revolve",
|
||||
"surface.ruled",
|
||||
"surface.stitch",
|
||||
"surface.sweep",
|
||||
"surface.thicken",
|
||||
"surface.trim",
|
||||
"surface.unstitch",
|
||||
"surface.untrim",
|
||||
"timeline.dependents",
|
||||
"view.apply",
|
||||
"view.delete",
|
||||
"view.fit",
|
||||
"view.grid",
|
||||
"view.ground_shadow",
|
||||
"view.home",
|
||||
"view.list",
|
||||
"view.navigate",
|
||||
"view.navigation_bar",
|
||||
"view.rename",
|
||||
"view.save",
|
||||
"view.spin",
|
||||
"view.tool",
|
||||
"view.visual_style",
|
||||
];
|
||||
|
||||
/// Every command the ribbon offers people, gathered the way the toolbar lays out its panels. A
|
||||
/// planned command only says "not supported yet"; it gets its help when it is built.
|
||||
fn toolbar_commands() -> BTreeSet<&'static str> {
|
||||
let specs = solidcraft_engine::command_specs();
|
||||
let mut ids = BTreeSet::new();
|
||||
for section in crate::workspace::SECTIONS {
|
||||
if let Some((commands, _)) = crate::workspace::layout(section.tab, section.panel, &specs) {
|
||||
ids.extend(commands.iter().filter(|c| c.planned.is_none() && !crate::workspace::AGENT_ONLY.contains(&c.id)).map(|c| c.id));
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_toolbar_command_has_help_or_is_on_the_backlog() {
|
||||
let toolbar = toolbar_commands();
|
||||
let missing: Vec<_> = toolbar.iter().filter(|id| !DOCUMENTED.contains(id) && !BACKLOG.contains(id)).collect();
|
||||
let stale: Vec<_> = BACKLOG.iter().filter(|id| !toolbar.contains(*id) || DOCUMENTED.contains(id)).collect();
|
||||
assert!(
|
||||
missing.is_empty() && stale.is_empty(),
|
||||
"toolbar commands without help: {missing:?}. Add `help.<id>` (two short sentences: what the tool does, \
|
||||
then when to use it) to a file in assets/locales/pending/ and the id to DOCUMENTED. Take these off the backlog \
|
||||
(documented now, or not on the toolbar): {stale:?}"
|
||||
);
|
||||
assert!(BACKLOG.is_sorted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_documented_id_is_a_real_command() {
|
||||
for id in DOCUMENTED {
|
||||
@@ -109,7 +405,7 @@ mod tests {
|
||||
for id in DOCUMENTED {
|
||||
assert!(help(id).is_some_and(|text| !text.is_empty()), "{id}");
|
||||
}
|
||||
assert!(help("solid.draft").is_none());
|
||||
assert!(help("solid.rib").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -117,7 +413,7 @@ mod tests {
|
||||
assert_eq!(row_tip("solid.shell", "Shell", Some("S"), None).as_deref(), Some("Shell (S)"));
|
||||
assert_eq!(row_tip("solid.hole", "Hole", None, None).as_deref(), Some("Hole"));
|
||||
assert_eq!(row_tip("solid.hole", "Hole", None, Some("Select a face")).as_deref(), Some("Select a face"));
|
||||
assert_eq!(row_tip("solid.draft", "Draft", None, None), None);
|
||||
assert_eq!(row_tip("solid.draft", "Draft", None, Some("No body")).as_deref(), Some("No body"));
|
||||
assert_eq!(row_tip("solid.rib", "Rib", None, None), None);
|
||||
assert_eq!(row_tip("solid.rib", "Rib", None, Some("No body")).as_deref(), Some("No body"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,6 +398,7 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
|
||||
.collect(),
|
||||
Target::NamedView { name } => crate::browser::view_items(app, name),
|
||||
};
|
||||
v.extend(crate::browser::folded_folder_items(app, target));
|
||||
// Several items of the folder selected: they can be grouped.
|
||||
if let Some(p) = crate::browser::selection_group(app, target) {
|
||||
v.push(Item::sep());
|
||||
|
||||
@@ -1546,7 +1546,7 @@ mod tests {
|
||||
h.frames(3);
|
||||
h.settle();
|
||||
assert!(!h.app.home.open);
|
||||
assert_eq!(h.app.session.path.as_deref(), h.dir.join("Copy.solidcraft").to_str());
|
||||
assert_eq!(h.app.session.path.as_deref(), h.dir.join("Copy.sc").to_str());
|
||||
assert_eq!(std::fs::read(source).unwrap(), bytes);
|
||||
}
|
||||
|
||||
|
||||
@@ -300,6 +300,14 @@ pub fn tr(id: &str) -> &str {
|
||||
pub fn tr_label(label: &str) -> &str {
|
||||
CURRENT.with(|current| current.get().label(label))
|
||||
}
|
||||
|
||||
/// Every English catalog entry `(id, text)`, for Preferences search to match a translation
|
||||
/// back to the words it was written in.
|
||||
pub(crate) fn each_english_message(mut f: impl FnMut(&str, &str)) {
|
||||
for (id, text) in ENGLISH.messages() {
|
||||
f(id, text);
|
||||
}
|
||||
}
|
||||
pub fn format(id: &str, args: &[(&str, &str)]) -> String {
|
||||
CURRENT.with(|current| current.get().format(id, args))
|
||||
}
|
||||
|
||||
@@ -547,7 +547,7 @@ pub fn crash_draft(report: &serde_json::Value) -> Draft {
|
||||
if recent.is_empty() { "(none recorded)".to_string() } else { recent.join(", ") },
|
||||
s("id"),
|
||||
match (report.get("design_path").is_some(), report.get("replay_path").is_some()) {
|
||||
(true, true) => "the design as it was before the crash (design.solidcraft) and the commands that crash it (replay.json)",
|
||||
(true, true) => "the design as it was before the crash (design.sc) and the commands that crash it (replay.json)",
|
||||
(false, true) => "the commands that crash it on a new design (replay.json)",
|
||||
_ => "no replay (see its notes)",
|
||||
},
|
||||
|
||||
@@ -436,9 +436,11 @@ pub fn reset(app: &mut SolveApp, id: Option<&str>) {
|
||||
/// reset; a key another command uses is reported and taken over only on request.
|
||||
pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let filtering = crate::prefs_search::querying(ui);
|
||||
// Keep a pending chord until it has settled and all keys are released. Modifier-only
|
||||
// input never starts the timer, so modifiers can be assembled at the user's own pace.
|
||||
if let Some(id) = app.keymap.capturing.clone() {
|
||||
// A Preferences search is using the keyboard; don't swallow those keys to finish a capture.
|
||||
if !filtering && let Some(id) = app.keymap.capturing.clone() {
|
||||
match ctx.input(|i| app.keymap.recorder.update(i)) {
|
||||
Recording::Waiting => {
|
||||
if app.keymap.recorder.key.is_some() {
|
||||
@@ -479,83 +481,95 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let mut action: Option<(String, &str)> = None;
|
||||
ui_kit::scope(ui, |ui| {
|
||||
let actions = [
|
||||
ui_kit::SearchAction { label: app.tr_label("Reset All"), icon: None, visible: !app.keymap.read_only, tooltip: None },
|
||||
ui_kit::SearchAction { label: app.tr_label("Import…"), icon: Some("import_tray"), visible: !app.keymap.read_only, tooltip: None },
|
||||
ui_kit::SearchAction {
|
||||
label: app.tr_label("Export…"),
|
||||
icon: Some("export_tray"),
|
||||
visible: true,
|
||||
tooltip: Some(app.tr_label("Keys, S box favourites and the toolbar, as a file")),
|
||||
},
|
||||
];
|
||||
let (filter, toolbar_action) = ui_kit::search_actions_row(
|
||||
ui,
|
||||
egui::Id::new("sc_keymap_filter"),
|
||||
&mut app.keymap.filter,
|
||||
crate::i18n::tr_label("Filter commands or keys"),
|
||||
&actions,
|
||||
);
|
||||
crate::scenario::publish_target("keymap:filter", filter.rect);
|
||||
crate::scenario::publish_handle("shortcut:filter", filter.rect.center());
|
||||
if filter.clicked() {
|
||||
action = Some((String::new(), "cancel"));
|
||||
}
|
||||
if let Some(index) = toolbar_action {
|
||||
action = Some((
|
||||
String::new(),
|
||||
match index {
|
||||
0 => "reset_all",
|
||||
1 => "import",
|
||||
_ => "export",
|
||||
if !filtering {
|
||||
let actions = [
|
||||
ui_kit::SearchAction { label: app.tr_label("Reset All"), icon: None, visible: !app.keymap.read_only, tooltip: None },
|
||||
ui_kit::SearchAction { label: app.tr_label("Import…"), icon: Some("import_tray"), visible: !app.keymap.read_only, tooltip: None },
|
||||
ui_kit::SearchAction {
|
||||
label: app.tr_label("Export…"),
|
||||
icon: Some("export_tray"),
|
||||
visible: true,
|
||||
tooltip: Some(app.tr_label("Keys, S box favourites and the toolbar, as a file")),
|
||||
},
|
||||
));
|
||||
];
|
||||
let (filter, toolbar_action) = ui_kit::search_actions_row(
|
||||
ui,
|
||||
egui::Id::new("sc_keymap_filter"),
|
||||
&mut app.keymap.filter,
|
||||
crate::i18n::tr_label("Filter commands or keys"),
|
||||
&actions,
|
||||
);
|
||||
crate::scenario::publish_target("keymap:filter", filter.rect);
|
||||
crate::scenario::publish_handle("shortcut:filter", filter.rect.center());
|
||||
if filter.clicked() {
|
||||
action = Some((String::new(), "cancel"));
|
||||
}
|
||||
if let Some(index) = toolbar_action {
|
||||
action = Some((
|
||||
String::new(),
|
||||
match index {
|
||||
0 => "reset_all",
|
||||
1 => "import",
|
||||
_ => "export",
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(m) = &app.keymap.message {
|
||||
ui_kit::text(ui, m, ui_kit::Text::Report("keymap_message", true));
|
||||
}
|
||||
if let Some((id, key, _)) = app.keymap.conflict.clone() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let replace = ui_kit::button(ui, crate::i18n::format("keymap.use_anyway", &[("key", &display(&key))]), ui_kit::Button::Normal);
|
||||
crate::scenario::publish_target("keymap:replace", replace.rect);
|
||||
if replace.clicked() {
|
||||
action = Some((format!("{id}\n{key}"), "replace"));
|
||||
}
|
||||
if ui_kit::button(ui, app.tr_label("Cancel"), ui_kit::Button::Normal).clicked() {
|
||||
action = Some((String::new(), "cancel"));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui_kit::text(ui, app.tr_label("Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation)."), ui_kit::Text::Note("navigation"));
|
||||
}
|
||||
if let Some(m) = &app.keymap.message {
|
||||
ui_kit::text(ui, m, ui_kit::Text::Report("keymap_message", true));
|
||||
}
|
||||
if let Some((id, key, _)) = app.keymap.conflict.clone() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let replace = ui_kit::button(ui, crate::i18n::format("keymap.use_anyway", &[("key", &display(&key))]), ui_kit::Button::Normal);
|
||||
crate::scenario::publish_target("keymap:replace", replace.rect);
|
||||
if replace.clicked() {
|
||||
action = Some((format!("{id}\n{key}"), "replace"));
|
||||
}
|
||||
if ui_kit::button(ui, app.tr_label("Cancel"), ui_kit::Button::Normal).clicked() {
|
||||
action = Some((String::new(), "cancel"));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui_kit::text(ui, app.tr_label("Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation)."), ui_kit::Text::Note("navigation"));
|
||||
if !app.keymap.read_only {
|
||||
if !filtering && !app.keymap.read_only {
|
||||
ui_kit::text(ui, app.tr("ui.shortcut_capture_hint"), ui_kit::Text::Note("shortcut_capture_help"));
|
||||
if app.keymap.capturing.is_some() {
|
||||
let hint = ctx.input(|input| app.keymap.recorder.hint(input));
|
||||
ui_kit::text(ui, app.tr(hint), ui_kit::Text::Hint("shortcut_capture_state"));
|
||||
}
|
||||
}
|
||||
let q = app.keymap.filter.to_lowercase();
|
||||
let q = if filtering { app.prefs_window.filter.trim().to_lowercase() } else { app.keymap.filter.to_lowercase() };
|
||||
let commands: Vec<_> = command_specs()
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
let key = effective_for_spec(app, c).unwrap_or_default();
|
||||
let label = app.tr_label(c.label);
|
||||
(q.is_empty() || [c.label, c.id, key, label].iter().any(|s| s.to_lowercase().contains(&q))).then_some((c, key, label))
|
||||
let hit = if filtering {
|
||||
crate::prefs_search::matches_text(&q, &[c.label, c.id, key, label])
|
||||
} else {
|
||||
q.is_empty() || [c.label, c.id, key, label].iter().any(|s| s.to_lowercase().contains(&q))
|
||||
};
|
||||
hit.then_some((c, key, label))
|
||||
})
|
||||
.collect();
|
||||
let t = crate::theme::Tokens::get();
|
||||
let key_width = commands
|
||||
.iter()
|
||||
.map(|(_, key, _)| ui_kit::ContentWidth::button(ui, &display(key)))
|
||||
.chain(app.keymap.recorder.preview().filter(|_| app.keymap.capturing.is_some()).map(|text| ui_kit::ContentWidth::button(ui, &text)))
|
||||
.fold(ui_kit::ContentWidth::button(ui, app.tr_label("press a key…")), f32::max)
|
||||
.max(ui_kit::ContentWidth::label(ui, app.tr_label("Keyboard Shortcuts")) + t.space_8);
|
||||
let action_width = if app.keymap.read_only {
|
||||
t.control_height
|
||||
} else {
|
||||
ui_kit::ContentWidth::button(ui, app.tr_label("Clear")) + ui_kit::ContentWidth::button(ui, app.tr_label("Reset")) + t.space_4
|
||||
};
|
||||
ui_kit::settings_scroll(ui, "sc_keymap_scroll", |ui| {
|
||||
if filtering && commands.is_empty() {
|
||||
return;
|
||||
}
|
||||
let count = commands.len();
|
||||
let shortcut_title = app.tr("prefs.shortcuts").to_string();
|
||||
let paint = |ui: &mut egui::Ui| {
|
||||
let t = crate::theme::Tokens::get();
|
||||
let key_width = commands
|
||||
.iter()
|
||||
.map(|(_, key, _)| ui_kit::ContentWidth::button(ui, &display(key)))
|
||||
.chain(app.keymap.recorder.preview().filter(|_| app.keymap.capturing.is_some()).map(|text| ui_kit::ContentWidth::button(ui, &text)))
|
||||
.fold(ui_kit::ContentWidth::button(ui, app.tr_label("press a key…")), f32::max)
|
||||
.max(ui_kit::ContentWidth::label(ui, app.tr_label("Keyboard Shortcuts")) + t.space_8);
|
||||
let action_width = if app.keymap.read_only {
|
||||
t.control_height
|
||||
} else {
|
||||
ui_kit::ContentWidth::button(ui, app.tr_label("Clear")) + ui_kit::ContentWidth::button(ui, app.tr_label("Reset")) + t.space_4
|
||||
};
|
||||
let columns = [
|
||||
ui_kit::Column::new("Command", 1.5).flex().min_content_width(t.form_label_width),
|
||||
ui_kit::Column::new(crate::i18n::tr("keymap.command_id"), 1.8).flex().min_content_width(t.form_label_width),
|
||||
@@ -619,7 +633,12 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
if filtering {
|
||||
ui_kit::search_fold(ui, &shortcut_title, count, paint);
|
||||
} else {
|
||||
ui_kit::settings_scroll(ui, "sc_keymap_scroll", paint);
|
||||
}
|
||||
});
|
||||
crate::scenario::publish_count("shortcut_recording", f64::from(app.keymap.capturing.is_some()));
|
||||
if action.is_some() {
|
||||
@@ -897,6 +916,17 @@ mod tests {
|
||||
assert!(a.keymap.recorder.key.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_a_shortcut_does_not_type_into_preferences_search() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_SHORTCUTS);
|
||||
h.app.keymap.capturing = Some("solid.extrude".into());
|
||||
h.frames(1);
|
||||
h.step(&json!({"text": "z"})).expect("key while recording");
|
||||
assert!(h.app.prefs_window.filter.is_empty(), "a shortcut being recorded keeps the key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_only_recording_and_no_command_fires_during_capture() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
|
||||
@@ -194,6 +194,7 @@ pub(crate) mod place_gizmo;
|
||||
pub(crate) mod plugins;
|
||||
pub mod prefs;
|
||||
pub mod prefs_notice;
|
||||
mod prefs_search;
|
||||
#[cfg(test)]
|
||||
mod preselect_tests;
|
||||
pub mod preview;
|
||||
|
||||
@@ -36,7 +36,7 @@ pub(crate) fn start(app: &mut SolveApp, component: u64) {
|
||||
return;
|
||||
}
|
||||
let document = app.session.doc.clone();
|
||||
app.pick_open_for(&["solidcraft"], move |app, path| {
|
||||
app.pick_open_for(&solidcraft_engine::io::DESIGN_EXTS, move |app, path| {
|
||||
let Some(path) = path else { return };
|
||||
// A host or programmatic caller can replace/edit a design while a native picker is
|
||||
// pending. Never apply that old component id to a different design or newer revision.
|
||||
@@ -68,14 +68,15 @@ pub(crate) fn extract(app: &mut SolveApp, component: u64) {
|
||||
}
|
||||
let Some(name) = app.session.doc.components.iter().find(|c| c.id == component).map(|c| c.name.clone()) else { return };
|
||||
let document = app.session.doc.clone();
|
||||
app.pick_save(&format!("{name}.solidcraft"), &["solidcraft"], move |app, path| {
|
||||
let ext = solidcraft_engine::io::DESIGN_EXT;
|
||||
app.pick_save(&format!("{name}.{ext}"), &[ext], move |app, path| {
|
||||
let Some(mut path) = path else { return };
|
||||
if !Arc::ptr_eq(&document, &app.session.doc) {
|
||||
app.set_status(app.tr_label("ui.extract_design_changed").to_string(), true);
|
||||
return;
|
||||
}
|
||||
if std::path::Path::new(&path).extension().is_none() {
|
||||
path.push_str(".solidcraft");
|
||||
path = format!("{path}.{ext}");
|
||||
}
|
||||
if let Ok(result) = app.run("component.extract", json!({"component": component, "path": path})) {
|
||||
report(app, &result, "ui.extract_done");
|
||||
@@ -177,7 +178,7 @@ mod tests {
|
||||
|
||||
fn answer(app: &mut SolveApp, path: Option<String>) {
|
||||
app.services.pick_open = Some(Box::new(move |extensions| {
|
||||
assert_eq!(extensions, &["solidcraft"]);
|
||||
assert_eq!(extensions, &["sc", "solidcraft", "solvecraft"], "a design of any release");
|
||||
let result = path.clone();
|
||||
Box::pin(async move { result })
|
||||
}));
|
||||
@@ -271,15 +272,15 @@ mod tests {
|
||||
let item = find(&app, component).unwrap();
|
||||
let chosen = folder.join("bracket").to_string_lossy().into_owned();
|
||||
app.services.pick_save = Some(Box::new(move |name, extensions| {
|
||||
assert_eq!((name, extensions), ("Bracket.solidcraft", &["solidcraft"][..]));
|
||||
assert_eq!((name, extensions), ("Bracket.sc", &["sc"][..]));
|
||||
let result = Some(chosen.clone());
|
||||
Box::pin(async move { result })
|
||||
}));
|
||||
context_menu::run_item(&mut app, &item, egui::Pos2::ZERO);
|
||||
assert!(app.file_picker_busy());
|
||||
crate::file_picker::tick(&mut app, &egui::Context::default());
|
||||
assert!(folder.join("bracket.solidcraft").is_file());
|
||||
assert!(app.session.doc.links[&component].path.ends_with("bracket.solidcraft"));
|
||||
assert!(folder.join("bracket.sc").is_file());
|
||||
assert!(app.session.doc.links[&component].path.ends_with("bracket.sc"));
|
||||
assert!(app.status.as_ref().is_some_and(|(s, _, error)| s.contains("its own design") && !error));
|
||||
assert!(find(&app, component).is_none(), "a linked component is not extracted again");
|
||||
assert!(menu(&app, Target::Component { id: component }).is_some());
|
||||
|
||||
@@ -335,3 +335,125 @@ fn a_copied_component_pastes_into_its_parent_not_into_itself() {
|
||||
assert!((pasted.transform[3][0] - 50.0).abs() < 1e-9, "where the copied instance is");
|
||||
assert!(!crate::browser::is_open(&h.app, &format!("o:{}", pasted.id), true), "it arrives folded");
|
||||
}
|
||||
|
||||
/// #4217: whether a component's Bodies folder folds away, under Always, Several bodies only and
|
||||
/// Never. Several bodies only (the default) folds a lone body with no groups, Never every
|
||||
/// component's folder, Always none. The design's own folder, an empty one and a hidden one never
|
||||
/// fold, and hiding is per placement.
|
||||
#[test]
|
||||
fn bodies_folders_fold_by_the_preference_but_never_the_designs_or_a_hidden_one() {
|
||||
use crate::prefs::BodiesFolders;
|
||||
let (mut h, component, first, second) = fixture();
|
||||
assert_eq!(h.app.preferences.browser_bodies_folders, BodiesFolders::Several, "the default");
|
||||
let modes = |h: &mut Harness, comp: u64, scope: &str| {
|
||||
let bodies = super::entries(&h.app, comp, "bodies").len();
|
||||
BodiesFolders::ALL.map(|mode| {
|
||||
h.app.preferences.browser_bodies_folders = mode;
|
||||
super::bodies_folded(&h.app, comp, scope, bodies)
|
||||
})
|
||||
};
|
||||
let (one, two) = (super::occurrence_key(&[first]), super::occurrence_key(&[second]));
|
||||
assert_eq!(modes(&mut h, component, &one), [false, true, true], "one body");
|
||||
assert_eq!(modes(&mut h, component, &two), [false, true, true], "one body, the other placement");
|
||||
h.app.run("solid.box", json!({"length":5,"width":5,"height":5,"corner":[0,40,0]})).unwrap();
|
||||
assert_eq!(modes(&mut h, 0, "c0"), [false; 3], "the design's own folder");
|
||||
let empty = h.app.run("component.create", json!({"name":"Empty"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
let empty_scope = super::occurrence_key(&[empty["occurrence"].as_u64().unwrap()]);
|
||||
assert_eq!(modes(&mut h, empty["component"].as_u64().unwrap(), &empty_scope), [false; 3], "no bodies");
|
||||
let show = |h: &mut Harness, visible: bool| {
|
||||
h.app.run("browser.visibility", json!({"tree_folders":[format!("{one}/bodies")],"visible":visible})).unwrap()
|
||||
};
|
||||
show(&mut h, false);
|
||||
assert_eq!(modes(&mut h, component, &one), [false; 3], "a hidden folder stays: its eye is the way back");
|
||||
assert_eq!(modes(&mut h, component, &two), [false, true, true], "the other placement's folder has its own eye");
|
||||
show(&mut h, true);
|
||||
let group = h.app.run("browser.group", json!({"component":component,"folder":"bodies","items":["Body1"]})).unwrap()["group"].clone();
|
||||
assert_eq!(modes(&mut h, component, &one), [false, false, true], "a group");
|
||||
h.app.run("browser.ungroup", json!({"group":group})).unwrap();
|
||||
assert_eq!(modes(&mut h, component, &one), [false, true, true], "the group gone");
|
||||
h.app.run("component.activate", json!({"component":component})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":5,"width":5,"height":5,"corner":[20,0,0]})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
assert_eq!(modes(&mut h, component, &one), [false, false, true], "two bodies");
|
||||
}
|
||||
|
||||
/// #4217: a folded folder's lone body is drawn one level under its placement, two placements
|
||||
/// deep, with no Bodies row, even when that folder was closed while it was still drawn (nothing
|
||||
/// could open it again). Always draws the folder, closed as it was left, then open with the body
|
||||
/// two levels under the placement.
|
||||
#[test]
|
||||
fn a_folded_body_sits_one_level_under_its_nested_placement() {
|
||||
use crate::prefs::BodiesFolders;
|
||||
let mut h = Harness::new();
|
||||
let parent = h.app.run("component.create", json!({"name":"Assembly"})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
let child = h.app.run("component.create", json!({"name":"Child"})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Pin"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
h.app.tree.collapsed.insert(format!("{}/bodies", super::occurrence_key(&[parent, child])));
|
||||
// How many levels the body's row sits under its placement's (each eye sits at the same offset
|
||||
// in its row).
|
||||
let levels = || -> Option<i32> {
|
||||
let placement = crate::scenario::handle_at(&format!("eye:occurrence:{parent}/{child}"))?;
|
||||
let body = crate::scenario::handle_at("eye:Pin")?;
|
||||
Some(((body.x - placement.x) / super::INDENT).round() as i32)
|
||||
};
|
||||
for (mode, want) in [(BodiesFolders::Several, Some(1)), (BodiesFolders::Never, Some(1)), (BodiesFolders::Always, None)] {
|
||||
h.app.preferences.browser_bodies_folders = mode;
|
||||
h.frames(2);
|
||||
assert_eq!(levels(), want, "{mode:?}");
|
||||
assert_eq!(crate::scenario::handle_at("fold:Bodies").is_some(), mode == BodiesFolders::Always, "{mode:?}: the Bodies row");
|
||||
}
|
||||
h.app.tree.collapsed.clear();
|
||||
h.frames(2);
|
||||
assert_eq!(levels(), Some(2), "Always, open: under the Bodies folder");
|
||||
}
|
||||
|
||||
/// #4217: Find in Browser reaches a body whose folder is folded away: its closed placement opens
|
||||
/// and the body's row is drawn and scrolled to.
|
||||
#[test]
|
||||
fn find_in_browser_reaches_a_folded_body() {
|
||||
let (mut h, _, first, second) = fixture();
|
||||
let body = h.app.session.occurrence_bodies(&[second], true).unwrap()[0].clone();
|
||||
let scope = super::occurrence_key(&[second]);
|
||||
h.app.tree.collapsed.extend([super::occurrence_key(&[first]), scope.clone(), format!("{scope}/bodies")]);
|
||||
h.frames(2);
|
||||
assert!(!h.app.tree.order.contains(&format!("b:{body}")), "the body's row is closed away");
|
||||
h.app.session.selection = vec![Sel::Body { name: body.clone() }];
|
||||
let find = context_menu::items(&h.app, &Target::Viewport).into_iter().find(|i| i.id == "ui.findBrowser").unwrap();
|
||||
context_menu::run_item(&mut h.app, &find, egui::Pos2::ZERO);
|
||||
assert_eq!(h.app.tree.reveal.as_deref(), Some(body.as_str()));
|
||||
h.frames(2);
|
||||
assert!(h.app.tree.reveal.is_none(), "revealed");
|
||||
assert!(!h.app.tree.collapsed.contains(&scope));
|
||||
assert!(h.app.tree.order.contains(&format!("b:{body}")), "the row is drawn");
|
||||
assert!(crate::scenario::handle_at("fold:Bodies").is_none(), "with no folder row");
|
||||
}
|
||||
|
||||
/// #4217: while a placement's Bodies folder is folded away, the placement's menu takes the
|
||||
/// folder's items it lacks: Create Components from Bodies (the folder's bodies) and, with several
|
||||
/// of them selected, Group Selected. With the folder drawn they stay on the folder's own menu.
|
||||
#[test]
|
||||
fn a_folded_folders_menu_items_move_to_the_placements_menu() {
|
||||
use crate::prefs::BodiesFolders;
|
||||
let (mut h, component, first, _) = fixture();
|
||||
h.app.run("component.activate", json!({"component":component})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":5,"width":5,"height":5,"corner":[20,0,0],"body_name":"Second"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
let target = Target::Occurrence { path: vec![first] };
|
||||
let has = |h: &Harness, id: &str| context_menu::items(&h.app, &target).iter().any(|i| i.id == id);
|
||||
assert!(!has(&h, "component.from_bodies"), "Several bodies only: two bodies keep their folder");
|
||||
h.app.preferences.browser_bodies_folders = BodiesFolders::Never;
|
||||
let items = context_menu::items(&h.app, &target);
|
||||
let from = items.iter().find(|i| i.id == "component.from_bodies").unwrap();
|
||||
assert_eq!(from.params.as_ref().unwrap()["bodies"], json!(["Body1", "Second"]));
|
||||
assert!(has(&h, "ui.newGroup") && has(&h, "ui.showAll"), "on every component's menu");
|
||||
assert!(!has(&h, "ui.groupSelected"), "nothing selected");
|
||||
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }, Sel::Body { name: "Second".into() }];
|
||||
let group = context_menu::items(&h.app, &target).into_iter().find(|i| i.id == "ui.groupSelected").unwrap();
|
||||
context_menu::run_item(&mut h.app, &group, egui::Pos2::ZERO);
|
||||
let made = h.app.session.doc.browser_groups.iter().find(|g| g.component == component).unwrap();
|
||||
assert_eq!(made.items, ["Body1", "Second"]);
|
||||
h.app.preferences.browser_bodies_folders = BodiesFolders::Always;
|
||||
assert!(!has(&h, "component.from_bodies"), "Always: on the folder's own menu");
|
||||
}
|
||||
|
||||
@@ -152,12 +152,22 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.label(crate::i18n::tr("ui.plugins_desktop"));
|
||||
return;
|
||||
};
|
||||
crate::ui_kit::search_field(ui, ui.id().with("plugins_search"), &mut app.prefs_window.plugin_query, crate::i18n::tr("ui.plugins_search"));
|
||||
ui.add_space(8.0);
|
||||
let path = dir.display().to_string();
|
||||
ui.add(egui::Label::new(RichText::new(crate::i18n::format("ui.plugins_note", &[("path", &path)])).size(t.font_small).color(t.text_dim)).wrap());
|
||||
let searching = crate::prefs_search::querying(ui);
|
||||
// The page's own box is for browsing Plugins. The dialog search is the filter while it is active.
|
||||
if !searching {
|
||||
crate::ui_kit::search_field(ui, ui.id().with("plugins_search"), &mut app.prefs_window.plugin_query, crate::i18n::tr("ui.plugins_search"));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
if searching {
|
||||
crate::prefs_search::begin_section(ui, crate::i18n::tr("ui.plugins"));
|
||||
} else {
|
||||
ui.add(
|
||||
egui::Label::new(RichText::new(crate::i18n::format("ui.plugins_note", &[("path", &path)])).size(t.font_small).color(t.text_dim)).wrap(),
|
||||
);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if crate::ui_kit::button(ui, crate::i18n::tr_label("Show in folder"), crate::ui_kit::Button::Normal).clicked() {
|
||||
if !searching && crate::ui_kit::button(ui, crate::i18n::tr_label("Show in folder"), crate::ui_kit::Button::Normal).clicked() {
|
||||
// `show_in_folder` opens the parent of the path it is given, so the argument is a name
|
||||
// inside the plugins directory (it need not exist).
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
@@ -169,12 +179,19 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.add_space(8.0);
|
||||
let (plugins, skipped) = scan(&dir);
|
||||
let query = app.prefs_window.plugin_query.clone();
|
||||
if plugins.is_empty() {
|
||||
if plugins.is_empty() && !searching {
|
||||
ui.label(crate::i18n::tr("ui.plugins_empty"));
|
||||
}
|
||||
let mut shown = 0usize;
|
||||
for plugin in plugins.iter().filter(|plugin| matches_query(plugin, &query)) {
|
||||
for plugin in plugins.iter().filter(|plugin| searching || matches_query(plugin, &query)) {
|
||||
if searching && !crate::prefs_search::texts_hit(ui, &[&plugin.name, &plugin.id, &plugin.description, &plugin.version]) {
|
||||
continue;
|
||||
}
|
||||
shown += 1;
|
||||
if searching {
|
||||
crate::prefs_search::reveal(ui);
|
||||
crate::prefs_search::push_force(ui);
|
||||
}
|
||||
let mut on = enabled(app, &plugin.id);
|
||||
ui.horizontal(|ui| {
|
||||
let check = crate::ui_kit::checkbox(ui, "", &mut on);
|
||||
@@ -197,11 +214,14 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
if searching {
|
||||
crate::prefs_search::pop_force(ui);
|
||||
}
|
||||
}
|
||||
if !plugins.is_empty() && shown == 0 {
|
||||
if !searching && !plugins.is_empty() && shown == 0 {
|
||||
ui.label(crate::i18n::tr("ui.plugins_none"));
|
||||
}
|
||||
if query.trim().is_empty() && !skipped.is_empty() {
|
||||
if !searching && query.trim().is_empty() && !skipped.is_empty() {
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(crate::i18n::format("ui.plugins_skipped", &[("count", &skipped.len().to_string())])).size(t.font_small).color(t.text_dim),
|
||||
|
||||
+247
-14
@@ -281,11 +281,129 @@ const WINDOW_PAGES: [ui_kit::WindowPage<SolveApp>; 9] = [
|
||||
},
|
||||
];
|
||||
|
||||
fn search_header(ui: &mut egui::Ui, app: &mut SolveApp) {
|
||||
let typed = drive_search(ui, app);
|
||||
let (field, _) =
|
||||
ui_kit::search_actions_row(ui, egui::Id::new("prefs_search"), &mut app.prefs_window.filter, crate::i18n::tr("prefs.search"), &[]);
|
||||
if typed {
|
||||
place_search_cursor(ui.ctx(), egui::Id::new("prefs_search"), app.prefs_window.filter.chars().count());
|
||||
}
|
||||
crate::scenario::publish_handle("prefs:search", field.rect.center());
|
||||
if field.has_focus() {
|
||||
crate::prefs_search::note_key_sink(ui.ctx());
|
||||
}
|
||||
settle_search(app);
|
||||
crate::prefs_search::set_reveal(ui.ctx(), app.prefs_window.reveal.clone());
|
||||
crate::prefs_search::set_force_scroll(ui.ctx(), !app.prefs_window.filter.trim().is_empty());
|
||||
ui.add_space(Tokens::get().space_8);
|
||||
}
|
||||
|
||||
/// Keys for the search field. Shortcut capture keeps the key for itself.
|
||||
fn drive_search(ui: &mut egui::Ui, app: &mut SolveApp) -> bool {
|
||||
let ctx = ui.ctx().clone();
|
||||
if crate::keymap::capturing(app) {
|
||||
return false;
|
||||
}
|
||||
let id = egui::Id::new("prefs_search");
|
||||
if ctx.input_mut(|input| input.consume_key(egui::Modifiers::COMMAND, egui::Key::F)) {
|
||||
ctx.memory_mut(|memory| memory.request_focus(id));
|
||||
}
|
||||
let sink = crate::prefs_search::key_sink(&ctx);
|
||||
let mut typed = false;
|
||||
if !sink {
|
||||
let modifiers = ctx.input(|input| input.modifiers);
|
||||
if !modifiers.command && !modifiers.ctrl && !modifiers.alt {
|
||||
let mut chars = String::new();
|
||||
ctx.input_mut(|input| {
|
||||
input.events.retain(|event| {
|
||||
let egui::Event::Text(text) = event else { return true };
|
||||
if text.chars().any(char::is_control) {
|
||||
return true;
|
||||
}
|
||||
chars.push_str(text);
|
||||
false
|
||||
});
|
||||
});
|
||||
if !chars.is_empty() {
|
||||
app.prefs_window.filter.push_str(&chars);
|
||||
ctx.memory_mut(|memory| memory.request_focus(id));
|
||||
typed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let query = app.prefs_window.filter.trim().to_lowercase();
|
||||
if query.is_empty() {
|
||||
return typed;
|
||||
}
|
||||
let (nav_query, cursor, hits) = crate::prefs_search::nav(&ctx);
|
||||
let fresh = nav_query == query;
|
||||
let cursor = if fresh { cursor.min(hits.len().saturating_sub(1)) } else { 0 };
|
||||
if ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)) && fresh && !hits.is_empty() {
|
||||
crate::prefs_search::set_cursor(&ctx, (cursor + 1).min(hits.len() - 1));
|
||||
} else if ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)) && fresh && !hits.is_empty() {
|
||||
crate::prefs_search::set_cursor(&ctx, cursor.saturating_sub(1));
|
||||
} else if fresh && !hits.is_empty() && ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) {
|
||||
if let Some(hit) = hits.get(cursor) {
|
||||
open_result(app, hit.page, &hit.label);
|
||||
ctx.memory_mut(|memory| memory.surrender_focus(id));
|
||||
}
|
||||
} else {
|
||||
crate::prefs_search::set_cursor(&ctx, cursor);
|
||||
}
|
||||
if !app.prefs_window.filter.trim().is_empty() && ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
app.prefs_window.filter.clear();
|
||||
ctx.memory_mut(|memory| memory.surrender_focus(id));
|
||||
}
|
||||
typed
|
||||
}
|
||||
|
||||
fn place_search_cursor(ctx: &egui::Context, id: egui::Id, chars: usize) {
|
||||
let Some(mut state) = egui::TextEdit::load_state(ctx, id) else { return };
|
||||
let cursor = egui::text::CCursor::new(chars);
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(cursor)));
|
||||
state.store(ctx, id);
|
||||
}
|
||||
|
||||
fn open_result(app: &mut SolveApp, page: usize, label: &str) {
|
||||
app.prefs_window.filter.clear();
|
||||
app.prefs_window.search_from = None;
|
||||
app.prefs_window.section = page;
|
||||
app.prefs_window.land_on = Some(page);
|
||||
app.prefs_window.reveal = Some(crate::prefs_search::reveal_for(page, label));
|
||||
}
|
||||
|
||||
/// An empty field returns to the page that was up when the search started.
|
||||
fn settle_search(app: &mut SolveApp) {
|
||||
if app.prefs_window.filter.trim().is_empty() {
|
||||
if app.prefs_window.reveal.is_none()
|
||||
&& let Some(page) = app.prefs_window.search_from.take()
|
||||
{
|
||||
app.prefs_window.section = page;
|
||||
app.prefs_window.land_on = Some(page);
|
||||
}
|
||||
} else if app.prefs_window.search_from.is_none() {
|
||||
app.prefs_window.search_from = Some(app.prefs_window.section);
|
||||
}
|
||||
}
|
||||
|
||||
/// A page tab leaves search and shows that page on its own.
|
||||
fn clear_search(app: &mut SolveApp) {
|
||||
app.prefs_window.filter.clear();
|
||||
app.prefs_window.search_from = None;
|
||||
app.prefs_window.reveal = None;
|
||||
}
|
||||
|
||||
fn window_page<const INDEX: usize>(ui: &mut egui::Ui, app: &mut SolveApp) {
|
||||
if let Some(rect) = crate::scenario::rect_of("prefs:page:Sketch") {
|
||||
crate::scenario::publish_target("prefs:page:Selection", rect);
|
||||
}
|
||||
if app.prefs_window.section != INDEX {
|
||||
if let Some(page) = app.prefs_window.land_on.take() {
|
||||
if app.prefs_window.section != page {
|
||||
crate::keymap::stop_capture(app);
|
||||
app.keymap.read_only = false;
|
||||
}
|
||||
app.prefs_window.section = page;
|
||||
} else if app.prefs_window.section != INDEX {
|
||||
crate::keymap::stop_capture(app);
|
||||
app.keymap.read_only = false;
|
||||
app.prefs_window.section = INDEX;
|
||||
@@ -478,6 +596,35 @@ pub const DEFAULT_BROWSER_WIDTH: f32 = 250.0;
|
||||
/// split; the column clamps what the user drags so both cards keep their header and two rows.
|
||||
pub const DEFAULT_BROWSER_SHARE: f32 = 0.5;
|
||||
|
||||
/// When a component's bodies sit in a Bodies folder of their own in the browser (#4217): each
|
||||
/// folder is one more level that pushes deep assemblies right. Only folders inside components
|
||||
/// fold; the design's own Bodies folder, and a folder hidden with its eye (its eye is the way
|
||||
/// back), always stay (`browser::bodies_folded`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BodiesFolders {
|
||||
Always,
|
||||
/// A component with one body and no groups lists the body straight under it. Also what a
|
||||
/// value this version doesn't know reads as (`lenient`).
|
||||
#[default]
|
||||
Several,
|
||||
/// Components list their bodies and groups straight under them.
|
||||
Never,
|
||||
}
|
||||
|
||||
impl BodiesFolders {
|
||||
pub const ALL: [Self; 3] = [Self::Always, Self::Several, Self::Never];
|
||||
|
||||
/// Its name in the Preferences (a message id).
|
||||
pub fn label_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Always => "prefs.bodies_folders_always",
|
||||
Self::Several => "prefs.bodies_folders_several",
|
||||
Self::Never => "prefs.bodies_folders_never",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The stored fields, their defaults, page resets and simple form controls share this table.
|
||||
// Omitted pages keep fields such as grid_follows_view and added agents when restoring defaults.
|
||||
// Accepted control-channel ranges may differ from slider ranges (look_at_speed).
|
||||
@@ -716,6 +863,8 @@ preference_settings! {
|
||||
dialog_place: crate::dialog_column::DialogPlace = Default::default(), PAGE_INTERFACE;
|
||||
/// The names the browser, dialogs and lists show for the origin planes (#6491).
|
||||
origin_plane_names: OriginPlaneNames = OriginPlaneNames::default(), PAGE_INTERFACE;
|
||||
/// When components' bodies sit in a Bodies folder in the browser (#4217).
|
||||
browser_bodies_folders: BodiesFolders = BodiesFolders::Several, PAGE_INTERFACE;
|
||||
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
|
||||
zoom_to_cursor: bool = true, PAGE_NAVIGATION => check("Zoom to the cursor", "The wheel zooms toward the point under the cursor (off: toward the view's centre)");
|
||||
/// Spread discrete wheel input across frames (off: apply each event immediately).
|
||||
@@ -899,6 +1048,14 @@ pub struct PrefsWindow {
|
||||
pub(crate) plugin_query: String,
|
||||
/// The followed theme file's state (#7573): its last good theme and why a read failed.
|
||||
pub(crate) follow: crate::theme_files::Follow,
|
||||
/// Filters every page. Empty leaves the selected page as it is.
|
||||
pub filter: String,
|
||||
/// The page on screen when the current search started. Clearing the field returns there.
|
||||
search_from: Option<usize>,
|
||||
/// Set when a result is chosen, so the page draw does not put the previous tab back.
|
||||
land_on: Option<usize>,
|
||||
/// The row to scroll to after a result is chosen.
|
||||
reveal: Option<crate::prefs_search::Reveal>,
|
||||
}
|
||||
|
||||
/// Following a theme file needs the file system: the desktop app only (#7573).
|
||||
@@ -1588,6 +1745,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
open_at(app, PAGE_SHORTCUTS);
|
||||
}
|
||||
if !app.prefs_window.open {
|
||||
app.prefs_window.filter.clear();
|
||||
app.prefs_window.search_from = None;
|
||||
app.prefs_window.land_on = None;
|
||||
app.prefs_window.reveal = None;
|
||||
crate::prefs_search::set_reveal(ctx, None);
|
||||
app.prefs_window.snapshot = None;
|
||||
// A question left up when the dialog closed is not still asked when it opens again.
|
||||
app.space_mouse.restore_pending = false;
|
||||
@@ -1600,6 +1762,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
app.prefs_window.snapshot = Some(Snapshot::take(app));
|
||||
}
|
||||
unfold(ctx);
|
||||
if let Some(reveal) = &app.prefs_window.reveal
|
||||
&& crate::now_ms() > reveal.until
|
||||
{
|
||||
app.prefs_window.reveal = None;
|
||||
}
|
||||
let sink = crate::prefs_search::shift_key_sink(ctx);
|
||||
crate::prefs_search::set_key_sink_prev(ctx, sink);
|
||||
let mut button: Option<&str> = None;
|
||||
let before = app.preferences.clone();
|
||||
let capturing = crate::keymap::capturing(app);
|
||||
@@ -1611,7 +1780,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
.actions("OK", "Cancel", Some("Apply"));
|
||||
let window = if capturing { window.keep_open_on_escape() } else { window };
|
||||
let mut selected = app.prefs_window.section;
|
||||
let (closed, action) = window.show_pages(ctx, &mut selected, &WINDOW_PAGES, app, |ui| {
|
||||
let (closed, action) = window.show_pages_with_header(ctx, &mut selected, &WINDOW_PAGES, app, search_header, clear_search, |ui| {
|
||||
if ui_kit::button(ui, crate::i18n::tr("ui.restore_defaults"), ui_kit::Button::Normal)
|
||||
.on_hover_text(crate::i18n::tr("ui.reset_the_settings_on_this_page"))
|
||||
.clicked()
|
||||
@@ -1668,6 +1837,10 @@ pub(crate) fn close(app: &mut SolveApp, revert: bool) {
|
||||
}
|
||||
}
|
||||
app.prefs_window.open = false;
|
||||
app.prefs_window.filter.clear();
|
||||
app.prefs_window.search_from = None;
|
||||
app.prefs_window.land_on = None;
|
||||
app.prefs_window.reveal = None;
|
||||
app.keymap.read_only = false;
|
||||
crate::keymap::stop_capture(app);
|
||||
}
|
||||
@@ -1683,20 +1856,47 @@ fn up_axis_changed(app: &mut SolveApp) {
|
||||
}
|
||||
|
||||
/// One page of the dialog. Inputs share the kit's aligned rows and tokenised controls.
|
||||
/// A search draws every page and keeps the controls that match.
|
||||
fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
if app.prefs_window.section == PAGE_SHORTCUTS {
|
||||
crate::keymap::page(app, ui);
|
||||
return;
|
||||
let _search = crate::prefs_search::begin(ui, &app.prefs_window.filter);
|
||||
if crate::prefs_search::querying(ui) {
|
||||
// Paint only the pages that match. A first pass is invisible, so the 3D mouse rows and the
|
||||
// agent list cannot draw on top of the setting that did match.
|
||||
let mut keep = vec![false; WINDOW_PAGES.len()];
|
||||
for (index, item) in WINDOW_PAGES.iter().enumerate() {
|
||||
crate::prefs_search::begin_page(ui, index, crate::i18n::tr(item.label));
|
||||
let before = crate::prefs_search::hits(ui);
|
||||
let mut probe = crate::prefs_search::detached_child(ui, ("prefs-search-probe", index), true);
|
||||
draw_page(app, &mut probe, index, t);
|
||||
keep[index] = crate::prefs_search::hits(ui) > before;
|
||||
}
|
||||
crate::prefs_search::rewind_search_layout(ui);
|
||||
for (index, item) in WINDOW_PAGES.iter().enumerate() {
|
||||
if !keep.get(index).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
crate::prefs_search::begin_page(ui, index, crate::i18n::tr(item.label));
|
||||
let before = crate::prefs_search::hits(ui);
|
||||
let mut child = crate::prefs_search::detached_child(ui, ("prefs-search-page", index), false);
|
||||
draw_page(app, &mut child, index, t);
|
||||
crate::prefs_search::commit_search_child(ui, &child, before);
|
||||
}
|
||||
if crate::prefs_search::hits(ui) == 0 {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("prefs.search_empty")).size(t.font_body).color(t.form_dim));
|
||||
}
|
||||
} else {
|
||||
let label = WINDOW_PAGES.get(app.prefs_window.section).map(|item| crate::i18n::tr(item.label)).unwrap_or("");
|
||||
crate::prefs_search::begin_page(ui, app.prefs_window.section, label);
|
||||
draw_page(app, ui, app.prefs_window.section, t);
|
||||
}
|
||||
if app.prefs_window.section == PAGE_AI {
|
||||
crate::agent_chat::settings::page(app, ui);
|
||||
return;
|
||||
}
|
||||
if app.prefs_window.section == PAGE_PLUGINS {
|
||||
crate::plugins::page(app, ui);
|
||||
return;
|
||||
}
|
||||
match app.prefs_window.section {
|
||||
crate::prefs_search::store_nav(ui);
|
||||
}
|
||||
|
||||
fn draw_page(app: &mut SolveApp, ui: &mut egui::Ui, index: usize, t: &Tokens) {
|
||||
match index {
|
||||
PAGE_SHORTCUTS => crate::keymap::page(app, ui),
|
||||
PAGE_AI => crate::agent_chat::settings::page(app, ui),
|
||||
PAGE_PLUGINS => crate::plugins::page(app, ui),
|
||||
PAGE_GENERAL => general::show(app, ui),
|
||||
PAGE_INTERFACE => interface::interface(app, ui, t),
|
||||
PAGE_DESIGN => design::show(app, ui),
|
||||
@@ -3743,6 +3943,39 @@ mod tests {
|
||||
assert_eq!(app.preferences.ui_font, "");
|
||||
}
|
||||
|
||||
/// Bodies folders (#4217): Several bodies only by default, also in preferences saved before it
|
||||
/// existed. Each value is saved and read back; `ui.prefs` refuses anything else and changes
|
||||
/// nothing, and saved preferences read such a value as the default and keep the rest. The
|
||||
/// Interface page's segments set it, Cancel puts it back and the page's Restore Defaults resets it.
|
||||
#[test]
|
||||
fn bodies_folders_is_saved_restored_and_refuses_unknown_values() {
|
||||
use super::*;
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
assert_eq!(h.app.preferences.browser_bodies_folders, BodiesFolders::Several);
|
||||
assert_eq!(lenient(&json!({"theme": "light"})).browser_bodies_folders, BodiesFolders::Several, "saved before it existed");
|
||||
for (mode, id) in [(BodiesFolders::Always, "always"), (BodiesFolders::Several, "several"), (BodiesFolders::Never, "never")] {
|
||||
assert_eq!(control(&mut h.app, &json!({"browser_bodies_folders": id})).unwrap()["browser_bodies_folders"], id);
|
||||
let mut restored = SolveApp::new(Default::default(), Default::default());
|
||||
restored.load_prefs(&h.app.prefs());
|
||||
assert_eq!(restored.preferences.browser_bodies_folders, mode, "{id}");
|
||||
}
|
||||
for bad in [json!("sometimes"), json!("Never"), json!(""), json!(2), json!(null), json!(["never"])] {
|
||||
let before = h.app.preferences.clone();
|
||||
assert!(set(&mut h.app, &json!({"browser_bodies_folders": bad})).is_err(), "{bad}");
|
||||
assert_eq!(h.app.preferences, before, "{bad}: a refused value changes nothing");
|
||||
let saved = lenient(&json!({"browser_bodies_folders": bad, "theme": "light"}));
|
||||
assert_eq!((saved.browser_bodies_folders, saved.theme.as_str()), (BodiesFolders::Several, "light"), "{bad}");
|
||||
}
|
||||
h.call("ui.prefs", json!({"open": "Interface"}));
|
||||
reveal_preference(&mut h, "dialog:Bodies folders:Always");
|
||||
h.step(&json!({"click": {"handle": "dialog:Bodies folders:Always"}})).unwrap();
|
||||
assert_eq!(h.app.preferences.browser_bodies_folders, BodiesFolders::Always);
|
||||
h.call("ui.prefs", json!({"button": "cancel"}));
|
||||
assert_eq!(h.app.preferences.browser_bodies_folders, BodiesFolders::Never, "Cancel puts it back");
|
||||
defaults(&mut h.app, PAGE_INTERFACE);
|
||||
assert_eq!(h.app.preferences.browser_bodies_folders, BodiesFolders::Several, "Interface's Restore Defaults");
|
||||
}
|
||||
|
||||
/// Theme files (#2255): a built-in one and an imported one turn on through `ui.prefs`, the
|
||||
/// next frame draws with their colours (dark following the base), an export gives the theme
|
||||
/// back complete, imported themes are saved with the preferences, Remove falls back to the
|
||||
|
||||
@@ -159,6 +159,16 @@ pub(super) fn interface(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let check = setting::view_confirm_corner(app, ui).on_hover_text(app.tr("prefs.view_confirm_corner_note"));
|
||||
crate::scenario::publish_target("prefs:view_confirm_corner", check.rect);
|
||||
});
|
||||
ui_kit::row(ui, app.tr("prefs.bodies_folders"), |ui| {
|
||||
let modes = BodiesFolders::ALL;
|
||||
let labels: Vec<_> = modes.iter().map(|m| app.tr(m.label_id())).collect();
|
||||
let mut selected = modes.iter().position(|m| *m == app.preferences.browser_bodies_folders).unwrap_or(0);
|
||||
if ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments { proportional: true, compact: true, ..Default::default() })
|
||||
&& let Some(mode) = modes.get(selected)
|
||||
{
|
||||
app.preferences.browser_bodies_folders = *mode;
|
||||
}
|
||||
});
|
||||
ui_kit::text(ui, app.tr("prefs.layout_note"), ui_kit::Text::Hint("layout"));
|
||||
ui_kit::text(ui, app.tr("prefs.history_sketches_note"), ui_kit::Text::Hint("history_sketches"));
|
||||
ui_kit::LayoutPreview {
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
//! Search across Preferences. The field is the Keyboard Shortcuts magnifier. A match is the
|
||||
//! control's own label, its section, its page, or the value a dropdown is showing, scored the
|
||||
//! same way as command search. English counts too, from the message catalog, so a new setting
|
||||
//! is found by being drawn — nothing is registered for search.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use egui::{Context, Id, Pos2, Rect, Response, RichText, Sense, Ui, UiBuilder, Vec2, vec2};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
|
||||
const STATE: &str = "prefs_search_state";
|
||||
const SCROLL: &str = "prefs_search_force_scroll";
|
||||
const NAV: &str = "prefs_search_nav";
|
||||
const SINK_NOW: &str = "prefs_search_key_sink";
|
||||
const SINK_PREV: &str = "prefs_search_key_sink_prev";
|
||||
const REVEAL_ID: &str = "prefs_search_reveal";
|
||||
|
||||
/// How long an opened result stays outlined, in milliseconds.
|
||||
const REVEAL_MS: f64 = 900.0;
|
||||
|
||||
/// One keyboard-reachable match from the last finished pass.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct NavHit {
|
||||
pub page: usize,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PaintedHit {
|
||||
page: usize,
|
||||
label: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct NavStore {
|
||||
query: String,
|
||||
cursor: usize,
|
||||
hits: Vec<NavHit>,
|
||||
}
|
||||
|
||||
/// The row to scroll to and outline after a result is chosen.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Reveal {
|
||||
pub page: usize,
|
||||
pub label: String,
|
||||
pub until: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct State {
|
||||
live: bool,
|
||||
query: String,
|
||||
page: String,
|
||||
page_shown: bool,
|
||||
section: String,
|
||||
section_matches: bool,
|
||||
section_shown: String,
|
||||
block_matches: bool,
|
||||
force: u32,
|
||||
/// Set while a matching row draws, so its own slider or buttons stay visible.
|
||||
passthrough: u32,
|
||||
/// A heading matched, so the controls under it stay visible until the next heading.
|
||||
heading_open: bool,
|
||||
hiding: u32,
|
||||
key: String,
|
||||
hits: u32,
|
||||
page_index: usize,
|
||||
/// Rows whose own text matched, in the order they were drawn this pass.
|
||||
nav: Vec<PaintedHit>,
|
||||
dirty: bool,
|
||||
child_matched: bool,
|
||||
memory_query: String,
|
||||
extras: HashMap<String, String>,
|
||||
open_blocks: HashSet<String>,
|
||||
seen_blocks: HashSet<String>,
|
||||
}
|
||||
|
||||
/// Held until the preferences body finishes. A dropdown value learned while a row was hidden
|
||||
/// asks egui to draw the body again in this frame, so the match appears at once.
|
||||
pub(crate) struct SearchFrame {
|
||||
ctx: Context,
|
||||
}
|
||||
|
||||
impl Drop for SearchFrame {
|
||||
fn drop(&mut self) {
|
||||
if finish(&self.ctx) {
|
||||
self.ctx.request_discard("preferences search");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a pass over the preferences body. An empty filter leaves every control drawn.
|
||||
pub(crate) fn begin(ui: &Ui, filter: &str) -> SearchFrame {
|
||||
let query: String = filter.trim().chars().take(256).collect::<String>().to_lowercase();
|
||||
ui.ctx().data_mut(|data| {
|
||||
let state = data.get_temp_mut_or_insert_with(Id::new(STATE), State::default);
|
||||
let changed = state.memory_query != query;
|
||||
if changed {
|
||||
state.memory_query = query.clone();
|
||||
state.open_blocks.clear();
|
||||
state.seen_blocks.clear();
|
||||
}
|
||||
state.live = true;
|
||||
state.query = query;
|
||||
state.page.clear();
|
||||
state.page_shown = false;
|
||||
state.section.clear();
|
||||
state.section_matches = false;
|
||||
state.section_shown.clear();
|
||||
state.block_matches = false;
|
||||
state.force = 0;
|
||||
state.passthrough = 0;
|
||||
state.heading_open = false;
|
||||
state.hiding = 0;
|
||||
state.key.clear();
|
||||
state.hits = 0;
|
||||
state.nav.clear();
|
||||
state.dirty = false;
|
||||
state.child_matched = false;
|
||||
});
|
||||
SearchFrame { ctx: ui.ctx().clone() }
|
||||
}
|
||||
|
||||
fn finish(ctx: &Context) -> bool {
|
||||
let mut dirty = false;
|
||||
ctx.data_mut(|data| {
|
||||
let state = data.get_temp_mut_or_default::<State>(Id::new(STATE));
|
||||
dirty = state.live && state.dirty;
|
||||
state.live = false;
|
||||
});
|
||||
dirty
|
||||
}
|
||||
|
||||
fn with<R>(ui: &Ui, f: impl FnOnce(&mut State) -> R) -> Option<R> {
|
||||
let mut out = None;
|
||||
ui.ctx().data_mut(|data| {
|
||||
let state = data.get_temp_mut_or_default::<State>(Id::new(STATE));
|
||||
if state.live {
|
||||
out = Some(f(state));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn querying(ui: &Ui) -> bool {
|
||||
with(ui, |state| !state.query.is_empty()).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn hits(ui: &Ui) -> u32 {
|
||||
with(ui, |state| state.hits).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(crate) fn query(ui: &Ui) -> String {
|
||||
with(ui, |state| state.query.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A child that does not move the parent until [`commit_search_child`] says this group matched.
|
||||
/// An invisible child only counts hits, so a miss cannot paint over the setting that matched.
|
||||
pub(crate) fn detached_child(ui: &mut Ui, salt: impl std::hash::Hash + std::fmt::Debug, invisible: bool) -> Ui {
|
||||
let mut builder = UiBuilder::new().id_salt(salt).max_rect(ui.available_rect_before_wrap()).layout(egui::Layout::top_down(egui::Align::Min));
|
||||
if invisible {
|
||||
builder = builder.invisible();
|
||||
}
|
||||
ui.new_child(builder)
|
||||
}
|
||||
|
||||
/// Keep the child's layout only when it found something. An empty page or section leaves no gap.
|
||||
pub(crate) fn commit_search_child(ui: &mut Ui, child: &Ui, before: u32) {
|
||||
if hits(ui) > before {
|
||||
place_search_child(ui, child);
|
||||
}
|
||||
}
|
||||
|
||||
/// The group is already known to match. Keep whatever it drew.
|
||||
pub(crate) fn place_search_child(ui: &mut Ui, child: &Ui) {
|
||||
let rect = child.min_rect();
|
||||
if rect.height().is_finite() && rect.height() > 0.0 && rect.width().is_finite() {
|
||||
ui.advance_cursor_after_rect(rect);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct SearchLayoutMark {
|
||||
page_shown: bool,
|
||||
section_shown: String,
|
||||
hits: u32,
|
||||
heading_open: bool,
|
||||
child_matched: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn search_layout_mark(ui: &Ui) -> SearchLayoutMark {
|
||||
with(ui, |state| SearchLayoutMark {
|
||||
page_shown: state.page_shown,
|
||||
section_shown: state.section_shown.clone(),
|
||||
hits: state.hits,
|
||||
heading_open: state.heading_open,
|
||||
child_matched: state.child_matched,
|
||||
})
|
||||
.unwrap_or(SearchLayoutMark { page_shown: false, section_shown: String::new(), hits: 0, heading_open: false, child_matched: false })
|
||||
}
|
||||
|
||||
pub(crate) fn restore_search_layout(ui: &Ui, mark: SearchLayoutMark) {
|
||||
with(ui, |state| {
|
||||
state.page_shown = mark.page_shown;
|
||||
state.section_shown = mark.section_shown;
|
||||
state.hits = mark.hits;
|
||||
state.heading_open = mark.heading_open;
|
||||
state.child_matched = mark.child_matched;
|
||||
});
|
||||
}
|
||||
|
||||
/// Forget the headings counted while every page was probed, and count the visible pass again.
|
||||
/// Dropdown values learned on the probe stay, so the next pass can show them.
|
||||
pub(crate) fn rewind_search_layout(ui: &Ui) {
|
||||
with(ui, |state| {
|
||||
state.page_shown = false;
|
||||
state.section_shown.clear();
|
||||
state.section_matches = false;
|
||||
state.block_matches = false;
|
||||
state.heading_open = false;
|
||||
state.hits = 0;
|
||||
state.child_matched = false;
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn shows_all(ui: &Ui) -> bool {
|
||||
with(ui, |state| state.section_matches || state.block_matches || state.force > 0 || state.heading_open || state.passthrough > 0).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Stray notes, figures and buttons. A matching row opts out with [`push_passthrough`].
|
||||
pub(crate) fn suppress_chrome(ui: &Ui) -> bool {
|
||||
querying(ui) && !shows_all(ui)
|
||||
}
|
||||
|
||||
pub(crate) fn set_heading_open(ui: &Ui, open: bool) {
|
||||
with(ui, |state| state.heading_open = open);
|
||||
}
|
||||
|
||||
pub(crate) fn push_passthrough(ui: &Ui) {
|
||||
with(ui, |state| state.passthrough = state.passthrough.saturating_add(1));
|
||||
}
|
||||
|
||||
pub(crate) fn pop_passthrough(ui: &Ui) {
|
||||
with(ui, |state| state.passthrough = state.passthrough.saturating_sub(1));
|
||||
}
|
||||
|
||||
pub(crate) fn set_force_scroll(ctx: &Context, on: bool) {
|
||||
ctx.data_mut(|data| data.insert_temp(Id::new(SCROLL), on));
|
||||
}
|
||||
|
||||
pub(crate) fn force_scroll(ctx: &Context) -> bool {
|
||||
ctx.data(|data| data.get_temp(Id::new(SCROLL))).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn begin_page(ui: &Ui, index: usize, label: &str) {
|
||||
with(ui, |state| {
|
||||
state.page_index = index;
|
||||
state.page = label.to_string();
|
||||
state.page_shown = false;
|
||||
state.section.clear();
|
||||
state.section_matches = false;
|
||||
state.section_shown.clear();
|
||||
state.heading_open = false;
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn begin_section(ui: &Ui, title: &str) {
|
||||
let matches = text_hit(ui, title);
|
||||
with(ui, |state| {
|
||||
state.section = title.to_string();
|
||||
state.section_matches = matches || state.force > 0;
|
||||
state.section_shown.clear();
|
||||
state.heading_open = false;
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) struct BlockGate {
|
||||
key: String,
|
||||
discover: bool,
|
||||
pub show: bool,
|
||||
}
|
||||
|
||||
/// `Some` while a search is active. `show` draws the block; otherwise the caller runs it hidden
|
||||
/// so a dropdown value inside can still be learned.
|
||||
pub(crate) fn block_gate(ui: &Ui, label: &str, hint: Option<&str>) -> Option<BlockGate> {
|
||||
if !querying(ui) {
|
||||
return None;
|
||||
}
|
||||
let label_hit = text_hit(ui, label) || hint.is_some_and(|hint| text_hit(ui, hint));
|
||||
with(ui, |state| {
|
||||
let key = format!("{}\n{}\n{}", state.page, state.section, label);
|
||||
state.child_matched = false;
|
||||
state.key = key.clone();
|
||||
let discover = !state.seen_blocks.contains(&key);
|
||||
let show = state.section_matches || state.force > 0 || label_hit || state.open_blocks.contains(&key);
|
||||
state.block_matches = state.section_matches || state.force > 0 || label_hit;
|
||||
BlockGate { key, discover, show }
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn block_finish(ui: &Ui, gate: &BlockGate, showed: bool, hits_before: u32) {
|
||||
with(ui, |state| {
|
||||
let gained = state.hits > hits_before || state.child_matched;
|
||||
state.seen_blocks.insert(gate.key.clone());
|
||||
if showed {
|
||||
if gained || state.block_matches {
|
||||
state.open_blocks.insert(gate.key.clone());
|
||||
} else if gate.discover {
|
||||
state.open_blocks.remove(&gate.key);
|
||||
state.dirty = true;
|
||||
}
|
||||
} else if gained {
|
||||
state.open_blocks.insert(gate.key.clone());
|
||||
state.dirty = true;
|
||||
}
|
||||
state.child_matched = false;
|
||||
state.block_matches = false;
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn push_force(ui: &Ui) {
|
||||
with(ui, |state| state.force = state.force.saturating_add(1));
|
||||
}
|
||||
|
||||
pub(crate) fn pop_force(ui: &Ui) {
|
||||
with(ui, |state| state.force = state.force.saturating_sub(1));
|
||||
}
|
||||
|
||||
pub(crate) fn set_control(ui: &Ui, label: &str) {
|
||||
with(ui, |state| state.key = format!("{}\n{}\n{}", state.page, state.section, label));
|
||||
}
|
||||
|
||||
/// Draw this control. A miss returns false and draws nothing; the caller still runs the control
|
||||
/// off-screen when it needs the widget's current value.
|
||||
pub(crate) fn visible(ui: &mut Ui, texts: &[&str]) -> bool {
|
||||
let show = wanted(ui, texts);
|
||||
if show && querying(ui) {
|
||||
if with(ui, |state| state.hiding > 0).unwrap_or(false) {
|
||||
with(ui, |state| state.child_matched = true);
|
||||
} else {
|
||||
reveal(ui);
|
||||
}
|
||||
}
|
||||
show
|
||||
}
|
||||
|
||||
fn wanted(ui: &Ui, texts: &[&str]) -> bool {
|
||||
if !querying(ui) {
|
||||
return true;
|
||||
}
|
||||
if shows_all(ui) || texts.iter().any(|text| text_hit(ui, text)) {
|
||||
return true;
|
||||
}
|
||||
with(ui, |state| {
|
||||
let key = state.key.clone();
|
||||
extra_hit(state, &key)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The value a dropdown is showing. Stored for this control so the next pass can match it.
|
||||
pub(crate) fn note_current(ui: &Ui, text: &str) {
|
||||
if !querying(ui) || text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let clipped: String = text.chars().take(256).collect();
|
||||
with(ui, |state| {
|
||||
if state.key.is_empty() {
|
||||
return;
|
||||
}
|
||||
let key = state.key.clone();
|
||||
let old = state.extras.get(&key).cloned();
|
||||
if old.as_deref() == Some(clipped.as_str()) {
|
||||
return;
|
||||
}
|
||||
let was = extra_hit_text(state, old.as_deref());
|
||||
state.extras.insert(key, clipped.clone());
|
||||
let now = extra_hit_text(state, Some(clipped.as_str()));
|
||||
if was != now {
|
||||
state.child_matched = true;
|
||||
state.dirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// After a hidden run, show the control on the next pass if its value now matches.
|
||||
pub(crate) fn recheck(ui: &Ui, texts: &[&str]) {
|
||||
if wanted(ui, texts) {
|
||||
with(ui, |state| {
|
||||
state.child_matched = true;
|
||||
state.dirty = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reveal(ui: &mut Ui) {
|
||||
let Some((page, section, draw_page, draw_section)) = with(ui, |state| {
|
||||
if state.hiding > 0 {
|
||||
return None;
|
||||
}
|
||||
let draw_page = !state.page_shown && !state.page.is_empty();
|
||||
let draw_section = !state.section.is_empty() && state.section_shown != state.section;
|
||||
if draw_page {
|
||||
state.page_shown = true;
|
||||
state.section_shown.clear();
|
||||
}
|
||||
if draw_section {
|
||||
state.section_shown = state.section.clone();
|
||||
}
|
||||
state.hits = state.hits.saturating_add(1);
|
||||
Some((state.page.clone(), state.section.clone(), draw_page, draw_section))
|
||||
})
|
||||
.flatten() else {
|
||||
return;
|
||||
};
|
||||
let t = Tokens::get();
|
||||
if draw_page {
|
||||
ui.add_space(t.space_8);
|
||||
ui.label(RichText::new(page).strong().size(t.font_label).color(t.form_ink));
|
||||
}
|
||||
if draw_section {
|
||||
let response = ui.label(RichText::new(section.to_uppercase()).size(t.font_small).color(t.text_dim));
|
||||
remember(ui, §ion, response.rect);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hidden_response(ui: &mut Ui) -> Response {
|
||||
let mut child = ui.new_child(UiBuilder::new().invisible().max_rect(Rect::from_min_size(Pos2::ZERO, vec2(1.0, 1.0))));
|
||||
child.allocate_response(Vec2::ZERO, Sense::hover())
|
||||
}
|
||||
|
||||
pub(crate) fn run_hidden<R>(ui: &mut Ui, salt: &str, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
with(ui, |state| state.hiding = state.hiding.saturating_add(1));
|
||||
let mut child = ui.new_child(
|
||||
UiBuilder::new()
|
||||
.invisible()
|
||||
.id_salt(("prefs-search-hidden", salt))
|
||||
.max_rect(Rect::from_min_size(egui::pos2(0.0, -10_000.0), vec2(480.0, 800.0))),
|
||||
);
|
||||
let result = add(&mut child);
|
||||
with(ui, |state| state.hiding = state.hiding.saturating_sub(1));
|
||||
result
|
||||
}
|
||||
|
||||
fn extra_hit(state: &State, key: &str) -> bool {
|
||||
extra_hit_text(state, state.extras.get(key).map(String::as_str))
|
||||
}
|
||||
|
||||
fn extra_hit_text(state: &State, text: Option<&str>) -> bool {
|
||||
text.is_some_and(|text| setting_score(&state.query, text) > 0 || english_hit(&state.query, text))
|
||||
}
|
||||
|
||||
/// Whether any of `texts` matches the current query. Does not draw a heading.
|
||||
pub(crate) fn texts_hit(ui: &Ui, texts: &[&str]) -> bool {
|
||||
texts.iter().any(|text| text_hit(ui, text))
|
||||
}
|
||||
|
||||
fn text_hit(ui: &Ui, text: &str) -> bool {
|
||||
if text.is_empty() {
|
||||
return false;
|
||||
}
|
||||
with(ui, |state| text_matches(&state.query, text)).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn text_matches(query: &str, text: &str) -> bool {
|
||||
setting_score(query, text) > 0 || english_hit(query, text) || keywords(text).iter().any(|word| setting_score(query, word) > 0)
|
||||
}
|
||||
|
||||
/// Prefix, the start of a word, or a run of at least three characters. Scattered letters are not
|
||||
/// a match: "zoom" finds "Reverse zoom direction", and "rzd" does not. Marks are ignored, so
|
||||
/// "preferences" finds "préférences".
|
||||
fn setting_score(query: &str, text: &str) -> u32 {
|
||||
if query.chars().count() < 2 || text.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let query = fold(query);
|
||||
let text = fold(text);
|
||||
if text.starts_with(&query) {
|
||||
return 1000;
|
||||
}
|
||||
if text.split(|c: char| !c.is_alphanumeric()).any(|word| !word.is_empty() && word.starts_with(&query)) {
|
||||
return 800;
|
||||
}
|
||||
if query.chars().count() >= 3 && text.contains(&query) {
|
||||
return 600;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Whether `query` matches any of `texts`, including the English source of a translation.
|
||||
pub(crate) fn matches_text(query: &str, texts: &[&str]) -> bool {
|
||||
let query = query.trim().to_lowercase();
|
||||
if query.is_empty() {
|
||||
return true;
|
||||
}
|
||||
texts.iter().any(|text| text_matches(&query, text))
|
||||
}
|
||||
|
||||
/// Words that find a setting even though they are not written on it.
|
||||
fn keywords(text: &str) -> &'static [&'static str] {
|
||||
match fold(text).as_str() {
|
||||
"theme" => &["dark", "light", "appearance"],
|
||||
"anti-aliasing" | "antialiasing" => &["antialiasing", "msaa"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower case, without the marks that distinguish "é" from "e".
|
||||
fn fold(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'œ' | 'Œ' => out.push_str("oe"),
|
||||
'æ' | 'Æ' => out.push_str("ae"),
|
||||
'ß' => out.push_str("ss"),
|
||||
c => out.extend(strip_mark(c).to_lowercase()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn strip_mark(c: char) -> char {
|
||||
match c {
|
||||
'À'..='Å' | 'à'..='å' | 'Ā' | 'ā' | 'Ă' | 'ă' | 'Ą' | 'ą' => 'a',
|
||||
'Ç' | 'ç' | 'Ć' | 'ć' | 'Č' | 'č' => 'c',
|
||||
'È'..='Ë' | 'è'..='ë' | 'Ē' | 'ē' | 'Ĕ' | 'ĕ' | 'Ė' | 'ė' | 'Ę' | 'ę' | 'Ě' | 'ě' => 'e',
|
||||
'Ì'..='Ï' | 'ì'..='ï' | 'Ī' | 'ī' | 'Į' | 'į' => 'i',
|
||||
'Ñ' | 'ñ' | 'Ń' | 'ń' | 'Ň' | 'ň' => 'n',
|
||||
'Ò'..='Ö' | 'ò'..='ö' | 'Ō' | 'ō' | 'Ő' | 'ő' => 'o',
|
||||
'Ù'..='Ü' | 'ù'..='ü' | 'Ū' | 'ū' | 'Ů' | 'ů' | 'Ű' | 'ű' | 'Ų' | 'ų' => 'u',
|
||||
'Ý' | 'ý' | 'Ÿ' | 'ÿ' => 'y',
|
||||
'Ž' | 'ž' => 'z',
|
||||
c => c,
|
||||
}
|
||||
}
|
||||
|
||||
fn english_hit(query: &str, shown: &str) -> bool {
|
||||
use std::cell::RefCell;
|
||||
thread_local! {
|
||||
static MAP: RefCell<(String, HashMap<String, Vec<String>>)> = RefCell::new((String::new(), HashMap::new()));
|
||||
}
|
||||
MAP.with(|slot| {
|
||||
let lang = crate::i18n::language().to_string();
|
||||
let mut slot = slot.borrow_mut();
|
||||
if slot.0 != lang {
|
||||
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let locale = crate::i18n::Locale::for_language(&lang);
|
||||
crate::i18n::each_english_message(|id, english| {
|
||||
if english.contains('{') {
|
||||
return;
|
||||
}
|
||||
let shown = locale.text(id);
|
||||
if shown != english {
|
||||
map.entry(shown.to_string()).or_default().push(english.to_string());
|
||||
}
|
||||
});
|
||||
*slot = (lang, map);
|
||||
}
|
||||
slot.1.get(shown).is_some_and(|lines| {
|
||||
lines.iter().any(|english| setting_score(query, english) > 0 || keywords(english).iter().any(|word| setting_score(query, word) > 0))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// A text field or drag value had the keyboard on the previous frame.
|
||||
pub(crate) fn shift_key_sink(ctx: &Context) -> bool {
|
||||
let held = ctx.data(|data| data.get_temp::<bool>(Id::new(SINK_NOW))).unwrap_or(false);
|
||||
ctx.data_mut(|data| data.insert_temp(Id::new(SINK_NOW), false));
|
||||
held
|
||||
}
|
||||
|
||||
pub(crate) fn key_sink(ctx: &Context) -> bool {
|
||||
ctx.data(|data| data.get_temp::<bool>(Id::new(SINK_PREV))).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn note_key_sink(ctx: &Context) {
|
||||
ctx.data_mut(|data| data.insert_temp(Id::new(SINK_NOW), true));
|
||||
}
|
||||
|
||||
pub(crate) fn set_key_sink_prev(ctx: &Context, held: bool) {
|
||||
ctx.data_mut(|data| data.insert_temp(Id::new(SINK_PREV), held));
|
||||
}
|
||||
|
||||
/// The matches drawn last frame, for Up/Down and Enter before this pass rebuilds them.
|
||||
pub(crate) fn nav(ctx: &Context) -> (String, usize, Vec<NavHit>) {
|
||||
ctx.data(|data| {
|
||||
let store = data.get_temp::<NavStore>(Id::new(NAV)).unwrap_or_default();
|
||||
(store.query, store.cursor, store.hits)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn set_cursor(ctx: &Context, cursor: usize) {
|
||||
ctx.data_mut(|data| {
|
||||
let store = data.get_temp_mut_or_default::<NavStore>(Id::new(NAV));
|
||||
store.cursor = cursor;
|
||||
});
|
||||
}
|
||||
|
||||
/// Keep this pass's matches for the next frame's keys.
|
||||
pub(crate) fn store_nav(ui: &Ui) {
|
||||
let (query, hits) =
|
||||
with(ui, |state| (state.query.clone(), state.nav.iter().map(|hit| NavHit { page: hit.page, label: hit.label.clone() }).collect::<Vec<_>>()))
|
||||
.unwrap_or_default();
|
||||
ui.ctx().data_mut(|data| {
|
||||
let store = data.get_temp_mut_or_default::<NavStore>(Id::new(NAV));
|
||||
store.query = query;
|
||||
store.hits = hits;
|
||||
if store.cursor >= store.hits.len() {
|
||||
store.cursor = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A row whose own words matched. Section titles and controls both call this once they have a rect.
|
||||
pub(crate) fn remember(ui: &mut Ui, label: &str, rect: Rect) {
|
||||
if !ui.is_visible() || !querying(ui) || label.is_empty() || !text_hit(ui, label) {
|
||||
return;
|
||||
}
|
||||
if with(ui, |state| state.hiding > 0).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
let cursor = ui.ctx().data(|data| data.get_temp::<NavStore>(Id::new(NAV)).unwrap_or_default().cursor);
|
||||
let selected = with(ui, |state| {
|
||||
let selected = state.nav.len() == cursor;
|
||||
state.nav.push(PaintedHit { page: state.page_index, label: label.to_string() });
|
||||
selected
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !selected {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
ui.painter().rect_stroke(rect, t.radius_sm, egui::Stroke::new(t.space_2, t.form_primary), egui::StrokeKind::Outside);
|
||||
ui.scroll_to_rect(rect, Some(egui::Align::Center));
|
||||
crate::scenario::publish_handle("prefs:search:current", rect.center());
|
||||
}
|
||||
|
||||
pub(crate) fn set_reveal(ctx: &Context, reveal: Option<Reveal>) {
|
||||
ctx.data_mut(|data| {
|
||||
if let Some(reveal) = reveal {
|
||||
data.insert_temp(Id::new(REVEAL_ID), reveal);
|
||||
} else {
|
||||
data.remove::<Reveal>(Id::new(REVEAL_ID));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_for(page: usize, label: &str) -> Reveal {
|
||||
Reveal { page, label: label.to_string(), until: crate::now_ms() + REVEAL_MS }
|
||||
}
|
||||
|
||||
/// Scroll the opened setting into view and outline it until [`Reveal::until`].
|
||||
pub(crate) fn follow(ui: &Ui, label: &str, rect: Rect) {
|
||||
let Some(reveal) = ui.data(|data| data.get_temp::<Reveal>(Id::new(REVEAL_ID))) else { return };
|
||||
if crate::now_ms() > reveal.until || fold(label) != fold(&reveal.label) {
|
||||
return;
|
||||
}
|
||||
let page = with(ui, |state| state.page_index);
|
||||
if page.is_some_and(|page| page != reveal.page) {
|
||||
return;
|
||||
}
|
||||
ui.scroll_to_rect(rect, Some(egui::Align::Center));
|
||||
let t = Tokens::get();
|
||||
ui.painter().rect_stroke(rect, t.radius_sm, egui::Stroke::new(t.space_2, t.form_primary), egui::StrokeKind::Outside);
|
||||
crate::scenario::publish_handle("prefs:revealed", rect.center());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn search_matches_a_label_a_subsequence_and_the_english_source() {
|
||||
assert!(matches_text("zoom", &["Reverse zoom direction"]));
|
||||
assert!(matches_text("zo", &["Reverse zoom direction"]));
|
||||
assert!(!matches_text("rzd", &["Reverse zoom direction"]));
|
||||
assert!(!matches_text("q", &["Units of new designs"]));
|
||||
assert!(!matches_text("qqqq", &["Units of new designs"]));
|
||||
let _language = crate::i18n::scope("fr");
|
||||
assert!(matches_text("units", &["Unités des nouvelles conceptions"]));
|
||||
assert!(matches_text("preferences", &["préférences"]));
|
||||
assert!(matches_text("dark", &["Theme"]));
|
||||
assert!(matches_text("antialiasing", &["Anti-aliasing"]));
|
||||
assert!(!matches_text("dark", &["Units of new designs"]));
|
||||
}
|
||||
|
||||
/// Typing focuses the field, Enter opens that setting, and Escape returns to the page
|
||||
/// that was up when the search started. Recording a shortcut keeps the key.
|
||||
#[test]
|
||||
fn preferences_search_types_opens_and_returns() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_GRAPHICS);
|
||||
h.frames(2);
|
||||
h.step(&serde_json::json!({"key": "F", "cmd": true})).expect("find");
|
||||
h.frames(1);
|
||||
assert!(h.ctx.memory(|memory| memory.has_focus(egui::Id::new("prefs_search"))), "Cmd+F focuses search");
|
||||
h.step(&serde_json::json!({"text": "zo"})).expect("type");
|
||||
h.frames(3);
|
||||
assert!(h.widget("Reverse zoom direction").is_some(), "typed letters search from the open page");
|
||||
assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_GRAPHICS);
|
||||
let first = crate::scenario::handle_at("prefs:search:current");
|
||||
h.step(&serde_json::json!({"key": "ArrowDown"})).expect("next match");
|
||||
h.frames(1);
|
||||
let second = crate::scenario::handle_at("prefs:search:current");
|
||||
assert!(first.is_some() && second.is_some() && first != second, "Down moves to the next match");
|
||||
h.step(&serde_json::json!({"key": "ArrowUp"})).expect("previous match");
|
||||
h.frames(1);
|
||||
assert_eq!(crate::scenario::handle_at("prefs:search:current"), first, "Up moves back");
|
||||
h.step(&serde_json::json!({"key": "Enter"})).expect("open");
|
||||
h.frames(2);
|
||||
assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_NAVIGATION, "Enter opens the match's page");
|
||||
assert!(h.app.prefs_window.filter.is_empty());
|
||||
assert!(h.widget("Reverse zoom direction").is_some());
|
||||
assert!(crate::scenario::handle_at("prefs:revealed").is_some(), "the opened row is highlighted");
|
||||
crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_GRAPHICS);
|
||||
h.frames(2);
|
||||
h.step(&serde_json::json!({"text": "zo"})).expect("search again");
|
||||
h.frames(2);
|
||||
h.step(&serde_json::json!({"key": "Escape"})).expect("clear");
|
||||
h.frames(1);
|
||||
assert!(h.app.prefs_window.filter.is_empty());
|
||||
assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_GRAPHICS, "Escape returns to the page from before the search");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_search_filters_live_controls_across_pages() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_GENERAL);
|
||||
h.frames(2);
|
||||
assert!(h.widget("Units of new designs").is_none(), "another page stays put until a search");
|
||||
h.app.prefs_window.filter = "units".into();
|
||||
h.frames(3);
|
||||
assert!(h.widget("Units of new designs").is_some(), "a row is found from its label");
|
||||
assert!(h.widget("Reverse zoom direction").is_none());
|
||||
h.app.prefs_window.filter = "zoom".into();
|
||||
h.frames(3);
|
||||
assert!(h.widget("Reverse zoom direction").is_some());
|
||||
assert!(h.widget("Units of new designs").is_none());
|
||||
assert!(h.widget("Grid").is_none(), "a checkbox on another page stays hidden");
|
||||
assert!(h.widget("Interface language").is_none(), "the page around a match stays hidden");
|
||||
assert!(h.widget("Advanced").is_none(), "a folded heading is not a match");
|
||||
let search = crate::scenario::handle_at("prefs:search").expect("search field");
|
||||
let hit = h.widget("Reverse zoom direction").expect("zoom row");
|
||||
let gap = hit[1] - f64::from(search.y);
|
||||
assert!(gap < 520.0, "search results leave a {gap:.0}px gap under the field");
|
||||
assert!(h.widget("Translation X").is_none(), "unmatched pages do not stack on the result");
|
||||
h.app.prefs_window.filter = "extrude".into();
|
||||
h.frames(3);
|
||||
assert!(crate::scenario::rect_of("prefs:search:shortcuts").is_some(), "shortcut matches collapse to one row");
|
||||
assert!(crate::scenario::rect_of("dialog:cell:Extrude").is_none(), "the shortcut list stays closed");
|
||||
h.step(&serde_json::json!({"click": {"handle": "prefs:search:clear"}})).expect("clear");
|
||||
h.frames(2);
|
||||
assert!(h.app.prefs_window.filter.is_empty(), "the search X clears the field");
|
||||
h.app.prefs_window.filter = "fusion".into();
|
||||
h.frames(3);
|
||||
assert!(h.widget("Mouse mapping").is_some(), "the dropdown's current value counts");
|
||||
h.app.prefs_window.filter = "zzzz-no-setting".into();
|
||||
h.frames(2);
|
||||
assert!(h.widget("No matching settings").is_some());
|
||||
h.app.prefs_window.filter = "units".into();
|
||||
h.frames(2);
|
||||
h.step(&serde_json::json!({"click": {"handle": "prefs:page:Design"}})).expect("design page");
|
||||
h.frames(2);
|
||||
assert!(h.app.prefs_window.filter.is_empty(), "a page tab clears the search");
|
||||
assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_DESIGN);
|
||||
assert!(h.widget("Units of new designs").is_some(), "the tab shows its own page");
|
||||
assert!(crate::scenario::rect_of("prefs:search:shortcuts").is_none(), "shortcut matches leave with the search");
|
||||
h.app.prefs_window.filter = "zoom".into();
|
||||
h.frames(2);
|
||||
h.step(&serde_json::json!({"click": {"handle": "prefs:page:Design"}})).expect("same page");
|
||||
h.frames(2);
|
||||
assert!(h.app.prefs_window.filter.is_empty(), "the current tab also clears the search");
|
||||
assert!(h.widget("Reverse zoom direction").is_none());
|
||||
assert!(h.widget("Units of new designs").is_some());
|
||||
}
|
||||
}
|
||||
@@ -402,7 +402,7 @@ mod tests {
|
||||
edit(&mut app);
|
||||
tick_and_wait(&mut app);
|
||||
let id = entries(&dir)[0].id.clone();
|
||||
let blocked = dir.join(format!("{id}.solidcraft"));
|
||||
let blocked = dir.join(format!("{id}.sc"));
|
||||
fs::remove_file(&blocked).unwrap();
|
||||
fs::create_dir(&blocked).unwrap();
|
||||
edit(&mut app);
|
||||
|
||||
@@ -790,7 +790,7 @@ fn save_as_named(app: &mut SolveApp, name: &str, extension: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
/// File > Download design (browser build): the open design as a `.solidcraft` file download.
|
||||
/// File > Download design (browser build): the open design as a `.sc` file download.
|
||||
fn download_design(app: &mut SolveApp) {
|
||||
let name = format!("{}.{}", app.session.doc.name, solidcraft_engine::io::DESIGN_EXT);
|
||||
let result = app
|
||||
@@ -2423,6 +2423,49 @@ mod header_report_tests {
|
||||
assert!(FileAction::Export("step").enabled(&h.app));
|
||||
}
|
||||
|
||||
/// New designs are saved as `.sc`. A design opened from a `.solidcraft` file is saved back to
|
||||
/// that file by Save (no rename behind the user's back); Save As offers `.sc` and leaves it.
|
||||
#[test]
|
||||
fn save_offers_sc_and_keeps_an_opened_solidcraft_file() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let offered = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(String, Vec<String>)>::new()));
|
||||
let (seen, dir) = (offered.clone(), h.dir.clone());
|
||||
h.app.services.pick_save = Some(Box::new(move |name, extensions| {
|
||||
seen.borrow_mut().push((name.to_string(), extensions.iter().map(|e| e.to_string()).collect()));
|
||||
let path = dir.join(name).to_string_lossy().into_owned();
|
||||
Box::pin(std::future::ready(Some(path)))
|
||||
}));
|
||||
h.app.run("solid.box", json!({"length":2,"width":3,"height":4})).unwrap();
|
||||
h.app.session.doc_mut().name = "Bracket".into();
|
||||
FileAction::Save.run(&mut h.app);
|
||||
h.frames(3);
|
||||
h.settle();
|
||||
assert_eq!(offered.borrow().last().map(|(n, e)| (n.as_str(), e.clone())), Some(("Bracket.sc", vec!["sc".to_string(), "json".to_string()])));
|
||||
assert!(h.dir.join("Bracket.sc").is_file(), "a new design is saved as .sc");
|
||||
|
||||
// A design from an earlier release.
|
||||
let old = h.dir.join("Old.solidcraft");
|
||||
std::fs::rename(h.dir.join("Bracket.sc"), &old).unwrap();
|
||||
h.app.run("doc.open", json!({"path": old.to_string_lossy()})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":1,"width":1,"height":1})).unwrap();
|
||||
let asked = offered.borrow().len();
|
||||
FileAction::Save.run(&mut h.app);
|
||||
h.frames(3);
|
||||
h.settle();
|
||||
assert_eq!(offered.borrow().len(), asked, "Save writes the opened file without asking");
|
||||
assert_eq!(h.app.session.path.as_deref(), Some(old.to_string_lossy().as_ref()));
|
||||
assert!(!h.app.session.is_dirty());
|
||||
assert_eq!(solidcraft_engine::io::read_design(&std::fs::read(&old).unwrap()).unwrap().features.len(), 2);
|
||||
assert!(!h.dir.join("Old.sc").exists() && !h.dir.join("Bracket.sc").exists(), "nothing renamed");
|
||||
|
||||
FileAction::SaveAs.run(&mut h.app);
|
||||
h.frames(3);
|
||||
h.settle();
|
||||
let name = offered.borrow().last().map(|(n, _)| n.clone()).unwrap();
|
||||
assert!(name.ends_with(".sc"), "Save As offers .sc: {name}");
|
||||
assert!(h.dir.join(&name).is_file() && old.is_file(), "Save As leaves the .solidcraft file as it was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_menu_actions_are_disabled_behind_an_open_issue_report() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
@@ -2558,7 +2601,7 @@ mod header_report_tests {
|
||||
h.step(&json!({"widget":"Download design"})).unwrap();
|
||||
let got = got.borrow();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].0, "Plate.solidcraft");
|
||||
assert_eq!(got[0].0, "Plate.sc");
|
||||
assert!(solidcraft_engine::io::is_design_container(&got[0].1));
|
||||
assert!(h.app.session.path.is_none(), "a download is no save: the design keeps no file");
|
||||
}
|
||||
@@ -2577,11 +2620,7 @@ mod header_report_tests {
|
||||
h.step(&json!({"key":"S","cmd":true,"shift":true})).unwrap();
|
||||
h.frames(3);
|
||||
h.settle();
|
||||
assert_eq!(
|
||||
h.app.session.path.as_deref(),
|
||||
h.dir.join("Copy.solidcraft").to_str(),
|
||||
"Ctrl+Shift+S must open Save As, not save over the current file"
|
||||
);
|
||||
assert_eq!(h.app.session.path.as_deref(), h.dir.join("Copy.sc").to_str(), "Ctrl+Shift+S must open Save As, not save over the current file");
|
||||
assert_eq!(std::fs::read(&source).unwrap(), bytes, "Save As keeps the file the design came from");
|
||||
// The File menu shows the shortcut of the command it runs.
|
||||
let spec = solidcraft_engine::find_command("file.save_as").expect("file.save_as is a command");
|
||||
|
||||
@@ -423,6 +423,9 @@ fn button_widget(label: egui::WidgetText, options: Button) -> egui::Button<'stat
|
||||
pub fn button(ui: &mut Ui, label: impl Into<egui::WidgetText>, options: Button) -> Response {
|
||||
let label = label.into();
|
||||
let key = label.text().to_owned();
|
||||
if crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, &[&key]) {
|
||||
return crate::prefs_search::hidden_response(ui);
|
||||
}
|
||||
let response = match options {
|
||||
Button::Enabled(enabled) => ui.add_enabled_ui(enabled, |ui| ui.add(button_widget(crate::i18n::tr_label(&key).into(), options))).inner,
|
||||
_ => ui.add(button_widget(label, options)),
|
||||
|
||||
@@ -58,6 +58,9 @@ pub(crate) fn checkbox(ui: &mut Ui, label: &str, value: &mut bool) -> Response {
|
||||
/// Mixed values become checked when activated; the caller owns whether selection is mixed.
|
||||
/// Tokens: space_14/3/2/6, radius_sm, control_fill/border, form_primary/ink, hover, on_accent.
|
||||
pub(crate) fn checkbox_indeterminate(ui: &mut Ui, label: &str, value: &mut bool, mixed: bool) -> Response {
|
||||
if crate::prefs_search::querying(ui) && !crate::prefs_search::visible(ui, &[label]) {
|
||||
return crate::prefs_search::hidden_response(ui);
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let font = egui::FontId::proportional(t.font_label);
|
||||
let galley = ui.painter().layout_no_wrap(label.to_owned(), font.clone(), t.form_ink);
|
||||
@@ -65,6 +68,8 @@ pub(crate) fn checkbox_indeterminate(ui: &mut Ui, label: &str, value: &mut bool,
|
||||
let width = (t.space_14 + gap + galley.size().x).max(ui.spacing().interact_size.y).min(super::metrics::dimension(ui.available_width()));
|
||||
let height = ui.spacing().interact_size.y.max(t.space_14).max(galley.size().y);
|
||||
let (rect, mut response) = ui.allocate_exact_size(vec2(width, height), egui::Sense::click());
|
||||
crate::prefs_search::remember(ui, label, rect);
|
||||
crate::prefs_search::follow(ui, label, rect);
|
||||
if response.clicked() {
|
||||
*value = mixed || !*value;
|
||||
response.mark_changed();
|
||||
|
||||
@@ -24,6 +24,12 @@ pub(crate) struct Segments<'a> {
|
||||
}
|
||||
|
||||
pub(crate) fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], config: Segments<'_>) -> bool {
|
||||
if crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, options) {
|
||||
return false;
|
||||
}
|
||||
if crate::prefs_search::querying(ui) && !crate::prefs_search::shows_all(ui) {
|
||||
crate::prefs_search::reveal(ui);
|
||||
}
|
||||
let Segments { icons, workspace_accent, dense, states, compact, primary, intrinsic, proportional } = config;
|
||||
let accent = crate::theme::TabAccent::current(ui.ctx());
|
||||
let t = Tokens::get();
|
||||
|
||||
@@ -168,6 +168,7 @@ pub(crate) fn searchable_dropdown(
|
||||
custom: Option<&str>,
|
||||
pending: Option<&str>,
|
||||
) -> Option<String> {
|
||||
crate::prefs_search::note_current(ui, current);
|
||||
let mut chosen = None;
|
||||
let row = label(ui).to_owned();
|
||||
let response = egui::ComboBox::from_id_salt(id)
|
||||
@@ -290,6 +291,14 @@ pub(crate) fn dropdown_menu_with_close_behavior(
|
||||
close_behavior: egui::PopupCloseBehavior,
|
||||
add: impl FnOnce(&mut Ui),
|
||||
) -> Response {
|
||||
crate::prefs_search::note_current(ui, current);
|
||||
// Only a search probe may skip the real control. A dialog's first frame is not visible yet,
|
||||
// and a stand-in there makes the next frame too short to show OK.
|
||||
if crate::prefs_search::querying(ui)
|
||||
&& (!ui.is_visible() || (crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, &[current])))
|
||||
{
|
||||
return crate::prefs_search::hidden_response(ui);
|
||||
}
|
||||
let response = egui::ComboBox::from_id_salt(id)
|
||||
.width(dimension(ui.available_width()))
|
||||
.height(Tokens::get().space_24 * 14.0)
|
||||
@@ -468,6 +477,9 @@ impl Default for Slider {
|
||||
/// Native input, keyboard routing, enabled state and accessibility; one token-painted track/thumb.
|
||||
/// Tokens: control_height, space_24/8/4/2, dialog_body/rule, control_fill, form_primary.
|
||||
pub(crate) fn slider<T: egui::emath::Numeric>(ui: &mut Ui, value: &mut T, range: std::ops::RangeInclusive<T>, options: Slider) -> Response {
|
||||
if crate::prefs_search::suppress_chrome(ui) {
|
||||
return crate::prefs_search::hidden_response(ui);
|
||||
}
|
||||
// Own form styling even in callers such as the viewport menu which have no kit scope.
|
||||
super::scope(ui, |ui| {
|
||||
let t = Tokens::get();
|
||||
@@ -498,6 +510,12 @@ pub(crate) fn slider<T: egui::emath::Numeric>(ui: &mut Ui, value: &mut T, range:
|
||||
widget = widget.integer();
|
||||
}
|
||||
let response = ui.add(widget);
|
||||
if ui.ctx().memory(|memory| memory.focused()).is_some_and(|id| {
|
||||
ui.ctx().read_response(id).is_some_and(|focused| response.rect.contains(focused.rect.center()))
|
||||
|| egui::TextEdit::load_state(ui.ctx(), id).is_some()
|
||||
}) {
|
||||
crate::prefs_search::note_key_sink(ui.ctx());
|
||||
}
|
||||
let slider = egui::Rect::from_min_size(response.rect.min, vec2(ui.spacing().slider_width, response.rect.height()));
|
||||
let mut background = ui.painter().clone();
|
||||
background.set_opacity(if ui.is_visible() { 1.0 } else { 0.0 });
|
||||
|
||||
@@ -308,7 +308,7 @@ const ELEMENTS: &[Element] = &[
|
||||
super::file_view::RecoveryMessage {
|
||||
title: "1 design was recovered",
|
||||
before_filename: " after SolidCraft closed unexpectedly: ",
|
||||
filename: "enclosure-lid.solidcraft",
|
||||
filename: "enclosure-lid.sc",
|
||||
after_filename: ", 2 minutes ago.",
|
||||
},
|
||||
"Recover",
|
||||
@@ -326,7 +326,7 @@ const ELEMENTS: &[Element] = &[
|
||||
id,
|
||||
RecentRow {
|
||||
title: "Sample Plate",
|
||||
path: "Designs/sample-plate.solidcraft",
|
||||
path: "Designs/sample-plate.sc",
|
||||
when: "Open now",
|
||||
workspace: "Design",
|
||||
thumbnail: None,
|
||||
@@ -342,7 +342,7 @@ const ELEMENTS: &[Element] = &[
|
||||
"gallery:file:recent",
|
||||
RecentRow {
|
||||
title: "Sample Plate",
|
||||
path: "Designs/sample-plate.solidcraft",
|
||||
path: "Designs/sample-plate.sc",
|
||||
when: "Open now",
|
||||
workspace: "Design",
|
||||
thumbnail: None,
|
||||
@@ -1092,6 +1092,9 @@ const ELEMENTS: &[Element] = &[
|
||||
element!("section", "dialog:section:Advanced", Some("dialog:section:Advanced"), |ui, _| {
|
||||
section(ui, "gallery", "Advanced", false, |_| {});
|
||||
}),
|
||||
element!("search_fold", "prefs:search:shortcuts", Some("prefs:search:shortcuts"), |ui, _| {
|
||||
search_fold(ui, "Shortcuts", 3, |_| {});
|
||||
}),
|
||||
element!("divider", "dialog:divider:gallery", None, |ui, _| {
|
||||
divider(ui, "gallery");
|
||||
}),
|
||||
@@ -1952,7 +1955,7 @@ fn file_view_application_evidence_in_both_themes_and_widths() {
|
||||
std::fs::create_dir_all(&output_dir).unwrap();
|
||||
let fixture_dir = std::env::temp_dir().join(format!("solidcraft-file-evidence-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&fixture_dir).unwrap();
|
||||
let path = fixture_dir.join("sample-plate.solidcraft");
|
||||
let path = fixture_dir.join("sample-plate.sc");
|
||||
let fixture = path.to_string_lossy().to_string();
|
||||
let mut sample = crate::test_support::sample_app();
|
||||
sample.session.execute("file.save_as", &serde_json::json!({"path":fixture})).unwrap();
|
||||
@@ -1971,7 +1974,7 @@ fn file_view_application_evidence_in_both_themes_and_widths() {
|
||||
app.ui.dark = dark;
|
||||
app.recoverable.push(solidcraft_engine::recovery::Entry {
|
||||
id: "public-evidence".into(),
|
||||
path: Some("enclosure-lid.solidcraft".into()),
|
||||
path: Some("enclosure-lid.sc".into()),
|
||||
name: "Enclosure lid".into(),
|
||||
saved_at: web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).unwrap().as_secs().saturating_sub(120),
|
||||
features: 14,
|
||||
|
||||
@@ -53,7 +53,12 @@ pub(crate) fn label(ui: &Ui) -> &str {
|
||||
pub(super) fn publish(ui: &Ui, response: &Response, kind: egui::WidgetType) {
|
||||
let t = Tokens::get();
|
||||
match kind {
|
||||
egui::WidgetType::TextEdit => t.input_bevel(ui, response),
|
||||
egui::WidgetType::TextEdit => {
|
||||
t.input_bevel(ui, response);
|
||||
if response.has_focus() {
|
||||
crate::prefs_search::note_key_sink(ui.ctx());
|
||||
}
|
||||
}
|
||||
egui::WidgetType::ComboBox | egui::WidgetType::Button => t.button_bevel(ui, response, false),
|
||||
_ => {}
|
||||
}
|
||||
@@ -94,9 +99,30 @@ pub(crate) fn choice_row(ui: &mut Ui, label: &str, id: &str, selected: &mut usiz
|
||||
|
||||
/// Internal form geometry for controls which need several lines (e.g. text areas).
|
||||
pub(super) fn sized_row<R>(ui: &mut Ui, text: &str, height: f32, control: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
let mut passthrough = false;
|
||||
if crate::prefs_search::querying(ui) {
|
||||
crate::prefs_search::set_control(ui, text);
|
||||
if !crate::prefs_search::visible(ui, &[text]) {
|
||||
let result = crate::prefs_search::run_hidden(ui, text, control);
|
||||
crate::prefs_search::recheck(ui, &[text]);
|
||||
return result;
|
||||
}
|
||||
crate::prefs_search::push_passthrough(ui);
|
||||
passthrough = true;
|
||||
}
|
||||
let result = sized_row_body(ui, text, height, control);
|
||||
if passthrough {
|
||||
crate::prefs_search::pop_passthrough(ui);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn sized_row_body<R>(ui: &mut Ui, text: &str, height: f32, control: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
let t = Tokens::get();
|
||||
let width = dimension(ui.available_width());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, height), egui::Sense::hover());
|
||||
crate::prefs_search::remember(ui, text, rect);
|
||||
crate::prefs_search::follow(ui, text, rect);
|
||||
// A nested row (`" Of"`) lines up under the one above: the lookup uses the text without its
|
||||
// indent and the indent stays in front of the translation. The `LABEL` tag keeps the original.
|
||||
let shown = indented_label(text);
|
||||
@@ -133,6 +159,9 @@ pub(super) fn sized_row<R>(ui: &mut Ui, text: &str, height: f32, control: impl F
|
||||
/// row's control. It says in words what a colour or an icon shows, so colour is never the only cue.
|
||||
/// Dim helper beneath a row control. Tokens: `form_icon_size, font_small, text_dim, space_8; label-column tokens`.
|
||||
pub(crate) fn caption(ui: &mut Ui, aligned_to: &str, text: &str) {
|
||||
if crate::prefs_search::suppress_chrome(ui) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let width = dimension(ui.available_width());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, t.form_icon_size), egui::Sense::hover());
|
||||
@@ -214,11 +243,18 @@ pub(crate) fn section_count<R>(
|
||||
count: Option<impl std::fmt::Display>,
|
||||
add: impl FnOnce(&mut Ui) -> R,
|
||||
) -> Option<R> {
|
||||
// A search still walks the body, so a setting inside a folded section can match, without
|
||||
// leaving the folded heading on screen for every query.
|
||||
if crate::prefs_search::querying(ui) && crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, &[title]) {
|
||||
return Some(add(ui));
|
||||
}
|
||||
let searching = crate::prefs_search::querying(ui);
|
||||
let t = Tokens::get();
|
||||
ui.add_space(t.space_2);
|
||||
let id = ui.make_persistent_id(id);
|
||||
let mut open = ui.ctx().data_mut(|data| data.get_persisted::<bool>(id)).unwrap_or(default_open);
|
||||
let mut open = searching || ui.ctx().data_mut(|data| data.get_persisted::<bool>(id)).unwrap_or(default_open);
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), row_height()), egui::Sense::click());
|
||||
crate::prefs_search::follow(ui, title, rect);
|
||||
if response.hovered() || response.has_focus() {
|
||||
ui.painter().rect_filled(rect.shrink2(vec2(0.0, 1.0)), t.radius_sm, t.hover);
|
||||
}
|
||||
@@ -263,11 +299,84 @@ pub(crate) fn section_count<R>(
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, ui.is_enabled(), open, crate::i18n::tr_label(title)));
|
||||
scenario::publish_handle(&format!("dialog:section:{title}"), response.rect.center());
|
||||
scenario::publish_rect(&format!("dialog:section:{title}"), rect);
|
||||
if response.clicked() && !searching {
|
||||
open = !open;
|
||||
}
|
||||
if !searching {
|
||||
ui.ctx().data_mut(|data| data.insert_persisted(id, open));
|
||||
}
|
||||
open.then(|| ui.push_id(id, add).inner)
|
||||
}
|
||||
|
||||
/// Shortcut matches during Preferences search. Closed until opened, and closed again when the
|
||||
/// query changes, so a short word does not lay out every command.
|
||||
pub(crate) fn search_fold<R>(ui: &mut Ui, title: &str, count: usize, add: impl FnOnce(&mut Ui) -> R) -> Option<R> {
|
||||
let t = Tokens::get();
|
||||
let id = egui::Id::new(("prefs_search_fold", title));
|
||||
let query = crate::prefs_search::query(ui);
|
||||
let mut open = ui.data(|data| data.get_temp::<bool>(id)).unwrap_or(false);
|
||||
let previous = ui.data(|data| data.get_temp::<String>(id.with("query"))).unwrap_or_default();
|
||||
if previous != query {
|
||||
open = false;
|
||||
}
|
||||
crate::prefs_search::reveal(ui);
|
||||
ui.add_space(t.space_2);
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), row_height()), egui::Sense::click());
|
||||
if response.hovered() || response.has_focus() {
|
||||
ui.painter().rect_filled(rect.shrink2(vec2(0.0, 1.0)), t.radius_sm, t.hover);
|
||||
}
|
||||
let icon_rect = egui::Rect::from_center_size(
|
||||
egui::pos2(rect.left() + t.form_section_icon_size / 2.0, rect.center().y),
|
||||
vec2(t.form_section_icon_size, t.form_section_icon_size),
|
||||
);
|
||||
super::disclosure(ui.painter(), icon_rect.center(), open, t.form_dim);
|
||||
let text_rect = egui::Rect::from_min_max(egui::pos2((icon_rect.right() + t.form_gap_y).min(rect.right()), rect.top()), rect.max);
|
||||
let caption = title.to_uppercase();
|
||||
let badge_value = count.to_string();
|
||||
let badge_width = label_width(ui, &badge_value) + t.space_12;
|
||||
let reserved = badge_width + t.space_12;
|
||||
let mut format = egui::TextFormat::simple(crate::fonts::semibold(t.font_caption), t.form_dim);
|
||||
format.extra_letter_spacing = t.font_caption * t.font_section_tracking;
|
||||
let title_width = ui
|
||||
.fonts_mut(|fonts| fonts.layout_job(egui::text::LayoutJob::single_section(caption.clone(), format)))
|
||||
.size()
|
||||
.x
|
||||
.ceil()
|
||||
.min((text_rect.width() - reserved).max(0.0));
|
||||
let title_rect = egui::Rect::from_min_max(text_rect.min, egui::pos2(text_rect.left() + title_width, text_rect.bottom()));
|
||||
paint_text(ui, title_rect, &caption, crate::fonts::semibold(t.font_caption), t.form_dim, true);
|
||||
let mut rule_left = title_rect.right() + t.space_12;
|
||||
let width = badge_width.min((rect.right() - rule_left).max(0.0));
|
||||
let badge = egui::Rect::from_min_size(egui::pos2(rule_left.min(rect.right()), rect.center().y - t.space_10), vec2(width, t.space_10 * 2.0));
|
||||
ui.painter().rect_filled(badge, t.radius_xl, t.dialog_footer);
|
||||
ui.painter().with_clip_rect(ui.clip_rect().intersect(badge)).text(
|
||||
badge.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
&badge_value,
|
||||
egui::FontId::proportional(t.font_small),
|
||||
t.form_dim,
|
||||
);
|
||||
rule_left = badge.right() + t.space_12;
|
||||
if rule_left < rect.right() {
|
||||
t.secondary_hline(ui.painter(), rule_left..=rect.right(), rect.center().y, t.dialog_rule);
|
||||
}
|
||||
let accessible = format!("{title} ({count})");
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, ui.is_enabled(), open, &accessible));
|
||||
scenario::publish_rect("prefs:search:shortcuts", rect);
|
||||
scenario::publish_handle("prefs:search:shortcuts", response.rect.center());
|
||||
if response.clicked() {
|
||||
open = !open;
|
||||
}
|
||||
ui.ctx().data_mut(|data| data.insert_persisted(id, open));
|
||||
open.then(|| ui.push_id(id, add).inner)
|
||||
ui.data_mut(|data| {
|
||||
data.insert_temp(id.with("query"), query);
|
||||
data.insert_temp(id, open);
|
||||
});
|
||||
open.then(|| {
|
||||
crate::prefs_search::push_force(ui);
|
||||
let result = add(ui);
|
||||
crate::prefs_search::pop_force(ui);
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
/// Picked-item list entry with a remove action. Returns that action's Response.
|
||||
@@ -288,6 +397,9 @@ pub(crate) fn list_row(ui: &mut Ui, key: &str, text: &str) -> Response {
|
||||
/// Full-width rule; Tokens: dialog_rule. Use between groups without extra padding.
|
||||
/// Full-width rule without extra padding. Tokens: `dialog_rule`.
|
||||
pub(crate) fn divider(ui: &mut Ui, key: &str) {
|
||||
if crate::prefs_search::suppress_chrome(ui) {
|
||||
return;
|
||||
}
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), 1.0), egui::Sense::hover());
|
||||
Tokens::get().secondary_hline(ui.painter(), rect.x_range(), rect.top(), Tokens::get().dialog_rule);
|
||||
scenario::publish_rect(&format!("dialog:divider:{key}"), rect);
|
||||
@@ -342,6 +454,22 @@ pub(crate) enum Text<'a> {
|
||||
}
|
||||
|
||||
pub(crate) fn text(ui: &mut Ui, text: &str, style: Text<'_>) -> Response {
|
||||
if crate::prefs_search::querying(ui) {
|
||||
let heading = matches!(style, Text::Heading(_));
|
||||
if heading {
|
||||
let hit = crate::prefs_search::texts_hit(ui, &[text]);
|
||||
crate::prefs_search::set_heading_open(ui, hit);
|
||||
if hit {
|
||||
crate::prefs_search::reveal(ui);
|
||||
} else if crate::prefs_search::suppress_chrome(ui) {
|
||||
return crate::prefs_search::hidden_response(ui);
|
||||
}
|
||||
} else if crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, &[text]) {
|
||||
return crate::prefs_search::hidden_response(ui);
|
||||
} else if crate::prefs_search::texts_hit(ui, &[text]) && !crate::prefs_search::shows_all(ui) {
|
||||
crate::prefs_search::reveal(ui);
|
||||
}
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let fixed = matches!(style, Text::Cell | Text::NameCell(_) | Text::MonoCell | Text::Value(_, _));
|
||||
let ink = match style {
|
||||
@@ -399,6 +527,10 @@ pub(crate) fn text(ui: &mut Ui, text: &str, style: Text<'_>) -> Response {
|
||||
};
|
||||
let id = format!("dialog:{kind}:{key}");
|
||||
scenario::publish_rect(&id, response.rect);
|
||||
if matches!(style, Text::Heading(_)) {
|
||||
crate::prefs_search::remember(ui, text, response.rect);
|
||||
crate::prefs_search::follow(ui, text, response.rect);
|
||||
}
|
||||
if !matches!(style, Text::Note(_) | Text::MonoNote(_)) {
|
||||
scenario::publish_handle(&id, response.rect.center());
|
||||
}
|
||||
|
||||
@@ -840,18 +840,48 @@ impl<'a> Window<'a> {
|
||||
}
|
||||
|
||||
/// Declarative page navigation: the kit owns geometry, selection and page scrolling.
|
||||
/// Preferences uses [`Self::show_pages_with_header`]; this entry is what the page-list tests call.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn show_pages<S>(
|
||||
mut self,
|
||||
self,
|
||||
ctx: &egui::Context,
|
||||
selected: &mut usize,
|
||||
pages: &[WindowPage<S>],
|
||||
state: &mut S,
|
||||
left: impl FnOnce(&mut Ui),
|
||||
) -> (bool, super::Action) {
|
||||
self.show_pages_inner(ctx, selected, pages, state, None, None, left)
|
||||
}
|
||||
|
||||
/// Page navigation with a strip above the page body (Preferences search).
|
||||
/// `on_select` runs when a page tab is clicked, before that page is drawn.
|
||||
pub(crate) fn show_pages_with_header<S>(
|
||||
self,
|
||||
ctx: &egui::Context,
|
||||
selected: &mut usize,
|
||||
pages: &[WindowPage<S>],
|
||||
state: &mut S,
|
||||
header: fn(&mut Ui, &mut S),
|
||||
on_select: fn(&mut S),
|
||||
left: impl FnOnce(&mut Ui),
|
||||
) -> (bool, super::Action) {
|
||||
self.show_pages_inner(ctx, selected, pages, state, Some(header), Some(on_select), left)
|
||||
}
|
||||
|
||||
fn show_pages_inner<S>(
|
||||
mut self,
|
||||
ctx: &egui::Context,
|
||||
selected: &mut usize,
|
||||
pages: &[WindowPage<S>],
|
||||
state: &mut S,
|
||||
header: Option<fn(&mut Ui, &mut S)>,
|
||||
on_select: Option<fn(&mut S)>,
|
||||
left: impl FnOnce(&mut Ui),
|
||||
) -> (bool, super::Action) {
|
||||
self.body_padding = false;
|
||||
let rows = self.page_rows;
|
||||
let padding = self.page_padding;
|
||||
self.show_with_left(ctx, true, |ui| page_list_sized(ui, selected, pages, state, rows, padding), left)
|
||||
self.show_with_left(ctx, true, |ui| page_list_sized(ui, selected, pages, state, rows, padding, header, on_select), left)
|
||||
}
|
||||
|
||||
/// The same bounded card frame, either movable or an input-blocking modal.
|
||||
@@ -914,13 +944,25 @@ pub(crate) struct WindowPage<S> {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn page_list<S>(ui: &mut Ui, selected: &mut usize, pages: &[WindowPage<S>], state: &mut S) {
|
||||
page_list_sized(ui, selected, pages, state, 16.0, vec2(Tokens::get().space_16, Tokens::get().space_16));
|
||||
page_list_sized(ui, selected, pages, state, 16.0, vec2(Tokens::get().space_16, Tokens::get().space_16), None, None);
|
||||
}
|
||||
|
||||
fn page_list_sized<S>(ui: &mut Ui, selected: &mut usize, pages: &[WindowPage<S>], state: &mut S, rows: f32, padding: egui::Vec2) {
|
||||
fn page_list_sized<S>(
|
||||
ui: &mut Ui,
|
||||
selected: &mut usize,
|
||||
pages: &[WindowPage<S>],
|
||||
state: &mut S,
|
||||
rows: f32,
|
||||
padding: egui::Vec2,
|
||||
header: Option<fn(&mut Ui, &mut S)>,
|
||||
on_select: Option<fn(&mut S)>,
|
||||
) {
|
||||
let t = Tokens::get();
|
||||
// The search strip, the gap under it, and the following item spacing sit above the page.
|
||||
// They must not eat the rows the page already had, or the graphics checkboxes fall out of view.
|
||||
let header_band = if header.is_some() { t.control_height + t.form_gap_y + t.space_8 } else { 0.0 };
|
||||
let height = (dimension(ui.ctx().content_rect().height()) - t.form_header_height - t.form_footer_height - t.space_24 * 3.0)
|
||||
.clamp(0.0, t.control_height * rows);
|
||||
.clamp(0.0, t.control_height * rows + header_band);
|
||||
let (body, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), height), egui::Sense::hover());
|
||||
let width = (t.form_label_width * 2.0 + t.space_24 * 2.0).min(body.width());
|
||||
let nav = egui::Rect::from_min_size(body.min, vec2(width, body.height()));
|
||||
@@ -944,6 +986,9 @@ fn page_list_sized<S>(ui: &mut Ui, selected: &mut usize, pages: &[WindowPage<S>]
|
||||
ui.allocate_exact_size(vec2(dimension(ui.available_width()), t.control_height + t.space_4), egui::Sense::click());
|
||||
if response.clicked() {
|
||||
*selected = index;
|
||||
if let Some(on_select) = on_select {
|
||||
on_select(state);
|
||||
}
|
||||
}
|
||||
let on = *selected == index;
|
||||
if on || response.hovered() || response.has_focus() {
|
||||
@@ -981,8 +1026,12 @@ fn page_list_sized<S>(ui: &mut Ui, selected: &mut usize, pages: &[WindowPage<S>]
|
||||
let clip = egui::Rect::from_min_max(content.min, egui::pos2(body.right(), content.bottom()));
|
||||
ui.set_clip_rect(ui.clip_rect().intersect(clip));
|
||||
ui.spacing_mut().item_spacing.y = t.form_gap_y;
|
||||
if let Some(header) = header {
|
||||
header(ui, state);
|
||||
}
|
||||
let force_scroll = header.is_some() && crate::prefs_search::force_scroll(ui.ctx());
|
||||
if let Some(page) = pages.get(*selected) {
|
||||
if page.scroll {
|
||||
if page.scroll || force_scroll {
|
||||
let scroll = super::settings_scroll(ui, ("sc_prefs_page", *selected), |ui| (page.draw)(ui, state));
|
||||
scenario::publish_rect("dialog:page_body", scroll.inner_rect);
|
||||
} else {
|
||||
|
||||
@@ -37,6 +37,9 @@ pub(crate) fn settings_scroll<R>(ui: &mut Ui, id: impl egui::AsIdSalt, add: impl
|
||||
}
|
||||
|
||||
pub(crate) fn settings_heading(ui: &mut Ui, title: &str, description: &str) {
|
||||
if crate::prefs_search::querying(ui) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = t.space_3;
|
||||
@@ -48,7 +51,34 @@ pub(crate) fn settings_heading(ui: &mut Ui, title: &str, description: &str) {
|
||||
}
|
||||
|
||||
/// Settings reuse the same disclosure, uppercase typography and rule as the design system.
|
||||
pub(crate) fn settings_section<R>(ui: &mut Ui, key: &str, title: &str, add: impl FnOnce(&mut Ui) -> R) -> Option<R> {
|
||||
pub(crate) fn settings_section<R>(ui: &mut Ui, key: &str, title: &str, mut add: impl FnMut(&mut Ui) -> R) -> Option<R> {
|
||||
if crate::prefs_search::querying(ui) {
|
||||
crate::prefs_search::begin_section(ui, title);
|
||||
if !ui.is_visible() {
|
||||
return Some(add(ui));
|
||||
}
|
||||
if crate::prefs_search::shows_all(ui) {
|
||||
let mut child = crate::prefs_search::detached_child(ui, ("prefs-search-section", key), false);
|
||||
let result = add(&mut child);
|
||||
crate::prefs_search::place_search_child(ui, &child);
|
||||
return Some(result);
|
||||
}
|
||||
let mark = crate::prefs_search::search_layout_mark(ui);
|
||||
let before = crate::prefs_search::hits(ui);
|
||||
let (gained, missed) = {
|
||||
let mut probe = crate::prefs_search::detached_child(ui, ("prefs-search-section-probe", key), true);
|
||||
let missed = add(&mut probe);
|
||||
(crate::prefs_search::hits(ui) > before, missed)
|
||||
};
|
||||
crate::prefs_search::restore_search_layout(ui, mark);
|
||||
if !gained {
|
||||
return Some(missed);
|
||||
}
|
||||
let mut child = crate::prefs_search::detached_child(ui, ("prefs-search-section", key), false);
|
||||
let result = add(&mut child);
|
||||
crate::prefs_search::place_search_child(ui, &child);
|
||||
return Some(result);
|
||||
}
|
||||
let t = Tokens::get();
|
||||
ui.add_space(t.space_8);
|
||||
super::section(ui, key, title, true, |ui| {
|
||||
@@ -77,6 +107,10 @@ impl From<Tokens> for ThemeSwatch {
|
||||
|
||||
/// Controls and one explanatory specimen side by side, stacked on narrow viewports.
|
||||
pub(crate) fn settings_pair<R>(ui: &mut Ui, controls: impl FnOnce(&mut Ui) -> R, preview: impl FnOnce(&mut Ui, R)) {
|
||||
if crate::prefs_search::suppress_chrome(ui) {
|
||||
let _ = controls(ui);
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let preview_width = t.form_card_width - t.space_8;
|
||||
let gap = t.space_16 + t.space_4;
|
||||
@@ -101,6 +135,14 @@ pub(crate) fn settings_pair<R>(ui: &mut Ui, controls: impl FnOnce(&mut Ui) -> R,
|
||||
/// A horizontally scrollable, draggable tile rail with its import action pinned outside it.
|
||||
/// The hidden scrollbar never reserves vertical space; a vertical wheel also moves the rail.
|
||||
pub(crate) fn theme_rail(ui: &mut Ui, tiles: impl FnOnce(&mut Ui), import: impl FnOnce(&mut Ui)) {
|
||||
if crate::prefs_search::suppress_chrome(ui) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = Tokens::get().space_8;
|
||||
tiles(ui);
|
||||
});
|
||||
import(ui);
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let action_width = t.form_label_width + t.space_24 + t.space_6;
|
||||
let rail_width = (dimension(ui.available_width()) - action_width - t.space_8).max(0.0);
|
||||
@@ -194,6 +236,9 @@ pub(super) fn theme_rail_arrow(ui: &mut Ui, rect: Rect, direction: f32, label: &
|
||||
|
||||
/// Dashed import tile matches the swatch tiles, while remaining permanently visible.
|
||||
pub(crate) fn theme_import_tile(ui: &mut Ui, label: &str, note: &str) -> Response {
|
||||
if crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, &[label, note]) {
|
||||
return crate::prefs_search::hidden_response(ui);
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let width = t.form_label_width + t.space_24 + t.space_6;
|
||||
let height = t.control_height + t.space_6 * 2.0 + t.space_4 + t.space_16 * 2.0;
|
||||
@@ -229,6 +274,9 @@ pub(crate) fn theme_import_tile(ui: &mut Ui, label: &str, note: &str) -> Respons
|
||||
|
||||
/// Compact informational state badge for settings; never an interactive control.
|
||||
pub(crate) fn settings_badge(ui: &mut Ui, text: &str, warning: bool) {
|
||||
if crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, &[text]) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let ink =
|
||||
if warning { if crate::theme::contrast_ratio(t.warning, t.dialog_body) >= 4.5 { t.warning } else { t.form_ink } } else { t.form_success };
|
||||
@@ -252,6 +300,15 @@ pub(crate) enum PreferenceFigure {
|
||||
/// Reusable illustrated radio card. Width comes from the figure, and wrapping is owned by
|
||||
/// the parent; labels remain accessible even when translations need an ellipsis.
|
||||
pub(crate) fn option_card(ui: &mut Ui, label: &str, note: Option<&str>, selected: bool, enabled: bool, figure: PreferenceFigure) -> Response {
|
||||
if crate::prefs_search::querying(ui) {
|
||||
let show = match note {
|
||||
Some(note) => crate::prefs_search::visible(ui, &[label, note]),
|
||||
None => crate::prefs_search::visible(ui, &[label]),
|
||||
};
|
||||
if !show {
|
||||
return crate::prefs_search::hidden_response(ui);
|
||||
}
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let theme = matches!(figure, PreferenceFigure::Theme { .. });
|
||||
let width = match figure {
|
||||
@@ -502,6 +559,9 @@ pub(crate) fn layout_preview(ui: &mut Ui, state: LayoutPreview) {
|
||||
}
|
||||
|
||||
pub(crate) fn tab_accent_strip(ui: &mut Ui) {
|
||||
if crate::prefs_search::suppress_chrome(ui) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
super::text(ui, crate::i18n::tr("prefs.tab_accent_note"), super::Text::Hint("tab_accent"));
|
||||
let names =
|
||||
@@ -584,6 +644,9 @@ pub(crate) fn settings_font_row(ui: &mut Ui, label: &str, mut add: impl FnMut(&m
|
||||
|
||||
/// Natural-width dim text, for inline suffixes and metadata without a full-width note row.
|
||||
pub(crate) fn settings_inline_note(ui: &mut Ui, text: &str) {
|
||||
if crate::prefs_search::suppress_chrome(ui) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let galley = ui.painter().layout_no_wrap(text.to_owned(), FontId::proportional(t.font_body), t.form_dim);
|
||||
let (r, response) = ui.allocate_exact_size(galley.size(), Sense::hover());
|
||||
@@ -593,6 +656,9 @@ pub(crate) fn settings_inline_note(ui: &mut Ui, text: &str) {
|
||||
|
||||
/// Framed font specimen, inheriting the chosen application font and its fallback glyphs.
|
||||
pub(crate) fn font_specimen(ui: &mut Ui, text: &str) {
|
||||
if crate::prefs_search::suppress_chrome(ui) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let (r, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), t.control_height + t.space_10), Sense::hover());
|
||||
t.container_rect(ui.painter(), r, t.control_radius, t.control_fill, t.dialog_rule);
|
||||
@@ -601,6 +667,9 @@ pub(crate) fn font_specimen(ui: &mut Ui, text: &str) {
|
||||
|
||||
/// Intrinsically sized segments, retaining the usual row label for accessibility and automation.
|
||||
pub(crate) fn settings_segments(ui: &mut Ui, label: &str, selected: &mut usize, options: &[&str]) -> bool {
|
||||
if crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, &[label]) && !crate::prefs_search::texts_hit(ui, options) {
|
||||
return false;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let widest = options.iter().map(|s| super::ContentWidth::name(ui, s)).fold(0.0, f32::max);
|
||||
let width = ((widest + t.space_16) * options.len() as f32).min(dimension(ui.available_width()));
|
||||
@@ -615,6 +684,9 @@ pub(crate) fn settings_segments(ui: &mut Ui, label: &str, selected: &mut usize,
|
||||
}
|
||||
|
||||
pub(crate) fn status_palette_sample(ui: &mut Ui, mode: ColorVision) {
|
||||
if crate::prefs_search::suppress_chrome(ui) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let colours = Tokens::themed(crate::theme::is_dark(), mode);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
|
||||
@@ -13,16 +13,35 @@ pub(crate) struct SettingsChoice<'a> {
|
||||
|
||||
/// Equal option cards keep the choices comparable without repeating long explanations.
|
||||
pub(crate) fn settings_choices(ui: &mut Ui, choices: &[SettingsChoice<'_>], selected: &mut usize) -> bool {
|
||||
if crate::prefs_search::querying(ui) && !crate::prefs_search::shows_all(ui) {
|
||||
let keep: Vec<usize> = choices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, choice)| crate::prefs_search::texts_hit(ui, &[choice.title, choice.summary]))
|
||||
.map(|(index, _)| index)
|
||||
.collect();
|
||||
if keep.is_empty() {
|
||||
return false;
|
||||
}
|
||||
crate::prefs_search::reveal(ui);
|
||||
return choice_row(ui, choices, selected, &keep);
|
||||
}
|
||||
let keep: Vec<usize> = (0..choices.len()).collect();
|
||||
choice_row(ui, choices, selected, &keep)
|
||||
}
|
||||
|
||||
fn choice_row(ui: &mut Ui, choices: &[SettingsChoice<'_>], selected: &mut usize, keep: &[usize]) -> bool {
|
||||
let t = Tokens::get();
|
||||
let count = choices.len().max(1);
|
||||
let count = keep.len().max(1);
|
||||
let width = ((dimension(ui.available_width()) - t.space_8 * count.saturating_sub(1) as f32) / count as f32).max(0.0);
|
||||
let mut changed = false;
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_8;
|
||||
for (index, choice) in choices.iter().enumerate() {
|
||||
let response = choice_card(ui, width, choice, *selected == index);
|
||||
for index in keep {
|
||||
let Some(choice) = choices.get(*index) else { continue };
|
||||
let response = choice_card(ui, width, choice, *selected == *index);
|
||||
if response.clicked() {
|
||||
*selected = index;
|
||||
*selected = *index;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,17 @@ use egui::{FontId, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
/// One mouse silhouette beside the actual mapping selector and binding legend.
|
||||
/// The figure is descriptive content, not an icon asset or an application viewport.
|
||||
pub(crate) fn settings_mouse_mapping(ui: &mut Ui, bindings: &[(Vec<String>, String)], mapping: impl FnOnce(&mut Ui)) {
|
||||
if crate::prefs_search::suppress_chrome(ui) {
|
||||
mapping(ui);
|
||||
for (keys, action) in bindings.iter().take(10) {
|
||||
let chord = keys.join(" ");
|
||||
if crate::prefs_search::texts_hit(ui, &[action, &chord]) {
|
||||
crate::prefs_search::reveal(ui);
|
||||
settings_gesture(ui, keys, action);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = t.space_24 - t.space_2;
|
||||
@@ -88,6 +99,9 @@ fn axis_columns(rect: Rect) -> [Rect; 5] {
|
||||
}
|
||||
|
||||
pub(crate) fn settings_axis_header(ui: &mut Ui) {
|
||||
if crate::prefs_search::suppress_chrome(ui) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), t.space_24), Sense::hover());
|
||||
for (column, label) in axis_columns(rect).into_iter().zip(["prefs.axis", "prefs.live_input", "", "prefs.speed", "prefs.source"]) {
|
||||
@@ -105,6 +119,17 @@ pub(crate) fn settings_axis_row(
|
||||
speed: impl FnOnce(&mut Ui),
|
||||
source: impl FnOnce(&mut Ui),
|
||||
) {
|
||||
if crate::prefs_search::querying(ui) && crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, &[name]) {
|
||||
crate::prefs_search::run_hidden(ui, name, |ui| {
|
||||
invert(ui);
|
||||
speed(ui);
|
||||
source(ui);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if crate::prefs_search::querying(ui) {
|
||||
crate::prefs_search::reveal(ui);
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), t.control_height + t.space_4), Sense::hover());
|
||||
t.secondary_hline(ui.painter(), rect.x_range(), rect.bottom(), t.dialog_rule);
|
||||
@@ -163,6 +188,9 @@ pub(crate) fn live_axis_bar_in(ui: &Ui, live_rect: Rect, value: f32, dead_zone:
|
||||
|
||||
/// Compact per-axis gain uses a consistent two-decimal specimen and the real accepted range.
|
||||
pub(crate) fn settings_axis_gain(ui: &mut Ui, gain: &mut f32) -> egui::Response {
|
||||
if crate::prefs_search::querying(ui) && (!ui.is_visible() || crate::prefs_search::suppress_chrome(ui)) {
|
||||
return crate::prefs_search::hidden_response(ui);
|
||||
}
|
||||
let response =
|
||||
ui.add(egui::DragValue::new(gain).range(0.0..=3.0).speed(0.05).suffix("×").fixed_decimals(2).custom_parser(crate::prefs::parse_number));
|
||||
response.widget_info(|| {
|
||||
|
||||
@@ -13,6 +13,21 @@ pub(crate) fn settings_block_hint<R>(ui: &mut Ui, label: &str, hint: &str, label
|
||||
}
|
||||
|
||||
fn settings_block_content<R>(ui: &mut Ui, label: &str, hint: Option<&str>, label_width: f32, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
if let Some(gate) = crate::prefs_search::block_gate(ui, label, hint) {
|
||||
let before = crate::prefs_search::hits(ui);
|
||||
if !gate.show {
|
||||
let result = crate::prefs_search::run_hidden(ui, label, add);
|
||||
crate::prefs_search::block_finish(ui, &gate, false, before);
|
||||
return result;
|
||||
}
|
||||
let result = settings_block_body(ui, label, hint, label_width, add);
|
||||
crate::prefs_search::block_finish(ui, &gate, true, before);
|
||||
return result;
|
||||
}
|
||||
settings_block_body(ui, label, hint, label_width, add)
|
||||
}
|
||||
|
||||
fn settings_block_body<R>(ui: &mut Ui, label: &str, hint: Option<&str>, label_width: f32, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
let t = Tokens::get();
|
||||
let width = super::metrics::dimension(ui.available_width());
|
||||
let label_width = label_width.min(width * 0.45);
|
||||
@@ -93,6 +108,9 @@ pub(crate) fn settings_autosave_timeline(ui: &mut Ui, minutes: f64) {
|
||||
|
||||
/// A keyboard binding table: keycaps are separate from actions and never imply fixed bindings.
|
||||
pub(crate) fn settings_bindings(ui: &mut Ui, bindings: &[(&[&str], &str)]) {
|
||||
if crate::prefs_search::querying(ui) && !crate::prefs_search::shows_all(ui) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
for (keys, action) in bindings {
|
||||
let width = super::metrics::dimension(ui.available_width());
|
||||
|
||||
@@ -69,6 +69,13 @@ pub(crate) fn path_chip(ui: &mut Ui, path: &str, add: impl FnOnce(&mut Ui)) {
|
||||
|
||||
/// Empty-list dashed outline and centred caption. Retains the note rectangle.
|
||||
pub(crate) fn empty_box(ui: &mut Ui, key: &str, message: &str) {
|
||||
if crate::prefs_search::querying(ui) {
|
||||
if crate::prefs_search::texts_hit(ui, &[message]) {
|
||||
crate::prefs_search::reveal(ui);
|
||||
} else if !ui.is_visible() || crate::prefs_search::suppress_chrome(ui) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width().max(0.0), t.control_height * 3.0), egui::Sense::hover());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), message));
|
||||
|
||||
@@ -72,7 +72,10 @@ fn search_with_icon(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) -
|
||||
);
|
||||
let icon = Rect::from_center_size(pos2(r.left() + t.space_8 + t.space_16 * 0.5, r.center().y), vec2(t.space_16, t.space_16));
|
||||
icons::paint_ink(ui.painter(), icon, "zoom", t.form_dim);
|
||||
let editor = Rect::from_min_max(pos2((icon.right() + t.space_6).min(r.right()), r.top()), r.right_bottom());
|
||||
let show_clear = !value.is_empty();
|
||||
let clear_side = if show_clear { t.control_height } else { 0.0 };
|
||||
let editor =
|
||||
Rect::from_min_max(pos2((icon.right() + t.space_6).min(r.right()), r.top()), pos2((r.right() - clear_side).max(icon.right()), r.bottom()));
|
||||
let response = ui
|
||||
.scope_builder(egui::UiBuilder::new().max_rect(editor), |ui| {
|
||||
ui.set_clip_rect(ui.clip_rect().intersect(editor));
|
||||
@@ -89,7 +92,19 @@ fn search_with_icon(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) -
|
||||
)
|
||||
})
|
||||
.inner;
|
||||
if frame.clicked() {
|
||||
let mut cleared = false;
|
||||
if show_clear {
|
||||
let clear_rect = Rect::from_center_size(pos2(r.right() - t.control_height * 0.5, r.center().y), vec2(t.control_height, t.control_height));
|
||||
let clear = ui.push_id(id.with("clear"), |ui| super::quiet_icon_button_at(ui, clear_rect, "close", "Clear")).inner;
|
||||
if id == egui::Id::new("prefs_search") {
|
||||
crate::scenario::publish_handle("prefs:search:clear", clear_rect.center());
|
||||
}
|
||||
if clear.clicked() {
|
||||
value.clear();
|
||||
cleared = true;
|
||||
}
|
||||
}
|
||||
if frame.clicked() && !cleared {
|
||||
response.request_focus();
|
||||
}
|
||||
super::layout::publish(ui, &response, egui::WidgetType::TextEdit);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user