Compare commits

...

17 Commits

Author SHA1 Message Date
jyviko b1f89a3c1d Docs: the architecture table lists the shared crates and the MCP split (#12947)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:27:54 -05:00
jyviko 3b73d1a820 MCP: the shared server tests what it owns, on its own (#12947)
Batches (mixed, notifications only, the 1000 limit), an unasked response,
the protocol version fallback, null arguments, enum mismatches, the empty
default lists and the list-then-call order Tools::call relies on are tested
in solidcraft-mcp-server with a fake Tools; the protocol-only lines move out
of SolidCraft's wrapper tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:27:54 -05:00
jyviko 9060ee20f2 MCP: the server machinery is a shared crate, solidcraft-mcp-server (#12947)
JSON-RPC over stdio, the MCP lifecycle, tool and resource methods, tool
definitions with their annotations, the argument check and the text and
image results move to crates/mcp-server, which has no engine or GPU
dependency and is classed Shared. SolidCraft's tools, instructions,
resources and the agent's folders stay in crates/mcp and register into it
through the Tools trait; its public API, tools list and replies are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 02:43:13 -05:00
jyviko bcd63adb98 Shared UI crates: ui-text, the message catalogs' machinery as a shared crate (#13290)
bidi, Catalog, Locale, lookup, formatting, labels and language choice move from ui-egui's
i18n into solidcraft-ui-text, a Shared crate any front end can use. The host installs its
catalogs once; with none installed every id reads as itself. SolidCraft's 35 catalogs, the
build script that merges them, and the adapters that read engine types (diagnostic,
tr_engine_error, and the given-name and browser-label vocabulary as the LocaleNames trait)
stay in the app's i18n. The app installs its catalogs when it starts, when an app is built
and in the widget test context. Call sites use the crate's path, with no re-export at the
old one; messages, ids and fallbacks are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 00:38:43 -05:00
jyviko a9d6c7d810 Material editor: the Remove buttons are translated (material.editor.remove)
The editor's two Remove buttons called tr("Remove"), which looks up an id, so they read
"Remove" in every language. They use a new id, material.editor.remove, whose text in each
locale is the one that locale already gives the plain Remove button
prefs.network_host_remove (33 of 35 catalogs have it; the other two show English, as now).
The label-literal guard found it once the file's import named tr directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 00:38:43 -05:00
jyviko ec36c0b04d Colour: the sRGB rule and the analysis palette are a shared crate, solidcraft-color (#13291)
geom's srgb module (the one sRGB transfer function every encode and decode goes through)
moves whole into solidcraft-color with its tests, and so does AnalysisColors, the analysis
palette the theme fills and the renderer reads. render, doc, io, engine, gpu and ui-egui use
the crate; there is no re-export at the old paths. The theme's unused material_color wrapper
is removed, so no non-test theme code names solidcraft_engine. Values are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 00:02:22 -05:00
jyviko e2664ea368 UI kit: length fields take unit scaling and length parsing from the host (#13270)
The kit's length field converted millimetres to and from the design's units through three
prefs helpers. Those are the host's unit rules, so Expressions gains unit_scale,
length_number and parse_length_mm, and length_drag reads them from the context.
DocExpressions implements them with the same prefs helpers, so the app is unchanged. With
nothing installed the field edits plain millimetres. No kit file calls prefs outside
tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-07 23:52:35 -05:00
jyviko fe45f81f56 UI kit: continuity rows leave the kit, the navigator figure takes its axes as input (#13261)
continuity_row and its explained and tip variants are CAD controls over the kernel's
Continuity: they move beside their callers in dialogs_surface, still drawn with the kit's
choice_row. The Preferences navigator figure takes its axes (screen direction and depth)
as NavigatorAxis input; SolidCraft computes them from its default camera. dashed_line is
painter geometry and moves into ui_kit::dash with its test. No kit file uses the engine,
the record or the viewport outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-07 23:46:44 -05:00
jyviko 6e8165674e UI kit: layout mode and card geometry in the kit, input shield set by the host (#13260)
LayoutMode (with its per-context publish and current), the card gap and the panic-free
clamp move into the kit's containers and panels, and Cards into stages. Cards::new takes
the workspace width instead of reading the ribbon's constant. The flag that says a report
covers the 3D view is now the kit's (input_blocked, set_input_blocked); the app's CadInput
guard sets it, and issue_report's own copy is gone. Serde names are unchanged, so saved
preferences load as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-07 23:46:44 -05:00
jyviko e3caced67b UI kit: navigation-bar values and the horizontal wheel in the kit (#13259)
The bar's edge, size, idle and button-kind values move beside the bar's drawing in
ui_kit::navigation as NavigationEdge, NavigationSize, NavigationIdle and NavigationKind (the
kit already has a container Edge). Serde names are unchanged, so saved preferences load as
before. take_horizontal_wheel moves into the theme picker, its only caller. SolidCraft's
slot table stays in navigation_bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-07 23:46:44 -05:00
jyviko d996441241 UI kit: number parsing and recent colours in the kit, expression support passed in by the host (#13258)
parse_number (decimal point or comma, Unicode minus) moves from prefs into the kit's
fields, and recent_colors moves whole into ui_kit::recent_colors beside the colour picker.
The kit no longer knows the expression language: a host installs an Expressions (is this
a literal, is this a unit, complete or step this text) per egui context, and fields read
it for label drags and completion. SolidCraft installs DocExpressions, built on doc::expr
and params_dialog::complete, when the app first draws into a context. Behaviour in the app
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-07 23:46:44 -05:00
jyviko 8a40998c2a UI kit: the kit owns the menu entry and the list width (#13257)
Item (a command or ui.* action with its label, shortcut, icon, params, children, swatch
and tip) and LIST_W move from context_menu into ui_kit::menu, with the same serde form, so
the control channel's menu report is byte-identical. context_menu keeps building
SolidCraft's entries (cmd, and act over Item::action) and returns kit Items. No kit file
refers to context_menu any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-07 23:46:44 -05:00
jyviko 924585be0a UI kit: Preferences search is a kit module, settings_search (#13256)
prefs_search moves whole into ui_kit::settings_search: it filters any settings page the
kit draws, and SolidCraft's Preferences window is one use of it. It publishes its handles
into the widget registry and reads the same wall clock as before (web_time), so no kit
file calls the app for search any more. Every caller uses the new path; state ids, handle
names, matches and scores are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-07 23:26:23 -05:00
jyviko b2c0fd71d0 Shared UI crates, tranche 1: merge #12936 and #12938 for the batch 2026-11-07 22:46:18 -05:00
jyviko 2605261665 Theme: the tab accent takes the tab, the sketch and the tool as data (#12938)
TabAccent::publish takes the open tab, whether a sketch is being edited and whether a tool
or dialog is active, instead of SolveApp; SolveApp::publish_tab_accent works the three out
in one place. scoped_panel_tokens is public, so a host can draw any component in another
palette or accent. The theme and its tests no longer name SolveApp, Services or workspace.
Accents are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-07 22:46:17 -05:00
jyviko 397bbf38c7 UI kit: the widget registry is a shared crate, ui-registry (#12936)
The handle, rect and count registry that UI tests, scenarios and the control channel read
moves out of scenario into solidcraft-ui-registry, the first Shared crate. The kit publishes
into it directly; scenario re-exports it, so scenarios, scripts and the rest of ui-egui are
unchanged and every handle keeps its name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-07 22:46:16 -05:00
jyviko 4db733ec68 Layers: a Shared crate class, and cargo xtask shared builds each one on its own (#12935)
A Shared crate depends only on other Shared crates, solidcore and external crates (UI
toolkits included); any layered or testkit crate may use one. The layer check reports a
Shared crate's dependency on an app crate, of any kind. cargo xtask shared checks each
Shared crate's manifest (workspace lints, licence, README) and that no include_str! or
include_bytes! reaches outside it, then builds and tests it with its Shared dependencies in
a scratch workspace of its own. cargo xtask ci runs it after the layer check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-07 22:46:16 -05:00
323 changed files with 6210 additions and 4147 deletions
Generated
+39 -2
View File
@@ -4097,6 +4097,7 @@ dependencies = [
"solidcraft-gpu",
"solidcraft-space-mouse",
"solidcraft-ui-egui",
"solidcraft-ui-text",
"ureq",
"windows-sys 0.61.2",
"winit",
@@ -4123,6 +4124,10 @@ dependencies = [
"solidcraft-mcp",
]
[[package]]
name = "solidcraft-color"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-derive"
version = "0.40.0-rc.3"
@@ -4141,6 +4146,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-derive",
"solidcraft-geom",
"solidcraft-kernel",
@@ -4173,6 +4179,7 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"solidcore-record",
"solidcraft-color",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-io",
@@ -4207,6 +4214,7 @@ dependencies = [
"serde",
"serde_json",
"solidcore-record",
"solidcraft-color",
"solidcraft-engine",
"solidcraft-geom",
"solidcraft-io",
@@ -4234,6 +4242,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-kernel",
@@ -4270,6 +4279,14 @@ dependencies = [
"serde_json",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-mcp-server",
]
[[package]]
name = "solidcraft-mcp-server"
version = "0.40.0-rc.3"
dependencies = [
"serde_json",
]
[[package]]
@@ -4293,6 +4310,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-geom",
"web-time",
"weezl",
@@ -4363,20 +4381,38 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-space-mouse",
"solidcraft-ui-registry",
"solidcraft-ui-text",
"syn 2.0.119",
"sys-locale",
"tiny-skia",
"toml",
"ttf-parser",
"unicode-bidi",
"web-time",
"winreg",
"zip",
]
[[package]]
name = "solidcraft-ui-registry"
version = "0.40.0-rc.3"
dependencies = [
"egui",
]
[[package]]
name = "solidcraft-ui-text"
version = "0.40.0-rc.3"
dependencies = [
"log",
"serde_json",
"sys-locale",
"unicode-bidi",
]
[[package]]
name = "solidcraft-web"
version = "0.40.0-rc.3"
@@ -4389,6 +4425,7 @@ dependencies = [
"serde_json",
"solidcraft-engine",
"solidcraft-ui-egui",
"solidcraft-ui-text",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
+4
View File
@@ -28,8 +28,12 @@ solidcraft-doc = { path = "crates/doc" }
solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
solidcraft-ui-registry = { path = "crates/ui-registry" }
solidcraft-color = { path = "crates/color" }
solidcraft-ui-text = { path = "crates/ui-text" }
solidcraft-space-mouse = { path = "crates/space-mouse" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-mcp-server = { path = "crates/mcp-server" }
solidcraft-derive = { path = "crates/derive" }
solidcraft-3dx-client = { path = "crates/threedx-client" }
# The Rust port of NVIDIA's SMLib (Apache-2.0), the B-rep kernel behind crates/smlib. Pinned;
+1
View File
@@ -23,6 +23,7 @@ profile = ["solidcraft-engine/profile"]
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-ui-egui = { workspace = true }
solidcraft-ui-text = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
+4 -4
View File
@@ -48,15 +48,15 @@ impl Download {
}
if solidcraft_ui_egui::documents::any_dirty(app) {
let mut answer = None;
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_egui::i18n::tr("web.open_link"))
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_text::tr("web.open_link"))
.required()
.show_body(ctx, |ui| {
ui.label(solidcraft_ui_egui::i18n::tr("web.open_link_confirm"));
ui.label(solidcraft_ui_text::tr("web.open_link_confirm"));
ui.horizontal(|ui| {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.open"), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.open"), ui_kit::Button::Normal).clicked() {
answer = Some(true);
}
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
answer = Some(false);
}
});
+9 -8
View File
@@ -810,7 +810,7 @@ mod web {
files::pick_from_disk(&self.files, Intent::Open);
}
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_drawings_hint"))
.clicked()
{
files::pick_from_disk(&self.files, Intent::Insert);
@@ -864,7 +864,7 @@ mod web {
let design = solidcraft_engine::io::is_design_path(&e.name);
if !design
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_stored_drawings_hint"))
.clicked()
{
act = Some(("insert", e.name.clone()));
@@ -1016,7 +1016,7 @@ mod web {
/// Whether the shell already has the on-demand face the live language needs.
fn script_font_ready(&self) -> bool {
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language)) {
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_text::effective_language(&self.app.preferences.language)) {
Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"),
Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"),
Some("ja") => self.cjk_bytes.is_some(),
@@ -1058,7 +1058,7 @@ mod web {
false
}
};
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) {
log::warn!("script font unavailable; showing English for this session");
}
@@ -1067,13 +1067,13 @@ mod web {
ctx.request_repaint();
}
let ready = self.script_font_ready();
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if self.font.tick(&mut self.app.preferences.language, ready) {
self.fetch_font(ctx, solidcraft_ui_egui::fonts::web_script_font_kind(effective).unwrap_or("ja"));
}
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
// to Ukrainian (or English) must put the Latin face back in front.
if solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language) != self.font_language {
if solidcraft_ui_text::effective_language(&self.app.preferences.language) != self.font_language {
self.apply_fonts(ctx);
}
}
@@ -1081,7 +1081,7 @@ mod web {
/// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks
/// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
fn apply_fonts(&mut self, ctx: &egui::Context) {
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() {
solidcraft_ui_egui::fonts::setup_fonts(ctx);
} else {
@@ -1149,7 +1149,7 @@ mod web {
UNSAVED.with(|u| u.set(self.asks_before_unload()));
if self.launch.embed {
ui_kit::Container::new(ui.ctx(), ui_kit::Surface::Chrome).edge("embed_header", ui_kit::Edge::Top).show(ui, |ui| {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("web.open_full"), ui_kit::Button::Normal).clicked()
if ui_kit::button(ui, solidcraft_ui_text::tr("web.open_full"), ui_kit::Button::Normal).clicked()
&& let (Some(window), Some(url)) = (web_sys::window(), &self.full_url)
{
let _ = window.open_with_url_and_target_and_features(url, "_blank", "noopener");
@@ -1225,6 +1225,7 @@ mod web {
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
solidcraft_ui_egui::i18n::install();
wasm_bindgen_futures::spawn_local(async {
let Some(window) = web_sys::window() else { return };
let launch = crate::Launch::parse(&query());
+1
View File
@@ -34,6 +34,7 @@ solidcraft-engine = { workspace = true }
# The D3D12 shader compiler the window starts with (#6756).
solidcraft-gpu = { workspace = true }
solidcraft-ui-egui = { workspace = true }
solidcraft-ui-text = { workspace = true }
solidcraft-space-mouse = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true }
+2 -1
View File
@@ -518,7 +518,7 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
// The groups come from the format registry, so a new format joins this dialog by itself.
let mut everything = io::formats::open_extensions(true);
everything.extend(["dxf", "svg"]);
dialog = dialog.add_filter(solidcraft_ui_egui::i18n::tr("file.open.drawing_filter"), &case_variants(&everything));
dialog = dialog.add_filter(solidcraft_ui_text::tr("file.open.drawing_filter"), &case_variants(&everything));
for (label, exts) in io::formats::import_filters(true) {
dialog = dialog.add_filter(label, &case_variants(&exts));
}
@@ -945,6 +945,7 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
mark("main");
solidcraft_ui_egui::i18n::install();
// On OCLP-patched Macs, wgpu picks a Metal Shading Language standard that the system's
// compiler refuses (`invalid value 'metal3.2'`), because the patched OS reports a newer
// macOS version than the GPU's Metal compiler supports. Re-launching with
@@ -0,0 +1,101 @@
{
"ar": {
"material.editor.remove": "إزالة"
},
"bg": {
"material.editor.remove": "Remove"
},
"bs": {
"material.editor.remove": "Ukloni"
},
"ca": {
"material.editor.remove": "Eliminar"
},
"cs": {
"material.editor.remove": "Odebrat"
},
"da": {
"material.editor.remove": "Fjern"
},
"de": {
"material.editor.remove": "Entfernen"
},
"en": {
"material.editor.remove": "Remove"
},
"es": {
"material.editor.remove": "Eliminar"
},
"fi": {
"material.editor.remove": "Poista"
},
"fr": {
"material.editor.remove": "Retirer"
},
"gl": {
"material.editor.remove": "Quitar"
},
"he": {
"material.editor.remove": "הסרה"
},
"hr": {
"material.editor.remove": "Ukloni"
},
"hu": {
"material.editor.remove": "Eltávolítás"
},
"id": {
"material.editor.remove": "Hapus"
},
"it": {
"material.editor.remove": "Rimuovi"
},
"ja": {
"material.editor.remove": "削除"
},
"ka": {
"material.editor.remove": "წაშლა"
},
"ms": {
"material.editor.remove": "Buang"
},
"nb": {
"material.editor.remove": "Fjern"
},
"nl": {
"material.editor.remove": "Verwijderen"
},
"pl": {
"material.editor.remove": "Usuń"
},
"pt-BR": {
"material.editor.remove": "Remover"
},
"ro": {
"material.editor.remove": "Elimină"
},
"ru": {
"material.editor.remove": "Убрать"
},
"sr": {
"material.editor.remove": "Уклони"
},
"sv": {
"material.editor.remove": "Ta bort"
},
"tr": {
"material.editor.remove": "Kaldır"
},
"uk": {
"material.editor.remove": "Вилучити"
},
"vi": {
"material.editor.remove": "Xóa"
},
"zh-TW": {
"material.editor.remove": "刪除"
},
"zh": {
"material.editor.remove": "删除"
}
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "solidcraft-color"
description = "Colour rules and data any crate may share: the sRGB transfer function and the surface analysis palette"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
+20
View File
@@ -0,0 +1,20 @@
# solidcraft-color
Colour rules and data that any crate may share. It has no dependencies and holds no app types,
so the renderer, the document, the importers and the UI theme all use the same rule and the same
colours instead of keeping copies.
The sRGB transfer function (IEC 61966-2-1) lives here once: every encode and decode in Rust goes
through `srgb`, so a change to the rule is one change. Shaders can't call Rust and keep their own
copies.
## API
| Item | What it is |
|---|---|
| `srgb::encode(linear)` | Linear light (0..1) to an sRGB value (0..1) |
| `srgb::decode(encoded)` | An sRGB value (0..1) to linear light |
| `srgb::encode8(linear)` | Linear light to the nearest 8-bit sRGB code (out of range clamps, NaN reads as 0) |
| `srgb::decode8(code)` | An 8-bit sRGB code to linear light |
| `AnalysisColors` | The surface analysis palette (draft, accessibility, curvature, hatch), straight sRGB 0..1 as the shader takes it; the theme fills it and the renderer reads it |
| `AnalysisColors::DEFAULT` | The light theme's colours with no colour vision mode, as headless pictures use |
+32
View File
@@ -0,0 +1,32 @@
//! The colours a surface analysis is shaded in.
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnalysisColors {
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
pub draft: [[f32; 3]; 3],
/// Accessibility Analysis: reachable, blocked.
pub access: [[f32; 3]; 2],
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius. Dark blue
/// to light yellow in one straight blend, so the lightness rises the whole way and the scale
/// reads with hue lost too (#975).
pub curvature: [[f32; 3]; 3],
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
pub hatch: bool,
}
impl AnalysisColors {
/// The light theme's colours (no colour vision mode): what headless pictures use.
pub const DEFAULT: AnalysisColors = AnalysisColors {
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
curvature: [[0.08, 0.12, 0.45], [0.53, 0.51, 0.425], [0.98, 0.90, 0.40]],
hatch: false,
};
}
impl Default for AnalysisColors {
fn default() -> Self {
AnalysisColors::DEFAULT
}
}
+8
View File
@@ -0,0 +1,8 @@
//! Colour rules and data shared by every crate: the sRGB transfer function ([`srgb`]) and the surface analysis palette
//! ([`AnalysisColors`]). No app types and no dependencies, so the renderer, the document and the theme all read the same
//! rule and the same colours.
mod analysis;
pub mod srgb;
pub use analysis::AnalysisColors;
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
solidcore-record = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
solidcraft-kernel = { workspace = true }
+1 -1
View File
@@ -154,7 +154,7 @@ impl VisualPreset {
match self.recipe {
Recipe::Opaque(r) => r.color,
// A UI swatch of the transmission values, not rendered glass or absorption.
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_geom::srgb::encode8),
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_color::srgb::encode8),
}
}
+1
View File
@@ -36,6 +36,7 @@ required-features = ["solver-profiling"]
[dependencies]
solidcore-record = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
solidcraft-kernel = { workspace = true }
+1 -1
View File
@@ -1721,7 +1721,7 @@ fn cpu_saved_thin_sheet_uses_its_authored_ior_beer_thickness_and_roundtrips_offl
let a = (-sigma * 2.0).exp();
let denominator = 1.0 - f * f * a * a;
let scene = f + (1.0 - f).powi(2) * (f * a * a + a) / denominator;
solidcraft_geom::srgb::encode8(scene / (1.0 + scene))
solidcraft_color::srgb::encode8(scene / (1.0 + scene))
});
assert_eq!(decoded.get_pixel(0, 0).0, [rgb[0], rgb[1], rgb[2], 255]);
assert!(Arc::ptr_eq(&before, &s.doc));
+1 -1
View File
@@ -401,7 +401,7 @@ pub(super) fn apply_appearance(s: &mut Session, params: &Value) -> Result<Value>
let value = item("surfaces", &input.id, command)?;
let lo = triple(&value["base_linear_low"], command)?;
let hi = triple(&value["base_linear_high"], command)?;
let color = std::array::from_fn::<_, 3, _>(|i| crate::geom::srgb::encode8((lo[i] + hi[i]) * 0.5));
let color = std::array::from_fn::<_, 3, _>(|i| solidcraft_color::srgb::encode8((lo[i] + hi[i]) * 0.5));
(
value,
LibraryLook {
+2 -1
View File
@@ -6,8 +6,9 @@ use solidcraft_geom::prelude::*;
use std::sync::Arc;
use solidcore_record::{Plane, Vec3};
use solidcraft_color::AnalysisColors;
use solidcraft_geom::Aabb3;
use solidcraft_render::{AnalysisColors, Camera, Rgb, Scene, SceneAnalysis, SceneLine, SceneMesh};
use solidcraft_render::{Camera, Rgb, Scene, SceneAnalysis, SceneLine, SceneMesh};
use solidcraft_sketch::Sketch;
use crate::{Result, Sel, Session};
+2 -2
View File
@@ -222,7 +222,7 @@ const MARK: Palette = Palette {
cap: Rgb(55, 56, 57),
hatch: [Rgb(61, 62, 63), Rgb(64, 65, 66)],
isocurve: Rgb(58, 59, 60),
analysis: solidcraft_render::AnalysisColors {
analysis: solidcraft_color::AnalysisColors {
draft: [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]],
access: [[0.11, 0.12, 0.13], [0.14, 0.15, 0.16]],
curvature: [[0.17, 0.18, 0.19], [0.21, 0.22, 0.23], [0.24, 0.25, 0.26]],
@@ -253,7 +253,7 @@ fn the_default_palette_is_the_colour_table() {
(colors::SKETCH_PROJECTED, colors::SKETCH_LOST, colors::SKETCH_CONSTRUCTION, colors::SKETCH_CENTERLINE)
);
assert_eq!((p.axes, p.cap, p.isocurve), ([colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z], colors::SECTION_CAP, colors::ISOCURVE));
assert_eq!(p.analysis, solidcraft_render::AnalysisColors::DEFAULT);
assert_eq!(p.analysis, solidcraft_color::AnalysisColors::DEFAULT);
}
#[test]
-1
View File
@@ -40,7 +40,6 @@ mod profile;
pub mod section;
pub mod simplify;
pub mod spline_space;
pub mod srgb;
pub mod stderr;
pub mod tess;
pub mod title_block;
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
solidcore-record = { workspace = true }
robust = "1.2"
solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-render = { workspace = true }
wgpu = { workspace = true }
+1 -1
View File
@@ -4527,7 +4527,7 @@ fn uniform_bytes_placing(cb: &Frame, w: f32, h: f32, linear: bool, reversed: boo
/// The shader's analysis colours (`ana_c`: draft positive, between, negative; reachable,
/// blocked; curvature flat, middle, tight; alpha 1) and flags (`ana_f.x`: 1 to stripe the bad
/// faces).
pub fn analysis_colors(c: &solidcraft_render::AnalysisColors) -> [f32; 36] {
pub fn analysis_colors(c: &solidcraft_color::AnalysisColors) -> [f32; 36] {
let mut v = [0.0; 36];
let colors = c.draft.iter().chain(&c.access).chain(&c.curvature).flat_map(|rgb| [rgb[0], rgb[1], rgb[2], 1.0]);
for (slot, x) in v.iter_mut().zip(colors) {
+2 -2
View File
@@ -301,7 +301,7 @@ fn pictures_show_the_surface_analysis() {
s.execute("inspect.curvature_map", &json!({})).unwrap();
let curvature = picture(&s);
let near = |p: [u8; 4], c: [u8; 3]| (0..3).all(|k| (i32::from(p[k]) - i32::from(c[k])).abs() < 60);
let flat = solidcraft_engine::render::AnalysisColors::DEFAULT.curvature[0].map(|v| (v * 255.0).round() as u8);
let flat = solidcraft_color::AnalysisColors::DEFAULT.curvature[0].map(|v| (v * 255.0).round() as u8);
let flats = |img: &[u8]| pixels(img).filter(|p| near(*p, flat)).count();
// The flat colour is dark, so the plain picture's edge lines come near it in places.
assert!(
@@ -317,7 +317,7 @@ fn pictures_show_the_surface_analysis() {
assert!(black > 500 && white > 500, "zebra: {black} black, {white} white pixels");
s.execute("inspect.accessibility", &json!({})).unwrap();
let access = picture(&s);
let [ok, blocked] = colors_of(solidcraft_engine::render::AnalysisColors::DEFAULT.access);
let [ok, blocked] = colors_of(solidcraft_color::AnalysisColors::DEFAULT.access);
let (reachable, under) = (pixels(&access).filter(|p| near(*p, ok)).count(), pixels(&access).filter(|p| near(*p, blocked)).count());
assert!(reachable > 1000 && under > 1000, "accessibility: {reachable} reachable, {under} blocked pixels");
}
+1
View File
@@ -14,6 +14,7 @@ trust-unknown-source = []
[dependencies]
solidcore-record = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-kernel = { workspace = true }
solidcraft-doc = { workspace = true }
+1 -1
View File
@@ -842,7 +842,7 @@ impl<'d> Reader<'d, '_, '_> {
/// A linear base colour channel as an 8-bit sRGB code; one that isn't a number reads as 0.8.
fn srgb(c: f32) -> u8 {
solidcraft_geom::srgb::encode8(if c.is_finite() { f64::from(c) } else { 0.8 })
solidcraft_color::srgb::encode8(if c.is_finite() { f64::from(c) } else { 0.8 })
}
/// A material's look: base colour, alpha when blended, metallic/roughness when opaque.
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "solidcraft-mcp-server"
description = "MCP server machinery any app may share: JSON-RPC 2.0 over stdio, the MCP lifecycle, tool and resource methods, tool schemas and argument checks, image results"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
serde_json = { workspace = true }
+31
View File
@@ -0,0 +1,31 @@
# solidcraft-mcp-server
The machinery of a Model Context Protocol server: JSON-RPC 2.0 over stdio (one message per
line), the MCP lifecycle (`initialize`, `ping`, `notifications/initialized`), the tools and
resources methods, tool definitions with their annotations, the check of a call's arguments
against the schema it was listed with, and the content blocks a tool replies with (text, images).
It holds no app types and depends on no engine or renderer, so any app can serve its own tools
through it.
An app implements `Tools`: its instructions, its tool definitions as they are listed now, how a
tool runs, and its resources. `Server` speaks the protocol around them. A `tools/call` lists the
tools, checks the arguments against that listing (an unknown tool, a missing or unknown argument,
a type or enum mismatch is a JSON-RPC invalid-params error and the tool never runs), then calls
the tool. Hostile input is answered, never fatal: a line longer than `Info::max_line` is refused
in bounded space, bad UTF-8 and bad JSON are parse errors, and serving goes on.
## API
| Item | What it does |
|---|---|
| `Tools` | An app's tools: `instructions`, `list`, `call`, and optionally `resources`, `read_resource` |
| `Server::new(info, tools)` | A server over `tools`; `serve(input, output)` until input closes, `handle_line`, `handle`, `tools()`, `is_initialized()` |
| `Info` | `name`, `title`, `version` for `serverInfo`, and `max_line`, the longest request line read |
| `PROTOCOL_VERSION`, `SUPPORTED_VERSIONS` | The protocol revisions spoken; the newest is offered for one not known |
| `tool(name, title, desc, schema, effect)` | A tool definition with annotations from its `Effect` |
| `Effect` | `ReadOnly`, `Edits`, `Replaces`, `Files`: the approval hints a client is given |
| `object(props, required)`, `string(desc)` | Input-schema pieces: a closed object, a described string |
| `check_arguments(defs, name, args)` | The top-level check `tools/call` makes |
| `text_result(text, is_error)` | A tool result of one text block |
| `image_result(data, mime, text)` | A tool result of an image (base64-encoded here) and its text |
| `base64(data)` | Standard base64 with padding (RFC 4648) |
+40
View File
@@ -0,0 +1,40 @@
//! The content blocks a tool result is made of (MCP `CallToolResult`).
use serde_json::{Value, json};
/// A tool result of one text block, flagged as an error when `is_error` (the model sees the
/// message; an error is still a result, not a JSON-RPC error).
pub fn text_result(text: impl Into<String>, is_error: bool) -> Value {
let mut r = json!({"content": [{"type": "text", "text": text.into()}]});
if is_error {
r["isError"] = json!(true);
}
r
}
/// A tool result of an image (`data` of type `mime`, base64-encoded here) followed by a text
/// block saying what it shows.
pub fn image_result(data: &[u8], mime: &str, text: impl Into<String>) -> Value {
json!({"content": [
{"type": "image", "data": base64(data), "mimeType": mime},
{"type": "text", "text": text.into()},
]})
}
/// Standard base64 with padding (RFC 4648), as MCP image content carries it.
pub fn base64(data: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
for (i, shift) in [18u32, 12, 6, 0].into_iter().enumerate() {
if i <= chunk.len() {
out.push(char::from(T[((n >> shift) & 63) as usize]));
} else {
out.push('=');
}
}
}
out
}
+20
View File
@@ -0,0 +1,20 @@
//! MCP server machinery any app may share: JSON-RPC 2.0 over stdio (newline-delimited), the MCP
//! lifecycle, the tools and resources methods, tool schemas with the check of a call's arguments
//! against them, and the content blocks tools reply with (text, images).
//!
//! An app implements [`Tools`]: what its tools are, how each one runs, its instructions and its
//! resources. [`Server`] speaks the protocol around them. Nothing here knows any app's design,
//! engine or renderer.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod content;
mod schema;
mod server;
pub use content::{base64, image_result, text_result};
pub use schema::{Effect, check_arguments, object, string, tool};
pub use server::{Info, PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server, Tools};
#[cfg(test)]
mod tests;
+101
View File
@@ -0,0 +1,101 @@
//! Tool definitions (MCP `Tool`: name, title, description, input schema, annotations) and the
//! check of a call's arguments against the definition it was listed with.
use serde_json::{Value, json};
/// What a tool may do, for the client's approval prompts (MCP tool annotations). Clients that
/// auto-approve on these hints must not be told less than the truth.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Effect {
/// Only looks.
ReadOnly,
/// Changes the app's document in memory, undoably.
Edits,
/// Replaces the app's document in memory (unsaved changes are lost).
Replaces,
/// May read, write, overwrite or delete files on disk.
Files,
}
/// A tool's definition, its annotations from `effect`.
pub fn tool(name: &str, title: &str, desc: &str, schema: Value, effect: Effect) -> Value {
let (read_only, destructive, open_world) = match effect {
Effect::ReadOnly => (true, false, false),
Effect::Edits => (false, false, false),
Effect::Replaces => (false, true, false),
Effect::Files => (false, true, true),
};
json!({
"name": name,
"title": title,
"description": desc,
"inputSchema": schema,
"annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": destructive, "idempotentHint": read_only, "openWorldHint": open_world},
})
}
/// An object schema with these `props`, the `required` ones, and no other keys.
pub fn object(props: Value, required: &[&str]) -> Value {
let mut o = json!({"type": "object", "properties": props, "additionalProperties": false});
if !required.is_empty() {
o["required"] = json!(required);
}
o
}
/// A string property described by `desc`.
pub fn string(desc: &str) -> Value {
json!({"type": "string", "description": desc})
}
fn type_matches(t: &str, v: &Value) -> bool {
match t {
"string" => v.is_string(),
"number" => v.is_number(),
"integer" => v.is_i64() || v.is_u64(),
"boolean" => v.is_boolean(),
"array" => v.is_array(),
"object" => v.is_object(),
"null" => v.is_null(),
_ => true,
}
}
/// Check `args` against tool `name`'s input schema as `defs` list it (top level): unknown tool,
/// missing required arguments, unknown arguments, type and enum mismatches.
pub fn check_arguments(defs: &[Value], name: &str, args: &Value) -> Result<(), String> {
let def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{name}`"))?;
let schema = &def["inputSchema"];
let Some(a) = args.as_object() else { return Err(format!("{name}: arguments must be an object")) };
for r in schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) {
if a.get(r).is_none_or(Value::is_null) {
return Err(format!("{name}: missing required argument `{r}`"));
}
}
let props = schema["properties"].as_object();
for (k, v) in a {
let Some(p) = props.and_then(|p| p.get(k)) else {
let valid: Vec<&str> = props.map(|p| p.keys().map(String::as_str).collect()).unwrap_or_default();
let valid = if valid.is_empty() { "none".to_string() } else { valid.join(", ") };
let k: String = k.chars().take(64).collect();
return Err(format!("{name}: unknown argument `{k}`; its arguments are: {valid}"));
};
if v.is_null() {
continue;
}
let ok = match &p["type"] {
Value::String(t) => type_matches(t, v),
Value::Array(ts) => ts.iter().filter_map(Value::as_str).any(|t| type_matches(t, v)),
_ => true,
};
if !ok {
return Err(format!("{name}: argument `{k}` must be of type {}", p["type"]));
}
if let Some(allowed) = p["enum"].as_array()
&& !allowed.contains(v)
{
return Err(format!("{name}: `{k}` must be one of {}", Value::Array(allowed.clone())));
}
}
Ok(())
}
+217
View File
@@ -0,0 +1,217 @@
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
use std::io::{BufRead, Write};
use serde_json::{Value, json};
use crate::schema::check_arguments;
/// The newest protocol revision we speak; offered when the client asks for one we don't know.
pub const PROTOCOL_VERSION: &str = "2025-06-18";
pub const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
const PARSE_ERROR: i64 = -32700;
const INVALID_REQUEST: i64 = -32600;
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
/// Most messages in one JSON-RPC batch.
const MAX_BATCH: usize = 1000;
/// An app's tools, served by [`Server`].
pub trait Tools {
/// What every client is told at `initialize`.
fn instructions(&self) -> String;
/// Every tool's definition ([`crate::tool`]), as `tools/list` serves it now.
fn list(&mut self) -> Vec<Value>;
/// Run tool `name`. The server calls it right after the [`Tools::list`] its arguments were
/// checked against ([`crate::check_arguments`]), so it runs the tool as that listing defined
/// it. Failures are tool results with `isError: true` ([`crate::text_result`]); it never
/// panics on hostile arguments.
fn call(&mut self, name: &str, args: &Value) -> Value;
/// The resources `resources/list` serves (MCP `Resource`: uri, name, description, mimeType).
fn resources(&self) -> Vec<Value> {
Vec::new()
}
/// The JSON of resource `uri`: `None` when there is no such resource, `Err` when it could not
/// be read now.
fn read_resource(&mut self, uri: &str) -> Option<Result<Value, String>> {
let _ = uri;
None
}
}
/// Who the server says it is at `initialize`, and the longest request line it reads.
#[derive(Clone, Copy, Debug)]
pub struct Info {
pub name: &'static str,
pub title: &'static str,
pub version: &'static str,
/// Longest accepted request line, in bytes; a longer one is refused in bounded space.
pub max_line: usize,
}
/// An MCP server over the app's [`Tools`].
pub struct Server<T: Tools> {
info: Info,
tools: T,
initialized: bool,
}
fn response(id: Value, result: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "result": result})
}
fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
}
impl<T: Tools> Server<T> {
pub fn new(info: Info, tools: T) -> Self {
Self { info, tools, initialized: false }
}
pub fn tools(&mut self) -> &mut T {
&mut self.tools
}
pub fn is_initialized(&self) -> bool {
self.initialized
}
/// Serve newline-delimited JSON-RPC until `input` closes. Only protocol messages go to
/// `output`; diagnostics belong on stderr. Consume oversized lines in bounded space so a
/// client cannot exhaust memory by sending an arbitrarily long request without a newline.
pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
loop {
let mut line = Vec::new();
let mut oversized = false;
let mut received = false;
loop {
let chunk = input.fill_buf()?;
if chunk.is_empty() {
break;
}
let count = chunk.iter().position(|&byte| byte == b'\n').map_or(chunk.len(), |i| i + 1);
let newline = chunk[count - 1] == b'\n';
let content_len = count - usize::from(newline);
if !oversized {
if line.len().saturating_add(content_len) > self.info.max_line {
oversized = true;
line.clear();
} else {
line.extend_from_slice(&chunk[..content_len]);
}
}
input.consume(count);
received = true;
if newline {
break;
}
}
if !received {
break;
}
let reply = if oversized {
Some(error(Value::Null, INVALID_REQUEST, "request too large").to_string())
} else {
match String::from_utf8(line) {
Ok(line) => self.handle_line(&line),
// Not UTF-8: answer with a parse error and keep serving.
Err(_) => Some(error(Value::Null, PARSE_ERROR, "parse error: input is not UTF-8").to_string()),
}
};
if let Some(reply) = reply {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
}
Ok(())
}
/// One line in, at most one line out (notifications get no reply).
pub fn handle_line(&mut self, line: &str) -> Option<String> {
// Check the raw input before trimming: a huge whitespace prefix is still a huge line.
if line.len() > self.info.max_line {
return Some(error(Value::Null, INVALID_REQUEST, "request too large").to_string());
}
let line = line.trim();
if line.is_empty() {
return None;
}
let reply = match serde_json::from_str::<Value>(line) {
Ok(Value::Array(batch)) if batch.is_empty() => Some(error(Value::Null, INVALID_REQUEST, "empty batch")),
Ok(Value::Array(batch)) if batch.len() > MAX_BATCH => Some(error(Value::Null, INVALID_REQUEST, "batch too large")),
Ok(Value::Array(batch)) => {
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
(!replies.is_empty()).then_some(Value::Array(replies))
}
Ok(msg) => self.handle(msg),
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
};
reply.map(|r| r.to_string())
}
pub fn handle(&mut self, msg: Value) -> Option<Value> {
let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) };
let id = o.get("id").cloned();
let Some(method) = o.get("method").and_then(Value::as_str) else {
// A response to a request of ours (we send none) or junk.
if o.contains_key("result") || o.contains_key("error") {
return None;
}
return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`"));
};
let params = o.get("params").cloned().unwrap_or(Value::Null);
let Some(id) = id else {
if method == "notifications/initialized" {
self.initialized = true;
}
return None;
};
Some(match self.request(method, &params) {
Ok(r) => response(id, r),
Err((code, m)) => error(id, code, m),
})
}
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => {
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
let Info { name, title, version: server_version, .. } = self.info;
Ok(json!({
"protocolVersion": version,
"capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false}},
"serverInfo": {"name": name, "title": title, "version": server_version},
"instructions": self.tools.instructions(),
}))
}
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({"tools": self.tools.list()})),
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
let args = match params.get("arguments") {
None | Some(Value::Null) => json!({}),
Some(a) => a.clone(),
};
check_arguments(&self.tools.list(), name, &args).map_err(|e| (INVALID_PARAMS, e))?;
Ok(self.tools.call(name, &args))
}
"resources/list" => Ok(json!({"resources": self.tools.resources()})),
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
"prompts/list" => Ok(json!({"prompts": []})),
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).unwrap_or("");
let v = self
.tools
.read_resource(uri)
.ok_or_else(|| (RESOURCE_NOT_FOUND, format!("unknown resource `{uri}`")))?
.map_err(|e| (INTERNAL_ERROR, e))?;
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": v.to_string()}]}))
}
_ => Err((METHOD_NOT_FOUND, format!("method `{method}` not found"))),
}
}
}
+217
View File
@@ -0,0 +1,217 @@
use serde_json::{Value, json};
use crate::{Effect, Info, PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server, Tools, check_arguments, object, string, text_result, tool};
/// One app's tools: `echo` and `level`, nothing behind them (no engine, no design). `log` records
/// every listing and call in order.
#[derive(Default)]
struct Echo {
calls: Vec<Value>,
log: Vec<String>,
}
impl Tools for Echo {
fn instructions(&self) -> String {
"Echo test tools.".into()
}
fn list(&mut self) -> Vec<Value> {
self.log.push("list".into());
vec![
tool("echo", "Echo", "Say `text` back.", object(json!({"text": string("What to say")}), &["text"]), Effect::ReadOnly),
tool("level", "Level", "Set the level.", object(json!({"level": {"type": "string", "enum": ["low", "high"]}}), &[]), Effect::Edits),
]
}
fn call(&mut self, name: &str, args: &Value) -> Value {
self.log.push(format!("call {name}"));
self.calls.push(json!({"name": name, "args": args}));
text_result(args["text"].as_str().unwrap_or_default(), false)
}
}
fn info() -> Info {
Info { name: "echo", title: "Echo", version: "1.0.0", max_line: 1 << 16 }
}
fn serve(lines: &[Value]) -> (Server<Echo>, Vec<Value>) {
let input: String = lines.iter().map(|l| format!("{l}\n")).collect();
let mut output = Vec::new();
let mut s = Server::new(info(), Echo::default());
s.serve(std::io::Cursor::new(input), &mut output).unwrap();
let replies = String::from_utf8(output).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
(s, replies)
}
/// A registered tool is listed and called over the stdio transport, with no engine behind it.
#[test]
fn a_registered_tool_is_served_over_stdio() {
let (mut s, replies) = serve(&[
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26"}}),
json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "echo", "arguments": {"text": "hi"}}}),
]);
assert_eq!(replies.len(), 3, "the notification gets no reply: {replies:?}");
let init = &replies[0]["result"];
assert_eq!(init["protocolVersion"], "2025-03-26");
assert_eq!(init["serverInfo"], json!({"name": "echo", "title": "Echo", "version": "1.0.0"}));
assert_eq!(init["instructions"], "Echo test tools.");
assert!(s.is_initialized());
assert_eq!(replies[1]["result"]["tools"][0]["name"], "echo");
assert_eq!(replies[1]["result"]["tools"][0]["annotations"]["readOnlyHint"], true);
assert_eq!(replies[2], json!({"jsonrpc": "2.0", "id": 3, "result": {"content": [{"type": "text", "text": "hi"}]}}));
assert_eq!(s.tools().calls, vec![json!({"name": "echo", "args": {"text": "hi"}})]);
}
/// Arguments are checked against the tool's schema before the tool runs; unknown tools and
/// methods are JSON-RPC errors.
#[test]
fn arguments_break_the_schema_before_the_tool_runs() {
let (mut s, replies) = serve(&[
json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "echo", "arguments": {}}}),
json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "echo", "arguments": {"text": 3}}}),
json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "echo", "arguments": {"text": "a", "loud": true}}}),
json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "shout", "arguments": {}}}),
json!({"jsonrpc": "2.0", "id": 5, "method": "nope"}),
json!({"jsonrpc": "2.0", "id": 6, "method": "resources/read", "params": {"uri": "x://y"}}),
]);
let codes: Vec<&Value> = replies.iter().map(|r| &r["error"]["code"]).collect();
assert_eq!(codes, [&json!(-32602), &json!(-32602), &json!(-32602), &json!(-32602), &json!(-32601), &json!(-32002)], "{replies:?}");
assert_eq!(replies[0]["error"]["message"], "echo: missing required argument `text`");
assert_eq!(replies[2]["error"]["message"], "echo: unknown argument `loud`; its arguments are: text");
assert!(s.tools().calls.is_empty());
let defs = s.tools().list();
assert!(check_arguments(&defs, "echo", &json!({"text": "a"})).is_ok());
}
/// Hostile framing: oversized lines, bad UTF-8 and bad JSON are answered and the server goes on.
#[test]
fn hostile_lines_are_answered_and_serving_goes_on() {
let mut input = vec![b'x'; (1 << 16) + 10];
input.push(b'\n');
input.extend_from_slice(b"\xff\xfe\n{not json\n[]\n");
input.extend_from_slice(json!({"jsonrpc": "2.0", "id": 9, "method": "ping"}).to_string().as_bytes());
let mut output = Vec::new();
Server::new(info(), Echo::default()).serve(std::io::Cursor::new(input), &mut output).unwrap();
let replies: Vec<Value> = String::from_utf8(output).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let codes: Vec<&Value> = replies.iter().map(|r| r.get("error").map_or(&r["result"], |e| &e["code"])).collect();
assert_eq!(codes, [&json!(-32600), &json!(-32700), &json!(-32700), &json!(-32600), &json!({})], "{replies:?}");
}
#[test]
fn base64_matches_rfc4648() {
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] {
assert_eq!(crate::base64(i.as_bytes()), o);
}
let r = crate::image_result(b"foo", "image/png", "info");
assert_eq!(r, json!({"content": [{"type": "image", "data": "Zm9v", "mimeType": "image/png"}, {"type": "text", "text": "info"}]}));
}
fn ping(id: usize) -> Value {
json!({"jsonrpc": "2.0", "id": id, "method": "ping"})
}
fn notification() -> Value {
json!({"jsonrpc": "2.0", "method": "notifications/progress"})
}
fn reply(s: &mut Server<Echo>, msg: &Value) -> Option<Value> {
s.handle_line(&msg.to_string()).map(|r| serde_json::from_str(&r).unwrap())
}
/// A batch answers each request in it and none of its notifications; a batch of notifications
/// only gets no reply at all; a batch holds at most 1000 messages.
#[test]
fn batches_answer_their_requests_up_to_the_limit() {
let mut s = Server::new(info(), Echo::default());
let mixed =
json!([ping(1), notification(), {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "echo", "arguments": {"text": "b"}}}]);
let replies = reply(&mut s, &mixed).unwrap();
assert_eq!(
replies,
json!([{"jsonrpc": "2.0", "id": 1, "result": {}}, {"jsonrpc": "2.0", "id": 2, "result": {"content": [{"type": "text", "text": "b"}]}}])
);
assert_eq!(reply(&mut s, &json!([notification(), notification()])), None);
let full: Vec<Value> = (0..1000).map(ping).collect();
assert_eq!(reply(&mut s, &Value::Array(full)).unwrap().as_array().map(Vec::len), Some(1000));
let over: Vec<Value> = (0..1001).map(ping).collect();
let r = reply(&mut s, &Value::Array(over)).unwrap();
assert_eq!(r, json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "batch too large"}}));
}
/// We send no requests, so a response is never answered, alone or in a batch; a message with
/// neither a method nor a result, or one that is not an object, is refused; a blank line is skipped.
#[test]
fn a_response_we_never_asked_for_is_ignored() {
let mut s = Server::new(info(), Echo::default());
assert_eq!(reply(&mut s, &json!({"jsonrpc": "2.0", "id": 1, "result": {}})), None);
assert_eq!(reply(&mut s, &json!({"jsonrpc": "2.0", "id": 2, "error": {"code": -1, "message": "no"}})), None);
assert_eq!(
reply(&mut s, &json!([{"jsonrpc": "2.0", "id": 3, "result": {}}, ping(4)])).unwrap(),
json!([{"jsonrpc": "2.0", "id": 4, "result": {}}])
);
let r = reply(&mut s, &json!({"jsonrpc": "2.0", "id": 5})).unwrap();
assert_eq!(r, json!({"jsonrpc": "2.0", "id": 5, "error": {"code": -32600, "message": "missing `method`"}}));
let r = reply(&mut s, &json!(42)).unwrap();
assert_eq!(r, json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "message must be an object"}}));
assert_eq!(s.handle_line(" "), None);
assert!(s.tools().log.is_empty());
}
/// A version we speak is echoed; one we don't, or none, gets ours.
#[test]
fn an_unknown_protocol_version_gets_ours() {
let mut s = Server::new(info(), Echo::default());
let version = |s: &mut Server<Echo>, params: Value| {
reply(s, &json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": params})).unwrap()["result"]["protocolVersion"].clone()
};
for v in SUPPORTED_VERSIONS {
assert_eq!(version(&mut s, json!({"protocolVersion": v})), json!(v));
}
assert_eq!(version(&mut s, json!({"protocolVersion": "1999-01-01"})), json!(PROTOCOL_VERSION));
assert_eq!(version(&mut s, json!({"protocolVersion": 7})), json!(PROTOCOL_VERSION));
assert_eq!(version(&mut s, json!({})), json!(PROTOCOL_VERSION));
assert!(!s.is_initialized(), "initialized only by the client's notification");
}
/// `arguments: null` is no arguments; a value outside a property's `enum` is refused before the
/// tool runs.
#[test]
fn null_arguments_are_none_and_enums_are_checked() {
let mut s = Server::new(info(), Echo::default());
let call = |s: &mut Server<Echo>, id: u64, args: Value| {
reply(s, &json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": "level", "arguments": args}})).unwrap()
};
assert!(call(&mut s, 1, Value::Null).get("result").is_some());
let none = reply(&mut s, &json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "level"}})).unwrap();
assert!(none.get("result").is_some(), "{none}");
assert!(call(&mut s, 3, json!({"level": "high"})).get("result").is_some());
let r = call(&mut s, 4, json!({"level": "loud"}));
assert_eq!(r["error"], json!({"code": -32602, "message": "level: `level` must be one of [\"low\",\"high\"]"}));
let r = call(&mut s, 5, json!([]));
assert_eq!(r["error"], json!({"code": -32602, "message": "level: arguments must be an object"}));
let args: Vec<&Value> = s.tools().calls.iter().map(|c| &c["args"]).collect();
assert_eq!(args, [&json!({}), &json!({}), &json!({"level": "high"})]);
}
/// An app that serves no resources, templates or prompts lists none of each.
#[test]
fn the_default_lists_are_empty() {
let mut s = Server::new(info(), Echo::default());
let result = |s: &mut Server<Echo>, method: &str| reply(s, &json!({"jsonrpc": "2.0", "id": 1, "method": method})).unwrap()["result"].clone();
assert_eq!(result(&mut s, "resources/list"), json!({"resources": []}));
assert_eq!(result(&mut s, "resources/templates/list"), json!({"resourceTemplates": []}));
assert_eq!(result(&mut s, "prompts/list"), json!({"prompts": []}));
}
/// `Tools::call` runs right after the listing its arguments were checked against, every time,
/// whatever was listed before it; a refused call is listed and never run.
#[test]
fn every_call_runs_right_after_its_listing() {
let mut s = Server::new(info(), Echo::default());
let call = |name: &str, args: Value| json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}});
reply(&mut s, &json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}));
reply(&mut s, &call("echo", json!({"text": "a"})));
reply(&mut s, &call("echo", json!({})));
reply(&mut s, &json!([call("level", json!({})), call("echo", json!({"text": "b"}))]));
assert_eq!(s.tools().log, ["list", "list", "call echo", "list", "list", "call level", "list", "call echo"]);
}
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-mcp-server = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
+5 -3
View File
@@ -1,7 +1,8 @@
//! SolidCraft MCP server.
//!
//! JSON-RPC 2.0 over stdio (newline-delimited), implementing the MCP lifecycle, tools and
//! resources. Tools map onto the control-channel methods (`engine.execute`, `document.inspect`,
//! SolidCraft's tools, instructions and resources, served over JSON-RPC 2.0 on stdio by the
//! shared MCP server (`solidcraft-mcp-server`: the lifecycle, tool schemas and argument checks).
//! Tools map onto the control-channel methods (`engine.execute`, `document.inspect`,
//! `ui.render`…, see `docs/control-protocol.md`), served either by an in-process headless
//! [`Headless`] session or by a running app through its loopback control port ([`Remote`]), so a
//! person can watch the agent model. Every modelling step is an engine command; tools never open
@@ -17,7 +18,8 @@ mod tools;
pub use backend::{Auto, Backend, Headless, Remote};
pub use options::{COMPACT_ENV, COMPACT_FLAG, Options};
pub use server::{PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server};
pub use server::Server;
pub use solidcraft_mcp_server::{PROTOCOL_VERSION, SUPPORTED_VERSIONS};
pub use tools::{call_tool, call_tool_with, tool_definitions, tool_definitions_with};
use std::path::PathBuf;
+107 -212
View File
@@ -1,30 +1,13 @@
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
use std::io::{BufRead, Write};
//! SolidCraft's MCP server: its tools, instructions and resources over the shared server
//! (`solidcraft-mcp-server`), with the agent's folders and the output options around each call.
use serde_json::{Value, json};
/// Longest accepted request line, shared with the control server.
use solidcraft_engine::MAX_LINE;
use solidcraft_engine::io::vfs::{self, PathScope};
use solidcraft_mcp_server::{Info, Tools};
use crate::backend::Backend;
use crate::options::Options;
use crate::tools::{call_tool_with, check_arguments_with, tool_definitions_with};
/// The newest protocol revision we speak; offered when the client asks for one we don't know.
pub const PROTOCOL_VERSION: &str = "2025-06-18";
pub const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
const PARSE_ERROR: i64 = -32700;
const INVALID_REQUEST: i64 = -32600;
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
/// Most messages in one JSON-RPC batch.
const MAX_BATCH: usize = 1000;
use crate::tools::{call_tool_with, tool_definitions_with};
/// What every MCP client is told at `initialize`: how to plan, build and verify a part. It stays
/// version-neutral (no workaround for a quirk of the current kernel or solver: those belong in
@@ -58,16 +41,6 @@ Fix, don't rebuild: a wrong number in a feature is changed with `timeline.edit`
(`parameters.list {\"filter\":\"feature\"}` names them); `edit.checkpoint` and `edit.restore_checkpoint` go back exactly.\n\n\
A call whose error starts `still running:` was not cancelled: don't send it again; `pending` waits for its result.";
pub struct Server {
backend: Box<dyn Backend>,
initialized: bool,
scope: Option<PathScope>,
/// The options from the command line and environment.
options: Options,
/// The options the app's Preferences ask for, once it has answered (`None`: not yet).
app_options: Option<Options>,
}
/// The design files a call opens or saves (#7181): `open` and `save`, and the commands they run
/// (`doc.open`, `file.save`, `file.save_as`) through `execute` or as `batch` steps. Each reads or
/// writes the file it names through the agent's scope.
@@ -91,34 +64,25 @@ fn design_files(name: &str, args: &Value) -> Vec<String> {
}
}
fn response(id: Value, result: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "result": result})
}
fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
/// SolidCraft's tools as the shared server serves them: the backend they run on, the files they
/// may reach and the options that shape what they return.
struct Agent {
backend: Box<dyn Backend>,
scope: Option<PathScope>,
/// The options from the command line and environment.
options: Options,
/// The options the app's Preferences ask for, once it has answered (`None`: not yet).
app_options: Option<Options>,
/// The options of the last listing: a call runs with the tools as the listing it was checked
/// against defined them.
listed: Options,
}
impl Server {
pub fn new(backend: Box<dyn Backend>) -> Self {
Self { backend, initialized: false, scope: None, options: Options::default(), app_options: None }
}
/// Confine the files tool calls may reach to `scope` (`None`: anywhere). `solidcraft-cli mcp`
/// always sets one unless started with `--allow-any-path` (docs/mcp.md).
pub fn with_path_scope(mut self, scope: Option<PathScope>) -> Self {
self.scope = scope;
self
}
/// Shape tool output with `options` (default: as always; `solidcraft-cli mcp --compact` or
/// `SOLIDCRAFT_MCP_COMPACT=1` turn on compact output, docs/mcp.md). The app's Preferences can
/// turn an option on too ([`Server::options`]).
pub fn with_options(mut self, options: Options) -> Self {
self.options = options;
self
}
impl Agent {
/// The options tools use now: the server's own, with what the app's Preferences turn on. The
/// app is asked until it answers once (it may start after the server, or be busy), and its
/// answer then holds for this session, so the tools an agent listed keep their shape.
pub fn options(&mut self) -> Options {
fn options(&mut self) -> Options {
if self.app_options.is_none()
&& let Ok(reply) = self.backend.app_options()
{
@@ -126,173 +90,104 @@ impl Server {
}
self.options.or(self.app_options.unwrap_or_default())
}
pub fn backend(&mut self) -> &mut dyn Backend {
self.backend.as_mut()
}
pub fn is_initialized(&self) -> bool {
self.initialized
}
impl Tools for Agent {
fn instructions(&self) -> String {
format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe())
}
/// Serve newline-delimited JSON-RPC until `input` closes. Only protocol messages go to
/// `output`; diagnostics belong on stderr. Consume oversized lines in bounded space so an
/// agent cannot exhaust memory by sending an arbitrarily long request without a newline.
pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
loop {
let mut line = Vec::new();
let mut oversized = false;
let mut received = false;
loop {
let chunk = input.fill_buf()?;
if chunk.is_empty() {
break;
}
let count = chunk.iter().position(|&byte| byte == b'\n').map_or(chunk.len(), |i| i + 1);
let newline = chunk[count - 1] == b'\n';
let content_len = count - usize::from(newline);
if !oversized {
if line.len().saturating_add(content_len) > MAX_LINE {
oversized = true;
line.clear();
} else {
line.extend_from_slice(&chunk[..content_len]);
}
}
input.consume(count);
received = true;
if newline {
break;
}
}
if !received {
break;
}
let reply = if oversized {
Some(error(Value::Null, INVALID_REQUEST, "request too large").to_string())
} else {
match String::from_utf8(line) {
Ok(line) => self.handle_line(&line),
// Not UTF-8: answer with a parse error and keep serving.
Err(_) => Some(error(Value::Null, PARSE_ERROR, "parse error: input is not UTF-8").to_string()),
}
};
if let Some(reply) = reply {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
}
Ok(())
fn list(&mut self) -> Vec<Value> {
self.listed = self.options();
tool_definitions_with(self.listed)
}
/// One line in, at most one line out (notifications get no reply).
pub fn handle_line(&mut self, line: &str) -> Option<String> {
// Check the raw input before trimming: a huge whitespace prefix is still a huge line.
if line.len() > MAX_LINE {
return Some(error(Value::Null, INVALID_REQUEST, "request too large").to_string());
}
let line = line.trim();
if line.is_empty() {
return None;
}
let reply = match serde_json::from_str::<Value>(line) {
Ok(Value::Array(batch)) if batch.is_empty() => Some(error(Value::Null, INVALID_REQUEST, "empty batch")),
Ok(Value::Array(batch)) if batch.len() > MAX_BATCH => Some(error(Value::Null, INVALID_REQUEST, "batch too large")),
Ok(Value::Array(batch)) => {
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
(!replies.is_empty()).then_some(Value::Array(replies))
fn call(&mut self, name: &str, args: &Value) -> Value {
let options = self.listed;
// The open design's folder counts, and goes on counting for the rest of this run when a
// save-as or open moves the design elsewhere (#7181).
let open = self.backend.design_path();
self.scope = self.scope.take().map(|s| s.with_design(open.as_deref()));
let scope = self.scope.clone();
let backend = self.backend.as_mut();
let mut result = vfs::scoped(scope, || call_tool_with(backend, name, args, options));
// So does the folder of each design file the call opened or saved: the call reached it,
// so it was one of the agent's folders already. Only paths given in full: a running app
// resolves a relative one against its own working folder.
if result["isError"] != true {
for file in design_files(name, args).iter().filter(|f| std::path::Path::new(f).is_absolute()) {
self.scope = self.scope.take().map(|s| s.with_design_file(file));
}
Ok(msg) => self.handle(msg),
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
};
reply.map(|r| r.to_string())
}
if let Some(note) = self.backend.notice()
&& let Some(content) = result.get_mut("content").and_then(Value::as_array_mut)
{
content.push(json!({"type": "text", "text": note}));
}
result
}
pub fn handle(&mut self, msg: Value) -> Option<Value> {
let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) };
let id = o.get("id").cloned();
let Some(method) = o.get("method").and_then(Value::as_str) else {
// A response to a request of ours (we send none) or junk.
if o.contains_key("result") || o.contains_key("error") {
return None;
}
return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`"));
};
let params = o.get("params").cloned().unwrap_or(Value::Null);
let Some(id) = id else {
if method == "notifications/initialized" {
self.initialized = true;
}
return None;
};
Some(match self.request(method, &params) {
Ok(r) => response(id, r),
Err((code, m)) => error(id, code, m),
fn resources(&self) -> Vec<Value> {
vec![
json!({"uri": "solidcraft://design", "name": "Active design", "description": "document.inspect with measurements", "mimeType": "application/json"}),
json!({"uri": "solidcraft://commands", "name": "Command catalog", "description": "every command with its parameter docs", "mimeType": "application/json"}),
]
}
fn read_resource(&mut self, uri: &str) -> Option<Result<Value, String>> {
Some(match uri {
// The solvecraft:// URIs are what earlier releases listed (compatibility shim,
// docs/compatibility.md); clients that saved them still read the resource.
"solidcraft://design" | "solvecraft://design" => self.backend.call("document.inspect", json!({"measure": true})),
"solidcraft://commands" | "solvecraft://commands" => self.backend.call("engine.commands", json!({})),
_ => return None,
})
}
}
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => {
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
Ok(json!({
"protocolVersion": version,
"capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false}},
"serverInfo": {"name": "solidcraft", "title": "SolidCraft", "version": env!("CARGO_PKG_VERSION")},
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
}))
}
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({"tools": tool_definitions_with(self.options())})),
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
let args = match params.get("arguments") {
None | Some(Value::Null) => json!({}),
Some(a) => a.clone(),
};
let options = self.options();
check_arguments_with(name, &args, options).map_err(|e| (INVALID_PARAMS, e))?;
// The open design's folder counts, and goes on counting for the rest of this run
// when a save-as or open moves the design elsewhere (#7181).
let open = self.backend.design_path();
self.scope = self.scope.take().map(|s| s.with_design(open.as_deref()));
let scope = self.scope.clone();
let backend = self.backend.as_mut();
let mut result = vfs::scoped(scope, || call_tool_with(backend, name, &args, options));
// So does the folder of each design file the call opened or saved: the call reached
// it, so it was one of the agent's folders already. Only paths given in full: a
// running app resolves a relative one against its own working folder.
if result["isError"] != true {
for file in design_files(name, &args).iter().filter(|f| std::path::Path::new(f).is_absolute()) {
self.scope = self.scope.take().map(|s| s.with_design_file(file));
}
}
if let Some(note) = backend.notice()
&& let Some(content) = result.get_mut("content").and_then(Value::as_array_mut)
{
content.push(json!({"type": "text", "text": note}));
}
Ok(result)
}
"resources/list" => Ok(json!({"resources": [
{"uri": "solidcraft://design", "name": "Active design", "description": "document.inspect with measurements", "mimeType": "application/json"},
{"uri": "solidcraft://commands", "name": "Command catalog", "description": "every command with its parameter docs", "mimeType": "application/json"},
]})),
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
"prompts/list" => Ok(json!({"prompts": []})),
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).unwrap_or("");
let v = match uri {
// The solvecraft:// URIs are what earlier releases listed (compatibility shim,
// docs/compatibility.md); clients that saved them still read the resource.
"solidcraft://design" | "solvecraft://design" => self.backend.call("document.inspect", json!({"measure": true})),
"solidcraft://commands" | "solvecraft://commands" => self.backend.call("engine.commands", json!({})),
_ => return Err((RESOURCE_NOT_FOUND, format!("unknown resource `{uri}`"))),
}
.map_err(|e| (INTERNAL_ERROR, e))?;
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": v.to_string()}]}))
}
_ => Err((METHOD_NOT_FOUND, format!("method `{method}` not found"))),
}
/// SolidCraft's MCP server over a [`Backend`].
pub struct Server {
inner: solidcraft_mcp_server::Server<Agent>,
}
impl Server {
pub fn new(backend: Box<dyn Backend>) -> Self {
let info = Info { name: "solidcraft", title: "SolidCraft", version: env!("CARGO_PKG_VERSION"), max_line: solidcraft_engine::MAX_LINE };
let agent = Agent { backend, scope: None, options: Options::default(), app_options: None, listed: Options::default() };
Self { inner: solidcraft_mcp_server::Server::new(info, agent) }
}
/// Confine the files tool calls may reach to `scope` (`None`: anywhere). `solidcraft-cli mcp`
/// always sets one unless started with `--allow-any-path` (docs/mcp.md).
pub fn with_path_scope(mut self, scope: Option<PathScope>) -> Self {
self.inner.tools().scope = scope;
self
}
/// Shape tool output with `options` (default: as always; `solidcraft-cli mcp --compact` or
/// `SOLIDCRAFT_MCP_COMPACT=1` turn on compact output, docs/mcp.md). The app's Preferences can
/// turn an option on too ([`Server::options`]).
pub fn with_options(mut self, options: Options) -> Self {
self.inner.tools().options = options;
self
}
/// The options tools use now: the server's own, with what the app's Preferences turn on.
pub fn options(&mut self) -> Options {
self.inner.tools().options()
}
pub fn backend(&mut self) -> &mut dyn Backend {
self.inner.tools().backend.as_mut()
}
pub fn is_initialized(&self) -> bool {
self.inner.is_initialized()
}
/// Serve newline-delimited JSON-RPC until `input` closes (see the shared server).
pub fn serve(&mut self, input: impl std::io::BufRead, output: impl std::io::Write) -> std::io::Result<()> {
self.inner.serve(input, output)
}
/// One line in, at most one line out (notifications get no reply).
pub fn handle_line(&mut self, line: &str) -> Option<String> {
self.inner.handle_line(line)
}
pub fn handle(&mut self, msg: Value) -> Option<Value> {
self.inner.handle(msg)
}
}
+3 -21
View File
@@ -426,9 +426,6 @@ fn handshake_and_tools_list() {
assert_eq!(init["result"]["serverInfo"]["name"], "solidcraft");
assert!(init["result"]["capabilities"]["tools"].is_object());
assert!(init["result"]["instructions"].as_str().unwrap().contains("headless"));
// An unknown version gets ours.
let init = call(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"}));
assert_eq!(init["result"]["protocolVersion"], PROTOCOL_VERSION);
assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
assert!(s.is_initialized());
assert_eq!(call(&mut s, 3, "ping", json!({}))["result"], json!({}));
@@ -1535,14 +1532,6 @@ fn unknown_tools_and_bad_arguments_are_clean_errors() {
assert_eq!(r["result"]["isError"], true, "{r}");
let r = raw_tool(&mut s, "execute", json!({"command": "NoSuchCommand"}));
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("unknown command"));
// Framing errors.
assert!(s.handle_line("{not json").unwrap().contains("-32700"));
assert!(s.handle_line("[]").unwrap().contains("-32600"));
assert!(s.handle_line("42").unwrap().contains("-32600"));
assert!(s.handle_line(" ").is_none());
assert!(s.handle_line(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#).is_none());
let both = s.handle_line(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/x"}]"#).unwrap();
assert_eq!(serde_json::from_str::<Value>(&both).unwrap().as_array().unwrap().len(), 1);
}
#[test]
@@ -1723,14 +1712,6 @@ fn hostile_arguments_never_panic() {
}
}
#[test]
fn base64_matches_rfc4648() {
use crate::tools::base64;
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] {
assert_eq!(base64(i.as_bytes()), o);
}
}
/// Filtering by a command id returns that command, not every command whose docs mention it (#269).
#[test]
fn list_commands_filter_by_a_command_id_matches_ids() {
@@ -3818,8 +3799,9 @@ fn compact_inspect_design_summarises_sketches_unless_asked() {
assert_eq!(def, crate::tool_definitions_with(crate::Options { compact: true }).into_iter().find(|d| d["name"] == "inspect_design").unwrap());
// Arguments are checked against the tools as listed: `bodies` only with compact output.
let bodies = json!({"bodies": ["Body1"]});
assert!(crate::tools::check_arguments_with("inspect_design", &bodies, crate::Options { compact: true }).is_ok());
assert!(crate::tools::check_arguments("inspect_design", &bodies).is_err_and(|e| e.contains("bodies")));
let check = |options| solidcraft_mcp_server::check_arguments(&crate::tool_definitions_with(options), "inspect_design", &bodies);
assert!(check(crate::Options { compact: true }).is_ok());
assert!(check(crate::Options::default()).is_err_and(|e| e.contains("bodies")));
let (_, summary) = inspect_text(&mut s, json!({"measure": false}));
assert!(summary.len() < 4000, "{} bytes: {summary}", summary.len());
+30 -152
View File
@@ -7,6 +7,8 @@ use serde_json::{Map, Value, json};
use solidcraft_engine::io::vfs;
use solidcraft_engine::timing;
use solidcraft_mcp_server::{Effect, object, string, tool};
use crate::backend::{Backend, STILL_RUNNING};
use crate::options::Options;
@@ -26,48 +28,6 @@ const VIEWS: &[&str] = &["iso", "home", "front", "back", "top", "bottom", "left"
/// What `check_parameters` can report.
const REPORTS: &[&str] = &["measure", "cylinders", "errors"];
fn obj(props: Value, required: &[&str]) -> Value {
let mut o = json!({"type": "object", "properties": props, "additionalProperties": false});
if !required.is_empty() {
o["required"] = json!(required);
}
o
}
/// What a tool may do, for the client's approval prompts (MCP tool annotations). Clients that
/// auto-approve on these hints must not be told less than the truth (#205).
#[derive(Clone, Copy)]
enum Effect {
/// Only looks.
ReadOnly,
/// Changes the design in memory, undoably.
Edits,
/// Replaces the design in memory (unsaved changes are lost).
Replaces,
/// May read, write, overwrite or delete files on disk (any command, a file path).
Files,
}
fn tool(name: &str, title: &str, desc: &str, schema: Value, effect: Effect) -> Value {
let (read_only, destructive, open_world) = match effect {
Effect::ReadOnly => (true, false, false),
Effect::Edits => (false, false, false),
Effect::Replaces => (false, true, false),
Effect::Files => (false, true, true),
};
json!({
"name": name,
"title": title,
"description": desc,
"inputSchema": schema,
"annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": destructive, "idempotentHint": read_only, "openWorldHint": open_world},
})
}
fn string(desc: &str) -> Value {
json!({"type": "string", "description": desc})
}
/// The `expect` argument of `execute` and `batch`: what the design should measure once the
/// call has gone through.
fn expect_schema() -> Value {
@@ -117,7 +77,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
(start of the id) and `panel` (a whole panel or tab name); shrink each entry with `compact` (id and label) \
or `fields`; page it with `limit` and `offset`, which make the reply {commands, total, offset, next_offset}. \
With none of these the reply is the whole catalog, about 350 KB.",
obj(
object(
json!({
"filter": string("Substring to match, e.g. \"sketch\", \"fillet\", \"pattern\""),
"prefix": string("Case-insensitive start of the command id, e.g. \"sketch.\""),
@@ -140,7 +100,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
"Describe a command",
"One command's parameter docs and, for commands with a typed contract (every new command, and more of the older ones as they are converted), the \
JSON Schemas (2020-12) of its parameters (`input`) and of a successful result (`output`); null for the others.",
obj(json!({"id": string("Command id, e.g. \"solid.extrude\"")}), &["id"]),
object(json!({"id": string("Command id, e.g. \"solid.extrude\"")}), &["id"]),
Effect::ReadOnly,
),
tool(
@@ -158,7 +118,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
and name, every body (bodies) and the bodies the feature created, changed and removed (created_bodies, changed_bodies, removed_bodies). \
view.pick {pixels: [[x,y]…]} answers the body, face, exact world point, normal and depth under pixels of a screenshot (read-only; docs/control-protocol.md). \
`expect` {volume_mm3, tol, bodies, bbox} checks the design after the command (as `batch` does) and flags a mismatch.",
obj(
object(
json!({
"command": string("Command id (see list_commands)"),
"params": {"type": "object", "description": "The command's parameters"},
@@ -186,7 +146,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
file and passed as `path` instead of `commands`. Give `expect` {volume_mm3, tol, bodies, bbox} with the numbers \
you computed beforehand: after the last step the design is measured and compared, so no separate measure call is \
needed; a mismatch is flagged as an error naming it, and the design is kept as built.",
obj(
object(
json!({
"commands": {
"type": "array",
@@ -218,7 +178,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
`min` and `max` distance and, where one is round, `center_distance` (centre to centre, or from its axis). \
`summary: true` keeps one short object per body (name, volume_mm3, bbox, faces, closed) and the total volume: \
enough to check a stage against the numbers you computed, at a fraction of the size.",
obj(
object(
json!({
"body": string("Body name (default: all bodies)"),
"names_only": {"type": "boolean", "description": "Only each body's name (assembly occurrences included) and its definition's, without measuring (default false)"},
@@ -238,7 +198,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
`faces: \"cylinders\"` lists only the cylindrical faces, as `cylinders`, with the halves the kernel splits one cylinder \
into merged: {radius, origin, axis, extent, names}, `extent` the [min, max] distance along `axis` from `origin` that \
the cylinder spans, and no edges. That is enough to check where every hole and boss is, at a fraction of the size.",
obj(
object(
json!({
"body": string("Body name, e.g. \"Body1\""),
"faces": {"type": "string", "enum": ["all", "cylinders"], "description": "\"all\" (default): every edge and face. \"cylinders\": only the cylindrical faces, merged, with their extent along the axis, and no edges"},
@@ -255,7 +215,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
uses. Give one of the two (both only when they agree). Returns the new value, the features recomputed (`recomputed`), the \
features whose earlier result for the same inputs was reused instead (`restored`, e.g. after setting a value back) and any \
features that now fail.",
obj(
object(
json!({
"name": string("Parameter name"),
"value": {"type": ["number", "string"], "description": "Number or expression (or give it as `expression`)"},
@@ -278,7 +238,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
`restored: true`; if the design could not be put back the call is an error with `restored: false` and the values \
still applied. An unknown parameter name is an error and nothing changes. Replaces the loop set_parameter, \
measure, body_topology, set_parameter back.",
obj(
object(
json!({
"set": {
"type": "object",
@@ -305,7 +265,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
`grid: false` and `axes: false` leave the ground grid (on Z 0) and the origin's axes out of a model render. \
A body whose appearance is see-through (appearance.assign with an `opacity` below 1) is drawn see-through, \
so what is inside it shows: that is how to look at the body a cut would remove inside the material it meets.",
obj(
object(
json!({
"view": string(
"Standard view (iso, home, front, back, top, bottom, left, right, fit) or the name of a saved named view (default: keep the current one; headless starts at iso)"
@@ -335,7 +295,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
\"both\" scales by width and reports `aspect_mismatch` when the shapes differ. Returns as text the overlap (0..1) and the model's top, \
bottom, left and right edges against the picture's in mm at nine stations (positive where the model reaches past the picture), and as \
an image the overlay PNG: model red, picture blue.",
obj(
object(
json!({
"reference": string("PNG or JPEG to match, inside the server's folders"),
"view": string("Standard view (front, top, right, iso, ...) the picture is seen from (default front)"),
@@ -357,7 +317,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
comes from the extension unless given. URDF (.urdf) exports the whole assembly for robot simulators: a link per \
component placement with mass and inertia, joints from the assembly's joints, and STL meshes in metres written \
next to it (docs/urdf.md); RViz needs mesh_prefix such as package://robot/meshes/.",
obj(
object(
json!({
"path": string("Output file"),
"format": {"type": "string", "enum": ["step", "3mf", "3mf-tagged", "stl", "stla", "obj", "urdf"]},
@@ -378,7 +338,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
message, location, backtrace, app version and commit, system, the command that crashed and the recent ones, \
and design_path / replay_path when it can be replayed: `solidcraft-cli crashes replay <id>`, or \
`solidcraft-cli run <replay_path> --in <design_path>`. Reports never leave this computer.",
obj(
object(
json!({
"id": string("A report id from the list"),
"limit": {"type": "integer", "description": "Reports to list (default 20)"},
@@ -394,7 +354,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
command id (a batch's step count), seconds since it arrived (running_s) and whether it still waits its turn \
(queued); `self` marks a request sent on the connection that asks. Answers at once, even while the app is busy, \
and never shows parameters. Ask it when a call timed out or the app seems slow, before sending a change again.",
obj(json!({}), &[]),
object(json!({}), &[]),
Effect::ReadOnly,
),
tool(
@@ -404,16 +364,16 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
starting \"still running:\"). Waits for it up to 60 s: then its own result (or error), or done: false while \
it still runs (call again). pending: null when nothing is running. A request still running was not \
cancelled: don't send it again; other calls wait for it before they are sent.",
obj(json!({}), &[]),
object(json!({}), &[]),
Effect::ReadOnly,
),
tool("undo", "Undo", "Undo the last change to the design.", obj(json!({}), &[]), Effect::Edits),
tool("redo", "Redo", "Redo the last undone change.", obj(json!({}), &[]), Effect::Edits),
tool("undo", "Undo", "Undo the last change to the design.", object(json!({}), &[]), Effect::Edits),
tool("redo", "Redo", "Redo the last undone change.", object(json!({}), &[]), Effect::Edits),
tool(
"new_design",
"New design",
"Start a new, empty design (unsaved changes to the current one are discarded).",
obj(json!({"name": string("Design name (default Untitled)")}), &[]),
object(json!({"name": string("Design name (default Untitled)")}), &[]),
Effect::Replaces,
),
tool(
@@ -426,7 +386,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
instead with the file.insert_step (STEP) or file.insert_mesh (mesh) commands. A STEP file from a system \
that has Y up opens lying on its back: `up: \"y\"` turns its component a quarter turn about X (Y becomes Z). glTF is Y up, \
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(
object(
json!({
"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"},
@@ -443,7 +403,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
By default the path must lie in one of the agent's folders: the folder of a design opened or saved in this session (not its hidden files), \
SolidCraft's recovery folder, or a folder this server was started with `--allow-path DIR`. For a folder outside them, ask the user \
to add `--allow-path` and that folder to this server's arguments in their MCP client configuration.",
obj(
object(
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)"}}),
&[],
),
@@ -455,7 +415,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
"Read a document of SolidCraft's bundled agent skills (solidcraft-modeling, solidcraft-mechanisms, \
solidcraft-editing, solidcraft-checking) from the copy built into SolidCraft. `file` defaults to reference.md; \
each skill also has SKILL.md. Unknown skills or files are refused with the available ones.",
obj(
object(
json!({
"skill": string("Skill name, such as solidcraft-modeling"),
"file": {"type": "string", "description": "Document of that skill", "default": "reference.md"},
@@ -480,7 +440,7 @@ fn inspect_design_tool(opts: Options) -> Value {
status), assembly joints (what each origin is attached to: occurrence, the feature, face, edge or sketch \
curve its face names refer to, whether it resolves and why not; `execute` `joint.inspect {joint}` gives one), \
the active sketch, selection and undo depth.",
obj(
object(
json!({
"measure": {"type": "boolean", "description": "Measure bodies (default true)"},
"sketches": {"type": "boolean", "description": "Full sketch contents (default true; false = summary only)"},
@@ -501,7 +461,7 @@ fn inspect_design_tool(opts: Options) -> Value {
(curves with ids and coordinates, points, constraints, profiles) only for the sketches named in `sketches`, or \
all with `sketches: true` (large: a 720-point sketch is some 200 KB). Assembly joints: what each origin is \
attached to (`execute` `joint.inspect {joint}` gives one). Arguments: measure, sketches, bodies, joints.",
obj(
object(
json!({
"measure": {"type": "boolean", "description": "Measure bodies (default true)"},
"sketches": {
@@ -518,65 +478,6 @@ fn inspect_design_tool(opts: Options) -> Value {
)
}
fn type_matches(t: &str, v: &Value) -> bool {
match t {
"string" => v.is_string(),
"number" => v.is_number(),
"integer" => v.is_i64() || v.is_u64(),
"boolean" => v.is_boolean(),
"array" => v.is_array(),
"object" => v.is_object(),
"null" => v.is_null(),
_ => true,
}
}
/// [`check_arguments_with`] the default options.
#[cfg(test)]
pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
check_arguments_with(name, args, Options::default())
}
/// Validates `args` against the tool's schema as listed with these [`Options`] (top level):
/// unknown tool, missing required arguments, unknown arguments, type and enum mismatches.
pub(crate) fn check_arguments_with(name: &str, args: &Value, opts: Options) -> Result<(), String> {
let defs = tool_definitions_with(opts);
let def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{name}`"))?;
let schema = &def["inputSchema"];
let Some(a) = args.as_object() else { return Err(format!("{name}: arguments must be an object")) };
for r in schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) {
if a.get(r).is_none_or(Value::is_null) {
return Err(format!("{name}: missing required argument `{r}`"));
}
}
let props = schema["properties"].as_object();
for (k, v) in a {
let Some(p) = props.and_then(|p| p.get(k)) else {
let valid: Vec<&str> = props.map(|p| p.keys().map(String::as_str).collect()).unwrap_or_default();
let valid = if valid.is_empty() { "none".to_string() } else { valid.join(", ") };
let k: String = k.chars().take(64).collect();
return Err(format!("{name}: unknown argument `{k}`; its arguments are: {valid}"));
};
if v.is_null() {
continue;
}
let ok = match &p["type"] {
Value::String(t) => type_matches(t, v),
Value::Array(ts) => ts.iter().filter_map(Value::as_str).any(|t| type_matches(t, v)),
_ => true,
};
if !ok {
return Err(format!("{name}: argument `{k}` must be of type {}", p["type"]));
}
if let Some(allowed) = p["enum"].as_array()
&& !allowed.contains(v)
{
return Err(format!("{name}: `{k}` must be one of {}", Value::Array(allowed.clone())));
}
}
Ok(())
}
/// A tool result: content blocks plus the error flag. JSON is pretty-printed, or compact with
/// compact output.
fn text_result(v: &Value, is_error: bool, opts: Options) -> Value {
@@ -584,16 +485,14 @@ fn text_result(v: &Value, is_error: bool, opts: Options) -> Value {
Value::String(s) => s.clone(),
v => opts.json_text(v),
};
let mut r = json!({"content": [{"type": "text", "text": text}]});
if is_error {
r["isError"] = json!(true);
if let Some(reason) = v
let mut r = solidcraft_mcp_server::text_result(text, is_error);
if is_error
&& let Some(reason) = v
.as_str()
.and_then(solidcraft_engine::panels::commands::structured_error)
.or_else(|| v.get("failed").and_then(|failed| failed.get("panel_error")).cloned())
{
r["structuredContent"] = json!({"panel_error":reason});
}
{
r["structuredContent"] = json!({"panel_error":reason});
}
r
}
@@ -2167,10 +2066,7 @@ static SHOT: AtomicU64 = AtomicU64::new(0);
/// `screenshot`: render or capture to a file, return it as an MCP image content block.
fn screenshot(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
match capture(b, a) {
Ok((png, info)) => json!({"content": [
{"type": "image", "data": base64(&png), "mimeType": "image/png"},
{"type": "text", "text": info.to_string()},
]}),
Ok((png, info)) => solidcraft_mcp_server::image_result(&png, "image/png", info.to_string()),
Err(e) => text_result(&Value::String(e), true, Options::default()),
}
}
@@ -2282,21 +2178,3 @@ fn capture(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<(Vec<u8>, Valu
}
Ok((png, reply))
}
/// Standard base64 with padding (RFC 4648).
pub(crate) fn base64(data: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
for (i, shift) in [18u32, 12, 6, 0].into_iter().enumerate() {
if i <= chunk.len() {
out.push(char::from(T[((n >> shift) & 63) as usize]));
} else {
out.push('=');
}
}
}
out
}
+1
View File
@@ -14,6 +14,7 @@ raytrace-cpu = ["dep:robust"]
[dependencies]
solidcore-record = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+1 -31
View File
@@ -1,37 +1,7 @@
//! Surface analysis as a view draws it: the shading mode and the colours it uses.
use solidcore_record::Vec3;
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnalysisColors {
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
pub draft: [[f32; 3]; 3],
/// Accessibility Analysis: reachable, blocked.
pub access: [[f32; 3]; 2],
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius. Dark blue
/// to light yellow in one straight blend, so the lightness rises the whole way and the scale
/// reads with hue lost too (#975).
pub curvature: [[f32; 3]; 3],
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
pub hatch: bool,
}
impl AnalysisColors {
/// The light theme's colours (no colour vision mode): what headless pictures use.
pub const DEFAULT: AnalysisColors = AnalysisColors {
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
curvature: [[0.08, 0.12, 0.45], [0.53, 0.51, 0.425], [0.98, 0.90, 0.40]],
hatch: false,
};
}
impl Default for AnalysisColors {
fn default() -> Self {
AnalysisColors::DEFAULT
}
}
use solidcraft_color::AnalysisColors;
/// A way of shading the model's faces to judge their shape.
#[derive(Clone, Copy, Debug, PartialEq)]
+1 -1
View File
@@ -10,7 +10,7 @@ use super::{CompletePass, MAX_BYTES, invalid, limit};
use crate::job::encoding::PngEncoding;
use crate::job::{CancelToken, CpuHost, ErrorCode, JobError, Phase, Progress, Reservation, Result};
use solidcore_record::Vec3;
use solidcraft_geom::srgb::encode8;
use solidcraft_color::srgb::encode8;
/// Encoded bytes retain the actual existing host lease. No detached byte getter.
pub struct CompletedPng {
+1 -1
View File
@@ -2757,7 +2757,7 @@ mod retained_request_tests {
assert!(frame.linear_exr().is_some());
let color = [128, 64, 255].map(|v| {
let x = crate::surface::srgb8_to_linear(v) as f32 as f64;
solidcraft_geom::srgb::encode8(x / (1.0 + x))
solidcraft_color::srgb::encode8(x / (1.0 + x))
});
assert_eq!(image::load_from_memory(frame.png()).unwrap().to_rgba8().get_pixel(0, 0).0, [color[0], color[1], color[2], 255]);
let held = job.clone();
+1 -1
View File
@@ -375,7 +375,7 @@ impl PhotographicState {
let grain = (random(index as u32 ^ self.options.seed) - 0.5) * self.options.grain * (luminance + 0.03).sqrt();
for channel in 0..3 {
let linear = (rgb[channel] + flare[channel] + grain).max(0.0);
self.output.push(solidcraft_geom::srgb::encode8(linear));
self.output.push(solidcraft_color::srgb::encode8(linear));
}
self.output.push(255);
}
+1 -1
View File
@@ -191,7 +191,7 @@ impl Display {
let strength = self.finishing.bloom.map_or(0.0, |b| b.strength);
value += strength * self.tone_map.map(f64::from(glow[channel]) * self.exposure * self.finishing.rgb_gain[channel]);
}
*out = solidcraft_geom::srgb::encode8(value);
*out = solidcraft_color::srgb::encode8(value);
}
rgba[3] = if self.transparent { (alpha * 255.0).round() as u8 } else { 255 };
}
+1 -1
View File
@@ -32,7 +32,7 @@ pub mod texture_lod;
pub mod texture_sampling;
pub mod transmission;
pub use analysis::{AnalysisColors, AnalysisMode, SceneAnalysis};
pub use analysis::{AnalysisMode, SceneAnalysis};
pub use backend::{
Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits,
RenderMode, RenderOperation, RenderPolicy, RenderProfile, RenderRequest, ResolveError, ResolvedRequest, ScratchLayout, builtin_backend,
+1 -1
View File
@@ -15,8 +15,8 @@ use crate::{
surface::{OpaqueSurface, srgb8_to_linear},
};
use solidcore_record::Vec3;
use solidcraft_color::srgb::encode8;
use solidcraft_geom::prelude::*;
use solidcraft_geom::srgb::encode8;
#[cfg(test)]
#[path = "environment_image_tests.rs"]
+1 -1
View File
@@ -111,7 +111,7 @@ impl StagePanel {
mesh.tri_face.extend([0, 0]);
// The surface carries exact linear reflectance. This display colour is not
// used as an additional multiplier by PBR consumers.
let color = self.reflectance_linear.map(solidcraft_geom::srgb::encode8);
let color = self.reflectance_linear.map(solidcraft_color::srgb::encode8);
Ok(ProductMesh {
name: format!("__render_stage__/{}", self.id),
mesh: Arc::new(mesh),
+2 -2
View File
@@ -160,8 +160,8 @@ impl OpaqueSurface {
}
}
/// Exact piecewise sRGB decoding of an existing 8-bit color channel ([`solidcraft_geom::srgb::decode8`]).
pub use solidcraft_geom::srgb::decode8 as srgb8_to_linear;
/// Exact piecewise sRGB decoding of an existing 8-bit color channel ([`solidcraft_color::srgb::decode8`]).
pub use solidcraft_color::srgb::decode8 as srgb8_to_linear;
/// Normalize finite directions without overflow or an arbitrary magnitude threshold.
pub fn unit_direction(v: Vec3) -> Result<Vec3, SurfaceError> {
@@ -179,7 +179,7 @@ impl NormalizationSource {
let linear = match conversion {
ColorConversion::RawData => bounded,
ColorConversion::PngGammaSrgbPrimaries { encoding_gamma } => bounded.powf(1.0 / encoding_gamma),
_ => solidcraft_geom::srgb::decode(bounded),
_ => solidcraft_color::srgb::decode(bounded),
};
if !linear.is_finite() || !(0.0..=1.0).contains(&linear) {
return Err(invalid("normalization produced invalid linear/data channels"));
+4 -2
View File
@@ -11,6 +11,10 @@ workspace = true
[dependencies]
solidcore-record = { workspace = true }
solidcraft-engine = { workspace = true }
# The widget registry (handles, rects, counts) the UI kit publishes into.
solidcraft-ui-registry = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-ui-text = { workspace = true }
egui = { workspace = true }
egui-wgpu = { workspace = true }
solidcraft-gpu = { workspace = true }
@@ -25,10 +29,8 @@ image = { workspace = true, features = ["jpeg", "ico", "gif", "webp", "bmp", "ti
ttf-parser = { workspace = true }
# Right-to-left interface text (Hebrew): the Unicode Bidirectional Algorithm puts translated
# strings in display order, which egui does not do itself (MIT OR Apache-2.0).
unicode-bidi = "0.3"
# The system's preferred languages on a first start (MIT OR Apache-2.0, maintained by
# 1Password): the OS locale on desktop and, with `js`, `navigator.languages` in the browser (#10138).
sys-locale = { version = "0.3.2", features = ["js"] }
semver = { workspace = true }
# Updates: the downloaded package's SHA-256 (against the release's SHA256SUMS.txt), its optional
# minisign signature (MIT), and the portable .zip's executables.
+4 -4
View File
@@ -248,7 +248,7 @@ impl AcpBackend {
// Drain replies first: an acknowledgement already waiting must keep the connection.
if self.cancelling.is_some_and(|(_, started)| now.saturating_duration_since(started) >= CANCEL_GRACE) {
self.proc.shutdown(false);
out.push(self.finish_exit(vec![crate::i18n::tr("ui.agent_chat_cancel_timeout").to_string()]));
out.push(self.finish_exit(vec![solidcraft_ui_text::tr("ui.agent_chat_cancel_timeout").to_string()]));
}
out
}
@@ -359,9 +359,9 @@ pub fn test_agent(argv: &[String], cwd: &std::path::Path) -> Result<String, Test
}
}
Ok(FromAgent::Exited(tail)) => {
return Err(TestError::Other(tail.last().cloned().unwrap_or_else(|| crate::i18n::tr("ui.agent_chat_exited").to_string())));
return Err(TestError::Other(tail.last().cloned().unwrap_or_else(|| solidcraft_ui_text::tr("ui.agent_chat_exited").to_string())));
}
Err(_) => return Err(TestError::Other(crate::i18n::tr("ui.ai_no_answer").to_string())),
Err(_) => return Err(TestError::Other(solidcraft_ui_text::tr("ui.ai_no_answer").to_string())),
}
}
};
@@ -372,7 +372,7 @@ pub fn test_agent(argv: &[String], cwd: &std::path::Path) -> Result<String, Test
p.send(&acp::request(2, "session/new", acp::session_new_params(&session_cwd, None, &v, acp::SYSTEM_NOTE)));
wait_for(&p, 2).map(|_| {
let who = acp::agent_info(&v);
crate::i18n::format("ui.ai_test_acp_ok", &[("agent", if who.is_empty() { "?" } else { &who })])
solidcraft_ui_text::format("ui.ai_test_acp_ok", &[("agent", if who.is_empty() { "?" } else { &who })])
})
});
p.shutdown(true);
+4 -4
View File
@@ -32,7 +32,7 @@ impl Mode {
Mode::ALL.into_iter().find(|m| m.id() == s)
}
pub fn label(self) -> &'static str {
crate::i18n::tr(match self {
solidcraft_ui_text::tr(match self {
Mode::Ask => "ui.ai_mode_ask",
Mode::Edits => "ui.ai_mode_edits",
Mode::All => "ui.ai_mode_all",
@@ -217,7 +217,7 @@ impl Entry {
match self.kind {
Kind::Api | Kind::Anthropic => format!("{} · {}", self.model, self.base_url),
Kind::Acp => match (&self.problem, &self.version) {
(Some(p), _) => crate::i18n::format("ui.agent_chat_cannot_start", &[("error", p)]),
(Some(p), _) => solidcraft_ui_text::format("ui.agent_chat_cannot_start", &[("error", p)]),
(None, Some(v)) => v.clone(),
(None, None) => super::panel::short_command(&self.argv.join(" ")),
},
@@ -228,7 +228,7 @@ impl Entry {
pub fn problem_hint(&self) -> Option<String> {
let problem = self.problem.as_deref()?;
let program = self.argv.first().map(String::as_str).unwrap_or_default();
Some(crate::i18n::format(
Some(solidcraft_ui_text::format(
"ui.agent_chat_reinstall",
&[("agent", &self.name), ("program", program), ("error", problem), ("install", self.install)],
))
@@ -381,7 +381,7 @@ pub fn normalize_url(url: &str) -> Result<String, String> {
let u = url.trim().trim_end_matches('/').to_string();
let lower = u.to_ascii_lowercase();
if !(lower.starts_with("http://") || lower.starts_with("https://")) || u.len() < 10 || u.contains(char::is_whitespace) {
return Err(crate::i18n::tr("ui.ai_bad_url").to_string());
return Err(solidcraft_ui_text::tr("ui.ai_bad_url").to_string());
}
Ok(u)
}
+19 -14
View File
@@ -672,7 +672,7 @@ pub struct ApiBackend {
impl ApiBackend {
pub fn start(cfg: ApiConfig, ctx: StartContext) -> Result<ApiBackend, String> {
if cfg.key.is_some() && !super::agents::key_allowed_for(&cfg.base_url, cfg.protocol == Protocol::OpenAi && cfg.allow_insecure_http) {
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
return Err(solidcraft_ui_text::tr("ui.ai_key_needs_https").to_string());
}
// One stable session ID per chat for OpenCode Go's routing and prompt cache.
let session_id = new_session_id();
@@ -1295,7 +1295,7 @@ impl Worker {
/// What the chat says when a request ran out of time: the limit, and where to raise it.
pub fn timed_out(limit_s: u64) -> String {
crate::i18n::format("ui.ai_request_timed_out", &[("seconds", &limit_s.to_string())])
solidcraft_ui_text::format("ui.ai_request_timed_out", &[("seconds", &limit_s.to_string())])
}
/// A failed request as people read it: one that ran into the endpoint's request timeout (the
@@ -1309,10 +1309,10 @@ pub fn http_error(status: u16, body: &Value) -> String {
let detail =
body.pointer("/error/message").or_else(|| body.get("error")).and_then(Value::as_str).unwrap_or("").chars().take(300).collect::<String>();
let what = match status {
401 | 403 => crate::i18n::tr("ui.ai_http_key"),
404 => crate::i18n::tr("ui.ai_http_not_found"),
429 => crate::i18n::tr("ui.ai_http_busy"),
413 => crate::i18n::tr("ui.ai_http_too_large"),
401 | 403 => solidcraft_ui_text::tr("ui.ai_http_key"),
404 => solidcraft_ui_text::tr("ui.ai_http_not_found"),
429 => solidcraft_ui_text::tr("ui.ai_http_busy"),
413 => solidcraft_ui_text::tr("ui.ai_http_too_large"),
_ => "",
};
format!(
@@ -1643,7 +1643,7 @@ fn anthropic_headers(base_url: &str, key: Option<&str>) -> Result<Vec<(String, S
let mut headers = vec![("anthropic-version".to_string(), ANTHROPIC_VERSION.to_string())];
if let Some(k) = key {
if !super::agents::key_safe_for(base_url) {
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
return Err(solidcraft_ui_text::tr("ui.ai_key_needs_https").to_string());
}
headers.push(("x-api-key".to_string(), k.to_string()));
}
@@ -1670,7 +1670,7 @@ pub fn anthropic_model_list(http: &HttpFn, base_url: &str, key: Option<&str>) ->
pub fn test_anthropic_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>, model: &str) -> Result<String, String> {
let model = model.trim();
if model.is_empty() {
return Err(crate::i18n::tr("ui.ai_need_model").to_string());
return Err(solidcraft_ui_text::tr("ui.ai_need_model").to_string());
}
let mut headers = anthropic_headers(base_url, key)?;
headers.push(("Content-Type".to_string(), "application/json".to_string()));
@@ -1687,7 +1687,7 @@ pub fn test_anthropic_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>,
if status != 200 {
return Err(http_error(status, &v));
}
Ok(crate::i18n::format("ui.ai_test_answered", &[("model", model)]))
Ok(solidcraft_ui_text::format("ui.ai_test_answered", &[("model", model)]))
}
/// A tool call's result as text for the model, and whether it failed.
@@ -1741,7 +1741,7 @@ fn tool_title(name: &str, args: &Value) -> String {
/// endpoint should be, say): an error, so the picker says so and asks again later.
fn listed_models(body: &Value, name_key: &str) -> Result<Vec<(String, Option<String>)>, String> {
let Some(entries) = body.get("data").and_then(Value::as_array) else {
return Err(crate::i18n::tr("ui.agent_chat_models_not_a_list").to_string());
return Err(solidcraft_ui_text::tr("ui.agent_chat_models_not_a_list").to_string());
};
Ok(entries
.iter()
@@ -1771,7 +1771,7 @@ pub fn model_list(http: &HttpFn, base_url: &str, key: Option<&str>, allow_insecu
let mut headers = Vec::new();
if let Some(k) = key {
if !super::agents::key_allowed_for(base_url, allow_insecure_http) {
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
return Err(solidcraft_ui_text::tr("ui.ai_key_needs_https").to_string());
}
headers.push(("Authorization".to_string(), format!("Bearer {k}")));
}
@@ -1783,13 +1783,16 @@ pub fn test_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>, model: &s
let mut headers = Vec::new();
if let Some(k) = key {
if !super::agents::key_allowed_for(base_url, allow_insecure_http) {
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
return Err(solidcraft_ui_text::tr("ui.ai_key_needs_https").to_string());
}
headers.push(("Authorization".to_string(), format!("Bearer {k}")));
}
let models = model_entries(http, base_url, headers)?;
let found = model.is_empty() || models.iter().any(|m| m.id == model);
Ok(crate::i18n::format(if found { "ui.ai_test_ok" } else { "ui.ai_test_no_model" }, &[("count", &models.len().to_string()), ("model", model)]))
Ok(solidcraft_ui_text::format(
if found { "ui.ai_test_ok" } else { "ui.ai_test_no_model" },
&[("count", &models.len().to_string()), ("model", model)],
))
}
#[cfg(test)]
@@ -2642,6 +2645,7 @@ mod tests {
/// id that could not be sent back is left out, and the list stays within its caps.
#[test]
fn a_listing_keeps_the_names_and_stays_within_its_caps() {
crate::i18n::install();
let mut data = vec![
json!({"id": "openai/gpt-x", "name": "OpenAI: GPT X", "description": "Long text", "supported_parameters": ["tools"]}),
json!({"id": "same", "name": "same", "display_name": "Same One"}),
@@ -2675,7 +2679,7 @@ mod tests {
/// picker says why and asks again later; a list with nothing in it is a list.
#[test]
fn an_answer_that_is_no_model_list_is_an_error() {
let expected = crate::i18n::tr("ui.agent_chat_models_not_a_list").to_string();
let expected = solidcraft_ui_text::tr("ui.agent_chat_models_not_a_list").to_string();
let page: HttpFn = Arc::new(|_: &HttpRequest| Ok((200, b"<!doctype html><html><body>Control UI</body></html>".to_vec())));
assert_eq!(model_list(&page, "http://localhost:18789/v1", None, false).unwrap_err(), expected);
assert_eq!(anthropic_model_list(&page, "http://localhost:18789/v1", None).unwrap_err(), expected);
@@ -3110,6 +3114,7 @@ mod tests {
#[test]
fn the_anthropic_test_sends_one_small_message_and_checks_the_key_rule() {
crate::i18n::install();
let seen = Arc::new(std::sync::Mutex::new(Vec::<HttpRequest>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
+7 -3
View File
@@ -8,7 +8,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use super::backend::{self, PromptPart};
use crate::i18n::format as trf;
use solidcraft_ui_text::format as trf;
/// A refusal in the app's language: the file's name, then why.
fn refused(key: &str, name: &str, args: &[(&str, &str)]) -> String {
@@ -508,6 +508,7 @@ mod tests {
#[test]
fn bad_files_are_refused_with_a_reason() {
crate::i18n::install();
let d = dir("bad");
let fake = d.join("fake.png");
std::fs::write(&fake, b"not a picture at all").unwrap();
@@ -533,15 +534,16 @@ mod tests {
/// Review of #1744: the refusals were English whatever the app's language.
#[test]
fn refusals_speak_the_apps_language() {
crate::i18n::install();
let d = dir("lang");
let binary = d.join("bin.json");
std::fs::write(&binary, [0xff, 0xfe, 0x00, 0x80]).unwrap();
{
let _ja = crate::i18n::scope("ja");
let _ja = solidcraft_ui_text::scope("ja");
assert!(read(&binary).unwrap_err().contains("UTF-8 のテキストではありません"));
}
{
let _zh = crate::i18n::scope("zh");
let _zh = solidcraft_ui_text::scope("zh");
assert!(read(&binary).unwrap_err().contains("不是 UTF-8 文本"));
}
assert!(read(&binary).unwrap_err().contains("not UTF-8 text"), "English again outside the scope");
@@ -552,6 +554,7 @@ mod tests {
/// sent as PNG; SVG goes as its source text; HEIC says why not.
#[test]
fn each_picture_format_goes_the_way_the_agent_reads_it() {
crate::i18n::install();
let d = dir("formats");
let img = image::RgbaImage::from_pixel(30, 20, image::Rgba([20, 120, 220, 255]));
for (file, format, mime) in [
@@ -617,6 +620,7 @@ mod tests {
/// its size and its place) instead of refused; the agent reads it with its own tools.
#[test]
fn other_files_go_by_reference() {
crate::i18n::install();
let d = dir("reference");
let zip = d.join("fixtures.zip");
std::fs::write(&zip, vec![0u8; 3000]).unwrap();
+1 -1
View File
@@ -21,7 +21,7 @@ pub enum TestError {
impl TestError {
pub fn message(&self) -> &str {
match self {
Self::AuthRequired => crate::i18n::tr("ui.agent_chat_state_auth"),
Self::AuthRequired => solidcraft_ui_text::tr("ui.agent_chat_state_auth"),
Self::Other(message) => message,
}
}
+2 -1
View File
@@ -483,7 +483,7 @@ fn detect_in(dirs: &[PathBuf], home: Option<&Path>, limit: Duration) -> Detectio
argv.extend(k.args.iter().map(|a| a.to_string()));
let mut result = if k.reports_version { probe(&path, limit) } else { Probe::default() };
if let Some(version) = result.old_node.as_deref() {
result.problem = Some(crate::i18n::format("ui.agent_chat_node_too_old", &[("version", version)]));
result.problem = Some(solidcraft_ui_text::format("ui.agent_chat_node_too_old", &[("version", version)]));
if uses_env_node(&path)
&& let Some(node) = compatible_node(dirs, limit)
{
@@ -561,6 +561,7 @@ mod tests {
#[test]
#[cfg(unix)]
fn a_node_adapter_recovers_from_an_incompatible_desktop_runtime() {
crate::i18n::install();
use super::super::process::{AgentProcess, FromAgent};
use std::os::unix::fs::PermissionsExt;
@@ -14,8 +14,8 @@ use super::markdown::{self, Block, Span};
use egui::text::{LayoutJob, TextFormat};
use egui::{Color32, FontId, Margin, RichText, Stroke, vec2};
use crate::i18n::tr;
use crate::theme::Tokens;
use solidcraft_ui_text::tr;
/// The parsed blocks of `src`, kept in the context's temporary memory under `slot` (the item's
/// index) and replaced when the text changes.
+3 -3
View File
@@ -45,9 +45,9 @@ use serde_json::{Value, json};
use context::Context;
use crate::i18n::{format as trf, tr};
use crate::{ControlRequest, SolveApp};
use backend::{ChatBackend, Event, PermissionOption, PromptPart, ToolStatus};
use solidcraft_ui_text::{format as trf, tr};
pub(crate) use panel::{left_inset, owns_keyboard_input, tool_space};
pub use panel::{show, tip as panel_tip, toggle, toggle_shortcut};
@@ -565,11 +565,11 @@ impl AgentChat {
job: impl FnOnce() -> std::result::Result<attach::Attachment, String> + Send + 'static,
) -> std::result::Result<String, String> {
let (tx, rx) = std::sync::mpsc::channel();
let language = crate::i18n::language();
let language = solidcraft_ui_text::language();
std::thread::Builder::new()
.name("chat-file".into())
.spawn(move || {
let _language = crate::i18n::scope(language);
let _language = solidcraft_ui_text::scope(language);
let _ = tx.send(job());
})
.map_err(|e| trf("ui.agent_chat_file_error", &[("name", &name), ("error", &e.to_string())]))?;
+5 -5
View File
@@ -189,7 +189,7 @@ pub(crate) fn left_inset(app: &SolveApp, available: Rect) -> f32 {
&& app.preferences.agent_chat_side == "left"
{
let width = if app.preferences.agent_chat_minimized { STRIP } else { app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH) };
(width + crate::shell::SHELL_GAP).min(available.width().max(0.0))
(width + crate::ui_kit::SHELL_GAP).min(available.width().max(0.0))
} else {
0.0
}
@@ -205,9 +205,9 @@ pub(crate) fn tool_space(app: &SolveApp, mut available: Rect) -> Rect {
&& let Some(chat) = app.agent_chat.panel_rect.filter(|r| r.is_finite() && r.is_positive())
{
if app.preferences.agent_chat_side == "left" {
available.min.x = crate::shell::within(chat.right() + crate::shell::SHELL_GAP, available.left(), available.right());
available.min.x = crate::ui_kit::within(chat.right() + crate::ui_kit::SHELL_GAP, available.left(), available.right());
} else {
available.max.x = crate::shell::within(chat.left() - crate::shell::SHELL_GAP, available.left(), available.right());
available.max.x = crate::ui_kit::within(chat.left() - crate::ui_kit::SHELL_GAP, available.left(), available.right());
}
}
available
@@ -216,7 +216,7 @@ pub(crate) fn tool_space(app: &SolveApp, mut available: Rect) -> Rect {
/// The same outer rectangle is used for the floating sheet and its docking preview.
fn floating_bounds(app: &SolveApp, available: Rect, left: bool) -> Rect {
let t = Tokens::get();
let gap = crate::shell::SHELL_GAP;
let gap = crate::ui_kit::SHELL_GAP;
let mut bounds = available.shrink(gap);
// The ribbon already supplies the top gap shared by browser and history cards.
bounds.min.y = available.top();
@@ -1659,7 +1659,7 @@ fn copy_message_menu(ui: &egui::Ui, response: &egui::Response, n: usize, text: &
.frame(ui_kit::Menu::frame())
.open_memory(if opened { Some(egui::SetOpenCommand::Bool(true)) } else { None })
.show(|ui| {
ui_kit::Menu::show(ui, crate::context_menu::LIST_W, |ui| {
ui_kit::Menu::show(ui, crate::ui_kit::LIST_W, |ui| {
let item = ui_kit::MenuItem::new("copy_message", tr("ui.agent_chat_copy_message"), "copy");
let row = item.show(ui);
crate::scenario::publish_handle(&format!("agent_chat:copy_menu:{n}"), row.rect.center());
+30 -30
View File
@@ -208,10 +208,10 @@ fn start_listing(app: &mut SolveApp, ctx: &egui::Context, key: ListingKey, secre
};
let (tx, rx) = std::sync::mpsc::channel();
let (protocol, url, allow_insecure_http) = (key.0, key.1.clone(), key.3);
let language = crate::i18n::language();
let language = solidcraft_ui_text::language();
let wake = ctx.clone();
let spawned = std::thread::Builder::new().name("endpoint-models".into()).spawn(move || {
let _language = crate::i18n::scope(language);
let _language = solidcraft_ui_text::scope(language);
let list = secret().and_then(|secret| match protocol {
Protocol::OpenAi => super::api_client::model_list(&http, &url, secret.as_deref(), allow_insecure_http),
Protocol::Anthropic => super::api_client::anthropic_model_list(&http, &url, secret.as_deref()),
@@ -519,7 +519,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
permissions_section(app, ui);
#[cfg(not(target_arch = "wasm32"))]
{
let browsing = !crate::prefs_search::querying(ui) || app.prefs_window.section == crate::prefs::PAGE_ASSISTANT;
let browsing = !crate::ui_kit::settings_search::querying(ui) || app.prefs_window.section == crate::prefs::PAGE_ASSISTANT;
if app.preferences.ai_features && browsing && app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
refresh(app, &ui.ctx().clone());
}
@@ -561,7 +561,7 @@ fn instructions_section(app: &mut SolveApp, ui: &mut egui::Ui) {
let response = ui_kit::text_area(ui, tr("ai.prefs.instructions"), id, text, 6, acp::INSTRUCTIONS_LIMIT);
if response.has_focus() {
// Typed text belongs to the field, not to Preferences search.
crate::prefs_search::note_key_sink(ui.ctx());
crate::ui_kit::settings_search::note_key_sink(ui.ctx());
}
crate::scenario::publish_target("prefs:agent_instructions", response.rect);
ui_kit::text(ui, tr("ai.prefs.instructions_note"), ui_kit::Text::Hint("instructions"));
@@ -658,20 +658,20 @@ fn panel_section(app: &mut SolveApp, ui: &mut egui::Ui) {
let previous = usize::from(p.agent_chat_turn_limit != 0);
let mut limit = previous;
let options = [tr("ui.agent_chat_turn_unlimited"), tr("prefs.assistant_limited")];
crate::prefs_search::note_current(ui, &options.join(" "));
crate::ui_kit::settings_search::note_current(ui, &options.join(" "));
let dropdown_width = ui_kit::ContentWidth::dropdown(ui, &options);
let response =
ui_kit::settings_control_width(ui, dropdown_width, |ui| ui_kit::dropdown(ui, "assistant_turn_limit", &mut limit, &options));
let query = crate::prefs_search::query(ui);
let query = crate::ui_kit::settings_search::query(ui);
if let Some(option) = options
.iter()
.find(|option| option.to_lowercase() == query)
.or_else(|| options.iter().find(|option| crate::prefs_search::texts_hit(ui, &[*option])))
.or_else(|| options.iter().find(|option| crate::ui_kit::settings_search::texts_hit(ui, &[*option])))
{
crate::prefs_search::remember(ui, option, response.rect);
crate::ui_kit::settings_search::remember(ui, option, response.rect);
}
for option in options {
crate::prefs_search::follow(ui, option, response.rect);
crate::ui_kit::settings_search::follow(ui, option, response.rect);
}
crate::scenario::publish_target("prefs:agent_chat_turn_limit:mode", response.rect);
if limit != previous {
@@ -699,14 +699,14 @@ fn pointer_section(app: &mut SolveApp, ui: &mut egui::Ui) {
ui,
|ui| {
let response = ui_kit::checkbox(ui, tr("ai.prefs.show_pointer"), &mut app.ui.agent_cursor.show)
.backing_on_hover_text(crate::i18n::tr_label("Show the pointer an AI agent moves (#35)"));
.backing_on_hover_text(solidcraft_ui_text::tr_label("Show the pointer an AI agent moves (#35)"));
crate::scenario::publish_target("prefs:agent_pointer_show", response.rect);
ui.add_enabled_ui(app.ui.agent_cursor.show, |ui| {
let speed_label_width = ui_kit::ContentWidth::label(ui, tr("ai.prefs.pointer_speed")) + crate::theme::Tokens::get().space_4;
ui_kit::settings_form(ui, speed_label_width, |ui| {
ui_kit::row(ui, tr("ai.prefs.pointer_speed"), |ui| {
let name = tr("prefs.assistant_pointer_speed");
crate::prefs_search::note_current(ui, name);
crate::ui_kit::settings_search::note_current(ui, name);
let response =
ui_kit::named_control(ui, name, |ui| crate::agent_cursor::speed_slider(ui, &mut app.ui.agent_cursor.speed));
crate::scenario::publish_target("prefs:agent_pointer_speed", response.rect);
@@ -717,11 +717,11 @@ fn pointer_section(app: &mut SolveApp, ui: &mut egui::Ui) {
response.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), value, name));
// The visible row owns Speed and its legacy Motion speed alias. The
// native editor's name owns queries that only match Pointer speed.
if !crate::prefs_search::texts_hit(ui, &[tr("ai.prefs.pointer_speed")]) {
crate::prefs_search::remember(ui, name, response.rect);
if !crate::ui_kit::settings_search::texts_hit(ui, &[tr("ai.prefs.pointer_speed")]) {
crate::ui_kit::settings_search::remember(ui, name, response.rect);
}
for label in [tr("ai.prefs.pointer_speed"), name] {
crate::prefs_search::follow(ui, label, response.rect);
crate::ui_kit::settings_search::follow(ui, label, response.rect);
}
});
});
@@ -734,7 +734,7 @@ fn pointer_section(app: &mut SolveApp, ui: &mut egui::Ui) {
});
(app.ui.agent_cursor.show, app.ui.agent_cursor.speed.label(), app.ui.agent_cursor.follow_camera)
},
|ui, (visible, speed, follow)| ui_kit::settings_pointer_preview(ui, visible, crate::i18n::tr_label(&speed), follow),
|ui, (visible, speed, follow)| ui_kit::settings_pointer_preview(ui, visible, solidcraft_ui_text::tr_label(&speed), follow),
);
});
}
@@ -781,12 +781,12 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
for entry in &all {
ui.push_id(&entry.id, |ui| {
let detail = entry.detail();
let gate = crate::prefs_search::block_gate(ui, &entry.name, Some(&format!("{} {detail}", entry.id)));
let before = crate::prefs_search::hits(ui);
let gate = crate::ui_kit::settings_search::block_gate(ui, &entry.name, Some(&format!("{} {detail}", entry.id)));
let before = crate::ui_kit::settings_search::hits(ui);
let mut paint = |ui: &mut egui::Ui| {
let card = ui_kit::settings_card(ui, |ui| {
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
crate::prefs_search::visible(ui, &[&entry.name, &entry.id, &detail]);
crate::ui_kit::settings_search::visible(ui, &[&entry.name, &entry.id, &detail]);
ui.label(egui::RichText::new(&entry.name).strong().size(t.font_label).color(t.form_ink));
ui.allocate_ui_with_layout(
egui::vec2(ui.available_width(), t.control_height),
@@ -851,7 +851,7 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
let shown = current.map_or_else(|| tr("ui.ai_mode_global").to_owned(), |mode| mode.label().to_owned());
let mut pick: Option<Option<Mode>> = None;
let options: Vec<_> = std::iter::once(tr("ui.ai_mode_global")).chain(Mode::ALL.map(Mode::label)).collect();
crate::prefs_search::note_current(ui, &options.join(" "));
crate::ui_kit::settings_search::note_current(ui, &options.join(" "));
let response = ui_kit::settings_control_width(ui, t.space_24 * 9.0 + t.space_4, |ui| {
ui_kit::dropdown_menu(ui, ("sc_ai_mode", &entry.id), &shown, |ui| {
if ui_kit::list_picker(ui, "global", tr("ui.ai_mode_global"), current.is_none()).clicked() {
@@ -864,16 +864,16 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
}
})
});
let query = crate::prefs_search::query(ui);
let query = crate::ui_kit::settings_search::query(ui);
if let Some(option) = options
.iter()
.find(|option| option.to_lowercase() == query)
.or_else(|| options.iter().find(|option| crate::prefs_search::texts_hit(ui, &[*option])))
.or_else(|| options.iter().find(|option| crate::ui_kit::settings_search::texts_hit(ui, &[*option])))
{
crate::prefs_search::remember(ui, option, response.rect);
crate::ui_kit::settings_search::remember(ui, option, response.rect);
}
for option in options {
crate::prefs_search::follow(ui, option, response.rect);
crate::ui_kit::settings_search::follow(ui, option, response.rect);
}
match pick {
Some(Some(mode)) => {
@@ -965,23 +965,23 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
});
crate::scenario::publish_rect(&format!("prefs:ai_card:{}", entry.id), card.response.rect);
let metadata = [&entry.name, &entry.id, &detail];
if let Some(text) = metadata.iter().find(|text| crate::prefs_search::texts_hit(ui, &[*text])) {
crate::prefs_search::remember(ui, text, card.response.rect);
if let Some(text) = metadata.iter().find(|text| crate::ui_kit::settings_search::texts_hit(ui, &[*text])) {
crate::ui_kit::settings_search::remember(ui, text, card.response.rect);
}
for text in metadata {
crate::prefs_search::follow(ui, text, card.response.rect);
crate::ui_kit::settings_search::follow(ui, text, card.response.rect);
}
};
let show = gate.as_ref().is_none_or(|gate| gate.show);
if show {
paint(ui);
} else {
crate::prefs_search::run_hidden(ui, &entry.id, |ui| {
crate::ui_kit::settings_search::run_hidden(ui, &entry.id, |ui| {
paint(ui);
});
}
if let Some(gate) = gate {
crate::prefs_search::block_finish(ui, &gate, show, before);
crate::ui_kit::settings_search::block_finish(ui, &gate, show, before);
}
});
}
@@ -1524,7 +1524,7 @@ mod card_tests {
#[test]
fn pointer_speed_search_reveals_one_real_slider_in_all_enablement_states() {
use crate::prefs_search::{nav, set_cursor};
use crate::ui_kit::settings_search::{nav, set_cursor};
for theme in ["light", "dark"] {
for (ai, show) in [(true, true), (true, false), (false, true), (false, false)] {
let mut h = Harness::new();
@@ -1569,7 +1569,7 @@ mod card_tests {
#[test]
fn overlapping_agent_metadata_has_one_card_target_and_keeps_fields_indexed() {
use crate::prefs_search::{nav, set_cursor};
use crate::ui_kit::settings_search::{nav, set_cursor};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
+1 -1
View File
@@ -8,7 +8,7 @@
use serde_json::{Map, Value};
use crate::i18n::{format as trf, tr, tr_label};
use solidcraft_ui_text::{format as trf, tr, tr_label};
/// Steps of a batch named before "and N more".
const MAX_STEPS: usize = 4;
+10 -10
View File
@@ -1,7 +1,7 @@
use super::*;
use std::sync::mpsc::channel;
fn transcript_clipboard_harness(layout: crate::shell::LayoutMode) -> crate::scenario::Harness {
fn transcript_clipboard_harness(layout: crate::ui_kit::LayoutMode) -> crate::scenario::Harness {
let mut h = narrow_chat();
h.app.ui.layout = layout;
h.app.preferences.agent_chat_width = 500.0;
@@ -36,7 +36,7 @@ fn transcript_click(h: &mut crate::scenario::Harness, pos: egui::Pos2) {
#[test]
fn transcript_copy_and_cut_do_not_touch_the_cad_clipboard() {
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
let mut h = transcript_clipboard_harness(layout);
let at = transcript_text_position(&h);
transcript_click(&mut h, at);
@@ -57,7 +57,7 @@ fn transcript_copy_and_cut_do_not_touch_the_cad_clipboard() {
#[test]
fn transcript_paste_does_not_insert_a_previously_copied_body() {
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
let mut h = transcript_clipboard_harness(layout);
h.frame_with(vec![egui::Event::Copy]);
assert!(!h.app.copied_bodies.is_empty(), "prime the real CAD clipboard");
@@ -77,7 +77,7 @@ fn transcript_paste_does_not_insert_a_previously_copied_body() {
#[test]
fn transcript_key_events_do_not_run_cad_shortcuts() {
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
let mut h = transcript_clipboard_harness(layout);
let at = transcript_text_position(&h);
transcript_click(&mut h, at);
@@ -106,7 +106,7 @@ fn transcript_key_events_do_not_run_cad_shortcuts() {
#[test]
fn transcript_shortcut_ownership_ends_when_the_panel_is_hidden() {
for hidden in ["closed", "minimized", "disabled"] {
let mut h = transcript_clipboard_harness(crate::shell::LayoutMode::Floating);
let mut h = transcript_clipboard_harness(crate::ui_kit::LayoutMode::Floating);
h.frame_with(vec![egui::Event::Copy]);
let at = transcript_text_position(&h);
transcript_click(&mut h, at);
@@ -2906,7 +2906,7 @@ fn pictures_are_read_off_the_window_thread() {
send(&mut app, &ctx, words);
assert_eq!(script.prompts().len(), 1);
{
let _ja = crate::i18n::scope("ja");
let _ja = solidcraft_ui_text::scope("ja");
control(&mut app, &ctx, &json!({"attach": at(&d, "fake.png")})).unwrap();
}
settle_files(&mut app, &ctx);
@@ -3184,7 +3184,7 @@ fn a_press_at_the_edge_without_a_drag_towards_it_scrolls_nothing() {
/// at the panel's edge or is cut off at the other one.
#[test]
fn the_agent_list_keeps_the_panels_side_margin() {
use crate::shell::LayoutMode;
use crate::ui_kit::LayoutMode;
for layout in [LayoutMode::Docked, LayoutMode::Floating] {
let mut h = narrow_chat();
h.app.ui.layout = layout;
@@ -4614,9 +4614,9 @@ fn the_composer_hint_stays_on_one_row_at_the_narrowest_width() {
continue;
}
let language = path.file_stem().unwrap().to_string_lossy().into_owned();
let _scope = crate::i18n::scope(&language);
let _scope = solidcraft_ui_text::scope(&language);
for key in ["ui.agent_chat_placeholder", "ui.agent_chat_placeholder_busy"] {
let hint = crate::i18n::tr(key).to_owned();
let hint = solidcraft_ui_text::tr(key).to_owned();
let mut rows = 0;
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
rows = crate::ui_kit::assistant_hint_galley(ui, &hint, width - 2.0 * crate::theme::Tokens::get().form_inset).rows.len();
@@ -4634,7 +4634,7 @@ fn the_composer_hint_stays_on_one_row_at_the_narrowest_width() {
#[test]
fn tool_room_beside_the_docked_assistant_survives_an_inside_out_view() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.ui.layout = crate::shell::LayoutMode::Floating;
app.ui.layout = crate::ui_kit::LayoutMode::Floating;
app.preferences.ai_features = true;
app.agent_chat.open = true;
app.preferences.agent_chat_popped = false;
+2 -2
View File
@@ -109,7 +109,7 @@ pub fn speed_slider(ui: &mut egui::Ui, speed: &mut Speed) -> egui::Response {
1.0..=5.5,
crate::ui_kit::Slider {
step: Some(0.5),
format: |v| if v > 5.25 { crate::i18n::tr_label("Instant").into() } else { format!("{v:.2}x") },
format: |v| if v > 5.25 { solidcraft_ui_text::tr_label("Instant").into() } else { format!("{v:.2}x") },
..Default::default()
},
);
@@ -504,7 +504,7 @@ pub fn paint(app: &SolveApp, ctx: &egui::Context) {
.show(ctx, |ui| {
let frame = Container::popup_frame(ui.style()).fill(t.overlay);
Container::show_backing(ui, frame, |ui| {
ui.label(egui::RichText::new(crate::i18n::tr_label(label)).size(t.font_label).color(t.text));
ui.label(egui::RichText::new(solidcraft_ui_text::tr_label(label)).size(t.font_label).color(t.text));
});
});
}
+1 -1
View File
@@ -118,7 +118,7 @@ pub fn tick(app: &mut SolveApp) {
}
let _ = std::fs::write(&file, serde_json::to_string(&all).unwrap_or_default());
let names = fresh.iter().map(|c| client_name(c)).collect::<Vec<_>>().join(", ");
app.set_status(crate::i18n::format("ai.connected_toast", &[("clients", &names)]), false);
app.set_status(solidcraft_ui_text::format("ai.connected_toast", &[("clients", &names)]), false);
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => app.ai_connect.job = None,
+5 -4
View File
@@ -115,7 +115,7 @@ fn cell<R>(ui: &mut egui::Ui, width: f32, add: impl FnOnce(&mut egui::Ui) -> R)
/// The label of one of [`BOM_KEYS`].
fn field_label(key: &str) -> &str {
metadata::key(key).map_or(key, |k| crate::i18n::tr(k.label))
metadata::key(key).map_or(key, |k| solidcraft_ui_text::tr(k.label))
}
/// Apply the picked row's fields to each of its definitions (one undo step each).
@@ -146,8 +146,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let bom = d.rows(app);
let rows: Vec<Value> = bom["rows"].as_array().cloned().unwrap_or_default();
let (mut export, mut apply_row) = (None, None);
let closed =
crate::ui_kit::Window::new(egui::Id::unique("sc_bom"), "bom", crate::i18n::tr_label("Bill of Materials")).cards(3.0).show_body(ctx, |ui| {
let closed = crate::ui_kit::Window::new(egui::Id::unique("sc_bom"), "bom", solidcraft_ui_text::tr_label("Bill of Materials"))
.cards(3.0)
.show_body(ctx, |ui| {
ui.horizontal_wrapped(|ui| {
let was = (d.top, d.by_definition, d.group_children);
let mut levels = usize::from(d.top);
@@ -166,7 +167,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
});
d.by_definition = grouping == 1;
ui.separator();
crate::ui_kit::checkbox(ui, crate::i18n::tr("bom.group_children"), &mut d.group_children);
crate::ui_kit::checkbox(ui, solidcraft_ui_text::tr("bom.group_children"), &mut d.group_children);
if was != (d.top, d.by_definition, d.group_children) {
d.picked = None;
}
+19 -13
View File
@@ -148,8 +148,9 @@ use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::{BrowserEntry, Document, FeatureKind};
use crate::context_menu::{Item, Target};
use crate::context_menu::Target;
use crate::theme::Tokens;
use crate::ui_kit::Item;
use crate::SolveApp;
@@ -1384,11 +1385,11 @@ fn browser_collapsed_rail(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::ui_kit::disclosure(ui.painter(), disclosure.center(), false, t.text_dim);
crate::scenario::publish_handle("left-browser-toggle", disclosure.center());
crate::scenario::publish_target("browser:header", disclosure);
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), crate::i18n::tr("ui.browser_title")));
if response.backing_on_hover_text(crate::i18n::tr("ui.browser_expand")).clicked() {
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), solidcraft_ui_text::tr("ui.browser_title")));
if response.backing_on_hover_text(solidcraft_ui_text::tr("ui.browser_expand")).clicked() {
app.preferences.timeline_left_browser_collapsed = false;
}
let galley = ui.painter().layout_no_wrap(crate::i18n::tr("ui.browser_title").to_owned(), crate::fonts::semibold(t.font_body), t.text);
let galley = ui.painter().layout_no_wrap(solidcraft_ui_text::tr("ui.browser_title").to_owned(), crate::fonts::semibold(t.font_body), t.text);
let title = Rect::from_min_max(rect.min + vec2(0.0, title_top), rect.max);
ui.painter().add(
egui::epaint::TextShape::new(title.center() - galley.size() / 2.0, galley, t.text)
@@ -1456,7 +1457,7 @@ fn browser_header(app: &mut SolveApp, ui: &mut egui::Ui, collapsible: bool) {
if collapsible && (response.hovered() || response.has_focus()) {
crate::ui_kit::panel_header_hover(ui.painter(), rect);
}
let title = crate::i18n::tr("ui.browser_title").to_string();
let title = solidcraft_ui_text::tr("ui.browser_title").to_string();
let kind = if collapsible { egui::Role::Button } else { egui::Role::Label };
response.widget_info(|| egui::WidgetInfo::labeled(kind, true, &title));
let galley = ui.painter().layout_no_wrap(title, crate::fonts::semibold(t.font_heading), t.text);
@@ -1468,11 +1469,11 @@ fn browser_header(app: &mut SolveApp, ui: &mut egui::Ui, collapsible: bool) {
}
let bodies = app.session.model.state().bodies.len();
let key = if bodies == 1 { "ui.browser_bodies_one" } else { "ui.browser_bodies_other" };
let count = crate::i18n::format(key, &[("count", &bodies.to_string())]);
let count = solidcraft_ui_text::format(key, &[("count", &bodies.to_string())]);
let galley = ui.painter().layout_no_wrap(count, FontId::proportional(t.font_body), t.text_dim);
ui.painter().galley(pos2(right - galley.size().x, rect.center().y - galley.size().y / 2.0), galley, t.text_dim);
if collapsible {
let tip = crate::i18n::tr(if collapsed { "ui.browser_expand" } else { "ui.browser_collapse" });
let tip = solidcraft_ui_text::tr(if collapsed { "ui.browser_expand" } else { "ui.browser_collapse" });
if response.on_hover_cursor(egui::CursorIcon::PointingHand).backing_on_hover_text(tip).clicked() {
if stacked {
app.preferences.timeline_left_browser_collapsed = !app.preferences.timeline_left_browser_collapsed;
@@ -2438,7 +2439,7 @@ fn canvas_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut A
}
}
if r.double && locked {
app.set_status(crate::i18n::tr("browser.canvas_locked"), true);
app.set_status(solidcraft_ui_text::tr("browser.canvas_locked"), true);
} else if r.double {
app.tree.canvas_panel = Some(CanvasPanel::new(app, id, false));
}
@@ -2462,7 +2463,7 @@ pub fn canvas_items(app: &SolveApp, id: u64) -> Vec<Item> {
let visible = canvas.is_some_and(|c| c.visible);
// A locked canvas (#12310) is not moved, calibrated or deleted until it is unlocked.
let locked = canvas.is_some_and(|c| c.locked);
let why = locked.then(|| crate::i18n::tr("browser.canvas_locked").to_string());
let why = locked.then(|| solidcraft_ui_text::tr("browser.canvas_locked").to_string());
vec![
Item::action("ui.editCanvas", "Edit Canvas", "canvas").with(json!({ "canvas": id })).off_because(why.clone()),
Item::action("ui.calibrateCanvas", "Calibrate", "measure").with(json!({ "canvas": id })).off_because(why.clone()),
@@ -2602,7 +2603,11 @@ impl Entry {
row_key: format!("b:{world}"),
patterns: None,
// A part the modelling kernel refuses says so (Repair Part is in its menu).
label: if b.body.smlib_refused().is_some() { crate::i18n::format("browser.needs_repair", &[("name", &b.name)]) } else { b.name.clone() },
label: if b.body.smlib_refused().is_some() {
solidcraft_ui_text::format("browser.needs_repair", &[("name", &b.name)])
} else {
b.name.clone()
},
icon,
tip: Some(tip),
visible: app.body_visible(&world),
@@ -2628,9 +2633,9 @@ impl Entry {
thing: Thing::Sketch(f.id),
patterns: crate::sketch_pattern_tree::has_patterns(app, f.id).then(|| format!("s:{}/patterns", f.id)),
label: if lost {
crate::i18n::format("browser.sketch_attachment_lost", &[("name", &crate::i18n::tr_name(&f.name))])
solidcraft_ui_text::format("browser.sketch_attachment_lost", &[("name", &crate::i18n::tr_name(&f.name))])
} else if active {
crate::i18n::format("browser.sketch_editing", &[("name", &crate::i18n::tr_name(&f.name))])
solidcraft_ui_text::format("browser.sketch_editing", &[("name", &crate::i18n::tr_name(&f.name))])
} else {
f.name.clone()
},
@@ -2994,7 +2999,8 @@ fn folder_level(
} else {
&group.label
};
let count = if render && !gopen { crate::i18n::format("render.tree_more", &[("count", &group.count.to_string())]) } else { String::new() };
let count =
if render && !gopen { solidcraft_ui_text::format("render.tree_more", &[("count", &group.count.to_string())]) } else { String::new() };
let r = draw_row(
ui,
ui.scope_id().with(gkey),
+2 -2
View File
@@ -95,7 +95,7 @@ pub(super) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, component: u64, path:
let scope = if path.is_empty() { format!("c{component}") } else { occurrence_key(path) };
// The history opens by default; the Features folder stays closed until opened.
let (key, label) =
if compact { (format!("{scope}/history"), crate::i18n::tr("ui.history_title")) } else { (format!("{scope}/features"), "Features") };
if compact { (format!("{scope}/history"), solidcraft_ui_text::tr("ui.history_title")) } else { (format!("{scope}/features"), "Features") };
let open = is_open(app, &key, compact);
// Folded, it carries the worst mark of what it holds. A row scrolled out of view paints
// nothing, so its mark is not worked out: in a long tree of placements of a part with a long
@@ -400,7 +400,7 @@ fn marker_row(app: &SolveApp, ui: &mut egui::Ui, depth: usize) -> egui::Response
if response.hovered() || response.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
response.backing_on_hover_text(crate::i18n::tr("ui.history_marker_tip"))
response.backing_on_hover_text(solidcraft_ui_text::tr("ui.history_marker_tip"))
}
/// Dragging the marker shows where it lands; letting go rolls the history there, through the
+19 -18
View File
@@ -12,8 +12,9 @@ use solidcraft_engine::view::POINT_CLOUDS_FOLDER;
use super::{Actions, Row, draw_row, is_open, panel_pos, rows_start_open, toggle, toggle_folder};
use crate::SolveApp;
use crate::context_menu::{Item, Target};
use crate::context_menu::Target;
use crate::ui_kit;
use crate::ui_kit::Item;
#[path = "point_cloud_tracks.rs"]
pub(crate) mod point_cloud_tracks;
@@ -267,7 +268,7 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
};
let mut edit: Option<Value> = None;
let mut reload = false;
let title = crate::i18n::format("browser.edit_point_cloud_title", &[("name", &c.name)]);
let title = solidcraft_ui_text::format("browser.edit_point_cloud_title", &[("name", &c.name)]);
let auto = if c.color_by == ColorBy::Height { m.auto_range(&c) } else { None };
let dots = m.dots(&c);
let now = ctx.input(|i| i.time);
@@ -299,16 +300,16 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
let (closed, action) = ui_kit::Window::new(egui::Id::unique("sc_point_cloud_panel"), "point", &title)
.placed(egui::Align2::LEFT_TOP, at)
.fields()
.actions("", crate::i18n::tr("ui.close"), None)
.actions("", solidcraft_ui_text::tr("ui.close"), None)
.show(ctx, true, |ui| {
ui_kit::row(ui, crate::i18n::tr_label("Points"), |ui| ui.label(c.len().to_string()));
ui_kit::row(ui, solidcraft_ui_text::tr_label("Points"), |ui| ui.label(c.len().to_string()));
// The dots' size, where some points are dots: those no segment or triangle ends on.
if dots > 0 {
if dots < c.len() {
ui_kit::row(ui, crate::i18n::tr_label("Dots"), |ui| ui.label(dots.to_string()));
ui_kit::row(ui, solidcraft_ui_text::tr_label("Dots"), |ui| ui.label(dots.to_string()));
}
let mut size = c.point_size;
if ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_size"), |ui| ui_kit::slider(ui, &mut size, 0.5..=16.0, Default::default()))
if ui_kit::row(ui, solidcraft_ui_text::tr("ui.point_cloud_size"), |ui| ui_kit::slider(ui, &mut size, 0.5..=16.0, Default::default()))
.changed()
{
edit = Some(json!({ "cloud": c.id, "point_size": size }));
@@ -318,7 +319,7 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
let was = units.iter().position(|u| *u == c.units).unwrap_or(0);
let mut unit = was;
// A dropdown's response doesn't report the pick: compare the index.
ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_units"), |ui| ui_kit::dropdown(ui, "point_cloud_units", &mut unit, &units));
ui_kit::row(ui, solidcraft_ui_text::tr("ui.point_cloud_units"), |ui| ui_kit::dropdown(ui, "point_cloud_units", &mut unit, &units));
if unit != was
&& let Some(u) = units.get(unit)
{
@@ -334,7 +335,7 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
by => COLOR_BY.iter().position(|(b, _)| *b == by).unwrap_or(0),
};
let mut by = was;
ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_color_by"), |ui| ui_kit::dropdown(ui, "point_cloud_color_by", &mut by, &labels));
ui_kit::row(ui, solidcraft_ui_text::tr("ui.point_cloud_color_by"), |ui| ui_kit::dropdown(ui, "point_cloud_color_by", &mut by, &labels));
if by != was {
let name = COLOR_BY.get(by).map(|(b, _)| b.name()).or_else(|| properties.get(by - COLOR_BY.len()).copied());
if let Some(name) = name {
@@ -345,12 +346,12 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
let legend_shown = colored.and_then(|n| label(&c, n));
match c.color_by {
ColorBy::File if c.colors.is_empty() => {
ui_kit::row(ui, "", |ui| ui.weak(crate::i18n::tr("ui.point_cloud_no_file_colors")));
ui_kit::row(ui, "", |ui| ui.weak(solidcraft_ui_text::tr("ui.point_cloud_no_file_colors")));
}
ColorBy::File => {}
ColorBy::Single => {
let mut rgb = c.color.unwrap_or(PLAIN).map(byte);
if ui_kit::row(ui, crate::i18n::tr_label("Colour"), |ui| ui_kit::color_field(ui, &mut rgb)).changed() {
if ui_kit::row(ui, solidcraft_ui_text::tr_label("Colour"), |ui| ui_kit::color_field(ui, &mut rgb)).changed() {
edit = Some(json!({ "cloud": c.id, "color": rgb.map(|v| f64::from(v) / 255.0) }));
}
}
@@ -366,7 +367,7 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
{
// A ramp over what is shown, unless its range is set.
let automatic = c.height_range.is_none();
if ui_kit::checkbox_row(ui, "point_cloud_auto_range", crate::i18n::tr("ui.point_cloud_auto_range"), automatic) {
if ui_kit::checkbox_row(ui, "point_cloud_auto_range", solidcraft_ui_text::tr("ui.point_cloud_auto_range"), automatic) {
edit = Some(json!({ "cloud": c.id, "color_range": if automatic { json!(crate::point_clouds::color_range(ui.ctx(), &live)) } else { Value::Null } }));
}
// The ramp's range is a range on a track of the property, over what the
@@ -422,9 +423,9 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
&& ui_kit::button(
ui,
if playing {
format!("⏸ {}", crate::i18n::tr_label("Pause"))
format!("⏸ {}", solidcraft_ui_text::tr_label("Pause"))
} else {
format!("▶ {}", crate::i18n::tr_label("Play"))
format!("▶ {}", solidcraft_ui_text::tr_label("Play"))
},
ui_kit::Button::Small,
)
@@ -442,7 +443,7 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
match (&f.keep, span) {
(Keep::Values { .. }, _) => match label(&c, &f.property) {
Some(names) if legend_shown.is_some_and(|l| l.property == names.property) => {
ui.weak(crate::i18n::tr_label("Shown and hidden in the legend above"));
ui.weak(solidcraft_ui_text::tr_label("Shown and hidden in the legend above"));
}
Some(names) => {
if let Some(v) = legend(ui, &c, names) {
@@ -520,10 +521,10 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
}
let all: Vec<&str> = c.property_names().collect();
if c.filters.len() < MAX_FILTERS {
let mut add_labels = vec![crate::i18n::tr_label("Add filter…")];
let mut add_labels = vec![solidcraft_ui_text::tr_label("Add filter…")];
add_labels.extend(all.iter().copied());
let mut pick = 0;
ui_kit::row(ui, crate::i18n::tr_label("Filter by"), |ui| ui_kit::dropdown(ui, "point_cloud_add_filter", &mut pick, &add_labels));
ui_kit::row(ui, solidcraft_ui_text::tr_label("Filter by"), |ui| ui_kit::dropdown(ui, "point_cloud_add_filter", &mut pick, &add_labels));
if let Some(name) = all.get(pick.wrapping_sub(1)) {
// A label keeps all its values at first, anything else its whole span.
let keep = match (label(&c, name), c.span(name)) {
@@ -540,7 +541,7 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
if changed {
edit = Some(json!({ "cloud": c.id, "filters": filters_json(&filters) }));
}
if !c.source.is_empty() && ui_kit::button(ui, crate::i18n::tr("ui.point_cloud_reload"), ui_kit::Button::Normal).clicked() {
if !c.source.is_empty() && ui_kit::button(ui, solidcraft_ui_text::tr("ui.point_cloud_reload"), ui_kit::Button::Normal).clicked() {
reload = true;
}
});
@@ -599,7 +600,7 @@ mod tests {
}
h.app.run("pointcloud.edit", json!({"cloud": id, "color_by": "height"})).unwrap();
h.frames(2);
assert!(h.widget(crate::i18n::tr("ui.point_cloud_auto_range")).is_some());
assert!(h.widget(solidcraft_ui_text::tr("ui.point_cloud_auto_range")).is_some());
h.step(&json!({"widget": "Close"})).unwrap();
h.frames(2);
assert!(h.app.tree.cloud_panel.is_none());
+19 -13
View File
@@ -22,10 +22,14 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
};
let mut edit: Option<Value> = None;
let (units, decimals) = (app.session.doc.units.clone(), app.preferences.length_decimals);
let title = crate::i18n::format(if m.calibrate { "browser.calibrate_title" } else { "browser.edit_canvas_title" }, &[("name", &c.name)]);
let title = solidcraft_ui_text::format(if m.calibrate { "browser.calibrate_title" } else { "browser.edit_canvas_title" }, &[("name", &c.name)]);
let at = panel_pos(app);
let (calibrate, name) = (m.calibrate, c.id);
let (ok, cancel) = if calibrate { (crate::i18n::tr_label("Calibrate"), crate::i18n::tr("ui.cancel")) } else { ("", crate::i18n::tr("ui.close")) };
let (ok, cancel) = if calibrate {
(solidcraft_ui_text::tr_label("Calibrate"), solidcraft_ui_text::tr("ui.cancel"))
} else {
("", solidcraft_ui_text::tr("ui.close"))
};
let (closed, action) = ui_kit::Window::new(egui::Id::unique("sc_canvas_panel"), "canvas", &title)
.placed(egui::Align2::LEFT_TOP, at)
.fields()
@@ -34,11 +38,11 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
if calibrate {
ui_kit::text(
ui,
crate::i18n::tr(if m.next == 0 { "browser.calibrate_click_a" } else { "browser.calibrate_click_b" }),
solidcraft_ui_text::tr(if m.next == 0 { "browser.calibrate_click_a" } else { "browser.calibrate_click_b" }),
ui_kit::Text::Note("calibrate_hint"),
);
for (label, p) in [("Point A", &mut m.a), ("Point B", &mut m.b)] {
ui_kit::row(ui, crate::i18n::tr_label(label), |ui| {
ui_kit::row(ui, solidcraft_ui_text::tr_label(label), |ui| {
let [x, y] = p;
ui.horizontal(|ui| {
ui_kit::length_field(ui, x, &units, decimals);
@@ -46,18 +50,20 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
});
});
}
ui_kit::row(ui, crate::i18n::tr_label("Distance"), |ui| ui_kit::length_field(ui, &mut m.distance, &units, decimals));
ui_kit::row(ui, solidcraft_ui_text::tr_label("Distance"), |ui| ui_kit::length_field(ui, &mut m.distance, &units, decimals));
m.distance = m.distance.clamp(0.001, 1e6);
} else {
let (mut x, mut y, mut w, mut ang, mut op, mut flip) = (c.center.x, c.center.y, c.width, c.angle.to_degrees(), c.opacity, c.flip);
let mut changed = false;
changed |= ui_kit::row(ui, crate::i18n::tr_label("Center X"), |ui| ui_kit::length_field(ui, &mut x, &units, decimals)).changed();
changed |= ui_kit::row(ui, crate::i18n::tr_label("Center Y"), |ui| ui_kit::length_field(ui, &mut y, &units, decimals)).changed();
changed |= ui_kit::row(ui, crate::i18n::tr_label("Width"), |ui| ui_kit::length_field(ui, &mut w, &units, decimals)).changed();
changed |= ui_kit::row(ui, crate::i18n::tr_label("Angle"), |ui| ui_kit::angle_field(ui, &mut ang)).changed();
changed |=
ui_kit::row(ui, crate::i18n::tr_label("Opacity"), |ui| ui_kit::slider(ui, &mut op, 0.0..=1.0, Default::default())).changed();
if ui_kit::checkbox_row(ui, "flip", crate::i18n::tr_label("Flip"), flip) {
ui_kit::row(ui, solidcraft_ui_text::tr_label("Center X"), |ui| ui_kit::length_field(ui, &mut x, &units, decimals)).changed();
changed |=
ui_kit::row(ui, solidcraft_ui_text::tr_label("Center Y"), |ui| ui_kit::length_field(ui, &mut y, &units, decimals)).changed();
changed |= ui_kit::row(ui, solidcraft_ui_text::tr_label("Width"), |ui| ui_kit::length_field(ui, &mut w, &units, decimals)).changed();
changed |= ui_kit::row(ui, solidcraft_ui_text::tr_label("Angle"), |ui| ui_kit::angle_field(ui, &mut ang)).changed();
changed |= ui_kit::row(ui, solidcraft_ui_text::tr_label("Opacity"), |ui| ui_kit::slider(ui, &mut op, 0.0..=1.0, Default::default()))
.changed();
if ui_kit::checkbox_row(ui, "flip", solidcraft_ui_text::tr_label("Flip"), flip) {
flip = !flip;
changed = true;
}
@@ -113,11 +119,11 @@ pub(crate) fn click(app: &mut SolveApp, proj: &Proj, screen: Pos2) -> bool {
if panel.next == 0 {
panel.a = point;
panel.next = 1;
app.set_status(crate::i18n::tr("browser.calibrate_click_b"), false);
app.set_status(solidcraft_ui_text::tr("browser.calibrate_click_b"), false);
} else {
panel.b = point;
panel.next = 0;
app.set_status(crate::i18n::tr("browser.calibrate_click_a"), false);
app.set_status(solidcraft_ui_text::tr("browser.calibrate_click_a"), false);
}
app.tree.canvas_panel = Some(panel);
true
+7 -7
View File
@@ -1,7 +1,7 @@
use super::*;
use crate::scenario::{Harness, handle_at, rect_of};
use crate::semantic_content::{self, ContentOwner, ContentRequest};
use crate::shell::{LayoutMode, SHELL_GAP};
use crate::ui_kit::{LayoutMode, SHELL_GAP};
#[test]
fn clicked_browser_folders_toggle_visibility_with_v() {
@@ -318,7 +318,7 @@ fn selected_row_paints_tab_soft_fill_and_accent_text_in_both_layouts() {
let mut app = SolveApp::new(Default::default(), Default::default());
app.ui.layout = mode;
app.ui.tab = tab.into();
crate::theme::TabAccent::publish(&app, &ctx);
app.publish_tab_accent(&ctx);
let accent = crate::theme::TabAccent::for_tab(&Tokens::get(), tab);
let mut row_rect = Rect::NOTHING;
let out = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| {
@@ -595,7 +595,7 @@ fn browser_title_uses_a_single_semibold_run() {
ui.set_width(250.0);
browser_header(&mut app, ui, true);
});
let title = crate::i18n::tr("ui.browser_title");
let title = solidcraft_ui_text::tr("ui.browser_title");
let titles: Vec<_> = output
.shapes
.iter()
@@ -646,7 +646,7 @@ fn browser_width_rejects_hostile_preferences_and_tiny_windows_still_fit() {
assert_eq!(browser_width(-100.0), MIN_BROWSER_WIDTH);
assert_eq!(browser_width(f32::MAX), MAX_BROWSER_WIDTH);
let available = Rect::from_min_size(Pos2::ZERO, vec2(100.0, 300.0));
for top_gap in [0.0, crate::shell::SHELL_GAP] {
for top_gap in [0.0, crate::ui_kit::SHELL_GAP] {
let bounds = panel_bounds(available, None, MAX_BROWSER_WIDTH, top_gap);
assert!(available.contains_rect(bounds));
}
@@ -677,7 +677,7 @@ fn collapsed_rail_shares_header_hover_inset_and_expands_from_its_whole_card() {
app.ui.tab = "SURFACE".into();
app.preferences.timeline_left = true;
app.preferences.timeline_left_browser_collapsed = collapsed;
crate::theme::TabAccent::publish(&app, &ctx);
app.publish_tab_accent(&ctx);
let bounds = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 700.0));
let draw = |app: &mut SolveApp, events| {
crate::test_support::frame(&ctx, egui::RawInput { screen_rect: Some(bounds), events, ..Default::default() }, |ui| {
@@ -751,8 +751,8 @@ fn mesh_and_solid_body_rows_have_different_icons_and_tooltips() {
let row = |name: &str| rows.iter().find(|e| e.key == name).unwrap_or_else(|| panic!("no {name} row in {:?}", body_rows(&h.app)));
assert_eq!((row("Mesh").icon, row("Mesh").tip), ("mesh_body", Some("ui.body_kind_mesh")));
assert_eq!((row("Solid").icon, row("Solid").tip), ("body", Some("ui.body_kind_solid")));
assert_eq!(crate::i18n::tr("ui.body_kind_mesh"), "Mesh body");
assert_eq!(crate::i18n::tr("ui.body_kind_solid"), "Solid body");
assert_eq!(solidcraft_ui_text::tr("ui.body_kind_mesh"), "Mesh body");
assert_eq!(solidcraft_ui_text::tr("ui.body_kind_solid"), "Solid body");
// Its own drawing in the mesh colours, not the solid body's grey cube.
let icon = crate::icon_svg::resolve("mesh_body");
assert!(!icon.placeholder);
+2 -6
View File
@@ -211,11 +211,7 @@ fn with_rows_starting_closed_a_designs_folders_open_with_their_arrow_or_find_in_
"the Bodies folder stays closed"
);
crate::context_menu::run_item(
&mut h.app,
&crate::context_menu::Item::action("ui.findBrowser", "", "").with(json!({"body":"Block"})),
egui::Pos2::ZERO,
);
crate::context_menu::run_item(&mut h.app, &crate::ui_kit::Item::action("ui.findBrowser", "", "").with(json!({"body":"Block"})), egui::Pos2::ZERO);
h.frames(2);
assert!(super::is_open(&h.app, "c0/bodies", false), "Find in Browser opens the Bodies folder");
assert!(
@@ -571,7 +567,7 @@ fn feature_editor_browser_rows_follow_the_marker_and_return_after_cancel() {
fn the_edited_sketch_is_marked_in_the_users_language() {
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", json!({"plane":"XY","name":"Sketch1"})).unwrap();
let _japanese = crate::i18n::scope("ja");
let _japanese = solidcraft_ui_text::scope("ja");
let rows = super::entries(&h.app, 0, "sketches");
assert_eq!(rows.iter().map(|r| r.label.as_str()).collect::<Vec<_>>(), ["スケッチ1 (編集中)"]);
}
+8 -8
View File
@@ -38,7 +38,7 @@ pub(crate) fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
let name = app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.name.clone()).unwrap_or_default();
let before = (m.translate, m.angles);
let shared = !m.path.is_empty() && app.session.occurrence_scope_count(&m.path).is_ok_and(|n| n > 1);
let title = crate::i18n::format("browser.move_title", &[("name", &name)]);
let title = solidcraft_ui_text::format("browser.move_title", &[("name", &name)]);
let at = panel_pos(app);
let (units, decimals) = (app.session.doc.units.clone(), app.preferences.length_decimals);
let (closed, action) = ui_kit::Window::new(egui::Id::unique("sc_occ_move"), "move", &title)
@@ -50,19 +50,19 @@ pub(crate) fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
if shared {
ui_kit::text(
ui,
crate::i18n::tr_label("This child is shared. Position changes apply to every copy of its parent."),
solidcraft_ui_text::tr_label("This child is shared. Position changes apply to every copy of its parent."),
ui_kit::Text::Note("shared"),
);
}
for (k, axis) in ["X distance", "Y distance", "Z distance"].into_iter().enumerate() {
if let Some(v) = m.translate.get_mut(k) {
let response = ui_kit::row(ui, crate::i18n::tr_label(axis), |ui| ui_kit::length_field(ui, v, &units, decimals));
let response = ui_kit::row(ui, solidcraft_ui_text::tr_label(axis), |ui| ui_kit::length_field(ui, v, &units, decimals));
crate::scenario::publish_handle(&format!("occurrence-move:distance:{k}"), response.rect.center());
}
}
for (k, axis) in ["X angle", "Y angle", "Z angle"].into_iter().enumerate() {
if let Some(v) = m.angles.get_mut(k) {
let response = ui_kit::row(ui, crate::i18n::tr_label(axis), |ui| ui_kit::angle_field(ui, v));
let response = ui_kit::row(ui, solidcraft_ui_text::tr_label(axis), |ui| ui_kit::angle_field(ui, v));
crate::scenario::publish_handle(&format!("occurrence-move:angle:{k}"), response.rect.center());
}
}
@@ -97,17 +97,17 @@ pub(crate) fn capture_prompt(app: &mut SolveApp, ctx: &egui::Context) {
return;
}
let n = app.session.pending_moves.len();
let (closed, action) = ui_kit::Window::new(egui::Id::unique("sc_capture_prompt"), "capture", crate::i18n::tr_label("Capture Position"))
let (closed, action) = ui_kit::Window::new(egui::Id::unique("sc_capture_prompt"), "capture", solidcraft_ui_text::tr_label("Capture Position"))
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, -80.0))
.actions("Capture Position", "Cancel", Some("Revert"))
.show(ctx, true, |ui| {
let question = if n == 1 {
crate::i18n::tr("browser.capture_one").to_string()
solidcraft_ui_text::tr("browser.capture_one").to_string()
} else {
crate::i18n::format("browser.capture_many", &[("count", &n.to_string())])
solidcraft_ui_text::format("browser.capture_many", &[("count", &n.to_string())])
};
ui_kit::text(ui, &question, ui_kit::Text::Report("question", false));
ui_kit::text(ui, crate::i18n::tr("browser.capture_note"), ui_kit::Text::Note("capture"));
ui_kit::text(ui, solidcraft_ui_text::tr("browser.capture_note"), ui_kit::Text::Note("capture"));
});
let answer = match action {
ui_kit::Action::Ok => Some("capture"),
+8 -7
View File
@@ -955,7 +955,7 @@ fn value_box_full(
let frame = Container::popup_frame(ui.style()).inner_margin(egui::Margin::symmetric(6, 3));
Container::show_backing(ui, frame, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr_label(label)).color(t.text_dim).size(crate::theme::Tokens::get().font_caption));
ui.label(egui::RichText::new(solidcraft_ui_text::tr_label(label)).color(t.text_dim).size(crate::theme::Tokens::get().font_caption));
let r = crate::ui_kit::canvas_value_field(ui, id, value, keep_tab);
crate::params_dialog::complete(ui, &r, value);
if select_all {
@@ -1049,7 +1049,7 @@ fn box_size(ui: &egui::Ui, area: egui::Id, label: &str, note: Option<&str>) -> e
let t = Tokens::get();
let font = egui::FontId::proportional(t.font_caption);
let width = |text: String| ui.painter().layout_no_wrap(text, font.clone(), t.text_dim).size().x;
let mut w = width(crate::i18n::tr_label(label).to_string()) + t.space_24 * 3.0;
let mut w = width(solidcraft_ui_text::tr_label(label).to_string()) + t.space_24 * 3.0;
let mut parts = 1;
if let Some(note) = note {
w += width(note.to_string());
@@ -1324,7 +1324,7 @@ fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Pain
let at = |u: f64| e.start + (e.other - e.start) * u;
let g = e.spline.greville();
let tips: Vec<Option<Pos2>> = g.iter().zip(&e.spline.radii).map(|(u, r)| proj.to_screen(at(*u) + e.n * *r)).collect();
painter.extend(crate::viewport::dashed_line(
painter.extend(crate::ui_kit::dashed_line(
&tips.iter().flatten().copied().collect::<Vec<_>>(),
painter.clip_rect(),
Stroke::new(1.0, t.manipulator),
@@ -1588,7 +1588,7 @@ fn measure_components(
let (at, offset) = if let Some((sa, sb)) = screen {
let leg = sb - sa;
if leg.length_sq() > 1.0 {
painter.extend(crate::viewport::dashed_line(&[sa, sb], painter.clip_rect(), Stroke::new(1.5, color), 5.0, 3.0));
painter.extend(crate::ui_kit::dashed_line(&[sa, sb], painter.clip_rect(), Stroke::new(1.5, color), 5.0, 3.0));
let normal = vec2(-leg.y, leg.x).normalized();
for p in [sa, sb] {
painter.line_segment([p - normal * 3.0, p + normal * 3.0], Stroke::new(1.0, color));
@@ -1709,7 +1709,7 @@ fn taper_handle(
crate::scenario::publish_handle("taper", es);
let hot = r.hovered() || r.dragged();
let col = if hot { t.accent } else { t.manipulator };
painter.extend(crate::viewport::dashed_line(&[fs, es], painter.clip_rect(), Stroke::new(1.2, col), 5.0, 3.0));
painter.extend(crate::ui_kit::dashed_line(&[fs, es], painter.clip_rect(), Stroke::new(1.2, col), 5.0, 3.0));
painter.circle(es, if hot { 6.5 } else { 5.0 }, t.overlay, Stroke::new(2.0, col));
if hot {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
@@ -1901,12 +1901,13 @@ fn draw_revolve(
} else {
"Choose target".into()
};
ui.label(text).backing_on_hover_text(crate::i18n::tr_label("Determined by To Object. Choose Angle to edit the angular extent."));
ui.label(text)
.backing_on_hover_text(solidcraft_ui_text::tr_label("Determined by To Object. Choose Angle to edit the angular extent."));
});
});
} else if let Kind::Revolve { angle, .. } = &mut d.kind {
let dragging = *angle != angle_before;
value_box(ui, crate::i18n::tr_label("Angle"), angle, box_at, focus || dragging, box_place(app, proj));
value_box(ui, solidcraft_ui_text::tr_label("Angle"), angle, box_at, focus || dragging, box_place(app, proj));
}
}
+1 -1
View File
@@ -265,7 +265,7 @@ fn analysis_shader_colours_come_from_the_uniform() {
"vec3<f32>(0.35)",
"vec3<f32>(1.0)",
],
"a new colour in the analysis shader must come from `ana_c` (theme::AnalysisColors)"
"a new colour in the analysis shader must come from `ana_c` (solidcraft_color::AnalysisColors)"
);
assert_eq!(body.matches("u.ana_c[").count(), 9, "every analysis colour reads `ana_c`");
}
+1 -2
View File
@@ -4,7 +4,6 @@
//! 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` fails otherwise.
use crate::i18n;
use crate::ui_kit::BackingTooltipExt as _;
/// The commands that have help text. Each one needs a `help.<id>` message in the catalogs.
@@ -330,7 +329,7 @@ pub fn help(command: &str) -> Option<String> {
return None;
}
let key = format!("help.{command}");
let text = i18n::tr(&key);
let text = solidcraft_ui_text::tr(&key);
(text != key).then(|| text.to_owned())
}
+17 -16
View File
@@ -50,7 +50,7 @@ impl Default for Editor {
}
}
fn choose(ui: &mut Ui, id: &str, value: &mut String, options: &[String]) {
let mut labels = vec![crate::i18n::tr_label("Choose…")];
let mut labels = vec![solidcraft_ui_text::tr_label("Choose…")];
labels.extend(options.iter().map(String::as_str));
let mut selected = options.iter().position(|o| o == value).map_or(0, |i| i + 1);
let before = selected;
@@ -92,13 +92,13 @@ pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option<String>,
let unlisted_features = suppression_features.len() < features.len();
let unlisted_parameters = all_parameters.iter().any(|p| !parameters.contains(p));
ui_kit::divider(ui, "configuration_rules");
ui_kit::text(ui, crate::i18n::tr_label("Configuration rules"), ui_kit::Text::Heading("rules_heading"));
ui_kit::text(ui, solidcraft_ui_text::tr_label("Configuration rules"), ui_kit::Text::Heading("rules_heading"));
ui_kit::text(
ui,
crate::i18n::tr_label("Rules run from top to bottom. Matched drives change effective values."),
solidcraft_ui_text::tr_label("Rules run from top to bottom. Matched drives change effective values."),
ui_kit::Text::Note("rules_drives"),
);
ui_kit::text(ui, crate::i18n::tr_label("Matched restrictions check the final values."), ui_kit::Text::Note("rules_restrictions"));
ui_kit::text(ui, solidcraft_ui_text::tr_label("Matched restrictions check the final values."), ui_kit::Text::Note("rules_restrictions"));
for (index, rule) in rules.iter().enumerate() {
ui.push_id(&rule.id, |ui| {
let summary = match &rule.effect {
@@ -114,21 +114,22 @@ pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option<String>,
+ crate::theme::Tokens::get().space_4 * 3.0;
let mut enabled = rule.enabled;
if ui_kit::summary_row(ui, &rule.name, &summary, &mut enabled, actions_width, |ui| {
if ui_kit::button(ui, crate::i18n::tr_label("Edit"), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_text::tr_label("Edit"), ui_kit::Button::Normal).clicked() {
editor.rule = rule.clone();
}
let up = ui_kit::button(ui, "↑", ui_kit::Button::Enabled(index > 0)).backing_on_hover_text(crate::i18n::tr_label("Move earlier"));
let up =
ui_kit::button(ui, "↑", ui_kit::Button::Enabled(index > 0)).backing_on_hover_text(solidcraft_ui_text::tr_label("Move earlier"));
crate::scenario::publish_handle(&format!("config:rule:{}:up", rule.id), up.rect.center());
if up.clicked() {
*error = app.run("config.rule", json!({"id":rule.id,"index":index-1})).err();
}
if ui_kit::button(ui, "↓", ui_kit::Button::Enabled(index + 1 < rules.len()))
.backing_on_hover_text(crate::i18n::tr_label("Move later"))
.backing_on_hover_text(solidcraft_ui_text::tr_label("Move later"))
.clicked()
{
*error = app.run("config.rule", json!({"id":rule.id,"index":index+1})).err();
}
if ui_kit::button(ui, crate::i18n::tr_label("Delete"), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_text::tr_label("Delete"), ui_kit::Button::Normal).clicked() {
*error = app.run("config.rule", json!({"id":rule.id,"delete":true})).err();
}
}) {
@@ -141,8 +142,8 @@ pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option<String>,
ui_kit::section(ui, "rule_editor", "Add or edit rule", false, |ui| {
ui.vertical(|ui| {
expression(ui, "Rule name", &mut editor.rule.name);
ui_kit::checkbox(ui, crate::i18n::tr_label("Enabled"), &mut editor.rule.enabled);
if ui_kit::button(ui, crate::i18n::tr_label("New"), ui_kit::Button::Small).clicked() {
ui_kit::checkbox(ui, solidcraft_ui_text::tr_label("Enabled"), &mut editor.rule.enabled);
if ui_kit::button(ui, solidcraft_ui_text::tr_label("New"), ui_kit::Button::Small).clicked() {
editor.rule = Editor::default().rule;
}
});
@@ -192,7 +193,7 @@ pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option<String>,
ui.vertical(|ui| {
feature_choice(ui, "effect_feature", feature, &suppression_features);
columns_note(ui, unlisted_features, "config.rule_suppression_columns");
ui_kit::checkbox(ui, crate::i18n::tr_label("Suppressed"), value);
ui_kit::checkbox(ui, solidcraft_ui_text::tr_label("Suppressed"), value);
});
}
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
@@ -258,7 +259,7 @@ pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option<String>,
}
if let Some(preview) = &editor.preview {
ui_kit::section(ui, "rule_preview", &format!("Preview: {}", preview["active"].as_str().unwrap_or("row")), false, |ui| {
report(ui, crate::i18n::tr_label("Preview leaves the current design unchanged."));
report(ui, solidcraft_ui_text::tr_label("Preview leaves the current design unchanged."));
if let Some(styles) = preview["styles"].as_array() {
for style in styles {
report(
@@ -326,11 +327,11 @@ pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option<String>,
/// how to add one.
fn columns_note(ui: &mut Ui, unlisted: bool, message: &'static str) {
if unlisted {
ui_kit::text(ui, crate::i18n::tr(message), ui_kit::Text::Note(message));
ui_kit::text(ui, solidcraft_ui_text::tr(message), ui_kit::Text::Note(message));
}
}
fn report(ui: &mut Ui, text: impl AsRef<str>) {
ui_kit::text(ui, crate::i18n::tr_label(text.as_ref()), ui_kit::Text::Report("rule_report", false));
ui_kit::text(ui, solidcraft_ui_text::tr_label(text.as_ref()), ui_kit::Text::Report("rule_report", false));
}
fn feature_choice(ui: &mut Ui, id: &str, feature: &mut u64, features: &[(u64, String)]) {
@@ -398,7 +399,7 @@ fn condition_editor(ui: &mut Ui, condition: &mut RuleCondition, parameters: &[St
RuleCondition::Suppression { feature, value } => {
ui.vertical(|ui| {
feature_choice(ui, "condition_feature", feature, features);
ui_kit::checkbox(ui, crate::i18n::tr_label("Suppressed"), value);
ui_kit::checkbox(ui, solidcraft_ui_text::tr_label("Suppressed"), value);
});
}
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
@@ -407,7 +408,7 @@ fn condition_editor(ui: &mut Ui, condition: &mut RuleCondition, parameters: &[St
ui.push_id(index, |ui| {
ui.indent("condition_group", |ui| {
condition_editor(ui, child, parameters, features, depth + 1);
if ui_kit::button(ui, crate::i18n::tr_label("Remove condition"), ui_kit::Button::Small).clicked() {
if ui_kit::button(ui, solidcraft_ui_text::tr_label("Remove condition"), ui_kit::Button::Small).clicked() {
remove = Some(index);
}
});
+12 -12
View File
@@ -26,7 +26,7 @@ pub(super) fn configurations_rows(
let run = |app: &mut SolveApp, error: &mut Option<String>, id: &str, p: Value| {
*error = app.run(id, p).err();
};
let labels: Vec<_> = std::iter::once(crate::i18n::tr_label("Configuration").to_owned())
let labels: Vec<_> = std::iter::once(solidcraft_ui_text::tr_label("Configuration").to_owned())
.chain(tb.columns.iter().map(|c| {
let (kind, name) = column_parts(c);
v["targets"]
@@ -36,13 +36,13 @@ pub(super) fn configurations_rows(
.filter(|_| matches!(kind, "component_appearance" | "face_appearance"))
.map(str::to_string)
.unwrap_or_else(|| match kind {
"material" => format!("{}: {name}", crate::i18n::tr_label("Material")),
"appearance" => format!("{}: {name}", crate::i18n::tr_label("Appearance")),
"suppress" => format!("{}: {name}", crate::i18n::tr_label("Suppress")),
"material" => format!("{}: {name}", solidcraft_ui_text::tr_label("Material")),
"appearance" => format!("{}: {name}", solidcraft_ui_text::tr_label("Appearance")),
"suppress" => format!("{}: {name}", solidcraft_ui_text::tr_label("Suppress")),
_ => name.to_owned(),
})
}))
.chain(std::iter::once(crate::i18n::tr_label("Actions").to_owned()))
.chain(std::iter::once(solidcraft_ui_text::tr_label("Actions").to_owned()))
.collect();
let gap = ui.spacing().item_spacing.x;
let name_width = tb.rows.iter().map(|(name, _)| ui_kit::ContentWidth::name(ui, name)).fold(0.0, f32::max);
@@ -76,7 +76,7 @@ pub(super) fn configurations_rows(
}
})
.collect();
ui_kit::table_custom(ui, "configuration_table", &columns, crate::i18n::tr("manage.empty"), |ui, table| {
ui_kit::table_custom(ui, "configuration_table", &columns, solidcraft_ui_text::tr("manage.empty"), |ui, table| {
table.headers(ui, |ui, index| {
if index == labels.len() - 1 {
column_menu(app, ui, error, &tb.columns, rule_editor);
@@ -112,14 +112,14 @@ pub(super) fn configurations_rows(
if active {
ui_kit::active_pill(ui);
if v.get("matches").and_then(Value::as_bool) == Some(false) {
let apply = ui_kit::button(ui, crate::i18n::tr_label("Apply"), ui_kit::Button::Small);
let apply = ui_kit::button(ui, solidcraft_ui_text::tr_label("Apply"), ui_kit::Button::Small);
crate::scenario::publish_handle(&format!("config:activate:{name}"), apply.rect.center());
if apply.clicked() {
run(app, error, "config.activate", json!({"row":name}));
}
}
} else {
let activate = ui_kit::button(ui, crate::i18n::tr_label("Activate"), ui_kit::Button::Small);
let activate = ui_kit::button(ui, solidcraft_ui_text::tr_label("Activate"), ui_kit::Button::Small);
crate::scenario::publish_handle(&format!("config:activate:{name}"), activate.rect.center());
if activate.clicked() {
run(app, error, "config.activate", json!({"row":name}));
@@ -343,7 +343,7 @@ fn column_menu(
let mut pick: Option<Value> = None;
let filter = &mut rule_editor.column_filter;
let was_open = rule_editor.column_menu_open;
let face_appearance = crate::i18n::tr_label("Face Appearance");
let face_appearance = solidcraft_ui_text::tr_label("Face Appearance");
let shown = ui_kit::plus_header(ui, |ui| {
// A design has many parameters, features and bodies: a filter narrows the list as the
// Parameters dialog's does. It takes focus as the menu opens, so typing filters at once.
@@ -361,7 +361,7 @@ fn column_menu(
}
let faces = query.is_empty() || face_appearance.to_lowercase().contains(&query);
if matching.is_empty() && !faces {
ui_kit::text(ui, crate::i18n::tr("config.column_filter_empty"), ui_kit::Text::Note("column_filter_empty"));
ui_kit::text(ui, solidcraft_ui_text::tr("config.column_filter_empty"), ui_kit::Text::Note("column_filter_empty"));
}
// The popup remembers its size: once the filter has emptied the list, a list that fills
// again would be clipped to that small height, so a list that scrolls takes the full one.
@@ -379,7 +379,7 @@ fn column_menu(
ui_kit::action_menu(ui, "Face Appearance", |ui| {
ui_kit::text(
ui,
crate::i18n::tr_label("Semantic faces share their appearance across instances."),
solidcraft_ui_text::tr_label("Semantic faces share their appearance across instances."),
ui_kit::Text::Note("semantic_faces"),
);
let state = app.session.model.state();
@@ -486,7 +486,7 @@ fn value_control(ui: &mut egui::Ui, id: egui::Id, text: &mut String, kind: &str,
"Default material"
};
ui_kit::library_cell(ui, id, text, follow, |ui| {
if ui_kit::button(ui, crate::i18n::tr_label(follow), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_text::tr_label(follow), ui_kit::Button::Normal).clicked() {
// An appearance's "Default" keyword follows; a material's is the default material.
let follow_value = if appearance { "Default" } else { DEFAULT_MATERIAL };
choice = Some(follow_value.to_owned());
+11 -103
View File
@@ -11,7 +11,7 @@
//! state; [`items`] lists them and [`run_item`] runs one, so the control channel (`ui.menu`,
//! `ui.menuPick`) and tests reach everything a click does.
use crate::ui_kit::{BackingTooltipExt as _, Container, Surface};
use crate::ui_kit::{BackingTooltipExt as _, Container, Item, LIST_W, Surface};
use egui::{Color32, Pos2, Rect, Sense, Vec2, pos2, vec2};
use serde::Serialize;
use serde_json::{Value, json};
@@ -127,88 +127,6 @@ pub enum Target {
},
}
/// One menu entry: a command id (run with `params` when given, else started like a toolbar
/// click) or a `ui.*` action. `-` is a separator, `#…` a section heading.
#[derive(Clone, Debug, Serialize)]
pub struct Item {
pub id: String,
pub label: String,
pub shortcut: String,
pub enabled: bool,
pub icon: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<Value>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub children: Vec<Item>,
#[serde(skip_serializing_if = "Option::is_none")]
pub swatch: Option<[u8; 3]>,
/// Why the entry is off: shown on hover, and in the control channel's report.
#[serde(skip_serializing_if = "Option::is_none")]
pub tip: Option<String>,
}
impl Item {
/// A view action (`ui.*`).
pub fn action(id: &str, label: &str, icon: &str) -> Item {
act(id, label, icon)
}
pub fn sep() -> Item {
Item {
id: "-".into(),
label: String::new(),
shortcut: String::new(),
enabled: false,
icon: String::new(),
params: None,
children: Vec::new(),
swatch: None,
tip: None,
}
}
pub(crate) fn heading(label: &str) -> Item {
Item {
id: format!("#{label}"),
label: label.into(),
shortcut: String::new(),
enabled: false,
icon: String::new(),
params: None,
children: Vec::new(),
swatch: None,
tip: None,
}
}
fn is_sep(&self) -> bool {
self.id == "-"
}
fn is_heading(&self) -> bool {
self.id.starts_with('#')
}
pub fn with(mut self, params: Value) -> Item {
self.params = Some(params);
self
}
pub fn on_if(self, enabled: bool) -> Item {
self.on(enabled)
}
pub(crate) fn on(mut self, enabled: bool) -> Item {
self.enabled &= enabled;
self
}
/// Off, saying why, when there is a reason.
pub(crate) fn off_because(mut self, why: Option<String>) -> Item {
if let Some(why) = why {
self.enabled = false;
self.tip = Some(why);
}
self
}
pub fn key(mut self, k: &str) -> Item {
self.shortcut = k.into();
self
}
}
/// A command entry: label, icon, shortcut and enablement come from the registry.
pub(crate) fn cmd(app: &SolveApp, id: &str, label: &str) -> Item {
let info = solidcraft_engine::find_command(id).map(|c| c.info(&app.session));
@@ -232,17 +150,7 @@ pub(crate) fn cmd(app: &SolveApp, id: &str, label: &str) -> Item {
/// A view action (`ui.*`).
pub(crate) fn act(id: &str, label: &str, icon: &str) -> Item {
Item {
id: id.into(),
label: label.into(),
shortcut: String::new(),
enabled: true,
icon: icon.into(),
params: None,
children: Vec::new(),
swatch: None,
tip: None,
}
Item::action(id, label, icon)
}
/// Menu state kept between frames. The anchor is `app.viewport.context_menu`.
@@ -1701,7 +1609,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
if inserted.is_ok() {
app.fit_view();
if p.get("unsaved").and_then(Value::as_bool) == Some(true) {
app.set_status(crate::i18n::format("ui.insert_design.saved_file", &[("name", name)]), false);
app.set_status(solidcraft_ui_text::format("ui.insert_design.saved_file", &[("name", name)]), false);
}
}
}
@@ -2015,7 +1923,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
let calibrate = item.id == "ui.calibrateCanvas";
app.tree.canvas_panel = Some(crate::browser::CanvasPanel::new(app, c, calibrate));
if calibrate {
app.set_status(crate::i18n::tr("browser.calibrate_click_a"), false);
app.set_status(solidcraft_ui_text::tr("browser.calibrate_click_a"), false);
}
}
}
@@ -2048,15 +1956,18 @@ fn sketches_to_parts(app: &mut SolveApp, p: Value) {
let mut why: Vec<String> = Vec::new();
for kept in out.get("kept").and_then(Value::as_array).into_iter().flatten() {
let code = kept.get("code").and_then(Value::as_str).unwrap_or_default();
let reason = crate::i18n::tr(&format!("browser.sketches_to_parts_{code}")).to_string();
let reason = solidcraft_ui_text::tr(&format!("browser.sketches_to_parts_{code}")).to_string();
if !why.contains(&reason) {
why.push(reason);
}
}
let message = if why.is_empty() {
crate::i18n::format("browser.sketches_to_parts_moved", &[("moved", &count("moved"))])
solidcraft_ui_text::format("browser.sketches_to_parts_moved", &[("moved", &count("moved"))])
} else {
crate::i18n::format("browser.sketches_to_parts_result", &[("moved", &count("moved")), ("stayed", &count("stayed")), ("why", &why.join("; "))])
solidcraft_ui_text::format(
"browser.sketches_to_parts_result",
&[("moved", &count("moved")), ("stayed", &count("stayed")), ("why", &why.join("; "))],
)
};
app.set_status(message, false);
}
@@ -2535,9 +2446,6 @@ fn list_place(c: Pos2, h: f32, screen: Rect) -> Pos2 {
pos2((c.x - RX - 12.0 - LIST_W).max(screen.left() + 4.0), y)
}
/// Width of list menus.
pub const LIST_W: f32 = 250.0;
/// Where a menu of `size` opened at `at` goes: right of and below the point (`gap` away) when it
/// fits in `screen`, flipped to the left or above it when it would run past that edge, and
/// against the far edge when it fits on neither side. A flipped menu ends at the cursor, so it
@@ -3405,7 +3313,7 @@ mod tests {
assert!(h.app.cam.back().dist(flat.plane_normal.unwrap()) < 2e-6);
assert_eq!((h.app.cam.distance, h.app.cam.fov), (before.distance, before.fov));
assert!(h.app.viewport.context_menu.is_none());
assert_eq!(crate::i18n::Locale::for_language("ja").label("Normal to Selection"), "選択アイテムに垂直");
assert_eq!(solidcraft_ui_text::Locale::for_language("ja").label("Normal to Selection"), "選択アイテムに垂直");
// Ctrl+8 and the context menu share Look At, including its repeat-to-flip behavior.
h.step(&json!({"key":"8","cmd":true})).unwrap();
h.frames(90);
+2 -2
View File
@@ -182,7 +182,7 @@ impl ControlJob {
}
fn label(app: &SolveApp, command: &str) -> String {
let language = crate::i18n::Locale::for_language(&app.preferences.language);
let language = solidcraft_ui_text::Locale::for_language(&app.preferences.language);
language.label(solidcraft_engine::find_command(command).map_or(command, |c| c.label)).to_string()
}
@@ -473,7 +473,7 @@ impl SolveApp {
let reply = reply.unwrap_or_else(|| json!({"ok": false, "error": format!("`{}` ended without a reply", job.label)}));
let export = executed(&job.req.params).and_then(|(command, _)| spec_id(command)).is_some_and(|id| ANSWERED_ON_A_COPY.contains(&id));
if reply["ok"] == true && export {
let language = crate::i18n::Locale::for_language(&self.preferences.language);
let language = solidcraft_ui_text::Locale::for_language(&self.preferences.language);
self.session.echo(language.format("ui.command_done", &[("command", job.label.as_str())]));
}
let _ = job.req.reply.send(reply);
+21 -17
View File
@@ -37,7 +37,7 @@ impl CrashUi {
}
fn kind_label(kind: &str) -> &'static str {
crate::i18n::tr(match kind {
solidcraft_ui_text::tr(match kind {
"command_panic" => "crash.kind_command",
"kernel_panic_recovered" => "crash.kind_kernel",
"unclean_exit" => "crash.kind_unclean",
@@ -48,7 +48,7 @@ fn kind_label(kind: &str) -> &'static str {
fn report_time(r: &Summary) -> String {
let time = r.time.replace('T', " ");
let time = time.trim_end_matches('Z');
if r.kind == "unclean_exit" { crate::i18n::format("crash.detected_time", &[("time", time)]) } else { format!("{time} UTC") }
if r.kind == "unclean_exit" { solidcraft_ui_text::format("crash.detected_time", &[("time", time)]) } else { format!("{time} UTC") }
}
fn report_heading(r: &Summary) -> String {
@@ -66,7 +66,7 @@ pub fn announce(app: &mut SolveApp, fresh: &[Summary]) {
}
app.crashes.open = true;
app.crashes.refresh();
let locale = crate::i18n::Locale::for_language(&app.preferences.language);
let locale = solidcraft_ui_text::Locale::for_language(&app.preferences.language);
app.set_status(locale.text("crash.previous_session"), true);
}
@@ -76,17 +76,17 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
let mut report: Option<String> = None;
let mut delete: Option<Option<String>> = None;
let (closed, action) = ui_kit::Window::new(egui::Id::unique("sc_crash_reports"), "bug_report", crate::i18n::tr_label("Crash Reports"))
let (closed, action) = ui_kit::Window::new(egui::Id::unique("sc_crash_reports"), "bug_report", solidcraft_ui_text::tr_label("Crash Reports"))
.cards(2.0)
.actions("", crate::i18n::tr("ui.close"), None)
.actions("", solidcraft_ui_text::tr("ui.close"), None)
.show(ctx, true, |ui| {
ui.spacing_mut().item_spacing.y = crate::theme::Tokens::get().space_10;
let intro = ui_kit::text(ui, crate::i18n::tr("crash.intro"), ui_kit::Text::Hint("intro"));
let intro = ui_kit::text(ui, solidcraft_ui_text::tr("crash.intro"), ui_kit::Text::Hint("intro"));
crate::scenario::publish_rect("dialog:note:intro", intro.rect);
if let Some(d) = app.crashes.dir.clone() {
// A long path is cut short (hover shows it all): it must not widen the card.
ui_kit::path_chip(ui, &d, |ui| {
if ui_kit::icon_label_button(ui, crate::i18n::tr("crash.copy_folder"), "copy", true, false).clicked() {
if ui_kit::icon_label_button(ui, solidcraft_ui_text::tr("crash.copy_folder"), "copy", true, false).clicked() {
ctx.copy_text(d.clone());
}
});
@@ -97,7 +97,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
egui::Rect::from_min_size(ui.next_widget_position(), egui::vec2(ui.available_width(), 1.0)),
);
if app.crashes.reports.is_empty() {
ui_kit::empty_box(ui, "none", crate::i18n::tr_label("No crash reports."));
ui_kit::empty_box(ui, "none", solidcraft_ui_text::tr_label("No crash reports."));
}
if !app.crashes.reports.is_empty() {
ui_kit::report_list(
@@ -112,27 +112,31 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.spacing_mut().item_spacing.y = crate::theme::Tokens::get().space_6;
ui.add_space(crate::theme::Tokens::get().space_10);
ui_kit::report_header(ui, kind_label(&r.kind), r.count, &report_time(r), &title, |ui| {
if ui_kit::bin_button(ui, crate::i18n::tr("ui.delete")).clicked() {
if ui_kit::bin_button(ui, solidcraft_ui_text::tr("ui.delete")).clicked() {
delete = Some(Some(r.id.clone()));
}
ui_kit::icon_label_button(ui, crate::i18n::tr("crash.copy_path"), "copy", true, false)
ui_kit::icon_label_button(ui, solidcraft_ui_text::tr("crash.copy_path"), "copy", true, false)
.clicked()
.then(|| ctx.copy_text(r.path.clone()));
if ui_kit::icon_label_button(ui, crate::i18n::tr("crash.report"), "bug_report", true, false).clicked() {
if ui_kit::icon_label_button(ui, solidcraft_ui_text::tr("crash.report"), "bug_report", true, false).clicked() {
report = Some(r.id.clone());
}
});
let mut detail = r.command.clone().unwrap_or_default();
if r.replayable {
let sep = if detail.is_empty() { "" } else { " · " };
detail = format!("{detail}{sep}{}", crate::i18n::tr("crash.replayable"));
detail = format!("{detail}{sep}{}", solidcraft_ui_text::tr("crash.replayable"));
}
if !detail.is_empty() {
ui_kit::text(ui, &detail, ui_kit::Text::MonoNote(&format!("detail:{}", r.id)));
}
ui_kit::text(ui, &r.message, ui_kit::Text::Report(&format!("message:{}", r.id), false));
if r.kind == "unclean_exit" {
ui_kit::text(ui, crate::i18n::tr("crash.unclean_explanation"), ui_kit::Text::Note(&format!("unclean:{}", r.id)));
ui_kit::text(
ui,
solidcraft_ui_text::tr("crash.unclean_explanation"),
ui_kit::Text::Note(&format!("unclean:{}", r.id)),
);
}
ui.add_space(crate::theme::Tokens::get().space_6);
if index != last {
@@ -146,7 +150,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if !app.crashes.note.is_empty() {
ui_kit::text(ui, &app.crashes.note, ui_kit::Text::Note("note"));
}
if ui_kit::icon_label_button(ui, crate::i18n::tr_label("Delete all"), "delete", !app.crashes.reports.is_empty(), true).clicked() {
if ui_kit::icon_label_button(ui, solidcraft_ui_text::tr_label("Delete all"), "delete", !app.crashes.reports.is_empty(), true).clicked() {
delete = Some(None);
}
});
@@ -163,7 +167,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
&& let Some(dir) = app.crashes.dir_path()
{
let n = crash::clear(&dir, which.as_deref());
app.crashes.note = crate::i18n::format("crash.deleted", &[("count", &n.to_string())]);
app.crashes.note = solidcraft_ui_text::format("crash.deleted", &[("count", &n.to_string())]);
app.crashes.refresh();
}
}
@@ -218,7 +222,7 @@ mod tests {
assert!(status.contains("detected at startup"), "{status}");
assert!(h.widget("Detected 2026-10-12 16:23:41 UTC Closed unexpectedly ×2").is_some());
assert!(h.widget(report["message"].as_str().unwrap()).is_some(), "keep the original session start visible");
assert!(h.widget(crate::i18n::tr("crash.unclean_explanation")).is_some());
assert!(h.widget(solidcraft_ui_text::tr("crash.unclean_explanation")).is_some());
let _ = std::fs::remove_dir_all(&d);
}
@@ -236,7 +240,7 @@ mod tests {
h.frames(6);
assert!(h.app.crashes.open);
assert!(h.widget("2026-10-12 16:23:41 UTC Internal error in a command ×2").is_some());
assert!(h.widget(crate::i18n::tr("crash.unclean_explanation")).is_none());
assert!(h.widget(solidcraft_ui_text::tr("crash.unclean_explanation")).is_none());
let _ = std::fs::remove_dir_all(&d);
}
+4 -4
View File
@@ -185,14 +185,14 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
};
let (deleted, fails) = (list("deleted"), list("would_fail"));
let shared = shared_occurrences(app, &p.params);
let (closed, action) = ui_kit::Confirm::new(egui::Id::unique("sc_delete_confirm"), "delete", crate::i18n::tr_label("Delete"))
let (closed, action) = ui_kit::Confirm::new(egui::Id::unique("sc_delete_confirm"), "delete", solidcraft_ui_text::tr_label("Delete"))
.tone(ui_kit::Tone::Danger)
.actions("Delete", "Cancel", None)
.show(ctx, true, |ui| {
if shared {
ui_kit::text(
ui,
crate::i18n::tr_label("This child is shared. Deleting it removes it from every copy of its parent."),
solidcraft_ui_text::tr_label("This child is shared. Deleting it removes it from every copy of its parent."),
ui_kit::Text::Report("shared", false),
);
}
@@ -202,7 +202,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if names.is_empty() {
continue;
}
ui_kit::text(ui, crate::i18n::tr_label(heading), ui_kit::Text::Report(key, error));
ui_kit::text(ui, solidcraft_ui_text::tr_label(heading), ui_kit::Text::Report(key, error));
for name in names.iter().take(MAX_LISTED) {
ui_kit::text(ui, &format!(" • {name}"), ui_kit::Text::Report(&format!("{key}:{name}"), error));
}
@@ -210,7 +210,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let more = (names.len() - MAX_LISTED).to_string();
ui_kit::text(
ui,
&crate::i18n::format("delete.and_more", &[("count", &more)]),
&solidcraft_ui_text::format("delete.and_more", &[("count", &more)]),
ui_kit::Text::Report(&format!("{key}:more"), error),
);
}
+16 -14
View File
@@ -99,10 +99,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
d.refused = None;
}
let metadata = app.session.doc.metadata.clone();
let title = crate::i18n::tr_label("Design Properties");
let title = solidcraft_ui_text::tr_label("Design Properties");
let (closed, action) = ui_kit::Window::new(egui::Id::unique("sc_design_properties"), "settings", title).fields().show(ctx, true, |ui| {
for (i, k) in keys(Owner::Design).enumerate() {
let (key, label) = (k.name, crate::i18n::tr(k.label));
let (key, label) = (k.name, solidcraft_ui_text::tr(k.label));
let Some(field) = d.fields.get_mut(i) else { continue };
if key == DESCRIPTION {
ui_kit::multiline_text_field(ui, label, egui::Id::unique(("sc_design_properties", key)), field);
@@ -111,15 +111,15 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
// Saved by another tool as something other than text: kept unless typed over.
if metadata.get(key).is_some() && metadata.text(key).is_none() && d.opened.get(i).is_some_and(String::is_empty) {
ui_kit::caption(ui, label, crate::i18n::tr("ui.design_properties.not_text"));
ui_kit::caption(ui, label, solidcraft_ui_text::tr("ui.design_properties.not_text"));
}
}
ui_kit::text(ui, crate::i18n::tr("ui.design_properties.exports"), ui_kit::Text::Note("exports"));
ui_kit::text(ui, solidcraft_ui_text::tr("ui.design_properties.exports"), ui_kit::Text::Note("exports"));
let others: Vec<(&String, &Value)> = metadata.others().collect();
if !others.is_empty() {
let heading = format!("{} ({})", crate::i18n::tr("ui.design_properties.others"), others.len());
let heading = format!("{} ({})", solidcraft_ui_text::tr("ui.design_properties.others"), others.len());
ui_kit::section(ui, "sc_design_properties_others", &heading, false, |ui| {
ui_kit::text(ui, crate::i18n::tr("ui.design_properties.others_note"), ui_kit::Text::Note("others"));
ui_kit::text(ui, solidcraft_ui_text::tr("ui.design_properties.others_note"), ui_kit::Text::Note("others"));
egui::ScrollArea::vertical().max_height(ui_kit::row_height() * OTHERS_ROWS).show(ui, |ui| {
for (key, value) in others {
ui_kit::row(ui, key, |ui| ui_kit::text(ui, &preview(value), ui_kit::Text::Cell));
@@ -263,7 +263,9 @@ fn show_measuring(app: &mut SolveApp, ctx: &egui::Context) {
let bodies = m.bodies();
let heading = app.tr("ui.properties").to_string();
let text = match bodies {
Some((done, total)) => crate::i18n::format("ui.properties.measuring_bodies", &[("done", &done.to_string()), ("total", &total.to_string())]),
Some((done, total)) => {
solidcraft_ui_text::format("ui.properties.measuring_bodies", &[("done", &done.to_string()), ("total", &total.to_string())])
}
None => app.tr("ui.properties.measuring").to_string(),
};
let (closed, action) = ui_kit::Window::new(egui::Id::unique("sc_props"), "settings", &heading).actions("", "Close", None).show(ctx, true, |ui| {
@@ -344,7 +346,7 @@ fn inertia_rows(app: &SolveApp, total: &Value) -> Vec<(String, Vec<String>)> {
}
for (n, axis) in total["principal_axes"].as_array().into_iter().flatten().enumerate() {
let Some(e) = point(axis) else { continue };
let label = crate::i18n::format("ui.properties.principal_axis", &[("number", &(n + 1).to_string())]);
let label = solidcraft_ui_text::format("ui.properties.principal_axis", &[("number", &(n + 1).to_string())]);
rows.push((label, [e.x, e.y, e.z].map(|x| format!("{:.4}", if x.abs() < 5e-5 { 0.0 } else { x })).to_vec()));
}
rows
@@ -354,9 +356,9 @@ fn inertia_rows(app: &SolveApp, total: &Value) -> Vec<(String, Vec<String>)> {
/// file's), else with the name of the component, placement or bodies it measured.
fn window_title(title: &str, v: &Value) -> String {
if v["component"].as_u64() == Some(0) {
crate::i18n::tr("ui.properties").to_string()
solidcraft_ui_text::tr("ui.properties").to_string()
} else {
crate::i18n::format("properties.title", &[("name", title)])
solidcraft_ui_text::format("properties.title", &[("name", title)])
}
}
@@ -406,7 +408,7 @@ pub fn show_object(app: &mut SolveApp, ctx: &egui::Context) {
rows.push(("Density", format!("{:.3} g/cm³", number(b, "density_g_cm3"))));
}
if !bodies.is_empty() && skipped == bodies.len() as u64 {
rows.push(("", crate::i18n::tr_label("Open surface — no volume").to_string()));
rows.push(("", solidcraft_ui_text::tr_label("Open surface — no volume").to_string()));
} else {
rows.push(("Mass", format!("{:.3} g", number(total, "mass_g"))));
rows.push(("Volume", crate::prefs::show_mm(app, number(total, "volume_mm3"), 3)));
@@ -436,7 +438,7 @@ pub fn show_object(app: &mut SolveApp, ctx: &egui::Context) {
format!("({}) g·{unit}", m.join(", "))
});
let inertia = inertia_rows(app, total);
let note = crate::i18n::format("ui.properties.inertia_note", &[("unit", unit)]);
let note = solidcraft_ui_text::format("ui.properties.inertia_note", &[("unit", unit)]);
// A component's closed bodies have mass, but a mesh that would not close gave no tensor.
let unavailable =
inertia.is_empty() && total["inertia_g_mm2"].is_null() && number(total, "mass_g") > 0.0 && total.get("first_moment_g_mm").is_some();
@@ -450,7 +452,7 @@ pub fn show_object(app: &mut SolveApp, ctx: &egui::Context) {
let heading = window_title(&title, &v);
let (closed, action) = ui_kit::Window::new(egui::Id::unique("sc_props"), "settings", &heading).actions("", "Close", None).show(ctx, true, |ui| {
for (label, value) in &rows {
ui_kit::row(ui, crate::i18n::tr_label(label), |ui| ui_kit::text(ui, value, ui_kit::Text::Cell));
ui_kit::row(ui, solidcraft_ui_text::tr_label(label), |ui| ui_kit::text(ui, value, ui_kit::Text::Cell));
}
if let Some(first) = &first {
ui_kit::row(ui, &first_label, |ui| ui_kit::text(ui, first, ui_kit::Text::Cell));
@@ -591,7 +593,7 @@ mod tests {
#[test]
fn each_field_is_labelled_from_the_table_of_keys() {
use solidcraft_engine::doc::metadata::{KEYS, Owner, keys};
let english = crate::i18n::Locale::for_language("en");
let english = solidcraft_ui_text::Locale::for_language("en");
for k in KEYS {
assert_ne!(english.text(k.label), k.label, "`{}`'s label is in the English catalog", k.name);
}
+32 -28
View File
@@ -2558,7 +2558,7 @@ pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usiz
}
fn tr(text: &str) -> &str {
crate::i18n::tr_label(text)
solidcraft_ui_text::tr_label(text)
}
fn publish_check(ui: &egui::Ui, response: &egui::Response, selected: bool) {
@@ -2743,7 +2743,8 @@ fn object_type_row(d: &mut Dialog, ui: &mut egui::Ui) {
let cur = usize::from(inp.accept == selection::FEATURES);
let mut ty = cur;
// What the two kinds are, and that a sketch is neither (#10057).
choice_row(ui, "Object Type", "pat_obj", &mut ty, &["Bodies", "Features"]).backing_on_hover_text(crate::i18n::tr("ui.pattern_object_type_tip"));
choice_row(ui, "Object Type", "pat_obj", &mut ty, &["Bodies", "Features"])
.backing_on_hover_text(solidcraft_ui_text::tr("ui.pattern_object_type_tip"));
if ty != cur {
inp.accept = if ty == 1 { selection::FEATURES } else { BODIES };
inp.label = objects_label(inp.accept);
@@ -2915,25 +2916,27 @@ fn occurrence_paths(s: &Session, items: &[Sel]) -> Vec<Vec<u64>> {
/// the name the preferences give it.
pub(crate) fn selection_label(s: &Session, names: crate::prefs::OriginPlaneNames, d: &Dialog, inp: &SelInput, item: &Sel) -> String {
let axis = inp.accept == selection::AXIS_PICKS && round_axis(s, item).is_some();
let sketch_name =
|id| s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_else(|| crate::i18n::format("browser.sketch_name", &[("number", &id.to_string())]));
let sketch_name = |id| {
s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_else(|| solidcraft_ui_text::format("browser.sketch_name", &[("number", &id.to_string())]))
};
match item {
Sel::SketchCurve { id, .. } => id.clone(),
Sel::SketchPoint { id } => crate::sketch_modify_ui::point_id(s, item).unwrap_or(id).to_string(),
Sel::SketchMidpoint { id } => crate::i18n::format("dialog.selection_midpoint", &[("id", id)]),
Sel::SketchMidpoint { id } => solidcraft_ui_text::format("dialog.selection_midpoint", &[("id", id)]),
Sel::ExternalSketchCurve { sketch, id } => {
crate::i18n::format("dialog.selection_external_curve", &[("sketch", &sketch_name(*sketch)), ("id", id)])
solidcraft_ui_text::format("dialog.selection_external_curve", &[("sketch", &sketch_name(*sketch)), ("id", id)])
}
// Another sketch's point (a Sphere's centre, #9403), named as Select Other names it.
Sel::ExternalSketchPoint { sketch, id } => {
let state = crate::open_meshing::session_world(s);
let point = state.sketch(*sketch).map_or_else(|| id.clone(), |ss| crate::select_other::point_label(&ss.sketch, id));
crate::i18n::format("dialog.selection_external_point", &[("point", &point), ("sketch", &sketch_name(*sketch))])
}
Sel::SketchConstraint { id } => crate::i18n::format("dialog.selection_constraint", &[("id", id)]),
Sel::Profile { sketch, index } => {
crate::i18n::format("dialog.selection_profile", &[("sketch", &sketch_name(*sketch)), ("number", &index.saturating_add(1).to_string())])
solidcraft_ui_text::format("dialog.selection_external_point", &[("point", &point), ("sketch", &sketch_name(*sketch))])
}
Sel::SketchConstraint { id } => solidcraft_ui_text::format("dialog.selection_constraint", &[("id", id)]),
Sel::Profile { sketch, index } => solidcraft_ui_text::format(
"dialog.selection_profile",
&[("sketch", &sketch_name(*sketch)), ("number", &index.saturating_add(1).to_string())],
),
Sel::Plane { name } => names.label(name).to_string(),
Sel::Axis { name } | Sel::ConstructionPoint { name } | Sel::Body { name } => name.clone(),
Sel::Face { body, index, .. } if axis => format!("{body} · face {index} axis"),
@@ -3033,18 +3036,18 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n
let filled = !inp.items.is_empty() || custom_axis || stored_reference.is_some();
let text = if filled {
RichText::new(if stored_reference.is_some() && matches!(d.kind, Kind::Section { .. }) {
crate::i18n::tr("ui.viewport_status_section_plane").into()
solidcraft_ui_text::tr("ui.viewport_status_section_plane").into()
} else if custom_axis || stored_reference.is_some() {
crate::i18n::tr("dialog.custom_axis").into()
solidcraft_ui_text::tr("dialog.custom_axis").into()
} else if !inp.multi {
inp.items.first().map(|item| selection_label(s, names, d, inp, item)).unwrap_or_default()
} else {
let count = if i == 0 { shell_count.unwrap_or(inp.items.len()) } else { inp.items.len() };
crate::i18n::format("dialog.selected_count", &[("count", &count.to_string())])
solidcraft_ui_text::format("dialog.selected_count", &[("count", &count.to_string())])
})
.color(t.on_accent)
} else {
RichText::new(crate::i18n::tr("dialog.select")).color(t.text)
RichText::new(solidcraft_ui_text::tr("dialog.select")).color(t.text)
};
let selected_text = text.text().strip_prefix("↖ ").unwrap_or_else(|| text.text());
let prompt = if !filled && inp.label == "Edges" { tr("Select edges") } else { selected_text };
@@ -3074,7 +3077,7 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n
hint(inp).to_string()
}
} else if let Some(id) = inp.tip {
crate::i18n::tr(id).to_string()
solidcraft_ui_text::tr(id).to_string()
} else {
format!("{} (click to pick into this input)", hint(inp))
};
@@ -3099,7 +3102,7 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n
if inp.multi {
let open = d.listed == Some(i);
let at = rect.translate(vec2(-t.space_24, 0.0));
let label = crate::i18n::tr_label(if open { "Hide the picked items" } else { "Show the picked items" });
let label = solidcraft_ui_text::tr_label(if open { "Hide the picked items" } else { "Show the picked items" });
let arrow = crate::ui_kit::disclosure_button_at(ui, at, open, label);
crate::scenario::publish_handle(&format!("dialog:list_toggle:{}", inp.label), arrow.rect.center());
if arrow.clicked() {
@@ -3231,7 +3234,7 @@ fn error_rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: &mut
// Under the fold in a short window, a new note scrolls into view once, as a refusal does.
let note_shown_id = egui::Id::unique("sc_dialog_pick_refused_shown");
if let Some(id) = d.pick_refused.filter(|_| d.error.is_none() && app.preview.error.is_none()) {
let note = crate::ui_kit::text(ui, crate::i18n::tr(id), crate::ui_kit::Text::Hint("pick_refused"));
let note = crate::ui_kit::text(ui, solidcraft_ui_text::tr(id), crate::ui_kit::Text::Hint("pick_refused"));
crate::scenario::publish_rect("dialog:pick_refused", note.rect);
if ui.data(|m| m.get_temp::<&'static str>(note_shown_id)) != Some(id) {
ui.scroll_to_rect(note.rect, None);
@@ -3263,13 +3266,13 @@ fn error_rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: &mut
ui.horizontal(|ui| {
let ready = solidcraft_engine::find_command(crate::refused_part::REPAIR).map(|c| c.info(&app.session));
let enabled = ready.as_ref().is_some_and(|i| i.enabled);
let repair = crate::ui_kit::button(ui, crate::i18n::tr("refused.repair"), crate::ui_kit::Button::Enabled(enabled));
let repair = crate::ui_kit::button(ui, solidcraft_ui_text::tr("refused.repair"), crate::ui_kit::Button::Enabled(enabled));
crate::scenario::publish_handle("dialog:repair_part", repair.rect.center());
if repair.clicked() {
crate::refused_part::repair(app, &part.body);
}
if part.face.is_some() {
let show = crate::ui_kit::button(ui, crate::i18n::tr("refused.show_face"), crate::ui_kit::Button::Normal);
let show = crate::ui_kit::button(ui, solidcraft_ui_text::tr("refused.show_face"), crate::ui_kit::Button::Normal);
crate::scenario::publish_handle("dialog:show_face", show.rect.center());
if show.clicked() {
crate::refused_part::show_face(app, &part);
@@ -3359,7 +3362,7 @@ pub(crate) fn fillet_plain_error(error: &str, setback_in_field: bool) -> Option<
if let Some((_, rest)) = error.split_once(solidcraft_engine::kernel::SETBACK_AT_LEAST)
&& let Some(least) = rest.split_once(" mm").map(|(n, _)| n.trim()).filter(|n| n.parse::<f64>().is_ok())
{
return Some(FilletRefusal::Plain(crate::i18n::format("ui.fillet_error_setback_short", &[("least", &format!("{least} mm"))])));
return Some(FilletRefusal::Plain(solidcraft_ui_text::format("ui.fillet_error_setback_short", &[("least", &format!("{least} mm"))])));
}
let too_large = error.contains("larger than the faces beside its edges allow");
let corner = error.contains("setback") || error.contains("G2 corner") || error.contains("corner patch");
@@ -3397,7 +3400,7 @@ fn reveal_refusal(on_ok: bool, typing: bool, shown: Option<&str>, error: &str) -
/// message is in the catalogs.
fn shown_error(error: &str) -> String {
let msg = error.strip_prefix("invalid parameters for `").and_then(|rest| rest.split_once("`: ")).map_or(error, |(_, msg)| msg);
crate::i18n::tr_diagnostic(crate::i18n::tr_label(msg))
crate::i18n::tr_diagnostic(solidcraft_ui_text::tr_label(msg))
}
/// Find the Boundary Fill cells again when the tool bodies changed (keeping the choices of
@@ -3764,7 +3767,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
return;
}
crate::sketch_text::sync_path(&mut d);
let _language = crate::i18n::scope(&app.preferences.language);
let _language = solidcraft_ui_text::scope(&app.preferences.language);
if let Kind::TextMove(k) = &d.kind
&& !crate::sketch_text_move::valid(app, k)
{
@@ -3832,7 +3835,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let wide = matches!(&d.kind, Kind::Motion(k) if k.wide());
let raw_title = title(&d.kind);
let heading = if d.editing.is_some() { crate::i18n::format("dialog.edit_title", &[("title", tr(raw_title))]) } else { tr(raw_title).to_string() };
let heading =
if d.editing.is_some() { solidcraft_ui_text::format("dialog.edit_title", &[("title", tr(raw_title))]) } else { tr(raw_title).to_string() };
let frame = crate::ui_kit::Panel::feature_frame(ctx, app.ui.layout, side);
// OK is offered once the inputs make a feature.
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Measure { .. });
@@ -3932,7 +3936,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
let text = app.applying.as_ref().map(|job| {
let seconds = ((crate::now_ms() - job.started) / 1000.0).floor().max(0.0);
crate::i18n::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))])
solidcraft_ui_text::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))])
});
crate::ui_kit::ComputeStatus { text, busy: app.applying.is_some() || app.preview.busy, applying: app.applying.is_some() }
};
@@ -5748,10 +5752,10 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
}
Kind::Material { index, editor } => {
if editor.editing() {
return Err(crate::i18n::tr("material.editor.save_or_discard").to_string());
return Err(solidcraft_ui_text::tr("material.editor.save_or_discard").to_string());
}
need(0, "bodies")?;
let name = s.doc.material_names().get(*index).cloned().ok_or_else(|| crate::i18n::tr("material.selection.choose").to_string())?;
let name = s.doc.material_names().get(*index).cloned().ok_or_else(|| solidcraft_ui_text::tr("material.selection.choose").to_string())?;
("material.assign", json!({"bodies": sels(d, 0).body_names(), "material": name}))
}
Kind::Section { offset, flip, hatch } => {
@@ -8206,7 +8210,7 @@ mod awaiting_selection_tests {
h.step(&serde_json::json!({"key":"Enter"})).unwrap();
h.frames(2);
assert_eq!(h.app.dialog.as_ref().and_then(|d| d.error.as_deref()), Some(english), "{command}");
let _japanese = crate::i18n::scope("ja");
let _japanese = solidcraft_ui_text::scope("ja");
let shown = super::shown_error(english);
assert!(shown != english && !shown.is_ascii(), "{command} shows {shown:?}");
}
+10 -10
View File
@@ -453,7 +453,7 @@ fn motion_dialog_hints_are_translated() {
}
let mut h = fixture();
h.app.preferences.language = "ja".into();
let _japanese = crate::i18n::scope("ja");
let _japanese = solidcraft_ui_text::scope("ja");
for (command, english) in [("motion.study", "add a key for a joint first"), ("motion.trace", "select a point on a component")] {
let mut d = Dialog::for_command(&h.app, command).unwrap();
let mut painted = Vec::new();
@@ -472,7 +472,7 @@ fn motion_dialog_hints_are_translated() {
out.shapes.iter().for_each(|shape| texts(&shape.shape, &mut painted));
}
assert!(!painted.iter().any(|p| p == english), "{command} paints {english:?} in Japanese");
assert!(painted.iter().any(|p| p == crate::i18n::tr_label(english)), "{command} paints {painted:?}");
assert!(painted.iter().any(|p| p == solidcraft_ui_text::tr_label(english)), "{command} paints {painted:?}");
}
}
@@ -746,7 +746,7 @@ fn hand_drawn_dialog_texts_are_translated() {
}
let mut h = fixture();
h.app.preferences.language = "ja".into();
let _japanese = crate::i18n::scope("ja");
let _japanese = solidcraft_ui_text::scope("ja");
for (command, english) in [
("joint.gear", "▶ Play"),
("motion.study", "Loop"),
@@ -790,7 +790,7 @@ fn revolve_layout_fixture_builds_a_valid_solid() {
#[test]
fn new_cards_settle_before_the_first_click_in_both_layouts() {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for command in ["solid.box", "solid.revolve", "solid.shell", "joint.create"] {
let mut h = fixture();
h.call("ui.set", json!({"layout":mode}));
@@ -1401,13 +1401,13 @@ fn extrude_builds_the_one_region_clicked_where_the_face_outline_cuts_a_profile()
#[test]
fn every_selection_field_label_is_translated() {
let h = fixture();
let _japanese = crate::i18n::scope("ja");
let _japanese = solidcraft_ui_text::scope("ja");
let mut missing = std::collections::BTreeSet::new();
for spec in solidcraft_engine::command_specs() {
let Some(d) = Dialog::for_command(&h.app, spec.id) else { continue };
for input in &d.inputs {
let label = input.label.trim();
if label.chars().any(|c| c.is_ascii_alphabetic()) && crate::i18n::tr_label(label) == label {
if label.chars().any(|c| c.is_ascii_alphabetic()) && solidcraft_ui_text::tr_label(label) == label {
missing.insert(format!("{}: {label}", spec.id));
}
}
@@ -1570,9 +1570,9 @@ fn pattern_says_why_a_clicked_sketch_is_not_taken() {
}
}
assert!(crate::dialogs::missing_pick(crate::dialogs::SKETCH_NOT_OBJECT), "shown as what to pick, with no Create Issue");
let _japanese = crate::i18n::scope("ja");
assert!(!crate::i18n::tr_label(crate::dialogs::SKETCH_NOT_OBJECT).is_ascii(), "the hint reads in the user's language");
assert!(!crate::i18n::tr("ui.pattern_object_type_tip").is_ascii(), "so does the Object Type tooltip");
let _japanese = solidcraft_ui_text::scope("ja");
assert!(!solidcraft_ui_text::tr_label(crate::dialogs::SKETCH_NOT_OBJECT).is_ascii(), "the hint reads in the user's language");
assert!(!solidcraft_ui_text::tr("ui.pattern_object_type_tip").is_ascii(), "so does the Object Type tooltip");
}
/// Features stored with bare numbers edit with their units written out, as the Plastic ones do
@@ -1682,7 +1682,7 @@ fn dialog_sketch_selection_labels_translate_and_keep_identifiers() {
(Sel::SketchConstraint { id: "k3".into() }, "Constraint k3", "Bedingung k3"),
] {
for (language, expected) in [("en", english), ("de", german)] {
let _language = crate::i18n::scope(language);
let _language = solidcraft_ui_text::scope(language);
assert_eq!(selection_label(&h.app.session, h.app.preferences.origin_plane_names, &d, &d.inputs[0], &sel), expected);
}
}
+5 -3
View File
@@ -385,7 +385,9 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
// Apply To: bodies or faces (the input follows).
if let Some(component) = &k.component {
ui_kit::row(ui, "Apply To", |ui| ui_kit::text(ui, &format!("Component: {component}"), ui_kit::Text::Cell));
if ui_kit::row(ui, "", |ui| ui_kit::button(ui, crate::i18n::tr_label("Select bodies or faces instead"), ui_kit::Button::Normal)).clicked() {
if ui_kit::row(ui, "", |ui| ui_kit::button(ui, solidcraft_ui_text::tr_label("Select bodies or faces instead"), ui_kit::Button::Normal))
.clicked()
{
k.component = None;
}
} else {
@@ -462,7 +464,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
});
}
if ui_kit::button(ui, "Remove override", ui_kit::Button::Enabled(has_target))
.backing_on_hover_text(crate::i18n::tr_label("Restore the inherited component or physical material appearance"))
.backing_on_hover_text(solidcraft_ui_text::tr_label("Restore the inherited component or physical material appearance"))
.clicked()
&& let Some(p) = clear_params(k, &picked)
{
@@ -523,7 +525,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
.filter(|(_, l)| matches_library_filter(l, (k.category != 0).then_some(want.as_str()), &query, k.only_available))
.collect();
if shown.is_empty() {
ui_kit::text(ui, crate::i18n::tr_label("No matching appearances"), ui_kit::Text::Note("appearances_empty"));
ui_kit::text(ui, solidcraft_ui_text::tr_label("No matching appearances"), ui_kit::Text::Note("appearances_empty"));
}
ui_kit::swatch_grid(ui, &shown, |ui, (i, l)| {
let picked_look = k.look.as_deref() == Some(l.name.as_str()) && k.pbr == l.pbr;
+52 -47
View File
@@ -73,7 +73,7 @@ pub(crate) fn kind_label(kind: JointKind) -> &'static str {
/// A joint as the joint lists name it: its name, and its type in the user's language.
pub(crate) fn joint_choice(name: &str, kind: JointKind) -> String {
format!("{name} ({})", crate::i18n::tr_label(kind_label(kind)))
format!("{name} ({})", solidcraft_ui_text::tr_label(kind_label(kind)))
}
/// What a joint origin input accepts: snaps on faces, edges and vertices, the world origin, and
@@ -819,7 +819,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
}
}
Asm::NewComponent { name, activate } => {
enter |= text_row(ui, "Name", name, crate::i18n::tr_label("Component1")).0;
enter |= text_row(ui, "Name", name, solidcraft_ui_text::tr_label("Component1")).0;
crate::ui_kit::options(ui, "component_options", &mut [("Activate", activate)]);
}
Asm::Joint(f) => {
@@ -832,8 +832,8 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
ui.vertical(|ui| {
crate::ui_kit::status_line(ui, &why, crate::ui_kit::StatusTone::Warning);
if !bodies.is_empty()
&& crate::ui_kit::button(ui, crate::i18n::tr("cmd.component_from_bodies"), crate::ui_kit::Button::Normal)
.backing_on_hover_text(crate::i18n::tr("joint.from_bodies_hint"))
&& crate::ui_kit::button(ui, solidcraft_ui_text::tr("cmd.component_from_bodies"), crate::ui_kit::Button::Normal)
.backing_on_hover_text(solidcraft_ui_text::tr("joint.from_bodies_hint"))
.clicked()
{
// A refusal shows on the status line; the picks stay either way.
@@ -869,9 +869,9 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
let kind = f.joint_kind();
if kind != JointKind::Rigid {
form_row(ui, "Animate", |ui| {
let label = crate::i18n::tr(if f.animate { "ui.joint_stop" } else { "ui.joint_play" });
let label = solidcraft_ui_text::tr(if f.animate { "ui.joint_stop" } else { "ui.joint_play" });
if crate::ui_kit::button(ui, label, crate::ui_kit::Button::Normal)
.backing_on_hover_text(crate::i18n::tr_label("Play the joint's motion in the view"))
.backing_on_hover_text(solidcraft_ui_text::tr_label("Play the joint's motion in the view"))
.clicked()
{
f.animate = !f.animate;
@@ -915,7 +915,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
}
}
Asm::JointOrigin { name, .. } => {
enter |= text_row(ui, "Name", name, crate::i18n::tr_label("Joint Origin1")).0;
enter |= text_row(ui, "Name", name, solidcraft_ui_text::tr_label("Joint Origin1")).0;
}
Asm::RigidGroup => {}
Asm::Drive { joint, values, focus } => {
@@ -927,9 +927,9 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
if let Some((id, name)) = &rigid {
let mut edit = false;
ui.vertical(|ui| {
let why = crate::i18n::format("drive.rigid", &[("joint", name.as_str())]);
let why = solidcraft_ui_text::format("drive.rigid", &[("joint", name.as_str())]);
crate::ui_kit::status_line(ui, &why, crate::ui_kit::StatusTone::Warning);
let r = crate::ui_kit::button(ui, crate::i18n::tr_label("Edit Joint"), crate::ui_kit::Button::Normal);
let r = crate::ui_kit::button(ui, solidcraft_ui_text::tr_label("Edit Joint"), crate::ui_kit::Button::Normal);
crate::scenario::publish_handle("drive:edit_joint", r.rect.center());
edit = r.clicked();
});
@@ -941,7 +941,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
if list.is_empty() {
if rigid.is_none() {
ui.vertical(|ui| {
ui.label(RichText::new(crate::i18n::tr_label("no joint that moves yet")).color(t.text_dim));
ui.label(RichText::new(solidcraft_ui_text::tr_label("no joint that moves yet")).color(t.text_dim));
});
}
return false;
@@ -1085,13 +1085,15 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
ui.horizontal(|ui| {
let seconds = ((crate::now_ms() - started) / 1000.0).floor().max(0.0);
ui.add(egui::Spinner::new().size(t.font_small));
ui.label(RichText::new(crate::i18n::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))])).color(t.text_dim));
ui.label(
RichText::new(solidcraft_ui_text::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))])).color(t.text_dim),
);
});
});
}
form_row(ui, "", |ui| {
if check.is_some() {
if crate::ui_kit::button(ui, crate::i18n::tr("ui.cancel"), crate::ui_kit::Button::Normal).clicked() {
if crate::ui_kit::button(ui, solidcraft_ui_text::tr("ui.cancel"), crate::ui_kit::Button::Normal).clicked() {
// Its thread finishes the check; the reply is left unread.
*check = None;
}
@@ -1100,7 +1102,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
let stale = result.is_some() && *of != picked;
if crate::ui_kit::button(
ui,
crate::i18n::tr(if stale { "dialog.compute_again" } else { "dialog.compute" }),
solidcraft_ui_text::tr(if stale { "dialog.compute_again" } else { "dialog.compute" }),
crate::ui_kit::Button::Normal,
)
.clicked()
@@ -1132,7 +1134,8 @@ fn find_limits_row(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut JointForm, id:
let t = Tokens::get();
if let Some(search) = &f.search {
// A poisoned lock: the search's thread panicked.
let reply = search.reply.lock().map(|mut r| r.take()).unwrap_or_else(|_| Some(Err(crate::i18n::tr("joint.find_limits_failed").to_string())));
let reply =
search.reply.lock().map(|mut r| r.take()).unwrap_or_else(|_| Some(Err(solidcraft_ui_text::tr("joint.find_limits_failed").to_string())));
match reply {
Some(reply) => {
// Found for a design changed since (by a script, the control channel, undo), or
@@ -1143,7 +1146,7 @@ fn find_limits_row(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut JointForm, id:
f.searched = Some(searched);
fill_limits(&app.session.doc, f, reply);
} else {
f.found_note = Some(crate::i18n::tr("joint.find_limits_stale").to_string());
f.found_note = Some(solidcraft_ui_text::tr("joint.find_limits_stale").to_string());
}
// The rows above were drawn before this: once more, with what was found.
ui.ctx().request_repaint();
@@ -1159,20 +1162,20 @@ fn find_limits_row(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut JointForm, id:
{
f.searched = None;
f.found.clear();
f.found_note = Some(crate::i18n::tr("joint.find_limits_stale").to_string());
f.found_note = Some(solidcraft_ui_text::tr("joint.find_limits_stale").to_string());
}
form_row(ui, "", |ui| {
if f.search.is_some() {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(t.font_small));
ui.label(RichText::new(crate::i18n::tr("joint.finding_limits")).color(t.text_dim));
if crate::ui_kit::button(ui, crate::i18n::tr("ui.cancel"), crate::ui_kit::Button::Normal).clicked() {
ui.label(RichText::new(solidcraft_ui_text::tr("joint.finding_limits")).color(t.text_dim));
if crate::ui_kit::button(ui, solidcraft_ui_text::tr("ui.cancel"), crate::ui_kit::Button::Normal).clicked() {
// Its thread finishes within its work budget; the reply is left unread.
f.search = None;
}
});
} else if crate::ui_kit::button(ui, crate::i18n::tr("joint.find_limits"), crate::ui_kit::Button::Normal)
.backing_on_hover_text(crate::i18n::tr("joint.find_limits_hint"))
} else if crate::ui_kit::button(ui, solidcraft_ui_text::tr("joint.find_limits"), crate::ui_kit::Button::Normal)
.backing_on_hover_text(solidcraft_ui_text::tr("joint.find_limits_hint"))
.clicked()
{
find_limits(app, f, id, inputs);
@@ -1274,20 +1277,20 @@ fn fill_limits(doc: &solidcraft_engine::doc::Document, f: &mut JointForm, reply:
let (min, max) = (lo.map(shown), hi.map(shown));
let mut lines = Vec::new();
for (label, found, by) in [("Minimum", &min, &v["min_by"]), ("Maximum", &max, &v["max_by"])] {
let which = crate::i18n::tr_label(label);
let which = solidcraft_ui_text::tr_label(label);
let value = found.as_ref().map(|(_, shown)| shown.as_str()).unwrap_or_default();
lines.push(match by.as_str() {
Some("loop") => crate::i18n::format("joint.limit_loop", &[("side", which), ("value", value)]),
Some("limit") => crate::i18n::format("joint.limit_limit", &[("side", which), ("value", value)]),
Some("turn") => crate::i18n::format("joint.limit_turn", &[("side", which), ("value", value)]),
Some("loop") => solidcraft_ui_text::format("joint.limit_loop", &[("side", which), ("value", value)]),
Some("limit") => solidcraft_ui_text::format("joint.limit_limit", &[("side", which), ("value", value)]),
Some("turn") => solidcraft_ui_text::format("joint.limit_turn", &[("side", which), ("value", value)]),
Some(_) => {
// Down to a tenth, so it never claims more than was searched.
let range = (v["range"].as_f64().filter(|x| x.is_finite()).unwrap_or(0.0) / scale * 10.0).floor() / 10.0;
crate::i18n::format("joint.limit_range", &[("side", which), ("range", &num(range, &unit))])
solidcraft_ui_text::format("joint.limit_range", &[("side", which), ("range", &num(range, &unit))])
}
None => {
let (a, b) = (by["a"].as_str().unwrap_or_default(), by["b"].as_str().unwrap_or_default());
crate::i18n::format("joint.limit_contact", &[("side", which), ("value", value), ("a", a), ("b", b)])
solidcraft_ui_text::format("joint.limit_contact", &[("side", which), ("value", value), ("a", a), ("b", b)])
}
});
}
@@ -1308,7 +1311,7 @@ fn fill_limits(doc: &solidcraft_engine::doc::Document, f: &mut JointForm, reply:
.map(|o| format!("{} / {}", o["a"].as_str().unwrap_or_default(), o["b"].as_str().unwrap_or_default()))
.collect();
if !overlapping.is_empty() {
f.found_note = Some(crate::i18n::format("joint.limits_overlapping", &[("pairs", &overlapping.join(", "))]));
f.found_note = Some(solidcraft_ui_text::format("joint.limits_overlapping", &[("pairs", &overlapping.join(", "))]));
}
}
@@ -1343,7 +1346,7 @@ fn interference(app: &SolveApp, picked: &[Sel], clearance: &str) -> Check {
}
check_now(app.session.ahead(), &crate::open_meshing::session_world(&app.session), p)
}
_ => (json!({ "error": crate::i18n::tr("dialog.clearance_invalid") }), Vec::new()),
_ => (json!({ "error": solidcraft_ui_text::tr("dialog.clearance_invalid") }), Vec::new()),
};
if let Ok(mut slot) = check.reply.lock() {
*slot = Some(reply);
@@ -1389,7 +1392,7 @@ fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) {
}
let hits = r["overlaps"].as_array().cloned().unwrap_or_default();
form_row(ui, "Checked", |ui| {
ui.label(crate::i18n::format("dialog.bodies_count", &[("count", &r["bodies"].as_u64().unwrap_or(0).to_string())]));
ui.label(solidcraft_ui_text::format("dialog.bodies_count", &[("count", &r["bodies"].as_u64().unwrap_or(0).to_string())]));
});
let unchecked = r["unchecked"].as_array().cloned().unwrap_or_default();
let near = r["near"].as_array().cloned().unwrap_or_default();
@@ -1400,7 +1403,7 @@ fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) {
// Pairs closer than the clearance, nearest first.
if !near.is_empty() {
form_row(ui, "Too close", |ui| {
ui.label(RichText::new(crate::i18n::tr("ui.distance")).semibold().color(crate::theme::Tokens::get().text));
ui.label(RichText::new(solidcraft_ui_text::tr("ui.distance")).semibold().color(crate::theme::Tokens::get().text));
});
}
for h in &near {
@@ -1412,7 +1415,7 @@ fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) {
let nearest = r["nearest"].as_object().cloned().unwrap_or_default();
if !nearest.is_empty() {
form_row(ui, "Nearest", |ui| {
ui.label(RichText::new(crate::i18n::tr("ui.distance")).semibold().color(crate::theme::Tokens::get().text));
ui.label(RichText::new(solidcraft_ui_text::tr("ui.distance")).semibold().color(crate::theme::Tokens::get().text));
});
}
for (body, list) in &nearest {
@@ -1426,7 +1429,7 @@ fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) {
}
if !hits.is_empty() {
form_row(ui, "Interferes", |ui| {
ui.label(RichText::new(crate::i18n::tr_label("Volume")).semibold().color(crate::theme::Tokens::get().text));
ui.label(RichText::new(solidcraft_ui_text::tr_label("Volume")).semibold().color(crate::theme::Tokens::get().text));
});
}
for h in hits {
@@ -1437,7 +1440,7 @@ fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) {
// Pairs the check could not work out: not clear, not known to interfere.
for u in unchecked {
form_row(ui, &format!("{} · {}", u["a"].as_str().unwrap_or(""), u["b"].as_str().unwrap_or("")), |ui| {
ui.label(RichText::new(crate::i18n::tr("dialog.could_not_be_checked")).color(t.warning))
ui.label(RichText::new(solidcraft_ui_text::tr("dialog.could_not_be_checked")).color(t.warning))
.backing_on_hover_text(u["reason"].as_str().unwrap_or(""));
});
}
@@ -1474,8 +1477,8 @@ fn joint_problem(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Option<(String
let occs: Vec<Option<u64>> = bodies.iter().map(|b| occurrence_of_body(&app.session, b)).collect();
if occs.first().is_some_and(|first| first.is_some() && occs.iter().all(|o| o == first)) {
return Some(match bodies.as_slice() {
[body] => (crate::i18n::format("joint.one_body", &[("body", body.as_str())]), Vec::new()),
_ => (crate::i18n::format("joint.one_component", &[("bodies", &bodies.join(", "))]), bodies),
[body] => (solidcraft_ui_text::format("joint.one_body", &[("body", body.as_str())]), Vec::new()),
_ => (solidcraft_ui_text::format("joint.one_component", &[("bodies", &bodies.join(", "))]), bodies),
});
}
commands(app, k, inputs).err().map(|e| (e, Vec::new()))
@@ -1509,7 +1512,7 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result<Vec<(Str
Asm::FromBodies => {
let bodies = sel_items(inputs, 0).body_names();
if bodies.is_empty() {
return Err(crate::i18n::tr("ui.components_from_bodies_required").into());
return Err(solidcraft_ui_text::tr("ui.components_from_bodies_required").into());
}
cmd("component.from_bodies", json!({ "bodies": bodies }))
}
@@ -1599,12 +1602,12 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result<Vec<(Str
let occs = occurrences(s, items);
if occs.len() < 2 {
if occs.len() == 1 && items.len() > 1 && items.iter().all(|x| matches!(x, Sel::Body { .. })) {
return Err(crate::i18n::format(
return Err(solidcraft_ui_text::format(
"joint.rigid_group_one_component",
&[("count", &items.len().to_string()), ("command", crate::i18n::tr("cmd.component_from_bodies"))],
&[("count", &items.len().to_string()), ("command", solidcraft_ui_text::tr("cmd.component_from_bodies"))],
));
}
return Err(crate::i18n::tr("joint.rigid_group_select_components").into());
return Err(solidcraft_ui_text::tr("joint.rigid_group_select_components").into());
}
cmd("joint.rigid_group", json!({ "occurrences": occs }))
}
@@ -1612,7 +1615,7 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result<Vec<(Str
let j = s.doc.assembly.joints.get(*joint).ok_or("no joint that moves yet")?;
// A rigid joint has nothing to drive: the dialog says why and offers Edit Joint (#6981).
if j.kind == JointKind::Rigid {
return Err(crate::i18n::format("drive.rigid", &[("joint", &j.name)]));
return Err(solidcraft_ui_text::format("drive.rigid", &[("joint", &j.name)]));
}
cmd("joint.drive", json!({"joint": j.id, "values": values}))
}
@@ -1725,7 +1728,7 @@ pub fn world_preview(s: &Session, cmds: &[(String, Value)], colors: Colors) -> O
/// joined to nothing grounded moves nothing, and says which part that is.
pub fn drive_stop(reply: &Value) -> Option<String> {
if let Some(part) = reply.get("unconnected").and_then(Value::as_str) {
return Some(crate::i18n::format("drive.unconnected", &[("part", part)]));
return Some(solidcraft_ui_text::format("drive.unconnected", &[("part", part)]));
}
// The values come as joint.list gives them: degrees and millimetres.
let at: Vec<String> = reply
@@ -1739,10 +1742,10 @@ pub fn drive_stop(reply: &Value) -> Option<String> {
let at = at.join(", ");
let name = |v: &Value| v.as_str().unwrap_or_default().to_string();
if let Some(l) = reply.get("stopped_by_loop").filter(|v| v.is_object()) {
return Some(crate::i18n::format("drive.stopped_by_loop", &[("value", &at), ("joint", &name(&l["joint"]))]));
return Some(solidcraft_ui_text::format("drive.stopped_by_loop", &[("value", &at), ("joint", &name(&l["joint"]))]));
}
let c = reply.get("stopped_by_contact").filter(|v| v.is_object())?;
Some(crate::i18n::format("drive.stopped_by_contact", &[("value", &at), ("a", &name(&c["a"])), ("b", &name(&c["b"]))]))
Some(solidcraft_ui_text::format("drive.stopped_by_contact", &[("value", &at), ("a", &name(&c["a"])), ("b", &name(&c["b"]))]))
}
// ---- overlay ----
@@ -1848,20 +1851,22 @@ fn joint_menu(
open: &mut Option<Dialog>,
run: &mut Option<(&'static str, Value)>,
) {
if crate::ui_kit::button(ui, crate::i18n::tr_label("Edit Joint"), crate::ui_kit::Button::Normal).clicked() {
if crate::ui_kit::button(ui, solidcraft_ui_text::tr_label("Edit Joint"), crate::ui_kit::Button::Normal).clicked() {
*open = edit_joint(app, id);
ui.close();
}
if kind != JointKind::Rigid && crate::ui_kit::button(ui, crate::i18n::tr_label("Drive Joint"), crate::ui_kit::Button::Normal).clicked() {
if kind != JointKind::Rigid && crate::ui_kit::button(ui, solidcraft_ui_text::tr_label("Drive Joint"), crate::ui_kit::Button::Normal).clicked() {
*open = drive_joint(app, id);
ui.close();
}
if crate::ui_kit::button(ui, crate::i18n::tr_label(if suppressed { "Unsuppress" } else { "Suppress" }), crate::ui_kit::Button::Normal).clicked() {
if crate::ui_kit::button(ui, solidcraft_ui_text::tr_label(if suppressed { "Unsuppress" } else { "Suppress" }), crate::ui_kit::Button::Normal)
.clicked()
{
*run = Some(("joint.edit", json!({"joint": id, "suppressed": !suppressed})));
ui.close();
}
ui.separator();
if crate::ui_kit::button(ui, crate::i18n::tr_label("Delete"), crate::ui_kit::Button::Normal).clicked() {
if crate::ui_kit::button(ui, solidcraft_ui_text::tr_label("Delete"), crate::ui_kit::Button::Normal).clicked() {
*run = Some(("selection.delete", json!({ "joints": [id] })));
ui.close();
}
+11 -7
View File
@@ -740,9 +740,9 @@ fn picks_on_one_component_say_why_and_offer_components() {
frame(&mut app, vec![]);
}
let out = frame(&mut app, vec![]);
let why = crate::i18n::format("joint.one_component", &[("bodies", "Body1, Body2")]);
let why = solidcraft_ui_text::format("joint.one_component", &[("bodies", "Body1, Body2")]);
assert!(text_at(&out, &why).is_some(), "the dialog says why");
let at = text_at(&out, crate::i18n::tr("cmd.component_from_bodies")).expect("and offers the way out");
let at = text_at(&out, solidcraft_ui_text::tr("cmd.component_from_bodies")).expect("and offers the way out");
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, vec![egui::Event::PointerMoved(at)]);
frame(&mut app, vec![button(true)]);
@@ -1334,7 +1334,7 @@ fn components_from_bodies_dialog_select_all_cancel_and_ok() {
assert!(matches!(d.kind, Kind::Assembly(Asm::FromBodies)));
assert!(apply_commands(&h.app, d).is_err(), "OK requires at least one body");
assert_eq!(h.app.session.doc, before);
h.step(&json!({"widget":crate::i18n::tr("ui.select_all")})).unwrap();
h.step(&json!({"widget":solidcraft_ui_text::tr("ui.select_all")})).unwrap();
let commands = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
assert_eq!(commands, vec![("component.from_bodies".into(), json!({"bodies":names}))]);
if let Ok(path) = std::env::var("SOLIDCRAFT_COMPONENTS_DIALOG_EVIDENCE") {
@@ -1361,7 +1361,7 @@ fn components_from_bodies_dialog_select_all_cancel_and_ok() {
h.step(&json!({"start":"component.from_bodies"})).unwrap();
let d = h.app.dialog.as_ref().unwrap();
assert_eq!(d.inputs[0].items.body_names(), vec![names[0].clone()]);
h.step(&json!({"widget":crate::i18n::tr("ui.select_all")})).unwrap();
h.step(&json!({"widget":solidcraft_ui_text::tr("ui.select_all")})).unwrap();
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none());
for name in &names {
@@ -1415,8 +1415,8 @@ fn find_limits_fills_the_limits_from_where_the_door_meets_the_block() {
frame(app, vec![button(true)]);
frame(app, vec![button(false)]);
};
click(&mut app, &crate::i18n::tr_label("Limits").to_uppercase());
click(&mut app, crate::i18n::tr("joint.find_limits"));
click(&mut app, &solidcraft_ui_text::tr_label("Limits").to_uppercase());
click(&mut app, solidcraft_ui_text::tr("joint.find_limits"));
assert_eq!(*app.session.doc, *before, "nothing changes until OK");
let Some(Kind::Assembly(Asm::Joint(f))) = app.dialog.as_ref().map(|d| &d.kind) else { panic!("the dialog stays open") };
// The door's face 4 mm off its hinge line meets the block's corner (80, 30) opening, and the
@@ -1455,7 +1455,11 @@ fn find_limits_searches_the_joint_as_edited_and_leaves_a_free_side() {
// from it the other, where nothing is in its way.
assert_eq!(f.limits[0].0, untouched.0, "one side found: the row stays as it was: {:?}", f.found);
assert!(f.limits[0].1 == untouched.1 || f.limits[0].2 == untouched.2, "{:?} {:?}", f.limits, f.found);
assert!(f.found[0].contains(crate::i18n::tr_label("Minimum")) && f.found[0].contains(crate::i18n::tr_label("Maximum")), "{:?}", f.found);
assert!(
f.found[0].contains(solidcraft_ui_text::tr_label("Minimum")) && f.found[0].contains(solidcraft_ui_text::tr_label("Maximum")),
"{:?}",
f.found
);
assert!(f.found_note.is_none(), "{:?}", f.found_note);
// Edited after the search, the joint is no longer the one searched (its notes then go).
let searched = f.searched.clone().expect("what was searched");
+5 -5
View File
@@ -116,25 +116,25 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut CamForm, inputs: &[Se
joint_row(ui, "Cam", "cam_a", &cams, &mut f.a);
joint_row(ui, "Follower", "cam_b", &followers, &mut f.b);
form_row(ui, "Type", |ui| {
let labels: Vec<&str> = FOLLOWER_LABELS.iter().map(|l| crate::i18n::tr_label(l)).collect();
let labels: Vec<&str> = FOLLOWER_LABELS.iter().map(|l| solidcraft_ui_text::tr_label(l)).collect();
combo(ui, "cam_follower", &labels, &mut f.follower);
});
if FOLLOWERS.get(f.follower) == Some(&"roller") {
form_row(ui, "Radius", |ui| match (pick(inputs, ROLLER), picked_radius(s, inputs, ROLLER)) {
(_, Some(r)) => {
ui.label(format!("{} mm ({})", trim(r), crate::i18n::tr_label("picked")));
ui.label(format!("{} mm ({})", trim(r), solidcraft_ui_text::tr_label("picked")));
}
(Some(_), None) => {
ui.label(RichText::new(crate::i18n::tr_label("not a round face")).color(t.warning));
ui.label(RichText::new(solidcraft_ui_text::tr_label("not a round face")).color(t.warning));
}
(None, None) => enter |= field(ui, &mut f.radius),
});
}
crate::ui_kit::options(ui, "cam_options", &mut [("Other side", &mut f.flip)]);
form_row(ui, "Animate", |ui| {
let label = crate::i18n::tr(if f.animate { "ui.joint_stop" } else { "ui.joint_play" });
let label = solidcraft_ui_text::tr(if f.animate { "ui.joint_stop" } else { "ui.joint_play" });
let r = crate::ui_kit::button(ui, label, crate::ui_kit::Button::Normal)
.backing_on_hover_text(crate::i18n::tr_label("Play the joint's motion in the view"));
.backing_on_hover_text(solidcraft_ui_text::tr_label("Play the joint's motion in the view"));
crate::scenario::publish_handle("dialog:Animate", r.rect.center());
if r.clicked() {
f.animate = !f.animate;
+2 -2
View File
@@ -105,9 +105,9 @@ fn a_picked_cam_edge_makes_the_cam() {
/// The dialog's rows pass their English text, which the catalogs translate by its key.
#[test]
fn the_cam_dialog_labels_translate() {
let de = crate::i18n::Locale::for_language("de");
let de = solidcraft_ui_text::Locale::for_language("de");
for (english, german) in [("Cam", "Nocken"), ("Follower", "Stößel"), ("Roller", "Rolle"), ("Tip (optional)", "Spitze (optional)")] {
assert_eq!(de.label(english), german);
}
assert_eq!(crate::i18n::Locale::for_language("zh").label("Knife edge"), "尖顶");
assert_eq!(solidcraft_ui_text::Locale::for_language("zh").label("Knife edge"), "尖顶");
}
+4 -4
View File
@@ -160,11 +160,11 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut CouplingForm, inputs:
form_row(ui, label, |ui| {
match (pick(inputs, i), picked_radius(s, inputs, i)) {
(_, Some(r)) => {
ui.label(format!("{} mm ({})", trim(r), crate::i18n::tr_label("picked")));
ui.label(format!("{} mm ({})", trim(r), solidcraft_ui_text::tr_label("picked")));
}
// A pick on a flat face or a straight edge has no radius: say so rather than fail on OK.
(Some(_), None) => {
ui.label(RichText::new(crate::i18n::tr_label("not a round face")).color(t.warning));
ui.label(RichText::new(solidcraft_ui_text::tr_label("not a round face")).color(t.warning));
}
(None, None) => *enter |= field(ui, text),
}
@@ -205,9 +205,9 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut CouplingForm, inputs:
ui.label(RichText::new(shown.unwrap_or_else(|| "-".into())).color(t.text_dim));
});
form_row(ui, "Animate", |ui| {
let label = crate::i18n::tr(if f.animate { "ui.joint_stop" } else { "ui.joint_play" });
let label = solidcraft_ui_text::tr(if f.animate { "ui.joint_stop" } else { "ui.joint_play" });
let r = crate::ui_kit::button(ui, label, crate::ui_kit::Button::Normal)
.backing_on_hover_text(crate::i18n::tr_label("Play the joint's motion in the view"));
.backing_on_hover_text(solidcraft_ui_text::tr_label("Play the joint's motion in the view"));
crate::scenario::publish_handle("dialog:Animate", r.rect.center());
if r.clicked() {
f.animate = !f.animate;
+4 -4
View File
@@ -9,7 +9,7 @@ use crate::dialogs::{Kind, apply_commands, preview_commands};
#[test]
fn missing_joint_messages_are_drawn_in_full() {
for language in ["en", "de", "vi"] {
let _language = crate::i18n::scope(language);
let _language = solidcraft_ui_text::scope(language);
for (command, message) in [
("joint.screw", "needs a joint that turns and one that slides"),
("joint.gear", "needs two joints that turn"),
@@ -44,7 +44,7 @@ fn missing_joint_messages_are_drawn_in_full() {
.save(std::path::Path::new(&dir).join(format!("{command}-{language}.png")))
.unwrap();
}
let message = crate::i18n::tr_label(message);
let message = solidcraft_ui_text::tr_label(message);
let text = crate::test_support::text_shapes(&output).find(|text| text.galley.text() == message).expect("missing-joint message");
assert!(!text.galley.elided, "{command} ({language}) cuts off: {message}");
let bounds = text.galley.rect.translate(text.pos.to_vec2());
@@ -220,7 +220,7 @@ fn play_turns_the_angle_of_a_planar_pinion() {
/// The dialog's rows pass their English text, which the catalogs translate by its key.
#[test]
fn the_dialog_labels_translate() {
let de = crate::i18n::Locale::for_language("de");
let de = solidcraft_ui_text::Locale::for_language("de");
for (english, german) in [
("Gear 1", "Zahnrad 1"),
("Radius 2", "Radius 2"),
@@ -230,5 +230,5 @@ fn the_dialog_labels_translate() {
] {
assert_eq!(de.label(english), german);
}
assert_eq!(crate::i18n::Locale::for_language("ja").label("not a round face"), "円筒面ではありません");
assert_eq!(solidcraft_ui_text::Locale::for_language("ja").label("not a round face"), "円筒面ではありません");
}
+18 -17
View File
@@ -1,5 +1,6 @@
//! Dense ports of the common feature forms. Commands and parameter construction stay in dialogs.
use super::*;
use crate::dialogs_surface::continuity_row_explained;
use crate::ui_kit;
use crate::ui_kit::{choice_row, expr_row, hint_row};
use solidcraft_engine::kernel::Continuity;
@@ -127,7 +128,7 @@ fn setback_factor() -> String {
/// The Corners row's hover text: a line for each choice.
fn fillet_corner_tip() -> String {
let factor = setback_factor();
FILLET_CORNERS.map(|(_, tip)| crate::i18n::format(tip, &[("factor", &factor)])).join("\n")
FILLET_CORNERS.map(|(_, tip)| solidcraft_ui_text::format(tip, &[("factor", &factor)])).join("\n")
}
/// What `solid.fillet` does with a radius too large for the faces beside an edge (`fit`): stop
@@ -213,7 +214,7 @@ fn fillet_corner_rows(
}
let mut enter = false;
let mut corner = if patch { Continuity::G2 } else { Continuity::G1 };
ui_kit::continuity_row_explained(ui, "ui.corners", "fi_corner", &mut corner, &FILLET_CORNERS.map(|(g, _)| g), &fillet_corner_tip());
continuity_row_explained(ui, "ui.corners", "fi_corner", &mut corner, &FILLET_CORNERS.map(|(g, _)| g), &fillet_corner_tip());
if (corner == Continuity::G2) != patch {
if corner == Continuity::G2 {
extra.insert("corner".into(), json!(Continuity::G2.as_str()));
@@ -233,13 +234,13 @@ fn fillet_corner_rows(
let least = r.and_then(|r| crate::dialogs::fillet_least_setback(ui.ctx(), s, items, &corners, r));
let warning = least
.filter(|least| length(&shown).is_some_and(|v| v <= least * (1.0 + 1e-9)))
.map(|least| crate::i18n::format("ui.fillet_setback_least", &[("least", &crate::length_input::shown(&s.doc, least))]));
.map(|least| solidcraft_ui_text::format("ui.fillet_setback_least", &[("least", &crate::length_input::shown(&s.doc, least))]));
let mut text = shown.clone();
let row = ui.scope(|ui| {
let hint = crate::i18n::format("ui.fillet_setback_hint", &[("factor", &factor)]);
let hint = solidcraft_ui_text::format("ui.fillet_setback_hint", &[("factor", &factor)]);
ui_kit::checked_row(ui, "ui.fillet_setback", &mut text, &hint, warning.as_deref())
});
row.response.backing_on_hover_text(crate::i18n::format("ui.fillet_setback_tip", &[("factor", &factor)]));
row.response.backing_on_hover_text(solidcraft_ui_text::format("ui.fillet_setback_tip", &[("factor", &factor)]));
let (accepted, field) = row.inner;
enter |= accepted;
if text != shown {
@@ -358,7 +359,7 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
ui_kit::text(ui, &prompt, ui_kit::Text::Hint("tool")).backing_on_hover_text(&prompt);
if matches!(&d.kind, Kind::SketchModify(k) if k.previews()) {
let label = d.active_input().map(|input| tr(input.label)).unwrap_or_else(|| tr("Objects"));
let step = crate::i18n::format(
let step = solidcraft_ui_text::format(
"dialog.selection_step",
&[("step", &(d.active + 1).to_string()), ("total", &d.inputs.len().to_string()), ("input", label)],
);
@@ -937,7 +938,7 @@ fn additional_rows(app: &mut SolveApp, d: &mut Dialog, ui: &mut egui::Ui) -> boo
ui_kit::segmented(ui, &mut i, &["Emboss", "Deboss"], Default::default());
*deboss = i == 1;
});
ui.add(egui::Label::new(crate::i18n::tr("ui.emboss_wrap_note")).wrap());
ui.add(egui::Label::new(solidcraft_ui_text::tr("ui.emboss_wrap_note")).wrap());
}
Kind::Align { flip, components, axes: [ax, ay, az], angle } => {
// Where on the picked face or edge to snap: as picked (a face turns to meet
@@ -1138,7 +1139,7 @@ fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>, show: &mut
for (i, it) in r["items"].as_array().into_iter().flatten().enumerate() {
let lines = crate::quick_measure::item_lines(app, it);
for (k, line) in lines.into_iter().enumerate() {
let label = if k == 0 { crate::i18n::format("measure.item", &[("number", &(i + 1).to_string())]) } else { String::new() };
let label = if k == 0 { solidcraft_ui_text::format("measure.item", &[("number", &(i + 1).to_string())]) } else { String::new() };
// Coordinates need all three values; a fixed-height form cell elides the end.
let text = if label.is_empty() { line } else { format!("{label} {line}") };
ui_kit::text(ui, &text, ui_kit::Text::Report(&format!("measure_item_{i}_{k}"), false));
@@ -1158,7 +1159,7 @@ fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>, show: &mut
}
} else {
let text = crate::prefs::show_mm(app, d, 1);
form_row(ui, crate::i18n::tr_label("Distance"), |ui| {
form_row(ui, solidcraft_ui_text::tr_label("Distance"), |ui| {
let size = crate::theme::Tokens::get().form_close_size;
// The value keeps the room left of the button, so the button stays inside the row.
ui.scope(|ui| {
@@ -1166,7 +1167,7 @@ fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>, show: &mut
ui_kit::text(ui, &text, ui_kit::Text::Cell);
});
let (rect, _) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover());
let copy = ui_kit::icon_button_at(ui, rect, "copy", crate::i18n::tr_label("Copy"));
let copy = ui_kit::icon_button_at(ui, rect, "copy", solidcraft_ui_text::tr_label("Copy"));
crate::scenario::publish_handle("measure:copy:distance", copy.rect.center());
if copy.clicked() {
ui.ctx().copy_text(text.clone());
@@ -1181,7 +1182,7 @@ fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>, show: &mut
if let Some(a) = r["angle_deg"].as_f64()
&& r["items"].as_array().is_some_and(|v| v.len() == 2)
{
value(ui, crate::i18n::tr_label("Angle"), &format!("{a:.3}°"));
value(ui, solidcraft_ui_text::tr_label("Angle"), &format!("{a:.3}°"));
}
}
@@ -1202,7 +1203,7 @@ mod tests {
#[test]
fn common_forms_fit_both_layouts_and_scales_and_pin_footer_at_small_heights() {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for scale in [1.0, 2.0] {
for height in [950.0, 720.0] {
let mut h = Harness::new();
@@ -1244,7 +1245,7 @@ mod tests {
#[test]
fn tiny_windows_do_not_panic_or_publish_non_finite_cards() {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for size in [(1.0, 1.0), (40.0, 80.0), (180.0, 240.0)] {
let mut h = Harness::new();
h.call("ui.set", json!({"layout": mode}));
@@ -1551,7 +1552,7 @@ mod tests {
};
let shown = |h: &Harness| {
let tree = h.output.accesskit_update.as_ref().unwrap();
let label = crate::i18n::tr_label("ui.fillet_setback");
let label = solidcraft_ui_text::tr_label("ui.fillet_setback");
tree.nodes.iter().find(|(_, n)| n.label() == Some(label)).and_then(|(_, n)| n.value()).unwrap_or_default().to_string()
};
let typed = |h: &Harness| match &h.app.dialog.as_ref().unwrap().kind {
@@ -1612,10 +1613,10 @@ mod tests {
fn fillet_labels_use_words_not_continuity_codes() {
let coded = |text: &str| ["G0", "G1", "G2", "G3"].iter().any(|code| text.contains(code));
for label in FILLET_TYPES.map(|(label, _)| label).into_iter().chain(FILLET_CORNERS.map(|(g, _)| g.label_id())) {
assert!(!coded(crate::i18n::tr_label(label)), "{label}: {}", crate::i18n::tr_label(label));
assert!(!coded(solidcraft_ui_text::tr_label(label)), "{label}: {}", solidcraft_ui_text::tr_label(label));
}
for (g, tip) in FILLET_CORNERS {
assert!(crate::i18n::tr(tip).contains(g.code()), "the hover text keeps the code for those who know it: {tip}");
assert!(solidcraft_ui_text::tr(tip).contains(g.code()), "the hover text keeps the code for those who know it: {tip}");
}
let tip = fillet_corner_tip();
assert!(tip.contains("rolling") && tip.contains("1.5") && tip.contains("Setback"), "what each corner builds: {tip}");
@@ -1649,7 +1650,7 @@ mod tests {
// A blend too large for its faces keeps its own words.
assert_eq!(
crate::dialogs::fillet_plain_error("the fillet is larger than the faces beside its edges allow", true),
Some(crate::dialogs::FilletRefusal::Plain(crate::i18n::tr("ui.fillet_error_too_large").to_string()))
Some(crate::dialogs::FilletRefusal::Plain(solidcraft_ui_text::tr("ui.fillet_error_too_large").to_string()))
);
}
+1 -1
View File
@@ -70,7 +70,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
}
fn tr(text: &str) -> &str {
crate::i18n::tr_label(text)
solidcraft_ui_text::tr_label(text)
}
/// The dialog's own rows (after the selection inputs); true when Enter was pressed in a value.
+11 -11
View File
@@ -128,12 +128,12 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelInpu
let _ = inputs;
ui_kit::text(
ui,
crate::i18n::tr_label("Open local libraries in the desktop app. You can export this design's library here."),
solidcraft_ui_text::tr_label("Open local libraries in the desktop app. You can export this design's library here."),
ui_kit::Text::Note("library_platform"),
);
}
ui_kit::checkbox(ui, crate::i18n::tr_label("Replace existing library file"), &mut k.local.overwrite);
let save = ui_kit::button(ui, crate::i18n::tr_label("Export design library..."), ui_kit::Button::Normal);
ui_kit::checkbox(ui, solidcraft_ui_text::tr_label("Replace existing library file"), &mut k.local.overwrite);
let save = ui_kit::button(ui, solidcraft_ui_text::tr_label("Export design library..."), ui_kit::Button::Normal);
crate::scenario::publish_handle("library:export", save.rect.center());
if save.clicked() {
if app.services.pick_save.is_some() {
@@ -144,7 +144,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelInpu
}
ui_kit::text(
ui,
crate::i18n::tr_label("Apply your appearance first. Exports assigned appearances and materials created in this design."),
solidcraft_ui_text::tr_label("Apply your appearance first. Exports assigned appearances and materials created in this design."),
ui_kit::Text::Note("library_export"),
);
});
@@ -162,7 +162,7 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI
k.local.selected = None;
}
ui.horizontal(|ui| {
if ui_kit::button(ui, crate::i18n::tr_label("Open library..."), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_text::tr_label("Open library..."), ui_kit::Button::Normal).clicked() {
open_library(app, k.local.token.clone());
}
let load_button = ui_kit::button(ui, "Load library", ui_kit::Button::Enabled(!k.local.path.is_empty()));
@@ -175,7 +175,7 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI
});
if let Some(catalog) = &k.local.catalog {
ui_kit::text(ui, &catalog.name, ui_kit::Text::Report("library_name", false));
let mode = ui_kit::checkbox(ui, crate::i18n::tr_label("Physical material definitions"), &mut k.local.materials);
let mode = ui_kit::checkbox(ui, solidcraft_ui_text::tr_label("Physical material definitions"), &mut k.local.materials);
crate::scenario::publish_handle("library:materials", mode.rect.center());
if mode.changed() {
k.local.selected = None;
@@ -200,7 +200,7 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI
.collect();
if appearances.is_empty() {
let message = if k.local.materials { "No matching physical materials." } else { "No matching appearance presets." };
ui_kit::text(ui, crate::i18n::tr_label(message), ui_kit::Text::Note("library_empty"));
ui_kit::text(ui, solidcraft_ui_text::tr_label(message), ui_kit::Text::Note("library_empty"));
}
let row_height = ui_kit::row_height();
ui_kit::list_frame(ui, "local_library_entries", |ui| {
@@ -267,7 +267,7 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI
{
ui_kit::text(
ui,
&format!("{}: {} g/cm³", crate::i18n::tr("material.editor.assigned_density"), material.density_g_cm3),
&format!("{}: {} g/cm³", solidcraft_ui_text::tr("material.editor.assigned_density"), material.density_g_cm3),
ui_kit::Text::Report("library_material_density", false),
);
if let Some(card) = &material.physical_card {
@@ -276,12 +276,12 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI
}
ui_kit::text(
ui,
crate::i18n::tr_label("Copies the definition only. Assign it from Physical Material when ready."),
solidcraft_ui_text::tr_label("Copies the definition only. Assign it from Physical Material when ready."),
ui_kit::Text::Note("library_material_copy"),
);
let copy = ui_kit::button(
ui,
crate::i18n::tr_label("Import material into design"),
solidcraft_ui_text::tr_label("Import material into design"),
ui_kit::Button::Enabled(entry.is_some() && !k.local.material_name.trim().is_empty()),
);
crate::scenario::publish_handle("library:material-import", copy.rect.center());
@@ -290,7 +290,7 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI
{
k.note = Some(
match app.run("library.apply", json!({"path":k.local.path,"entry":entry.id,"revision":entry.revision,"name":k.local.material_name})) {
Ok(_) => crate::i18n::tr_label("Material copied. Existing body assignments are unchanged.").to_string(),
Ok(_) => solidcraft_ui_text::tr_label("Material copied. Existing body assignments are unchanged.").to_string(),
Err(error) => error,
},
);
+31 -25
View File
@@ -208,7 +208,7 @@ fn transport(ui: &mut egui::Ui, what: Transport, tip: &str) -> egui::Response {
p.add(bar(c.x + 6.0));
}
}
resp.backing_on_hover_text(crate::i18n::tr_label(tip))
resp.backing_on_hover_text(solidcraft_ui_text::tr_label(tip))
}
/// Study `key` as the Motion Study dialog edits it: (id, name, steps, keys as tracks with values
@@ -423,7 +423,7 @@ fn export_animation(app: &mut SolveApp, params: Value, name: &str) {
)
});
match made {
Ok(_) => app.set_status(crate::i18n::format("ui.saved_to", &[("path", &path)]), false),
Ok(_) => app.set_status(solidcraft_ui_text::format("ui.saved_to", &[("path", &path)]), false),
Err(e) => app.set_status(e.to_string(), true),
}
});
@@ -459,7 +459,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
let list = app.session.execute("contact.list", &json!({})).unwrap_or(Value::Null);
form_row(ui, "Contact", |ui| {
let mut on = list.get("enabled").and_then(Value::as_bool).unwrap_or(false);
if crate::ui_kit::checkbox(ui, crate::i18n::tr_label("Enabled"), &mut on).changed() {
if crate::ui_kit::checkbox(ui, solidcraft_ui_text::tr_label("Enabled"), &mut on).changed() {
let _ = app.run("contact.enable_sets", json!({ "enabled": on }));
}
});
@@ -469,14 +469,20 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
crate::ui_kit::section(ui, format!("contact:{n}"), &n, true, |ui| {
ui.horizontal(|ui| {
let count = s.get("occurrences").and_then(Value::as_array).map_or(0, Vec::len);
ui.label(RichText::new(crate::i18n::format("contact.components_count", &[("count", &count.to_string())])).color(t.text_dim));
if crate::ui_kit::button(ui, crate::i18n::tr_label(if off { "Unsuppress" } else { "Suppress" }), crate::ui_kit::Button::Small)
.clicked()
ui.label(
RichText::new(solidcraft_ui_text::format("contact.components_count", &[("count", &count.to_string())])).color(t.text_dim),
);
if crate::ui_kit::button(
ui,
solidcraft_ui_text::tr_label(if off { "Unsuppress" } else { "Suppress" }),
crate::ui_kit::Button::Small,
)
.clicked()
{
let _ = app.run("contact.edit", json!({"set": n, "suppressed": !off}));
}
if crate::ui_kit::button(ui, crate::i18n::tr_label("×"), crate::ui_kit::Button::Small)
.backing_on_hover_text(crate::i18n::tr_label("Delete the set"))
if crate::ui_kit::button(ui, solidcraft_ui_text::tr_label("×"), crate::ui_kit::Button::Small)
.backing_on_hover_text(solidcraft_ui_text::tr_label("Delete the set"))
.clicked()
{
let _ = app.run("contact.edit", json!({"set": n, "delete": true}));
@@ -485,7 +491,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
});
}
ui.vertical(|ui| {
if crate::ui_kit::button(ui, crate::i18n::tr_label("Check collisions"), crate::ui_kit::Button::Normal).clicked() {
if crate::ui_kit::button(ui, solidcraft_ui_text::tr_label("Check collisions"), crate::ui_kit::Button::Normal).clicked() {
*check = Some(app.session.execute("contact.check", &json!({})).unwrap_or_else(|e| json!({"error": e.to_string()})));
}
});
@@ -493,9 +499,9 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
let hits = c.get("collisions").and_then(Value::as_array).cloned().unwrap_or_default();
crate::ui_kit::section(ui, "contact_collisions", "Collisions", true, |ui| {
if let Some(e) = c.get("error").and_then(Value::as_str) {
ui.label(RichText::new(crate::i18n::tr_label(e)).color(t.error));
ui.label(RichText::new(solidcraft_ui_text::tr_label(e)).color(t.error));
} else if hits.is_empty() {
ui.label(RichText::new(crate::i18n::tr_label("none")).color(t.text_dim));
ui.label(RichText::new(solidcraft_ui_text::tr_label("none")).color(t.text_dim));
} else {
ui.vertical(|ui| {
for h in hits {
@@ -533,7 +539,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
}
if let Some(e) = error {
ui.vertical(|ui| {
ui.label(RichText::new(crate::i18n::tr_label(e)).color(t.error));
ui.label(RichText::new(solidcraft_ui_text::tr_label(e)).color(t.error));
});
}
}
@@ -569,7 +575,7 @@ fn motion_study_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs:
let moving = joints_moving(app);
if moving.is_empty() {
ui.vertical(|ui| {
ui.label(RichText::new(crate::i18n::tr_label("no joint that moves yet")).color(t.text_dim));
ui.label(RichText::new(solidcraft_ui_text::tr_label("no joint that moves yet")).color(t.text_dim));
});
return enter;
}
@@ -602,7 +608,7 @@ fn motion_study_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs:
*playing = false;
}
ui.add_space(t.space_6);
crate::ui_kit::checkbox(ui, crate::i18n::tr_label("Loop"), looping);
crate::ui_kit::checkbox(ui, solidcraft_ui_text::tr_label("Loop"), looping);
let names: Vec<String> = SPEEDS.iter().map(|x| format!("{x}×")).collect();
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
ui.allocate_ui(vec2(t.form_label_width, t.control_height), |ui| combo(ui, "ms_speed", &refs, speed));
@@ -644,12 +650,12 @@ fn motion_study_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs:
let labels: Vec<String> = moving.iter().map(|(_, n, _)| n.clone()).collect();
let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
let mut pos = moving.iter().position(|(i, _, _)| *i == tr.joint).unwrap_or(0);
form_row(ui, &crate::i18n::format("motion.track_name", &[("number", &(ti + 1).to_string())]), |ui| {
form_row(ui, &solidcraft_ui_text::format("motion.track_name", &[("number", &(ti + 1).to_string())]), |ui| {
ui.horizontal(|ui| {
let width = (ui.available_width() - t.control_height - t.space_8).max(0.0);
ui.allocate_ui(vec2(width, t.control_height), |ui| combo(ui, &format!("ms_joint{ti}"), &refs, &mut pos));
if crate::ui_kit::button(ui, crate::i18n::tr_label("×"), crate::ui_kit::Button::Small)
.backing_on_hover_text(crate::i18n::tr_label("Remove the track"))
if crate::ui_kit::button(ui, solidcraft_ui_text::tr_label("×"), crate::ui_kit::Button::Small)
.backing_on_hover_text(solidcraft_ui_text::tr_label("Remove the track"))
.clicked()
{
remove = Some(ti);
@@ -678,8 +684,8 @@ fn motion_study_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs:
}
ui.vertical(|ui| {
ui.horizontal(|ui| {
if crate::ui_kit::button(ui, crate::i18n::tr_label("Key here"), crate::ui_kit::Button::Small)
.backing_on_hover_text(crate::i18n::tr_label("Add a key at this step with the joint's value now"))
if crate::ui_kit::button(ui, solidcraft_ui_text::tr_label("Key here"), crate::ui_kit::Button::Small)
.backing_on_hover_text(solidcraft_ui_text::tr_label("Add a key at this step with the joint's value now"))
.clicked()
&& !tr.points.iter().any(|(s, _)| *s == now)
{
@@ -694,7 +700,7 @@ fn motion_study_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs:
tr.points.push((now, format!("0 {unit}")));
tr.points.sort_by_key(|p| p.0);
}
if crate::ui_kit::button(ui, crate::i18n::tr_label("Delete key"), crate::ui_kit::Button::Small).clicked() {
if crate::ui_kit::button(ui, solidcraft_ui_text::tr_label("Delete key"), crate::ui_kit::Button::Small).clicked() {
tr.points.retain(|(s, _)| *s != now);
}
});
@@ -706,16 +712,16 @@ fn motion_study_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs:
let mut export = false;
ui.vertical(|ui| {
ui.horizontal(|ui| {
if crate::ui_kit::button(ui, crate::i18n::tr_label("Add Key"), crate::ui_kit::Button::Normal)
.backing_on_hover_text(crate::i18n::tr_label("Key a joint's value at this step"))
if crate::ui_kit::button(ui, solidcraft_ui_text::tr_label("Add Key"), crate::ui_kit::Button::Normal)
.backing_on_hover_text(solidcraft_ui_text::tr_label("Key a joint's value at this step"))
.clicked()
&& let Some((ji, _, kind)) = moving.first()
{
let unit = if kind.is_angle(0) { "deg" } else { crate::length_input::unit(&app.session.doc) };
tracks.push(Track { joint: *ji, index: 0, points: vec![(now, format!("0 {unit}"))] });
}
export = crate::ui_kit::button(ui, crate::i18n::tr_label("Export Animation…"), crate::ui_kit::Button::Normal)
.backing_on_hover_text(crate::i18n::tr_label("Save the study as an animated GIF, seen as the viewport shows it"))
export = crate::ui_kit::button(ui, solidcraft_ui_text::tr_label("Export Animation…"), crate::ui_kit::Button::Normal)
.backing_on_hover_text(solidcraft_ui_text::tr_label("Save the study as an animated GIF, seen as the viewport shows it"))
.clicked();
});
});
@@ -745,7 +751,7 @@ fn motion_study_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs:
}
if let Some(e) = error {
ui.vertical(|ui| {
ui.label(RichText::new(crate::i18n::tr_label(e)).color(t.text_dim));
ui.label(RichText::new(solidcraft_ui_text::tr_label(e)).color(t.text_dim));
});
}
enter
+6 -5
View File
@@ -285,7 +285,7 @@ fn collision_rows(
ui.vertical(|ui| {
ui.vertical(|ui| {
if hits.is_empty() {
ui.label(RichText::new(crate::i18n::tr("motion.no_collisions")).color(t.text_dim));
ui.label(RichText::new(solidcraft_ui_text::tr("motion.no_collisions")).color(t.text_dim));
}
for (i, c) in hits.iter().enumerate() {
let steps: Vec<String> = c["steps"]
@@ -298,7 +298,8 @@ fn collision_rows(
})
.collect();
let name = |k: &str| c[k].as_str().unwrap_or_default().to_string();
let text = crate::i18n::format("motion.collision", &[("a", &name("a")), ("b", &name("b")), ("steps", &steps.join(", "))]);
let text =
solidcraft_ui_text::format("motion.collision", &[("a", &name("a")), ("b", &name("b")), ("steps", &steps.join(", "))]);
// A click shows where they first collide.
let link = ui.link(RichText::new(text).color(t.error));
crate::scenario::publish_handle(&format!("motion:collision:{i}"), link.rect.center());
@@ -310,14 +311,14 @@ fn collision_rows(
}
if v["truncated"] == true {
let to = v["checked_to"].as_f64().map(|x| x.round().to_string()).unwrap_or_default();
ui.label(RichText::new(crate::i18n::format("motion.checked_to", &[("step", &to)])).color(t.warning));
ui.label(RichText::new(solidcraft_ui_text::format("motion.checked_to", &[("step", &to)])).color(t.warning));
}
});
});
}
Some(Err(e)) => {
ui.vertical(|ui| {
ui.add(egui::Label::new(RichText::new(crate::i18n::tr_label(e)).color(t.error)).wrap());
ui.add(egui::Label::new(RichText::new(solidcraft_ui_text::tr_label(e)).color(t.error)).wrap());
});
}
None => {}
@@ -385,7 +386,7 @@ fn plot_rows(
});
}
Some(Err(e)) => {
ui.add(egui::Label::new(RichText::new(crate::i18n::tr_label(e)).color(t.text_dim)).wrap());
ui.add(egui::Label::new(RichText::new(solidcraft_ui_text::tr_label(e)).color(t.text_dim)).wrap());
}
None => {}
});
+1 -1
View File
@@ -197,7 +197,7 @@ pub fn rows(app: &SolveApp, ui: &mut egui::Ui, k: &mut Resize) -> bool {
ui.label(configuration_name(&k.family.id, size, k.length));
});
}
crate::ui_kit::text(ui, crate::i18n::tr("ui.part_size_shared"), crate::ui_kit::Text::Hint("part_size_shared"));
crate::ui_kit::text(ui, solidcraft_ui_text::tr("ui.part_size_shared"), crate::ui_kit::Text::Hint("part_size_shared"));
false
}
+11 -7
View File
@@ -58,7 +58,7 @@ pub fn families(v: &Value) -> Vec<Family> {
/// name for a family the catalogs don't know.
pub(crate) fn family_name(f: &Family) -> String {
let id = format!("parts.family.{}", f.id);
match crate::i18n::tr(&id) {
match solidcraft_ui_text::tr(&id) {
shown if shown == id => f.name.clone(),
shown => shown.to_string(),
}
@@ -170,7 +170,11 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Pt, inputs: &[SelInpu
};
if lib.is_empty() {
ui.vertical(|ui| {
crate::ui_kit::text(ui, crate::i18n::tr_label("the parts library is not available"), crate::ui_kit::Text::Note("parts_unavailable"));
crate::ui_kit::text(
ui,
solidcraft_ui_text::tr_label("the parts library is not available"),
crate::ui_kit::Text::Note("parts_unavailable"),
);
});
return false;
}
@@ -222,7 +226,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Pt, inputs: &[SelInpu
k.length = Some(i);
});
}
text_row(ui, "Name", &mut k.name, crate::i18n::tr("parts.automatic_name"));
text_row(ui, "Name", &mut k.name, solidcraft_ui_text::tr("parts.automatic_name"));
// The part, and its table values.
let key = (k.family, k.size, k.length);
let tex_id = egui::Id::unique("sc_part_picture");
@@ -244,7 +248,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Pt, inputs: &[SelInpu
ui.add(egui::Image::new(&tx).fit_to_exact_size(vec2(180.0, 112.0)).corner_radius(t.radius_sm));
}
None => {
crate::ui_kit::text(ui, crate::i18n::tr_label("no picture"), crate::ui_kit::Text::Note("parts_picture"));
crate::ui_kit::text(ui, solidcraft_ui_text::tr_label("no picture"), crate::ui_kit::Text::Note("parts_picture"));
}
});
if let Some((_, row)) = f.sizes.get(k.size)
@@ -264,7 +268,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Pt, inputs: &[SelInpu
}
if inputs.first().is_some_and(|i| i.items.is_empty()) {
ui.vertical(|ui| {
crate::ui_kit::text(ui, crate::i18n::tr("parts.placement_hint"), crate::ui_kit::Text::Note("parts_placement"));
crate::ui_kit::text(ui, solidcraft_ui_text::tr("parts.placement_hint"), crate::ui_kit::Text::Note("parts_placement"));
});
}
false
@@ -332,7 +336,7 @@ mod tests {
fn family_names_follow_the_language() {
let l = families(&solidcraft_engine::Session::default().execute("parts.library", &json!({})).unwrap());
assert!(l.len() >= 7, "{}", l.len());
let _german = crate::i18n::scope("de");
let _german = solidcraft_ui_text::scope("de");
for f in &l {
assert_ne!(family_name(f), f.name, "{}", f.id);
}
@@ -415,7 +419,7 @@ mod tests {
for f in &l {
for (size, row) in &f.sizes {
for key in row.as_object().into_iter().flat_map(|o| o.keys()).filter(|k| !matches!(k.as_str(), "size" | "default_length")) {
let label = dimension_label(key).map(crate::i18n::tr);
let label = dimension_label(key).map(solidcraft_ui_text::tr);
assert!(
label.is_some_and(|l| l.starts_with(char::is_uppercase) && !l.contains('_') && !l.starts_with("parts.")),
"{} {size}: {key} is labelled {label:?}",
+1 -1
View File
@@ -137,7 +137,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
/// A value row whose empty value means "from the rule" (or another default).
fn opt_field(ui: &mut egui::Ui, label: &str, v: &mut String, unit: &str, hint: &str) -> bool {
let hint = format!("{unit}, {}", crate::i18n::tr_label(hint));
let hint = format!("{unit}, {}", solidcraft_ui_text::tr_label(hint));
hint_row(ui, label, v, &hint)
}
+1 -1
View File
@@ -198,7 +198,7 @@ fn inch_plastic_rule_edit_preserves_material_angles_and_stored_lengths() {
/// face", …) are in the user's language: each has a message.
#[test]
fn optional_field_hints_have_messages() {
let ja = crate::i18n::Locale::for_language("ja");
let ja = solidcraft_ui_text::Locale::for_language("ja");
for hint in ["no hole", "to the face", "from the rule", "none", "at the face", "solid"] {
assert_ne!(ja.label(hint), hint, "`{hint}` has a Japanese message");
}

Some files were not shown because too many files have changed in this diff Show More