You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
596 lines
23 KiB
596 lines
23 KiB
use codespan_reporting::term::{emit, termcolor::NoColor, Config};
|
|
use g_code::parse::{into_diagnostic, snippet_parser};
|
|
use gloo_file::futures::read_as_text;
|
|
use gloo_timers::callback::Timeout;
|
|
use js_sys::TypeError;
|
|
use paste::paste;
|
|
use roxmltree::Document;
|
|
use std::num::ParseFloatError;
|
|
use wasm_bindgen::JsCast;
|
|
use wasm_bindgen_futures::JsFuture;
|
|
use web_sys::{window, FileList, HtmlElement, Response};
|
|
use yew::prelude::*;
|
|
use yewdux::prelude::{BasicStore, Dispatcher};
|
|
use yewdux_functional::use_store;
|
|
use yewdux_input::*;
|
|
|
|
use crate::{
|
|
spectre::*,
|
|
state::{AppState, AppStore, FormState, FormStore, Svg},
|
|
};
|
|
|
|
// TODO: make a nice, syntax highlighting editor for g-code.
|
|
// I started on this but it quickly got too complex.
|
|
// pub struct GCodeEditor {
|
|
// props: GCodeEditorProps,
|
|
// dispatch: AppDispatch,
|
|
// state: Rc<State>,
|
|
// validation_task: Option<TimeoutTask>,
|
|
// link: ComponentLink<Self>,
|
|
// parsed: Option<Result<Html, String>>,
|
|
// node_ref: NodeRef,
|
|
// }
|
|
|
|
// pub enum InputMessage {
|
|
// Validate(String),
|
|
// State(Rc<State>),
|
|
// Change(InputData),
|
|
// }
|
|
|
|
// impl Component for GCodeEditor {
|
|
// type Message = InputMessage;
|
|
|
|
// type Properties = GCodeEditorProps;
|
|
|
|
// fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
|
// Self {
|
|
// props,
|
|
// dispatch: Dispatch::bridge_state(link.callback(InputMessage::State)),
|
|
// state: Default::default(),
|
|
// validation_task: None,
|
|
// link,
|
|
// parsed: None,
|
|
// node_ref: NodeRef::default(),
|
|
// }
|
|
// }
|
|
|
|
// fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
|
// match msg {
|
|
// InputMessage::State(state) => {
|
|
// self.state = state;
|
|
// true
|
|
// }
|
|
// InputMessage::Validate(value) => {
|
|
// self.parsed = Some(snippet_parser(&value).map(|snippet| {
|
|
// html! {
|
|
// <>
|
|
// {
|
|
// for snippet.iter_emit_tokens().flat_map(|token| {
|
|
// if let Token::Field(field) = &token {
|
|
// vec![
|
|
// html! {
|
|
// <span class=classes!("hljs-type")>{field.letters.to_string()}</span>
|
|
// },
|
|
// {
|
|
// let class = match &field.value {
|
|
// Value::Rational(_) | Value::Integer(_) | Value::Float(_) => "hljs-number",
|
|
// Value::String(_) => "hljs-string",
|
|
// };
|
|
// html! {
|
|
// <span class=classes!(class)>{field.value.to_string()}</span>
|
|
// }
|
|
// }
|
|
// ]
|
|
// } else if let Token::Newline{..} = &token {
|
|
// vec![
|
|
// html! {
|
|
// "\r\n"
|
|
// }
|
|
// ]
|
|
// }
|
|
// else {
|
|
// let class = match &token {
|
|
// Token::Comment{..} => "hljs-comment",
|
|
// Token::Checksum(..) => "hljs-number",
|
|
// Token::Whitespace(..) => "whitespace",
|
|
// Token::Newline{..} => "newline",
|
|
// Token::Percent => "hljs-keyword",
|
|
// _ => unreachable!(),
|
|
// };
|
|
// vec![html!{
|
|
// <span class=classes!("token", class)>
|
|
// { token.to_string() }
|
|
// </span>
|
|
// }]
|
|
// }
|
|
// })
|
|
// }
|
|
// </>
|
|
// }
|
|
// }).map_err(|err| {
|
|
// let mut buf = Buffer::no_color();
|
|
// let config = Config::default();
|
|
// emit(
|
|
// &mut buf,
|
|
// &config,
|
|
// &codespan_reporting::files::SimpleFile::new("<input>", value),
|
|
// &into_diagnostic(&err),
|
|
// )
|
|
// .unwrap();
|
|
// String::from_utf8_lossy(buf.as_slice()).to_string()
|
|
// }));
|
|
// true
|
|
// }
|
|
// InputMessage::Change(InputData { value, .. }) => {
|
|
// self.parsed = None;
|
|
// self.validation_task = None;
|
|
// self.validation_task = Some(TimeoutService::spawn(
|
|
// self.props.validation_timeout,
|
|
// self.link
|
|
// .callback(move |_| InputMessage::Validate(value.clone())),
|
|
// ));
|
|
// true
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
// fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
|
// self.props.neq_assign(props)
|
|
// }
|
|
|
|
// fn view(&self) -> Html {
|
|
// let oninput = self.link.callback(|x: InputData| InputMessage::Change(x));
|
|
|
|
// html! {
|
|
// <>
|
|
// <div class=classes!("editor-container")>
|
|
// <label>
|
|
// {self.props.label}
|
|
// <textarea class=classes!("editor") ref=self.node_ref.clone() oninput=oninput />
|
|
// </label>
|
|
// <br/>
|
|
// <pre class=classes!("hljs") ref=self.node_ref.clone() aria-hidden="true">
|
|
// {
|
|
// if let Some(res) = self.parsed.as_ref() {
|
|
// match res.as_ref() {
|
|
// Ok(parsed) => parsed.clone(),
|
|
// Err(err) => err.into()
|
|
// }
|
|
// } else {
|
|
// html! {}
|
|
// }
|
|
// }
|
|
// </pre>
|
|
// </div>
|
|
// </>
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
macro_rules! gcode_input {
|
|
($($name: ident {
|
|
$label: literal,
|
|
$desc: literal,
|
|
$accessor: expr $(=> $idx: literal)?,
|
|
})*) => {
|
|
$(
|
|
paste! {
|
|
#[function_component([<$name Input>])]
|
|
fn [<$name:snake:lower _input>]() -> Html {
|
|
const VALIDATION_TIMEOUT: u32 = 350;
|
|
let app = use_store::<AppStore>();
|
|
let form = use_store::<FormStore>();
|
|
|
|
let timeout = use_state::<Option<Timeout>, _>(|| None);
|
|
let oninput = {
|
|
let timeout = timeout.clone();
|
|
form.dispatch().input(move |state, value| {
|
|
let res = Some(match snippet_parser(&value) {
|
|
Ok(_) => Ok(value),
|
|
Err(err) => {
|
|
let mut buf = NoColor::new(vec![]);
|
|
let config = Config::default();
|
|
emit(
|
|
&mut buf,
|
|
&config,
|
|
&codespan_reporting::files::SimpleFile::new("<input>", value),
|
|
&into_diagnostic(&err),
|
|
)
|
|
.unwrap();
|
|
Err(String::from_utf8_lossy(buf.get_ref().as_slice()).to_string())
|
|
}
|
|
}).filter(|res| {
|
|
!res.as_ref().ok().map(|value| value.is_empty()).unwrap_or(false)
|
|
});
|
|
|
|
let timeout_inner = timeout.clone();
|
|
timeout.set(Some(Timeout::new(VALIDATION_TIMEOUT, move || {
|
|
timeout_inner.set(None);
|
|
})));
|
|
state.$accessor $([$idx])? = res;
|
|
})
|
|
};
|
|
html! {
|
|
<FormGroup success={form.state().map(|state| (state.$accessor $([$idx])?).as_ref().map(Result::is_ok)).flatten()}>
|
|
<TextArea<String, String> label=$label desc=$desc
|
|
default={app.state().map(|state| (state.$accessor $([$idx])?).clone()).unwrap_or_else(|| AppState::default().$accessor $([$idx])?)}
|
|
parsed={form.state().and_then(|state| (state.$accessor $([$idx])?).clone()).filter(|_| timeout.is_none())}
|
|
oninput={oninput}
|
|
/>
|
|
</FormGroup>
|
|
}
|
|
}
|
|
}
|
|
)*
|
|
};
|
|
}
|
|
|
|
gcode_input! {
|
|
ToolOnSequence {
|
|
"Tool On Sequence",
|
|
"G-Code for turning on the tool",
|
|
tool_on_sequence,
|
|
}
|
|
ToolOffSequence {
|
|
"Tool Off Sequence",
|
|
"G-Code for turning off the tool",
|
|
tool_off_sequence,
|
|
}
|
|
BeginSequence {
|
|
"Program Begin Sequence",
|
|
"G-Code for initializing the machine at the beginning of the program",
|
|
begin_sequence,
|
|
}
|
|
EndSequence {
|
|
"Program End Sequence",
|
|
"G-Code for stopping/idling the machine at the end of the program",
|
|
end_sequence,
|
|
}
|
|
}
|
|
|
|
macro_rules! form_input {
|
|
($($name: ident {
|
|
$label: literal,
|
|
$desc: literal,
|
|
$accessor: expr $(=> $idx: literal)?,
|
|
})*) => {
|
|
$(
|
|
paste! {
|
|
#[function_component([<$name Input>])]
|
|
fn [<$name:snake:lower _input>]() -> Html {
|
|
let app = use_store::<AppStore>();
|
|
let form = use_store::<BasicStore<FormState>>();
|
|
let oninput = form.dispatch().input(|state, value| state.$accessor $([$idx])? = value.parse::<f64>());
|
|
html! {
|
|
<FormGroup success={form.state().map(|state| (state.$accessor $([$idx])?).is_ok())}>
|
|
<Input<f64, ParseFloatError> label=$label desc=$desc
|
|
default={app.state().map(|state| state.$accessor $([$idx])?).unwrap_or_else(|| AppState::default().$accessor $([$idx])?)}
|
|
parsed={form.state().map(|state| (state.$accessor $([$idx])?).clone())}
|
|
oninput={oninput}
|
|
/>
|
|
</FormGroup>
|
|
}
|
|
}
|
|
}
|
|
)*
|
|
};
|
|
}
|
|
|
|
form_input! {
|
|
Tolerance {
|
|
"Tolerance",
|
|
"Curve interpolation tolerance (mm)",
|
|
tolerance,
|
|
}
|
|
Feedrate {
|
|
"Feedrate",
|
|
"Machine feedrate (mm/min)",
|
|
feedrate,
|
|
}
|
|
Dpi {
|
|
"Dots per Inch",
|
|
"Used for scaling visual units (pixels, points, picas, etc.)",
|
|
dpi,
|
|
}
|
|
OriginX {
|
|
"Origin X",
|
|
"X-axis coordinate for the bottom left corner of the machine",
|
|
origin => 0,
|
|
}
|
|
OriginY {
|
|
"Origin Y",
|
|
"Y-axis coordinate for the bottom left corner of the machine",
|
|
origin => 1,
|
|
}
|
|
}
|
|
|
|
#[function_component(SettingsForm)]
|
|
pub fn settings_form() -> Html {
|
|
let app = use_store::<AppStore>();
|
|
let form = use_store::<BasicStore<FormState>>();
|
|
|
|
// let handle = use_state(|| None);
|
|
let disabled = form
|
|
.state()
|
|
.map(|state| {
|
|
state.tolerance.is_err()
|
|
|| state.feedrate.is_err()
|
|
|| state.dpi.is_err()
|
|
|| state.origin.iter().all(Result::is_err)
|
|
|| state
|
|
.tool_on_sequence
|
|
.as_ref()
|
|
.map(Result::is_err)
|
|
.unwrap_or(false)
|
|
|| state
|
|
.tool_off_sequence
|
|
.as_ref()
|
|
.map(Result::is_err)
|
|
.unwrap_or(false)
|
|
|| state
|
|
.begin_sequence
|
|
.as_ref()
|
|
.map(Result::is_err)
|
|
.unwrap_or(false)
|
|
|| state
|
|
.end_sequence
|
|
.as_ref()
|
|
.map(Result::is_err)
|
|
.unwrap_or(false)
|
|
})
|
|
.unwrap_or(true);
|
|
|
|
let close_ref = NodeRef::default();
|
|
|
|
let on_circular_interpolation_change =
|
|
form.dispatch().reduce_callback_with(|form, change_data| {
|
|
if let ChangeData::Value(_) = change_data {
|
|
form.circular_interpolation = !form.circular_interpolation;
|
|
}
|
|
});
|
|
let circular_interpolation_checked = form
|
|
.state()
|
|
.map(|state| state.circular_interpolation)
|
|
.unwrap_or(false);
|
|
|
|
let save_onclick = {
|
|
let close_ref = close_ref.clone();
|
|
app.dispatch().reduce_callback(move |app| {
|
|
if let (false, Some(form)) = (disabled, form.state()) {
|
|
app.tolerance = *form.tolerance.as_ref().unwrap();
|
|
app.feedrate = *form.feedrate.as_ref().unwrap();
|
|
app.origin = [
|
|
*form.origin[0].as_ref().unwrap(),
|
|
*form.origin[1].as_ref().unwrap(),
|
|
];
|
|
app.circular_interpolation = form.circular_interpolation;
|
|
app.dpi = *form.dpi.as_ref().unwrap();
|
|
app.tool_on_sequence = form.tool_on_sequence.clone().and_then(Result::ok);
|
|
app.tool_off_sequence = form.tool_off_sequence.clone().and_then(Result::ok);
|
|
app.begin_sequence = form.begin_sequence.clone().and_then(Result::ok);
|
|
app.end_sequence = form.end_sequence.clone().and_then(Result::ok);
|
|
|
|
// TODO: this is a poor man's crutch for closing the Modal.
|
|
// There is probably a better way.
|
|
if let Some(element) = close_ref.cast::<HtmlElement>() {
|
|
element.click();
|
|
}
|
|
}
|
|
})
|
|
};
|
|
|
|
html! {
|
|
<Modal
|
|
id="settings"
|
|
header={
|
|
html!(
|
|
<h5>{ "Settings" }</h5>
|
|
)
|
|
}
|
|
body={html!(
|
|
<>
|
|
<ToleranceInput/>
|
|
<FeedrateInput/>
|
|
<OriginXInput/>
|
|
<OriginYInput/>
|
|
<FormGroup>
|
|
<Checkbox
|
|
label="Enable circular interpolation"
|
|
desc="Please check if your machine supports G2/G3 commands before enabling this"
|
|
checked={circular_interpolation_checked}
|
|
onchange={on_circular_interpolation_change}
|
|
/>
|
|
</FormGroup>
|
|
<DpiInput/>
|
|
<ToolOnSequenceInput/>
|
|
<ToolOffSequenceInput/>
|
|
<BeginSequenceInput/>
|
|
<EndSequenceInput/>
|
|
</>
|
|
)}
|
|
footer={
|
|
html!(
|
|
<>
|
|
<p>
|
|
{"These settings are persisted using local storage. Learn more "}
|
|
// Opening new tabs is usually bad.
|
|
// But if we don't, the user is at risk of losing the settings they've entered so far.
|
|
<a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage" target="_blank">
|
|
{"on MDN"}
|
|
</a>
|
|
{"."}
|
|
</p>
|
|
<Button
|
|
style={ButtonStyle::Primary}
|
|
disabled={disabled}
|
|
title="Save"
|
|
onclick={save_onclick}
|
|
/>
|
|
{" "}
|
|
<HyperlinkButton
|
|
ref={close_ref}
|
|
style={ButtonStyle::Default}
|
|
title="Close"
|
|
href="#close"
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
>
|
|
</Modal>
|
|
}
|
|
}
|
|
|
|
#[function_component(SvgInput)]
|
|
pub fn svg_input() -> Html {
|
|
let app = use_store::<AppStore>();
|
|
|
|
let file_upload_state = use_ref(Vec::default);
|
|
let file_upload_state_cloned = file_upload_state.clone();
|
|
let file_upload_onchange =
|
|
app.dispatch()
|
|
.future_callback_with(move |app, file_list: FileList| {
|
|
let file_upload_state_cloned = file_upload_state_cloned.clone();
|
|
async move {
|
|
let mut results = Vec::with_capacity(file_list.length() as usize);
|
|
for file in (0..file_list.length()).filter_map(|i| file_list.item(i)) {
|
|
let filename = file.name();
|
|
results.push(
|
|
read_as_text(&gloo_file::File::from(file))
|
|
.await
|
|
.map_err(|err| err.to_string())
|
|
.and_then(|text| {
|
|
if let Some(err) = Document::parse(&text).err() {
|
|
Err(format!("Error parsing {}: {}", &filename, err))
|
|
} else {
|
|
Ok(Svg {
|
|
content: text,
|
|
filename,
|
|
dimensions: [None; 2],
|
|
})
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
app.reduce(move |app| {
|
|
// Clear any errors from previous entry, add new successfully parsed SVGs
|
|
(*file_upload_state_cloned).borrow_mut().clear();
|
|
for result in results.iter() {
|
|
(*file_upload_state_cloned)
|
|
.borrow_mut()
|
|
.push(result.clone().map(|_| ()));
|
|
}
|
|
app.svgs.extend(results.drain(..).filter_map(Result::ok));
|
|
});
|
|
}
|
|
});
|
|
|
|
let file_upload_errors = file_upload_state
|
|
.borrow()
|
|
.iter()
|
|
.filter_map(|res| res.as_ref().err())
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
let file_upload_res = if file_upload_state.borrow().is_empty() {
|
|
None
|
|
} else if file_upload_errors.is_empty() {
|
|
Some(Ok(()))
|
|
} else {
|
|
Some(Err(file_upload_errors.join("\n")))
|
|
};
|
|
|
|
let url_input_state = use_state(|| Option::<String>::None);
|
|
let url_input_parsed = use_state(|| Option::<Result<String, String>>::None);
|
|
let url_input_oninput = {
|
|
let url_input_state = url_input_state.clone();
|
|
let url_input_parsed = url_input_parsed.clone();
|
|
Callback::from(move |url: InputData| {
|
|
url_input_state.set(Some(url.value));
|
|
url_input_parsed.set(None);
|
|
})
|
|
};
|
|
|
|
let url_add_loading = use_state(|| false);
|
|
let url_add_onclick = {
|
|
let url_input_state = url_input_state.clone();
|
|
let url_input_parsed = url_input_parsed.clone();
|
|
let url_add_loading = url_add_loading.clone();
|
|
|
|
app.dispatch().future_callback_with(move |app, _| {
|
|
let url_input_state = url_input_state.clone();
|
|
let url_input_parsed = url_input_parsed.clone();
|
|
let url_add_loading = url_add_loading.clone();
|
|
url_add_loading.set(true);
|
|
|
|
let request_url = url_input_state.as_ref().unwrap().clone();
|
|
async move {
|
|
url_input_parsed.set(None);
|
|
let res = JsFuture::from(window().unwrap().fetch_with_str(&request_url))
|
|
.await
|
|
.map(|res| res.dyn_into::<Response>().unwrap());
|
|
url_add_loading.set(false);
|
|
match res {
|
|
Ok(res) => {
|
|
let response_url = res.url();
|
|
let text = JsFuture::from(res.text().unwrap())
|
|
.await
|
|
.unwrap()
|
|
.as_string()
|
|
.unwrap();
|
|
if let Some(err) = Document::parse(&text).err() {
|
|
url_input_parsed.set(Some(Err(format!(
|
|
"Error parsing {}: {}",
|
|
&response_url, err
|
|
))));
|
|
} else {
|
|
app.reduce(move |app| {
|
|
app.svgs.push(Svg {
|
|
content: text,
|
|
filename: response_url,
|
|
dimensions: [None; 2],
|
|
})
|
|
});
|
|
};
|
|
}
|
|
Err(err) => {
|
|
url_input_parsed.set(Some(Err(format!(
|
|
"Error fetching {}: {:?}",
|
|
&request_url,
|
|
err.dyn_into::<TypeError>().unwrap().message()
|
|
))));
|
|
}
|
|
}
|
|
}
|
|
})
|
|
};
|
|
|
|
html! {
|
|
<FormGroup success={file_upload_res.as_ref().map(Result::is_ok).or(url_input_parsed.as_ref().map(Result::is_ok))}>
|
|
<FileUpload<(), String>
|
|
label="Select SVG files"
|
|
accept=".svg"
|
|
multiple={true}
|
|
onchange={file_upload_onchange}
|
|
/>
|
|
<div class="divider text-center" data-content="OR"/>
|
|
<Input<String, String>
|
|
label="Add an SVG file by URL"
|
|
r#type={InputType::Url}
|
|
placeholder="https://raw.githubusercontent.com/sameer/svg2gcode/master/examples/Vanderbilt_Commodores_logo.svg"
|
|
oninput={url_input_oninput}
|
|
button={html_nested!(
|
|
<Button
|
|
style={ButtonStyle::Primary}
|
|
title="Add"
|
|
input_group=true
|
|
disabled={(*url_input_state).is_none()}
|
|
onclick={url_add_onclick}
|
|
loading={*url_add_loading}
|
|
/>
|
|
)}
|
|
parsed={(*url_input_parsed).clone()}
|
|
/>
|
|
</FormGroup>
|
|
}
|
|
}
|