elit 3.5.6 → 3.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.toml +1 -1
- package/README.md +1 -1
- package/desktop/build.rs +83 -0
- package/desktop/icon.rs +106 -0
- package/desktop/lib.rs +2 -0
- package/desktop/main.rs +235 -0
- package/desktop/native_main.rs +128 -0
- package/desktop/native_renderer/action_widgets.rs +184 -0
- package/desktop/native_renderer/app_models.rs +171 -0
- package/desktop/native_renderer/app_runtime.rs +140 -0
- package/desktop/native_renderer/container_rendering.rs +610 -0
- package/desktop/native_renderer/content_widgets.rs +634 -0
- package/desktop/native_renderer/css_models.rs +371 -0
- package/desktop/native_renderer/embedded_surfaces.rs +414 -0
- package/desktop/native_renderer/form_controls.rs +516 -0
- package/desktop/native_renderer/interaction_dispatch.rs +89 -0
- package/desktop/native_renderer/runtime_support.rs +135 -0
- package/desktop/native_renderer/utilities.rs +495 -0
- package/desktop/native_renderer/vector_drawing.rs +491 -0
- package/desktop/native_renderer.rs +4122 -0
- package/desktop/runtime/external.rs +422 -0
- package/desktop/runtime/mod.rs +67 -0
- package/desktop/runtime/quickjs.rs +106 -0
- package/desktop/window.rs +383 -0
- package/package.json +6 -3
- package/dist/build.d.mts +0 -20
- package/dist/chokidar.d.mts +0 -134
- package/dist/cli.d.mts +0 -81
- package/dist/config.d.mts +0 -254
- package/dist/coverage.d.mts +0 -85
- package/dist/database.d.mts +0 -52
- package/dist/desktop.d.mts +0 -68
- package/dist/dom.d.mts +0 -87
- package/dist/el.d.mts +0 -208
- package/dist/fs.d.mts +0 -255
- package/dist/hmr.d.mts +0 -38
- package/dist/http.d.mts +0 -169
- package/dist/https.d.mts +0 -108
- package/dist/index.d.mts +0 -13
- package/dist/mime-types.d.mts +0 -48
- package/dist/native.d.mts +0 -136
- package/dist/path.d.mts +0 -163
- package/dist/router.d.mts +0 -49
- package/dist/runtime.d.mts +0 -97
- package/dist/server-D0Dp4R5z.d.mts +0 -449
- package/dist/server.d.mts +0 -7
- package/dist/state.d.mts +0 -117
- package/dist/style.d.mts +0 -232
- package/dist/test-reporter.d.mts +0 -77
- package/dist/test-runtime.d.mts +0 -122
- package/dist/test.d.mts +0 -39
- package/dist/types.d.mts +0 -586
- package/dist/universal.d.mts +0 -21
- package/dist/ws.d.mts +0 -200
- package/dist/wss.d.mts +0 -108
- package/src/build.ts +0 -362
- package/src/chokidar.ts +0 -427
- package/src/cli.ts +0 -1162
- package/src/config.ts +0 -509
- package/src/coverage.ts +0 -1479
- package/src/database.ts +0 -1410
- package/src/desktop-auto-render.ts +0 -317
- package/src/desktop-cli.ts +0 -1533
- package/src/desktop.ts +0 -99
- package/src/dev-build.ts +0 -340
- package/src/dom.ts +0 -901
- package/src/el.ts +0 -183
- package/src/fs.ts +0 -609
- package/src/hmr.ts +0 -149
- package/src/http.ts +0 -856
- package/src/https.ts +0 -411
- package/src/index.ts +0 -16
- package/src/mime-types.ts +0 -222
- package/src/mobile-cli.ts +0 -2313
- package/src/native-background.ts +0 -444
- package/src/native-border.ts +0 -343
- package/src/native-canvas.ts +0 -260
- package/src/native-cli.ts +0 -414
- package/src/native-color.ts +0 -904
- package/src/native-estimation.ts +0 -194
- package/src/native-grid.ts +0 -590
- package/src/native-interaction.ts +0 -1289
- package/src/native-layout.ts +0 -568
- package/src/native-link.ts +0 -76
- package/src/native-render-support.ts +0 -361
- package/src/native-spacing.ts +0 -231
- package/src/native-state.ts +0 -318
- package/src/native-strings.ts +0 -46
- package/src/native-transform.ts +0 -120
- package/src/native-types.ts +0 -439
- package/src/native-typography.ts +0 -254
- package/src/native-units.ts +0 -441
- package/src/native-vector.ts +0 -910
- package/src/native.ts +0 -5606
- package/src/path.ts +0 -493
- package/src/pm-cli.ts +0 -2498
- package/src/preview-build.ts +0 -294
- package/src/render-context.ts +0 -138
- package/src/router.ts +0 -260
- package/src/runtime.ts +0 -97
- package/src/server.ts +0 -2294
- package/src/state.ts +0 -556
- package/src/style.ts +0 -1790
- package/src/test-globals.d.ts +0 -184
- package/src/test-reporter.ts +0 -609
- package/src/test-runtime.ts +0 -1359
- package/src/test.ts +0 -368
- package/src/types.ts +0 -381
- package/src/universal.ts +0 -81
- package/src/wapk-cli.ts +0 -3213
- package/src/workspace-package.ts +0 -102
- package/src/ws.ts +0 -648
- package/src/wss.ts +0 -241
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
|
|
3
|
+
use eframe::egui::{self, Vec2};
|
|
4
|
+
use serde_json::{Map, Value};
|
|
5
|
+
|
|
6
|
+
use super::utilities::{
|
|
7
|
+
is_probably_inline_html, load_color_image, resolve_resource_path, strip_markup_tags,
|
|
8
|
+
truncate_preview,
|
|
9
|
+
};
|
|
10
|
+
use super::{
|
|
11
|
+
DesktopInteraction, DesktopNativeApp, NativeDesktopCanvasSpec, NativeDesktopVectorSpec,
|
|
12
|
+
NativeElementNode,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
impl DesktopNativeApp {
|
|
16
|
+
fn resource_base_dir(&self) -> Option<&Path> {
|
|
17
|
+
self.resource_base_dir.as_deref()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
pub(super) fn resolve_resource_path(&self, source: &str) -> Option<PathBuf> {
|
|
21
|
+
resolve_resource_path(self.resource_base_dir(), source)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
pub(super) fn read_source_preview(&self, source: &str) -> Option<String> {
|
|
25
|
+
if is_probably_inline_html(source) {
|
|
26
|
+
return Some(truncate_preview(&strip_markup_tags(source), 360));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let path = self.resolve_resource_path(source)?;
|
|
30
|
+
let content = std::fs::read_to_string(path).ok()?;
|
|
31
|
+
Some(truncate_preview(&strip_markup_tags(&content), 360))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pub(super) fn get_vector_spec(&self, node: &NativeElementNode) -> Option<NativeDesktopVectorSpec> {
|
|
35
|
+
serde_json::from_value(node.props.get("desktopVectorSpec")?.clone()).ok()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
pub(super) fn get_canvas_spec(&self, node: &NativeElementNode) -> Option<NativeDesktopCanvasSpec> {
|
|
39
|
+
serde_json::from_value(node.props.get("desktopCanvasSpec")?.clone()).ok()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fn resolved_window_title(&self) -> String {
|
|
43
|
+
self.navigation
|
|
44
|
+
.current_route()
|
|
45
|
+
.map(|route| format!("{} - {}", self.base_title, route))
|
|
46
|
+
.unwrap_or_else(|| self.base_title.clone())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
pub(super) fn apply_navigation_title(&self, ctx: &egui::Context) {
|
|
50
|
+
ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.resolved_window_title()));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fn build_ready_interaction(&self) -> DesktopInteraction {
|
|
54
|
+
let mut payload = Map::new();
|
|
55
|
+
payload.insert(String::from("title"), Value::String(self.base_title.clone()));
|
|
56
|
+
payload.insert(String::from("autoClose"), Value::Bool(self.payload.window.auto_close));
|
|
57
|
+
if let Some(route) = self.navigation.current_route() {
|
|
58
|
+
payload.insert(String::from("route"), Value::String(route.to_string()));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
DesktopInteraction {
|
|
62
|
+
action: Some(String::from("desktop:ready")),
|
|
63
|
+
route: self.navigation.current_route().map(str::to_string),
|
|
64
|
+
payload: Some(Value::Object(payload)),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pub(super) fn emit_ready_interaction(&mut self) {
|
|
69
|
+
if self.ready_emitted || !self.interaction_output.emit_ready {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
self.ready_emitted = true;
|
|
74
|
+
self.record_interaction(self.build_ready_interaction());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pub(super) fn ensure_texture_for_source(
|
|
78
|
+
&mut self,
|
|
79
|
+
ctx: &egui::Context,
|
|
80
|
+
source: &str,
|
|
81
|
+
) -> Result<egui::TextureHandle, String> {
|
|
82
|
+
let resolved = self
|
|
83
|
+
.resolve_resource_path(source)
|
|
84
|
+
.ok_or_else(|| format!("unable to resolve image resource '{source}'"))?;
|
|
85
|
+
let key = resolved.to_string_lossy().into_owned();
|
|
86
|
+
|
|
87
|
+
if let Some(texture) = self.image_textures.get(&key) {
|
|
88
|
+
return Ok(texture.clone());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let image = load_color_image(&resolved)?;
|
|
92
|
+
let texture = ctx.load_texture(format!("elit-resource:{key}"), image, Default::default());
|
|
93
|
+
self.image_textures.insert(key, texture.clone());
|
|
94
|
+
Ok(texture)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
pub(super) fn resolve_display_size(&self, node: &NativeElementNode, fallback: Vec2) -> Vec2 {
|
|
98
|
+
let style = self.get_style_map(node);
|
|
99
|
+
let width = style
|
|
100
|
+
.and_then(|style| self.parse_css_number_with_viewport(style.get("width")))
|
|
101
|
+
.or_else(|| self.resolve_prop_number(node, "width"));
|
|
102
|
+
let height = style
|
|
103
|
+
.and_then(|style| self.parse_css_number_with_viewport(style.get("height")))
|
|
104
|
+
.or_else(|| self.resolve_prop_number(node, "height"));
|
|
105
|
+
let max_width = style.and_then(|style| self.parse_css_number_with_viewport(style.get("maxWidth")));
|
|
106
|
+
let max_height = style.and_then(|style| self.parse_css_number_with_viewport(style.get("maxHeight")));
|
|
107
|
+
|
|
108
|
+
let mut size = fallback;
|
|
109
|
+
match (width, height) {
|
|
110
|
+
(Some(width), Some(height)) => {
|
|
111
|
+
size = Vec2::new(width.max(1.0), height.max(1.0));
|
|
112
|
+
}
|
|
113
|
+
(Some(width), None) => {
|
|
114
|
+
let scale = if fallback.x > 0.0 { width / fallback.x } else { 1.0 };
|
|
115
|
+
size = Vec2::new(width.max(1.0), (fallback.y * scale).max(1.0));
|
|
116
|
+
}
|
|
117
|
+
(None, Some(height)) => {
|
|
118
|
+
let scale = if fallback.y > 0.0 { height / fallback.y } else { 1.0 };
|
|
119
|
+
size = Vec2::new((fallback.x * scale).max(1.0), height.max(1.0));
|
|
120
|
+
}
|
|
121
|
+
(None, None) => {}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if let Some(max_width) = max_width {
|
|
125
|
+
let scale = if size.x > 0.0 { (max_width / size.x).min(1.0) } else { 1.0 };
|
|
126
|
+
size *= scale;
|
|
127
|
+
}
|
|
128
|
+
if let Some(max_height) = max_height {
|
|
129
|
+
let scale = if size.y > 0.0 { (max_height / size.y).min(1.0) } else { 1.0 };
|
|
130
|
+
size *= scale;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
Vec2::new(size.x.max(1.0), size.y.max(1.0))
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
use std::fs::{OpenOptions, create_dir_all};
|
|
2
|
+
use std::io::Write;
|
|
3
|
+
use std::path::{Path, PathBuf};
|
|
4
|
+
|
|
5
|
+
#[cfg(not(target_os = "macos"))]
|
|
6
|
+
use tao::dpi::LogicalSize;
|
|
7
|
+
#[cfg(not(target_os = "macos"))]
|
|
8
|
+
use tao::event::{Event, WindowEvent};
|
|
9
|
+
#[cfg(not(target_os = "macos"))]
|
|
10
|
+
use tao::event_loop::{ControlFlow, EventLoopBuilder};
|
|
11
|
+
#[cfg(not(target_os = "macos"))]
|
|
12
|
+
use tao::window::WindowBuilder as TaoWindowBuilder;
|
|
13
|
+
#[cfg(not(target_os = "macos"))]
|
|
14
|
+
use wry::WebViewBuilder;
|
|
15
|
+
|
|
16
|
+
use crate::icon::load_icon_bitmap;
|
|
17
|
+
use eframe::egui::{self, Color32, Pos2};
|
|
18
|
+
use image::GenericImageView;
|
|
19
|
+
use serde_json::{Map, Value};
|
|
20
|
+
|
|
21
|
+
use super::app_models::{DesktopControlEventData, DesktopInteraction};
|
|
22
|
+
use super::{NativeDesktopColor, NativeElementNode};
|
|
23
|
+
|
|
24
|
+
pub(crate) fn configure_native_context_rendering(ctx: &egui::Context) {
|
|
25
|
+
ctx.tessellation_options_mut(|options| {
|
|
26
|
+
options.round_text_to_pixels = true;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
pub(crate) fn load_window_icon(path: Option<&str>) -> Option<egui::IconData> {
|
|
31
|
+
let path = Path::new(path?);
|
|
32
|
+
let bitmap = load_icon_bitmap(path)
|
|
33
|
+
.map_err(|error| {
|
|
34
|
+
eprintln!("failed to load native desktop icon '{}': {}", path.display(), error);
|
|
35
|
+
error
|
|
36
|
+
})
|
|
37
|
+
.ok()?;
|
|
38
|
+
let (rgba, width, height) = bitmap.into_rgba();
|
|
39
|
+
Some(egui::IconData { rgba, width, height })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
pub(crate) fn format_number(value: f64) -> String {
|
|
43
|
+
if value.fract().abs() < f64::EPSILON {
|
|
44
|
+
format!("{value:.0}")
|
|
45
|
+
} else {
|
|
46
|
+
value.to_string()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
pub(crate) fn color32_from_native_color(color: &NativeDesktopColor) -> Color32 {
|
|
51
|
+
Color32::from_rgba_unmultiplied(
|
|
52
|
+
color.red.clamp(0.0, 255.0).round() as u8,
|
|
53
|
+
color.green.clamp(0.0, 255.0).round() as u8,
|
|
54
|
+
color.blue.clamp(0.0, 255.0).round() as u8,
|
|
55
|
+
(color.alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
pub(crate) fn visible_color(color: Option<&NativeDesktopColor>) -> Option<Color32> {
|
|
60
|
+
let resolved = color.map(color32_from_native_color)?;
|
|
61
|
+
(resolved.a() > 0).then_some(resolved)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pub(crate) fn value_as_string(value: &Value) -> Option<String> {
|
|
65
|
+
match value {
|
|
66
|
+
Value::String(text) => Some(text.clone()),
|
|
67
|
+
Value::Number(number) => Some(number.to_string()),
|
|
68
|
+
Value::Bool(boolean) => Some(boolean.to_string()),
|
|
69
|
+
_ => None,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pub(crate) fn parse_native_bool(value: Option<&Value>) -> bool {
|
|
74
|
+
match value {
|
|
75
|
+
Some(Value::Bool(value)) => *value,
|
|
76
|
+
Some(Value::Number(value)) => value.as_i64().map(|number| number != 0).unwrap_or(false),
|
|
77
|
+
Some(Value::String(text)) => matches!(
|
|
78
|
+
text.trim().to_ascii_lowercase().as_str(),
|
|
79
|
+
"true" | "1" | "yes" | "on" | "checked" | "selected" | "disabled" | "readonly" | "multiple"
|
|
80
|
+
),
|
|
81
|
+
_ => false,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
pub(crate) fn resolve_surface_source(node: &NativeElementNode) -> Option<String> {
|
|
86
|
+
["source", "src", "data", "destination"]
|
|
87
|
+
.iter()
|
|
88
|
+
.find_map(|key| node.props.get(*key).and_then(value_as_string))
|
|
89
|
+
.map(|value| value.trim().to_string())
|
|
90
|
+
.filter(|value| !value.is_empty())
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
pub(crate) fn resolve_route_from_payload(payload: Option<&Value>) -> Option<String> {
|
|
94
|
+
match payload {
|
|
95
|
+
Some(Value::String(route)) if !route.trim().is_empty() => Some(route.trim().to_string()),
|
|
96
|
+
Some(Value::Object(payload)) => payload
|
|
97
|
+
.get("route")
|
|
98
|
+
.and_then(Value::as_str)
|
|
99
|
+
.map(str::trim)
|
|
100
|
+
.filter(|route| !route.is_empty())
|
|
101
|
+
.map(str::to_string),
|
|
102
|
+
_ => None,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
pub(crate) fn strip_markup_tags(input: &str) -> String {
|
|
107
|
+
let mut output = String::new();
|
|
108
|
+
let mut inside_tag = false;
|
|
109
|
+
|
|
110
|
+
for character in input.chars() {
|
|
111
|
+
match character {
|
|
112
|
+
'<' => {
|
|
113
|
+
inside_tag = true;
|
|
114
|
+
if !output.ends_with(' ') {
|
|
115
|
+
output.push(' ');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
'>' => {
|
|
119
|
+
inside_tag = false;
|
|
120
|
+
}
|
|
121
|
+
_ if !inside_tag => output.push(character),
|
|
122
|
+
_ => {}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
output.split_whitespace().collect::<Vec<_>>().join(" ")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
pub(crate) fn is_probably_inline_html(source: &str) -> bool {
|
|
130
|
+
let trimmed = source.trim_start();
|
|
131
|
+
trimmed.starts_with('<') || trimmed.contains("<html") || trimmed.contains("<body")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
pub(crate) fn truncate_preview(text: &str, max_chars: usize) -> String {
|
|
135
|
+
let trimmed = text.trim();
|
|
136
|
+
if trimmed.chars().count() <= max_chars {
|
|
137
|
+
return trimmed.to_string();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let truncated = trimmed.chars().take(max_chars).collect::<String>();
|
|
141
|
+
format!("{truncated}...")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
pub(crate) fn cubic_bezier_point(start: Pos2, control1: Pos2, control2: Pos2, end: Pos2, t: f32) -> Pos2 {
|
|
145
|
+
let inverse_t = 1.0 - t;
|
|
146
|
+
let inverse_t2 = inverse_t * inverse_t;
|
|
147
|
+
let inverse_t3 = inverse_t2 * inverse_t;
|
|
148
|
+
let t2 = t * t;
|
|
149
|
+
let t3 = t2 * t;
|
|
150
|
+
|
|
151
|
+
Pos2::new(
|
|
152
|
+
inverse_t3 * start.x
|
|
153
|
+
+ 3.0 * inverse_t2 * t * control1.x
|
|
154
|
+
+ 3.0 * inverse_t * t2 * control2.x
|
|
155
|
+
+ t3 * end.x,
|
|
156
|
+
inverse_t3 * start.y
|
|
157
|
+
+ 3.0 * inverse_t2 * t * control1.y
|
|
158
|
+
+ 3.0 * inverse_t * t2 * control2.y
|
|
159
|
+
+ t3 * end.y,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
pub(crate) fn is_external_destination(destination: &str) -> bool {
|
|
164
|
+
let normalized = destination.trim().to_ascii_lowercase();
|
|
165
|
+
normalized.starts_with("http://")
|
|
166
|
+
|| normalized.starts_with("https://")
|
|
167
|
+
|| normalized.starts_with("mailto:")
|
|
168
|
+
|| normalized.starts_with("tel:")
|
|
169
|
+
|| normalized.starts_with("//")
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
pub(crate) fn resolve_resource_path(resource_base_dir: Option<&Path>, source: &str) -> Option<PathBuf> {
|
|
173
|
+
let trimmed = source.trim();
|
|
174
|
+
if trimmed.is_empty() || is_external_destination(trimmed) || trimmed.starts_with("data:") {
|
|
175
|
+
return None;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let candidate = PathBuf::from(trimmed);
|
|
179
|
+
if candidate.is_absolute() {
|
|
180
|
+
return candidate.exists().then_some(candidate);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if let Some(base_dir) = resource_base_dir {
|
|
184
|
+
let joined = base_dir.join(trimmed);
|
|
185
|
+
if joined.exists() {
|
|
186
|
+
return Some(joined);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if let Ok(current_dir) = std::env::current_dir() {
|
|
191
|
+
let joined = current_dir.join(trimmed);
|
|
192
|
+
if joined.exists() {
|
|
193
|
+
return Some(joined);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
candidate.exists().then_some(candidate)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#[cfg(not(target_os = "macos"))]
|
|
201
|
+
pub(crate) fn spawn_native_surface_window(
|
|
202
|
+
title: String,
|
|
203
|
+
initial_width: f64,
|
|
204
|
+
initial_height: f64,
|
|
205
|
+
url: Option<String>,
|
|
206
|
+
html: Option<String>,
|
|
207
|
+
) -> Result<(), String> {
|
|
208
|
+
std::thread::Builder::new()
|
|
209
|
+
.name(String::from("elit-native-surface"))
|
|
210
|
+
.spawn(move || {
|
|
211
|
+
let event_loop = EventLoopBuilder::<()>::with_user_event().build();
|
|
212
|
+
let window = TaoWindowBuilder::new()
|
|
213
|
+
.with_title(&title)
|
|
214
|
+
.with_inner_size(LogicalSize::new(initial_width, initial_height))
|
|
215
|
+
.build(&event_loop)
|
|
216
|
+
.expect("Failed to build native surface window");
|
|
217
|
+
|
|
218
|
+
let mut builder = WebViewBuilder::new();
|
|
219
|
+
builder = if let Some(url) = url.as_deref() {
|
|
220
|
+
builder.with_url(url)
|
|
221
|
+
} else if let Some(html) = html.as_deref() {
|
|
222
|
+
builder.with_html(html)
|
|
223
|
+
} else {
|
|
224
|
+
builder.with_html("<!DOCTYPE html><html><body></body></html>")
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
let _webview = builder.build(&window).expect("Failed to build native surface webview");
|
|
228
|
+
event_loop.run(move |event, _target, control_flow| {
|
|
229
|
+
*control_flow = ControlFlow::Wait;
|
|
230
|
+
if let Event::WindowEvent {
|
|
231
|
+
event: WindowEvent::CloseRequested,
|
|
232
|
+
..
|
|
233
|
+
} = event
|
|
234
|
+
{
|
|
235
|
+
*control_flow = ControlFlow::Exit;
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
})
|
|
239
|
+
.map(|_| ())
|
|
240
|
+
.map_err(|error| error.to_string())
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#[cfg(target_os = "macos")]
|
|
244
|
+
pub(crate) fn spawn_native_surface_window(
|
|
245
|
+
_title: String,
|
|
246
|
+
_initial_width: f64,
|
|
247
|
+
_initial_height: f64,
|
|
248
|
+
_url: Option<String>,
|
|
249
|
+
_html: Option<String>,
|
|
250
|
+
) -> Result<(), String> {
|
|
251
|
+
Err(String::from("native surface windows are not available inside the macOS desktop renderer yet"))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
pub(crate) fn resolve_output_path(path: &str) -> PathBuf {
|
|
255
|
+
let candidate = PathBuf::from(path.trim());
|
|
256
|
+
if candidate.is_absolute() {
|
|
257
|
+
return candidate;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if let Ok(current_dir) = std::env::current_dir() {
|
|
261
|
+
return current_dir.join(&candidate);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if let Ok(current_exe) = std::env::current_exe() {
|
|
265
|
+
if let Some(parent) = current_exe.parent() {
|
|
266
|
+
return parent.join(&candidate);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
candidate
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
pub(crate) fn normalize_jsonish_value(value: Value) -> Value {
|
|
274
|
+
match value {
|
|
275
|
+
Value::String(text) => serde_json::from_str::<Value>(&text).unwrap_or(Value::String(text)),
|
|
276
|
+
other => other,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
pub(crate) fn write_interaction_output(file_path: Option<&Path>, interaction_json: &str) -> Result<(), String> {
|
|
281
|
+
let Some(file_path) = file_path else {
|
|
282
|
+
return Ok(());
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
if let Some(parent) = file_path.parent() {
|
|
286
|
+
create_dir_all(parent)
|
|
287
|
+
.map_err(|error| format!("create interaction output dir failed for {}: {error}", parent.display()))?;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let mut file = OpenOptions::new()
|
|
291
|
+
.create(true)
|
|
292
|
+
.append(true)
|
|
293
|
+
.open(file_path)
|
|
294
|
+
.map_err(|error| format!("open interaction output failed for {}: {error}", file_path.display()))?;
|
|
295
|
+
writeln!(file, "{interaction_json}")
|
|
296
|
+
.map_err(|error| format!("write interaction output failed for {}: {error}", file_path.display()))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
pub(crate) fn load_color_image(path: &Path) -> Result<egui::ColorImage, String> {
|
|
300
|
+
let extension = path
|
|
301
|
+
.extension()
|
|
302
|
+
.and_then(|value| value.to_str())
|
|
303
|
+
.map(|value| value.to_ascii_lowercase());
|
|
304
|
+
|
|
305
|
+
if extension.as_deref() == Some("svg") {
|
|
306
|
+
use resvg::{tiny_skia, usvg};
|
|
307
|
+
|
|
308
|
+
let svg = std::fs::read(path)
|
|
309
|
+
.map_err(|error| format!("read SVG failed for {}: {error}", path.display()))?;
|
|
310
|
+
let options = usvg::Options::default();
|
|
311
|
+
let tree = usvg::Tree::from_data(&svg, &options)
|
|
312
|
+
.map_err(|error| format!("parse SVG failed for {}: {error}", path.display()))?;
|
|
313
|
+
let size = tree.size();
|
|
314
|
+
let width = size.width().ceil().max(1.0) as u32;
|
|
315
|
+
let height = size.height().ceil().max(1.0) as u32;
|
|
316
|
+
let mut pixmap = tiny_skia::Pixmap::new(width, height)
|
|
317
|
+
.ok_or_else(|| format!("allocate SVG pixmap failed for {}", path.display()))?;
|
|
318
|
+
resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
|
|
319
|
+
let rgba = pixmap.take();
|
|
320
|
+
return Ok(egui::ColorImage::from_rgba_unmultiplied(
|
|
321
|
+
[width as usize, height as usize],
|
|
322
|
+
&rgba,
|
|
323
|
+
));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let image = image::open(path)
|
|
327
|
+
.map_err(|error| format!("decode image failed for {}: {error}", path.display()))?;
|
|
328
|
+
let rgba = image.to_rgba8();
|
|
329
|
+
let (width, height) = image.dimensions();
|
|
330
|
+
Ok(egui::ColorImage::from_rgba_unmultiplied(
|
|
331
|
+
[width as usize, height as usize],
|
|
332
|
+
&rgba.into_raw(),
|
|
333
|
+
))
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
pub(crate) fn build_event_payload(
|
|
337
|
+
node: &NativeElementNode,
|
|
338
|
+
event_name: &str,
|
|
339
|
+
input_type: Option<String>,
|
|
340
|
+
event_data: &DesktopControlEventData,
|
|
341
|
+
) -> Value {
|
|
342
|
+
let mut payload = Map::new();
|
|
343
|
+
payload.insert(String::from("event"), Value::String(String::from(event_name)));
|
|
344
|
+
payload.insert(String::from("sourceTag"), Value::String(node.source_tag.clone()));
|
|
345
|
+
|
|
346
|
+
if let Some(input_type) = input_type {
|
|
347
|
+
payload.insert(String::from("inputType"), Value::String(input_type));
|
|
348
|
+
}
|
|
349
|
+
if let Some(value) = &event_data.value {
|
|
350
|
+
payload.insert(String::from("value"), Value::String(value.clone()));
|
|
351
|
+
}
|
|
352
|
+
if let Some(values) = &event_data.values {
|
|
353
|
+
payload.insert(
|
|
354
|
+
String::from("values"),
|
|
355
|
+
Value::Array(values.iter().cloned().map(Value::String).collect()),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if let Some(checked) = event_data.checked {
|
|
359
|
+
payload.insert(String::from("checked"), Value::Bool(checked));
|
|
360
|
+
}
|
|
361
|
+
if let Some(detail) = node.props.get("nativePayload") {
|
|
362
|
+
payload.insert(String::from("detail"), normalize_jsonish_value(detail.clone()));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
Value::Object(payload)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
pub(crate) fn resolve_native_route(node: &NativeElementNode) -> Option<String> {
|
|
369
|
+
if let Some(route) = node
|
|
370
|
+
.props
|
|
371
|
+
.get("nativeRoute")
|
|
372
|
+
.and_then(Value::as_str)
|
|
373
|
+
.map(str::trim)
|
|
374
|
+
.filter(|value| !value.is_empty())
|
|
375
|
+
{
|
|
376
|
+
return Some(route.to_string());
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let destination = node
|
|
380
|
+
.props
|
|
381
|
+
.get("destination")
|
|
382
|
+
.and_then(Value::as_str)
|
|
383
|
+
.map(str::trim)
|
|
384
|
+
.filter(|value| !value.is_empty());
|
|
385
|
+
|
|
386
|
+
destination
|
|
387
|
+
.filter(|destination| !is_external_destination(destination))
|
|
388
|
+
.map(str::to_string)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
pub(crate) fn resolve_interaction(
|
|
392
|
+
node: &NativeElementNode,
|
|
393
|
+
default_action: Option<String>,
|
|
394
|
+
payload_override: Option<Value>,
|
|
395
|
+
) -> Option<DesktopInteraction> {
|
|
396
|
+
let action = node
|
|
397
|
+
.props
|
|
398
|
+
.get("nativeAction")
|
|
399
|
+
.and_then(Value::as_str)
|
|
400
|
+
.map(str::trim)
|
|
401
|
+
.filter(|value| !value.is_empty())
|
|
402
|
+
.map(str::to_string)
|
|
403
|
+
.or(default_action);
|
|
404
|
+
let route = resolve_native_route(node);
|
|
405
|
+
let payload = payload_override
|
|
406
|
+
.or_else(|| node.props.get("nativePayload").cloned())
|
|
407
|
+
.map(normalize_jsonish_value);
|
|
408
|
+
|
|
409
|
+
let interaction = DesktopInteraction { action, route, payload };
|
|
410
|
+
(!interaction.is_empty()).then_some(interaction)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
pub(crate) fn resolve_control_event_input_type(node: &NativeElementNode) -> Option<String> {
|
|
414
|
+
match node.component.as_str() {
|
|
415
|
+
"Picker" => Some(if parse_native_bool(node.props.get("multiple")) {
|
|
416
|
+
String::from("select-multiple")
|
|
417
|
+
} else {
|
|
418
|
+
String::from("select-one")
|
|
419
|
+
}),
|
|
420
|
+
"Toggle" => node
|
|
421
|
+
.props
|
|
422
|
+
.get("type")
|
|
423
|
+
.and_then(Value::as_str)
|
|
424
|
+
.map(str::trim)
|
|
425
|
+
.filter(|value| !value.is_empty())
|
|
426
|
+
.map(|value| value.to_ascii_lowercase())
|
|
427
|
+
.or_else(|| Some(String::from("checkbox"))),
|
|
428
|
+
"Slider" => Some(String::from("range")),
|
|
429
|
+
_ => {
|
|
430
|
+
if node.source_tag == "textarea" {
|
|
431
|
+
Some(String::from("text"))
|
|
432
|
+
} else {
|
|
433
|
+
node.props
|
|
434
|
+
.get("type")
|
|
435
|
+
.and_then(Value::as_str)
|
|
436
|
+
.map(str::trim)
|
|
437
|
+
.filter(|value| !value.is_empty())
|
|
438
|
+
.map(|value| value.to_ascii_lowercase())
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
pub(crate) fn should_dispatch_control_event(node: &NativeElementNode, event_name: &str) -> bool {
|
|
445
|
+
if !node.events.iter().any(|candidate| candidate == event_name) {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
!(event_name == "input"
|
|
450
|
+
&& node.props.get("nativeBinding").is_some()
|
|
451
|
+
&& node.events.iter().all(|candidate| candidate == "input"))
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
pub(crate) fn control_event_action(node: &NativeElementNode, event_name: &str) -> String {
|
|
455
|
+
node.props
|
|
456
|
+
.get("nativeAction")
|
|
457
|
+
.and_then(Value::as_str)
|
|
458
|
+
.map(str::trim)
|
|
459
|
+
.filter(|value| !value.is_empty())
|
|
460
|
+
.map(str::to_string)
|
|
461
|
+
.unwrap_or_else(|| format!("elit.event.{event_name}"))
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
pub(crate) fn parse_points_value(value: &Value) -> Vec<Pos2> {
|
|
465
|
+
match value {
|
|
466
|
+
Value::Array(points) => points
|
|
467
|
+
.iter()
|
|
468
|
+
.filter_map(|point| {
|
|
469
|
+
let object = point.as_object()?;
|
|
470
|
+
let x = object.get("x").and_then(Value::as_f64)? as f32;
|
|
471
|
+
let y = object.get("y").and_then(Value::as_f64)? as f32;
|
|
472
|
+
Some(Pos2::new(x, y))
|
|
473
|
+
})
|
|
474
|
+
.collect(),
|
|
475
|
+
Value::String(text) => text
|
|
476
|
+
.split_whitespace()
|
|
477
|
+
.filter_map(|entry| {
|
|
478
|
+
let mut parts = entry.split(',');
|
|
479
|
+
let x = parts.next()?.parse::<f32>().ok()?;
|
|
480
|
+
let y = parts.next()?.parse::<f32>().ok()?;
|
|
481
|
+
Some(Pos2::new(x, y))
|
|
482
|
+
})
|
|
483
|
+
.collect(),
|
|
484
|
+
_ => Vec::new(),
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
pub(crate) fn parse_view_box(value: Option<&Value>) -> Option<(f32, f32, f32, f32)> {
|
|
489
|
+
let view_box = value?.as_str()?;
|
|
490
|
+
let parts = view_box
|
|
491
|
+
.split_whitespace()
|
|
492
|
+
.filter_map(|part| part.parse::<f32>().ok())
|
|
493
|
+
.collect::<Vec<_>>();
|
|
494
|
+
(parts.len() == 4).then_some((parts[0], parts[1], parts[2], parts[3]))
|
|
495
|
+
}
|