mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 13:01:52 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1f89a3c1d | |||
| 3b73d1a820 | |||
| 9060ee20f2 | |||
| bcd63adb98 | |||
| a9d6c7d810 | |||
| ec36c0b04d | |||
| e2664ea368 | |||
| fe45f81f56 | |||
| 6e8165674e | |||
| e3caced67b | |||
| d996441241 | |||
| 8a40998c2a | |||
| 924585be0a | |||
| b2c0fd71d0 | |||
| 2605261665 | |||
| 397bbf38c7 | |||
| 4db733ec68 |
Generated
+39
-2
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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": "删除"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 }
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
@@ -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) |
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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, ¶ms) {
|
||||
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"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-mcp-server = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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
@@ -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, ¶ms) {
|
||||
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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())]))?;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (編集中)"]);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`");
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"), "尖顶");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"), "円筒面ではありません");
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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:?}",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user