diff --git a/src/js/app.js b/src/js/app.js index ceeb9ef..c8cd5ce 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -103,12 +103,23 @@ module.exports = new Vue({ return { status: "connecting", currentView: "loading", + // Top-level shell tab. Mapped from the URL hash by parse_hash(). + // One of: control | program | console | settings + top_tab: "control", + // Sub-route when a tab has internal pages (e.g. console:mdi, + // settings:admin-network, settings:motor:0). The settings sub + // also drives which inner view is mounted. + sub_tab: "", + sys_open: false, + has_camera: true, + messages_log: [], + messages_seen: 0, display_units: localStorage.getItem("display_units") || "METRIC", index: -1, modified: false, template: require("../resources/config-template.json"), config: { - settings: { + settings: { units: "METRIC", "easy-adapter": false }, @@ -143,22 +154,15 @@ module.exports = new Vue({ estop: { template: "#estop-template" }, "loading-view": { template: "

Loading...

" }, "control-view": require("./control-view"), - "settings-view": require("./settings-view"), - "motor-view": require("./motor-view"), - "tool-view": require("./tool-view"), - "io-view": require("./io-view"), - "admin-general-view": require("./admin-general-view"), - "admin-network-view": require("./admin-network-view"), - "macros-view": require('./macros'), - "help-view": require("./help-view"), - "cheat-sheet-view": { - template: "#cheat-sheet-view-template", - data: function() { - return { - showUnimplemented: false - }; - }, - }, + "program-view": require("./program-view"), + "console-view": require("./console-view"), + + // The settings-shell renders the rail + an inner routed view. + // All settings-family hashes (settings, admin-general, + // admin-network, motor:N, tool, io, macros, help, cheat-sheet) + // resolve to this same shell; parse_hash() sets sub_tab so the + // shell knows which inner template to mount. + "settings-shell-view": require("./settings-shell-view"), }, watch: { @@ -166,6 +170,25 @@ module.exports = new Vue({ localStorage.setItem("display_units", value); SvelteComponents.setDisplayUnits(value); }, + + // Mirror controller messages into a console log used by the + // Console > Messages tab and the header badge counter. + "state.messages": { + handler: function(messages) { + if (!Array.isArray(messages)) return; + this.messages_log = messages.map(m => ({ + text: m.text, + id: m.id, + level: /^#/.test(m.text || "") ? "info" : "warning", + ts: m.ts || Date.now(), + })); + if (this.top_tab === "console" && this.sub_tab === "messages") { + this.messages_seen = this.messages_log.length; + } + }, + deep: true, + immediate: true, + }, }, events: { @@ -252,18 +275,106 @@ module.exports = new Vue({ enable_rotary: function() { if(this.state["2an"] == 1 || this.state["2an"] == 3) return true; return false; - } + }, + + // ---------------- header chrome helpers ---------------- + + // Underlying machine state from the controller. Mirrors + // control-view's `mach_state` so the header has access without + // depending on the routed component. + mach_state: function() { + const cycle = this.state.cycle; + const xx = this.state.xx; + + if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) { + return cycle.toUpperCase(); + } + + return xx || ""; + }, + + // Short text for the READY pill in the header. + state_label: function() { + const s = this.mach_state; + if (!s) return "--"; + return s; + }, + + // Class added to the READY pill (.state-badge) so styling can + // reflect ready / running / holding / fault / estop. + state_class: function() { + const s = this.mach_state; + if (s == "ESTOPPED" || s == "FAULT" || s == "SHUTDOWN") return "bad"; + if (s == "HOLDING" || s == "STOPPING") return "warn"; + if (s == "RUNNING" || s == "HOMING" || s == "JOGGING") return "busy"; + if (s == "READY") return "ok"; + return "unknown"; + }, + + mach_state_full: function() { + const s = this.mach_state; + if (s == "ESTOPPED") return "E-Stopped \u2014 release to clear"; + if (s == "HOLDING") return "Feed hold (" + (this.state.pr || "paused") + ")"; + if (s == "RUNNING") return "Running program"; + if (s == "HOMING") return "Homing axes"; + if (s == "JOGGING") return "Jogging"; + if (s == "READY") return "Ready"; + return s; + }, + + // Pip color for the unified system pill. + sys_class: function() { + const wifi_off = !this.config.wifiName || this.config.wifiName == "not connected"; + const cam_off = !this.has_camera; + const hot = this.state && 80 <= this.state.rpi_temp; + if (hot) return "red"; + if (wifi_off || cam_off) return "amber"; + return "green"; + }, + + // Compact summary for the system pill. + sys_summary: function() { + const issues = []; + if (!this.config.wifiName || this.config.wifiName == "not connected") { + issues.push("WiFi off"); + } + if (!this.has_camera) issues.push("Camera offline"); + if (this.state && 80 <= this.state.rpi_temp) issues.push("Pi hot"); + if (this.is_rotary_active) issues.push("Rotary"); + if (issues.length === 0) return "All systems"; + if (issues.length === 1) return issues[0]; + return issues.length + " notes"; + }, + + // Number of unread Console > Messages entries. + messages_count: function() { + return Math.max(0, this.messages_log.length - this.messages_seen); + }, }, ready: function() { window.onhashchange = () => this.parse_hash(); + + // Resolve the initial route before the websocket connects so + // the shell shows the right view even on a slow / offline + // controller. update() will call parse_hash() again once the + // first config is in. + this.parse_hash(); + this.connect(); + // Close the system popover when clicking anywhere else. + document.addEventListener("click", () => { + if (this.sys_open) this.sys_open = false; + }); + SvelteComponents.registerControllerMethods({ dispatch: (...args) => this.$dispatch(...args) }); }, + + methods: { block_error_dialog: function() { this.errorTimeoutStart = Date.now(); @@ -421,6 +532,21 @@ module.exports = new Vue({ }; }, + // Maps a URL hash to (currentView, top_tab, sub_tab, index). + // Hash layouts supported (all kept for backward compat): + // #control -> control tab + // #program[:auto] -> program tab + // #console[:mdi|messages|indicators] + // -> console tab + // #settings -> settings tab home + // #admin-general -> settings tab, admin-general inside + // #admin-network -> settings tab, admin-network inside + // #motor:0..3 -> settings tab, motor 0..3 + // #tool -> settings tab, tool view + // #io -> settings tab, io view + // #macros -> settings tab, macros view + // #help -> settings tab, help view + // #cheat-sheet -> settings tab, cheat sheet view parse_hash: function() { const hash = location.hash.substr(1); @@ -430,12 +556,56 @@ module.exports = new Vue({ } const parts = hash.split(":"); + const head = parts[0]; - if (parts.length == 2) { - this.index = parts[1]; + this.index = parts.length > 1 ? parts[1] : -1; + + // Legacy / settings-managed views resolve under the + // Settings tab while keeping their existing top-level + // hash. This preserves all existing deep links. + const settingsViews = [ + "settings", "admin-general", "admin-network", + "motor", "tool", "io", "macros", + "help", "cheat-sheet", + ]; + + if (head == "control") { + this.top_tab = "control"; + this.sub_tab = ""; + this.currentView = "control"; + } else if (head == "program") { + this.top_tab = "program"; + this.sub_tab = parts[1] || "auto"; + this.currentView = "program"; + } else if (head == "console") { + this.top_tab = "console"; + this.sub_tab = parts[1] || "mdi"; + this.currentView = "console"; + } else if (settingsViews.indexOf(head) !== -1) { + this.top_tab = "settings"; + this.sub_tab = head; + // All settings-family routes mount the same shell; + // shell picks inner view from sub_tab. Vary the + // currentView token so Vue 1 fully remounts the + // shell on every navigation — this avoids stale :class + // bindings against the local `sub` data prop. + this.currentView = "settings-shell"; + } else { + // Unknown hash: route to settings shell anyway so we + // never end up rendering a bare loading screen. + this.top_tab = "settings"; + this.sub_tab = head; + this.currentView = "settings-shell"; } - this.currentView = parts[0]; + // Mark Console messages as seen when we enter that tab. + if (this.top_tab == "console" && this.sub_tab == "messages") { + this.messages_seen = this.messages_log.length; + } + }, + + toggle_sys_popover: function() { + this.sys_open = !this.sys_open; }, save: async function() { diff --git a/src/js/console-view.js b/src/js/console-view.js new file mode 100644 index 0000000..d2cd677 --- /dev/null +++ b/src/js/console-view.js @@ -0,0 +1,125 @@ +"use strict"; + +const api = require("./api"); + +// Console tab — MDI command input, message log and live indicators. +// Sub-tab state syncs with the URL hash (#console:mdi | +// #console:messages | #console:indicators) so deep links work. + +module.exports = { + template: "#console-view-template", + props: ["config", "template", "state"], + + data: function () { + return { + mdi: "", + history: [], + sub: "mdi", + // Local mirror of $root.messages_count so Vue 1 reactivity works. + unread_messages_local: 0, + }; + }, + + watch: { + sub: function () { + // Switching to messages marks them as seen so the header badge + // clears. + if (this.sub === "messages") { + this.$root.messages_seen = this.$root.messages_log.length; + this.unread_messages_local = 0; + } + }, + }, + + computed: { + unread_messages: function () { + return this.unread_messages_local; + }, + + mach_state: function () { + const cycle = this.state.cycle; + const xx = this.state.xx; + if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) { + return cycle.toUpperCase(); + } + return xx || ""; + }, + + is_idle: function () { return this.state.cycle == "idle"; }, + + can_mdi: function () { + return this.is_idle || this.state.cycle == "mdi"; + }, + + mach_units: function () { + return this.$root.display_units; + }, + }, + + ready: function () { + this._onHash = () => this.refresh_from_hash(); + window.addEventListener("hashchange", this._onHash); + this.refresh_from_hash(); + this._poll = setInterval(() => { + // Cheap re-poll for unread message count; Vue 1 cannot observe + // `$root.messages_count` directly so we mirror it here. + const c = this.$root && this.$root.messages_count; + if (typeof c === "number" && c !== this.unread_messages_local) { + this.unread_messages_local = c; + } + }, 500); + }, + + beforeDestroy: function () { + if (this._onHash) window.removeEventListener("hashchange", this._onHash); + if (this._poll) clearInterval(this._poll); + }, + + methods: { + refresh_from_hash: function () { + const hash = location.hash.substr(1); + const parts = hash.split(":"); + const sub = parts[0] === "console" ? (parts[1] || "mdi") : "mdi"; + this.sub = sub; + if (sub === "messages" && this.$root) { + this.$root.messages_seen = this.$root.messages_log.length; + this.unread_messages_local = 0; + } + }, + + select_sub: function (name) { + this.sub = name; + // Update URL hash for deep links / back-button. + const h = "#console" + (name && name !== "mdi" ? ":" + name : ""); + if (location.hash !== h) { + history.replaceState(null, "", h); + } + if (name === "messages") { + this.$root.messages_seen = this.$root.messages_log.length; + this.unread_messages_local = 0; + } + }, + + prepend: function (token) { + this.mdi = token + this.mdi.trimStart(); + }, + + append: function (token) { + const tail = this.mdi.endsWith(" ") || !this.mdi ? "" : " "; + this.mdi = this.mdi + tail + token; + }, + + submit_mdi: function () { + if (!this.mdi) return; + this.$dispatch("send", this.mdi); + if (!this.history.length || this.history[0] != this.mdi) { + this.history.unshift(this.mdi); + } + this.mdi = ""; + }, + + load_history: function (index) { + this.mdi = this.history[index]; + }, + }, +}; diff --git a/src/js/control-view.js b/src/js/control-view.js index a9c23f6..7fa1134 100644 --- a/src/js/control-view.js +++ b/src/js/control-view.js @@ -1,7 +1,6 @@ "use strict"; const api = require("./api"); -const utils = require("./utils"); const cookie = require("./cookie")("bbctrl-"); module.exports = { @@ -12,15 +11,7 @@ module.exports = { return { current_time: "", mach_units: this.$root.state.metric ? "METRIC" : "IMPERIAL", - mdi: "", - last_file: undefined, - last_file_time: undefined, - toolpath: {}, - toolpath_progress: 0, axes: "xyzabc", - history: [], - speed_override: 1, - feed_override: 1, jog_incr_amounts: { METRIC: { fine: 0.1, @@ -38,34 +29,14 @@ module.exports = { jog_incr: localStorage.getItem("jog_incr") || "small", jog_step: cookie.get_bool("jog-step"), jog_adjust: parseInt(cookie.get("jog-adjust", 2)), - deleteGCode: false, - tab: "auto", ask_home: true, - folder_name: "", - edited: false, - uploading_files: false, - confirmDelete: false, - create_folder: false, - showGcodeMessage: false, - showNoGcodeMessage: false, - macrosLoading: false, - show_gcodes: false, - GCodeNotFound: false, show_probe_dialog: false, - filesUploaded: 0, - totalFiles: 0, - files_sortby: "By Upload Date", - selected_items_to_delete: [], - search_query: "", - filtered_files: [], - selected_folder_index: null, + overrides_open: false, }; }, components: { "axis-control": require("./axis-control"), - "path-viewer": require("./path-viewer"), - "gcode-viewer": require("./gcode-viewer"), }, watch: { @@ -80,16 +51,6 @@ module.exports = { immediate: true, }, - "state.line": function () { - if (this.mach_state != "HOMING") { - this.$broadcast("gcode-line", this.state.line); - } - }, - - "state.selected_time": function () { - this.load(); - }, - jog_step: function () { cookie.set_bool("jog-step", this.jog_step); }, @@ -127,43 +88,16 @@ module.exports = { return state || ""; }, - pause_reason: function () { - return this.state.pr; - }, - - is_running: function () { - return this.mach_state == "RUNNING" || this.mach_state == "HOMING"; - }, - - is_stopping: function () { - return this.mach_state == "STOPPING"; - }, - - is_holding: function () { - return this.mach_state == "HOLDING"; - }, - - is_ready: function () { - return this.mach_state == "READY"; + can_set_axis: function () { + return this.state.cycle == "idle"; }, is_idle: function () { return this.state.cycle == "idle"; }, - is_paused: function () { - return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause"); - }, - - can_mdi: function () { - return this.is_idle || this.state.cycle == "mdi"; - }, - - can_set_axis: function () { - return this.is_idle; - - // TODO allow setting axis position during pause - // return this.is_idle || this.is_paused; + is_ready: function () { + return this.mach_state == "READY"; }, message: function () { @@ -191,57 +125,21 @@ module.exports = { }, plan_time_remaining: function () { - if (!(this.is_stopping || this.is_running || this.is_holding)) { - return 0; - } - - return this.toolpath.time - this.plan_time; + const stopping = this.mach_state == "STOPPING"; + const running = this.mach_state == "RUNNING" || this.mach_state == "HOMING"; + const holding = this.mach_state == "HOLDING"; + if (!(stopping || running || holding)) return 0; + const tp = this.$root && this.$root.toolpath ? this.$root.toolpath.time : 0; + return (tp || 0) - this.plan_time; }, - eta: function () { - if (this.mach_state != "RUNNING") { - return ""; - } - - const remaining = this.plan_time_remaining; - const d = new Date(); - d.setSeconds(d.getSeconds() + remaining); - return d.toLocaleString(); - }, - - progress: function () { - if (!this.toolpath.time || this.is_ready) { - return 0; - } - - const p = this.plan_time / this.toolpath.time; - return Math.min(1, p); - }, - gcode_files: function () { - if (!this.state.folder) { - return []; - } - const folder = this.state.gcode_list.find(item => item.name == this.state.folder); - if (!folder) { - return []; - } - const files = folder.files.filter(item => this.state.files.includes(item.file_name)).map(item => item.file_name); - if (this.files_sortby == "A-Z") { - return files.sort(); - } else if (this.files_sortby == "Z-A") { - return files.sort().reverse(); - } else { - return files; - } - }, - gcode_filtered_files: function () { - return this.filtered_files.filter(file => file.toLowerCase().includes(this.search_query.toLowerCase())); - }, - gcode_folders: function () { - return this.state.gcode_list - .map(item => item.name) - .filter(element => element !== "default") - .sort(); + state_kpi_class: function () { + const s = this.mach_state; + if (s == "ESTOPPED" || s == "FAULT" || s == "SHUTDOWN") return "bad"; + if (s == "HOLDING" || s == "STOPPING") return "warn"; + if (s == "RUNNING" || s == "HOMING" || s == "JOGGING") return "busy"; + if (s == "READY") return "ok"; + return ""; }, }, @@ -264,14 +162,9 @@ module.exports = { M72 `); }, - folder_name_edited: function () { - this.edited = true; - }, }, ready: function () { - this.load(); - setInterval(() => { this.current_time = new Date().toLocaleTimeString(); }, 1000); @@ -287,25 +180,9 @@ module.exports = { }, methods: { - save_config: async function (config) { - try { - await api.put("config/save", config); - this.$dispatch("update"); - } catch (error) { - console.error("Restore Failed: ", error); - alert("Restore failed"); - } - }, - - populateFiles(index) { - this.selected_folder_index = index; - this.filtered_files = this.state.gcode_list[index].files.map(item => item.file_name); - }, - getJogIncrStyle(value) { const weight = `font-weight:${this.jog_incr === value ? "bold" : "normal"}`; const color = this.jog_incr === value ? "color:#0078e7" : ""; - return [weight, color].join(";"); }, @@ -324,426 +201,6 @@ module.exports = { `); }, - send: function (msg) { - this.$dispatch("send", msg); - }, - - toggle_sorting: function () { - if (this.files_sortby === "By Upload Date") { - this.files_sortby = "A-Z"; - } else if (this.files_sortby === "A-Z") { - this.files_sortby = "Z-A"; - } else if (this.files_sortby === "Z-A") { - this.files_sortby = "By Upload Date"; - } - }, - - load: function () { - const file_time = this.state.selected_time; - const file = this.state.selected; - if (this.last_file == file && this.last_file_time == file_time) { - return; - } - - if (this.state.selected && !this.state.files.includes(this.state.selected)) { - this.GCodeNotFound = true; - return; - } - - this.last_file = file; - this.last_file_time = file_time; - - this.$broadcast("gcode-load", file); - this.$broadcast("gcode-line", this.state.line); - this.toolpath_progress = 0; - this.load_toolpath(file, file_time); - }, - - load_toolpath: async function (file, file_time) { - this.toolpath = {}; - - if (!file || this.last_file_time != file_time) { - return; - } - - this.showGcodeMessage = true; - - while (this.showGcodeMessage) { - try { - const toolpath = await api.get(`path/${file}`); - this.toolpath_progress = toolpath.progress; - - if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") { - this.showGcodeMessage = false; - - if (toolpath.bounds) { - toolpath.filename = file; - this.toolpath_progress = 1; - this.toolpath = toolpath; - - const state = this.$root.state; - for (const axis of "xyzabc") { - Vue.set(state, `path_min_${axis}`, toolpath.bounds.min[axis]); - Vue.set(state, `path_max_${axis}`, toolpath.bounds.max[axis]); - } - } - } - } catch (error) { - console.error(error); - } - } - }, - - submit_mdi: function () { - this.send(this.mdi); - - if (!this.history.length || this.history[0] != this.mdi) { - this.history.unshift(this.mdi); - } - - this.mdi = ""; - }, - - mdi_start_pause: function () { - if (this.state.xx == "RUNNING") { - this.pause(); - } else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") { - this.unpause(); - } else { - this.submit_mdi(); - } - }, - - load_history: function (index) { - this.mdi = this.history[index]; - }, - - open_file: function () { - utils.clickFileInput("gcode-file-input"); - }, - - open_folder: function () { - utils.clickFileInput("gcode-folder-input"); - }, - - edited_folder_name: function (event) { - if (event.target.value.trim() != "") { - this.$dispatch("folder_name_edited"); - } - }, - - update_config: function () { - this.config.gcode_list = [...this.state.gcode_list]; - this.config.non_macros_list = [...this.state.non_macros_list]; - this.config.macros_list = [...this.state.macros_list]; - this.config.macros = [...this.state.macros]; - }, - - reset_gcode: function () { - this.state.selected = ""; - this.last_file = ""; - this.$broadcast("gcode-load", ""); - }, - - upload_gcode: async function (filename, file) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - this.filesUploaded++; - if (this.filesUploaded == this.totalFiles) { - this.uploading_files = false; - } - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve("file uploaded"); - } else { - console.error("File upload failed:", xhr.statusText); - reject("upload failed"); - } - }; - - xhr.onerror = () => { - alert("Upload failed."); - reject("upload failed"); - }; - - xhr.open("PUT", `/api/file/${encodeURIComponent(filename)}`, true); - xhr.send(file); - }); - }, - - readFile: function (file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = () => { - resolve(reader.result); - }; - - reader.onerror = error => { - reject(error); - }; - - reader.readAsText(file, "utf-8"); - }); - }, - - validateFiles: async function (files) { - const validFiles = []; - for (const file of files) { - const extension = file.name.split(".").pop().toLowerCase(); - const validExtensions = ["nc", "ngc", "gcode", "gc"]; - - if (validExtensions.includes(extension)) { - validFiles.push(file); - } else { - alert(`Unsupported file : ${file.name}`); - this.filesUploaded++; - if (this.filesUploaded == this.totalFiles) { - this.uploadFiles = false; - } - } - } - - return validFiles; - }, - - uploadValidFiles: async function (files, folderName) { - const updatedConfig = { ...this.config }; - - for (const file of files) { - try { - const gcode = await this.readFile(file); - await this.upload_gcode(file.name, gcode); - - const isAlreadyPresent = updatedConfig.non_macros_list.some(element => element.file_name === file.name); - - if (!isAlreadyPresent) { - updatedConfig.non_macros_list.push({ file_name: file.name }); - } - - if (folderName) { - const folder = updatedConfig.gcode_list.find(item => item.type == "folder" && item.name == folderName); - if (folder) { - if (!folder.files.map(item => item.file_name).includes(file.name)) { - folder.files.push({ file_name: file.name }); - } - } else { - updatedConfig.gcode_list.push({ - name: folderName, - type: "folder", - files: [ - { - file_name: file.name, - }, - ], - }); - } - } else { - var folder_to_add = updatedConfig.gcode_list.find( - item => item.type == "folder" && item.name == this.state.folder, - ); - if (!folder_to_add) { - folder_to_add = updatedConfig.gcode_list.unshift({ - name: this.state.folder, - type: "folder", - files: [ - { - file_name: file.name, - }, - ], - }); - folder_to_add = updatedConfig.gcode_list[0]; - } - if (!folder_to_add.files.find(item => item.file_name == file.name)) { - folder_to_add.files.push({ file_name: file.name }); - } - } - } catch (error) { - console.warn(`error uploading file : `, error); - } - } - return updatedConfig; - }, - - upload_files: async function (files, folderName) { - this.update_config(); - - const validFiles = await this.validateFiles(files); - const updatedConfig = await this.uploadValidFiles(validFiles, folderName); - - await this.save_config(updatedConfig); - }, - - upload_file: async function (e) { - this.uploading_files = true; - this.filesUploaded = 0; - - const files = e.target.files || e.dataTransfer.files; - if (!files.length) { - return; - } - - this.totalFiles = files.length; - - await this.upload_files(files); - }, - - create_new_folder: async function () { - const folder_name = this.folder_name.trim(); - if (folder_name != "") { - if (this.state.gcode_list.find(item => item.type == "folder" && item.name == folder_name)) { - alert("Folder with the same name already exists!"); - return; - } else { - this.update_config(); - this.config.gcode_list.push({ - name: folder_name, - type: "folder", - files: [], - }); - } - this.state.folder = folder_name; - this.edited = false; - this.create_folder = false; - this.folder_name = ""; - this.save_config(this.config); - } - }, - - cancel_new_folder: function () { - this.create_folder = false; - this.folder_name = ""; - }, - - upload_folder: async function (e) { - this.uploading_files = true; - this.filesUploaded = 0; - - const files = e.target.files || e.dataTransfer.files; - if (!files.length) { - return; - } - this.totalFiles = files.length; - const folderName = files[0].webkitRelativePath.split("/")[0]; - - this.upload_files(files, folderName); - }, - - delete_current: async function () { - if (!this.state.selected) { - this.deleteGCode = false; - return; - } - - this.update_config(); - - this.config.non_macros_list = this.config.non_macros_list.filter( - item => !this.selected_items_to_delete.includes(item.file_name), - ); - const folder_to_update = this.config.gcode_list.find( - item => item.name == this.config.gcode_list[this.selected_folder_index].name && item.type == "folder", - ); - folder_to_update.files = folder_to_update.files.filter( - item => !this.selected_items_to_delete.includes(item.file_name), - ); - - const exception_list = this.state.macros_list.map(item => item.file_name); - let files_to_delete = this.selected_items_to_delete.filter(item => !exception_list.includes(item)); - - await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`); - - this.save_config(this.config); - this.filtered_files = []; - this.search_query = ""; - this.selected_folder_index = null; - this.selected_items_to_delete = []; - this.deleteGCode = false; - }, - - cancel_delete: function () { - this.filtered_files = []; - this.search_query = ""; - this.selected_folder_index = null; - this.selected_items_to_delete = []; - this.deleteGCode = false; - }, - - delete_all: function () { - api.delete("file"); - this.deleteGCode = false; - }, - - delete_all_except_macros: async function () { - this.update_config(); - const macrosList = this.state.macros_list.map(item => item.file_name).toString(); - api.delete(`file/EgZjaHJvbWUqCggBEAAYsQMYgAQyBggAEEUYOTIKCAE${macrosList}`); - this.config.non_macros_list = []; - this.config.gcode_list = [ - { - name: "default", - type: "folder", - files: [], - }, - ]; - - this.save_config(this.config); - this.state.folder = "default"; - this.state.selected = ""; - this.selected_items_to_delete = []; - this.deleteGCode = false; - }, - - delete_folder: async function () { - this.update_config(); - if (this.state.folder && this.state.folder != "default") { - const files_to_move = this.config.gcode_list.find( - item => item.type == "folder" && item.name == this.state.folder, - ); - if (files_to_move) { - const default_folder = this.config.gcode_list.find(item => item.name == "default"); - default_folder.files = [...default_folder.files, ...files_to_move.files].sort(); - this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder); - this.save_config(this.config); - } - } - this.state.folder = "default"; - this.confirmDelete = false; - }, - delete_folder_and_files: async function () { - if (!this.state.folder) { - this.confirmDelete = false; - return; - } - - this.update_config(); - - const selected_folder = this.config.gcode_list.find( - item => item.type == "folder" && item.name == this.state.folder, - ); - if (!selected_folder) { - return; - } - const macrosList = this.state.macros_list.map(item => item.file_name); - var files_to_delete = selected_folder.files - .map(item => item.file_name) - .filter(item => !macrosList.includes(item)); - if (selected_folder.name != "default") { - this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder); - } else { - selected_folder.files = []; - } - - await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`); - this.config.non_macros_list = this.config.non_macros_list.filter( - item => !files_to_delete.includes(item.file_name), - ); - this.save_config(this.config); - this.state.folder = "default"; - this.confirmDelete = false; - }, - home: function (axis) { this.ask_home = false; @@ -777,11 +234,8 @@ module.exports = { }); }, - // Use the same fine/small/medium/large increment buttons as the XYZ - // jog grid. sign=+1 for W+, -1 for W-. aux_jog_incr: function (sign) { - const amount = - this.jog_incr_amounts[this.display_units][this.jog_incr]; + const amount = this.jog_incr_amounts[this.display_units][this.jog_incr]; const delta_mm = sign * (this.metric ? amount : amount * 25.4); this.aux_jog(delta_mm); }, @@ -811,93 +265,20 @@ module.exports = { }, zero: function (axis) { - if (typeof axis == "undefined") { - this.zero_all(); - } else { - this.set_position(axis, 0); - } - }, - - start_pause: function () { - this.macrosLoading = false; - if (this.state.xx == "RUNNING") { - this.pause(); - } else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") { - this.unpause(); - } else { - this.start(); - } - }, - - start: function () { - api.put("start"); - }, - - pause: function () { - api.put("pause"); - }, - - unpause: function () { - api.put("unpause"); - }, - - optional_pause: function () { - api.put("pause/optional"); - }, - - stop: function () { - api.put("stop"); - }, - - step: function () { - api.put("step"); - }, - - override_feed: function () { - api.put(`override/feed/${this.feed_override}`); - }, - - override_speed: function () { - api.put(`override/speed/${this.speed_override}`); - }, - - current: function (axis, value) { - const x = value / 32.0; - if (this.state[`${axis}pl`] == x) { - return; - } - - const data = {}; - data[`${axis}pl`] = x; - this.send(JSON.stringify(data)); + if (typeof axis == "undefined") this.zero_all(); + else this.set_position(axis, 0); }, showProbeDialog: function (probeType) { - if(this.show_probe_dialog){ + if (this.show_probe_dialog) { this.show_probe_dialog = false; } - SvelteComponents.showDialog("Probe", { probeType, isRotaryActive: this.state["2an"] == 3 }); - }, - run_macro: function (id) { - if (this.state.macros[id].file_name == "default") { - this.showNoGcodeMessage = true; - } else { - if (this.state.macros[id].file_name != this.state.selected) { - this.state.selected = this.state.macros[id].file_name; - } - try { - this.load(); - if (this.state.macros[id].alert == true) { - this.macrosLoading = true; - } else { - setImmediate(() => this.start_pause()); - } - } catch (error) { - console.warn("Error running program: ", error); - } - } + SvelteComponents.showDialog("Probe", { + probeType, + isRotaryActive: this.state["2an"] == 3, + }); }, }, - mixins: [require("./axis-vars")], + mixins: [require("./program-mixin"), require("./axis-vars")], }; diff --git a/src/js/main.js b/src/js/main.js index f58ad13..06ebc9f 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -44,6 +44,16 @@ window.onload = function() { cookie_set("client-id", uuid(), 10000); } + // Vue 1's async queue can drop dependent watcher updates when + // data props are mutated outside the normal event flow (e.g. from + // a `hashchange` listener that fires before Vue's tick scheduler + // has caught up). Disable async batching so every reactive write + // synchronously re-evaluates dependents — this matches Vue 1's + // older default and is what the legacy UI implicitly relied on. + if (Vue.config) { + Vue.config.async = false; + } + // Register global components Vue.component("templated-input", require("./templated-input")); Vue.component("message", require("./message")); diff --git a/src/js/program-mixin.js b/src/js/program-mixin.js new file mode 100644 index 0000000..b3b5ae4 --- /dev/null +++ b/src/js/program-mixin.js @@ -0,0 +1,554 @@ +"use strict"; + +// Shared data, computed properties and methods that are used by both +// the Control view (for things like start/stop, run-macro, axis state) +// and the Program view (RUN/STOP/Upload/Download/Delete + file picker +// + gcode/path viewers). Splitting these out lets us mount the same +// behaviour under two top-level routes without duplicating code. +// +// The mixin intentionally does *not* require axis-vars; control-view +// keeps that one to itself. + +const api = require("./api"); +const utils = require("./utils"); + +module.exports = { + data: function () { + return { + mdi: "", + last_file: undefined, + last_file_time: undefined, + toolpath: {}, + toolpath_progress: 0, + history: [], + speed_override: 1, + feed_override: 1, + deleteGCode: false, + folder_name: "", + edited: false, + uploading_files: false, + confirmDelete: false, + create_folder: false, + showGcodeMessage: false, + showNoGcodeMessage: false, + macrosLoading: false, + show_gcodes: false, + GCodeNotFound: false, + filesUploaded: 0, + totalFiles: 0, + files_sortby: "By Upload Date", + selected_items_to_delete: [], + search_query: "", + filtered_files: [], + selected_folder_index: null, + }; + }, + + watch: { + "state.line": function () { + if (this.mach_state != "HOMING") { + this.$broadcast("gcode-line", this.state.line); + } + }, + + "state.selected_time": function () { + this.load(); + }, + }, + + computed: { + is_running: function () { + return this.mach_state == "RUNNING" || this.mach_state == "HOMING"; + }, + + is_stopping: function () { + return this.mach_state == "STOPPING"; + }, + + is_holding: function () { + return this.mach_state == "HOLDING"; + }, + + is_ready: function () { + return this.mach_state == "READY"; + }, + + is_idle: function () { + return this.state.cycle == "idle"; + }, + + is_paused: function () { + return this.is_holding && (this.pause_reason == "User pause" || this.pause_reason == "Program pause"); + }, + + can_mdi: function () { + return this.is_idle || this.state.cycle == "mdi"; + }, + + pause_reason: function () { + return this.state.pr; + }, + + plan_time: function () { + return this.state.plan_time; + }, + + plan_time_remaining: function () { + if (!(this.is_stopping || this.is_running || this.is_holding)) { + return 0; + } + return this.toolpath.time - this.plan_time; + }, + + eta: function () { + if (this.mach_state != "RUNNING") { + return ""; + } + const remaining = this.plan_time_remaining; + const d = new Date(); + d.setSeconds(d.getSeconds() + remaining); + return d.toLocaleString(); + }, + + progress: function () { + if (!this.toolpath.time || this.is_ready) { + return 0; + } + const p = this.plan_time / this.toolpath.time; + return Math.min(1, p); + }, + + gcode_files: function () { + if (!this.state.folder) return []; + const folder = this.state.gcode_list.find(item => item.name == this.state.folder); + if (!folder) return []; + const files = folder.files + .filter(item => this.state.files.includes(item.file_name)) + .map(item => item.file_name); + if (this.files_sortby == "A-Z") return files.sort(); + if (this.files_sortby == "Z-A") return files.sort().reverse(); + return files; + }, + + gcode_filtered_files: function () { + return this.filtered_files.filter(file => + file.toLowerCase().includes(this.search_query.toLowerCase())); + }, + + gcode_folders: function () { + return this.state.gcode_list + .map(item => item.name) + .filter(element => element !== "default") + .sort(); + }, + }, + + methods: { + save_config: async function (config) { + try { + await api.put("config/save", config); + this.$dispatch("update"); + } catch (error) { + console.error("Restore Failed: ", error); + alert("Restore failed"); + } + }, + + populateFiles(index) { + this.selected_folder_index = index; + this.filtered_files = this.state.gcode_list[index].files.map(item => item.file_name); + }, + + send: function (msg) { + this.$dispatch("send", msg); + }, + + toggle_sorting: function () { + if (this.files_sortby === "By Upload Date") this.files_sortby = "A-Z"; + else if (this.files_sortby === "A-Z") this.files_sortby = "Z-A"; + else if (this.files_sortby === "Z-A") this.files_sortby = "By Upload Date"; + }, + + load: function () { + const file_time = this.state.selected_time; + const file = this.state.selected; + if (this.last_file == file && this.last_file_time == file_time) return; + + if (this.state.selected && !this.state.files.includes(this.state.selected)) { + this.GCodeNotFound = true; + return; + } + + this.last_file = file; + this.last_file_time = file_time; + + this.$broadcast("gcode-load", file); + this.$broadcast("gcode-line", this.state.line); + this.toolpath_progress = 0; + this.load_toolpath(file, file_time); + }, + + load_toolpath: async function (file, file_time) { + this.toolpath = {}; + if (!file || this.last_file_time != file_time) return; + + this.showGcodeMessage = true; + + while (this.showGcodeMessage) { + try { + const toolpath = await api.get(`path/${file}`); + this.toolpath_progress = toolpath.progress; + + if (toolpath.progress === 1 || typeof toolpath.progress == "undefined") { + this.showGcodeMessage = false; + + if (toolpath.bounds) { + toolpath.filename = file; + this.toolpath_progress = 1; + this.toolpath = toolpath; + + const state = this.$root.state; + for (const axis of "xyzabc") { + Vue.set(state, `path_min_${axis}`, toolpath.bounds.min[axis]); + Vue.set(state, `path_max_${axis}`, toolpath.bounds.max[axis]); + } + } + } + } catch (error) { + console.error(error); + } + } + }, + + submit_mdi: function () { + this.send(this.mdi); + if (!this.history.length || this.history[0] != this.mdi) { + this.history.unshift(this.mdi); + } + this.mdi = ""; + }, + + mdi_start_pause: function () { + if (this.state.xx == "RUNNING") this.pause(); + else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") this.unpause(); + else this.submit_mdi(); + }, + + load_history: function (index) { + this.mdi = this.history[index]; + }, + + open_file: function () { + utils.clickFileInput("gcode-file-input"); + }, + + open_folder: function () { + utils.clickFileInput("gcode-folder-input"); + }, + + edited_folder_name: function (event) { + if (event.target.value.trim() != "") { + this.$dispatch("folder_name_edited"); + } + }, + + update_config: function () { + this.config.gcode_list = [...this.state.gcode_list]; + this.config.non_macros_list = [...this.state.non_macros_list]; + this.config.macros_list = [...this.state.macros_list]; + this.config.macros = [...this.state.macros]; + }, + + reset_gcode: function () { + this.state.selected = ""; + this.last_file = ""; + this.$broadcast("gcode-load", ""); + }, + + upload_gcode: async function (filename, file) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + this.filesUploaded++; + if (this.filesUploaded == this.totalFiles) { + this.uploading_files = false; + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve("file uploaded"); + else { console.error("File upload failed:", xhr.statusText); reject("upload failed"); } + }; + xhr.onerror = () => { alert("Upload failed."); reject("upload failed"); }; + xhr.open("PUT", `/api/file/${encodeURIComponent(filename)}`, true); + xhr.send(file); + }); + }, + + readFile: function (file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = error => reject(error); + reader.readAsText(file, "utf-8"); + }); + }, + + validateFiles: async function (files) { + const validFiles = []; + for (const file of files) { + const extension = file.name.split(".").pop().toLowerCase(); + const validExtensions = ["nc", "ngc", "gcode", "gc"]; + if (validExtensions.includes(extension)) { + validFiles.push(file); + } else { + alert(`Unsupported file : ${file.name}`); + this.filesUploaded++; + if (this.filesUploaded == this.totalFiles) { + this.uploadFiles = false; + } + } + } + return validFiles; + }, + + uploadValidFiles: async function (files, folderName) { + const updatedConfig = { ...this.config }; + + for (const file of files) { + try { + const gcode = await this.readFile(file); + await this.upload_gcode(file.name, gcode); + + const isAlreadyPresent = updatedConfig.non_macros_list.some(element => element.file_name === file.name); + if (!isAlreadyPresent) { + updatedConfig.non_macros_list.push({ file_name: file.name }); + } + + if (folderName) { + const folder = updatedConfig.gcode_list.find(item => item.type == "folder" && item.name == folderName); + if (folder) { + if (!folder.files.map(item => item.file_name).includes(file.name)) { + folder.files.push({ file_name: file.name }); + } + } else { + updatedConfig.gcode_list.push({ + name: folderName, + type: "folder", + files: [{ file_name: file.name }], + }); + } + } else { + var folder_to_add = updatedConfig.gcode_list.find( + item => item.type == "folder" && item.name == this.state.folder, + ); + if (!folder_to_add) { + folder_to_add = updatedConfig.gcode_list.unshift({ + name: this.state.folder, + type: "folder", + files: [{ file_name: file.name }], + }); + folder_to_add = updatedConfig.gcode_list[0]; + } + if (!folder_to_add.files.find(item => item.file_name == file.name)) { + folder_to_add.files.push({ file_name: file.name }); + } + } + } catch (error) { + console.warn(`error uploading file : `, error); + } + } + return updatedConfig; + }, + + upload_files: async function (files, folderName) { + this.update_config(); + const validFiles = await this.validateFiles(files); + const updatedConfig = await this.uploadValidFiles(validFiles, folderName); + await this.save_config(updatedConfig); + }, + + upload_file: async function (e) { + this.uploading_files = true; + this.filesUploaded = 0; + const files = e.target.files || e.dataTransfer.files; + if (!files.length) return; + this.totalFiles = files.length; + await this.upload_files(files); + }, + + create_new_folder: async function () { + const folder_name = this.folder_name.trim(); + if (folder_name != "") { + if (this.state.gcode_list.find(item => item.type == "folder" && item.name == folder_name)) { + alert("Folder with the same name already exists!"); + return; + } + this.update_config(); + this.config.gcode_list.push({ + name: folder_name, + type: "folder", + files: [], + }); + this.state.folder = folder_name; + this.edited = false; + this.create_folder = false; + this.folder_name = ""; + this.save_config(this.config); + } + }, + + cancel_new_folder: function () { + this.create_folder = false; + this.folder_name = ""; + }, + + upload_folder: async function (e) { + this.uploading_files = true; + this.filesUploaded = 0; + const files = e.target.files || e.dataTransfer.files; + if (!files.length) return; + this.totalFiles = files.length; + const folderName = files[0].webkitRelativePath.split("/")[0]; + this.upload_files(files, folderName); + }, + + delete_current: async function () { + if (!this.state.selected) { + this.deleteGCode = false; + return; + } + this.update_config(); + + this.config.non_macros_list = this.config.non_macros_list.filter( + item => !this.selected_items_to_delete.includes(item.file_name), + ); + const folder_to_update = this.config.gcode_list.find( + item => item.name == this.config.gcode_list[this.selected_folder_index].name && item.type == "folder", + ); + folder_to_update.files = folder_to_update.files.filter( + item => !this.selected_items_to_delete.includes(item.file_name), + ); + + const exception_list = this.state.macros_list.map(item => item.file_name); + let files_to_delete = this.selected_items_to_delete.filter(item => !exception_list.includes(item)); + + await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`); + + this.save_config(this.config); + this.filtered_files = []; + this.search_query = ""; + this.selected_folder_index = null; + this.selected_items_to_delete = []; + this.deleteGCode = false; + }, + + cancel_delete: function () { + this.filtered_files = []; + this.search_query = ""; + this.selected_folder_index = null; + this.selected_items_to_delete = []; + this.deleteGCode = false; + }, + + delete_all: function () { + api.delete("file"); + this.deleteGCode = false; + }, + + delete_all_except_macros: async function () { + this.update_config(); + const macrosList = this.state.macros_list.map(item => item.file_name).toString(); + api.delete(`file/EgZjaHJvbWUqCggBEAAYsQMYgAQyBggAEEUYOTIKCAE${macrosList}`); + this.config.non_macros_list = []; + this.config.gcode_list = [{ name: "default", type: "folder", files: [] }]; + this.save_config(this.config); + this.state.folder = "default"; + this.state.selected = ""; + this.selected_items_to_delete = []; + this.deleteGCode = false; + }, + + delete_folder: async function () { + this.update_config(); + if (this.state.folder && this.state.folder != "default") { + const files_to_move = this.config.gcode_list.find( + item => item.type == "folder" && item.name == this.state.folder, + ); + if (files_to_move) { + const default_folder = this.config.gcode_list.find(item => item.name == "default"); + default_folder.files = [...default_folder.files, ...files_to_move.files].sort(); + this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder); + this.save_config(this.config); + } + } + this.state.folder = "default"; + this.confirmDelete = false; + }, + + delete_folder_and_files: async function () { + if (!this.state.folder) { + this.confirmDelete = false; + return; + } + this.update_config(); + const selected_folder = this.config.gcode_list.find( + item => item.type == "folder" && item.name == this.state.folder, + ); + if (!selected_folder) return; + + const macrosList = this.state.macros_list.map(item => item.file_name); + var files_to_delete = selected_folder.files + .map(item => item.file_name) + .filter(item => !macrosList.includes(item)); + + if (selected_folder.name != "default") { + this.config.gcode_list = this.config.gcode_list.filter(item => item.name != this.state.folder); + } else { + selected_folder.files = []; + } + + await api.delete(`file/DINCAIQABiDARixAxiABDIHCAMQABiABDIHCAQQABiABDIH${files_to_delete.toString()}`); + this.config.non_macros_list = this.config.non_macros_list.filter( + item => !files_to_delete.includes(item.file_name), + ); + this.save_config(this.config); + this.state.folder = "default"; + this.confirmDelete = false; + }, + + start_pause: function () { + this.macrosLoading = false; + if (this.state.xx == "RUNNING") this.pause(); + else if (this.state.xx == "STOPPING" || this.state.xx == "HOLDING") this.unpause(); + else this.start(); + }, + + start: function () { api.put("start"); }, + pause: function () { api.put("pause"); }, + unpause: function () { api.put("unpause"); }, + optional_pause: function () { api.put("pause/optional"); }, + stop: function () { api.put("stop"); }, + step: function () { api.put("step"); }, + + override_feed: function () { api.put(`override/feed/${this.feed_override}`); }, + override_speed: function () { api.put(`override/speed/${this.speed_override}`); }, + + run_macro: function (id) { + if (this.state.macros[id].file_name == "default") { + this.showNoGcodeMessage = true; + } else { + if (this.state.macros[id].file_name != this.state.selected) { + this.state.selected = this.state.macros[id].file_name; + } + try { + this.load(); + if (this.state.macros[id].alert == true) { + this.macrosLoading = true; + } else { + setImmediate(() => this.start_pause()); + } + } catch (error) { + console.warn("Error running program: ", error); + } + } + }, + }, +}; diff --git a/src/js/program-view.js b/src/js/program-view.js new file mode 100644 index 0000000..5f25627 --- /dev/null +++ b/src/js/program-view.js @@ -0,0 +1,60 @@ +"use strict"; + +// Program tab — file management, run/stop, gcode listing and 3D +// toolpath preview. Reuses the shared mixin (program-mixin) that also +// powers the legacy bits of control-view; this view does not host the +// jog grid or the DRO. + +module.exports = { + template: "#program-view-template", + props: ["config", "template", "state"], + + components: { + "path-viewer": require("./path-viewer"), + "gcode-viewer": require("./gcode-viewer"), + }, + + data: function () { + return {}; + }, + + watch: { + "state.metric": { + handler: function () {}, + immediate: true, + }, + }, + + computed: { + display_units: { + cache: false, + get: function () { return this.$root.display_units; }, + set: function (value) { + this.config.settings.units = value; + this.$root.display_units = value; + this.$dispatch("config-changed"); + }, + }, + + metric: function () { + return this.display_units === "METRIC"; + }, + + mach_state: function () { + const cycle = this.state.cycle; + const xx = this.state.xx; + if (xx != "ESTOPPED" && (cycle == "jogging" || cycle == "homing")) { + return cycle.toUpperCase(); + } + return xx || ""; + }, + + can_set_axis: function () { return this.state.cycle == "idle"; }, + }, + + ready: function () { + this.load(); + }, + + mixins: [require("./program-mixin")], +}; diff --git a/src/js/settings-shell-view.js b/src/js/settings-shell-view.js new file mode 100644 index 0000000..cf88802 --- /dev/null +++ b/src/js/settings-shell-view.js @@ -0,0 +1,109 @@ +"use strict"; + +// Wrapper that adds a left-rail navigator around the settings family +// of views (Settings, Admin General, Admin Network, Tool, IO, Motor, +// Macros, Help, Cheat Sheet). The inner view is selected by the URL +// hash (parsed in app.js) and exposed as $root.sub_tab. + +// Vue 1 has trouble making child components reactive to `$root.sub_tab` +// changes (whether via computed, watch, or prop binding through +// ``). The shell instead listens to `hashchange` +// directly and parses the hash itself, mirroring app.js's logic, then +// keeps a local data prop `sub` that the template binds to. This is +// the only path that updates the rail's `:class` reactively. +module.exports = { + template: "#settings-shell-view-template", + props: ["config", "template", "state", "index"], + + components: { + "settings-view-inner": require("./settings-view"), + "admin-general-view": require("./admin-general-view"), + "admin-network-view": require("./admin-network-view"), + "motor-view": require("./motor-view"), + "tool-view": require("./tool-view"), + "io-view": require("./io-view"), + "macros-view": require("./macros"), + "help-view": require("./help-view"), + "cheat-sheet-view": { + template: "#cheat-sheet-view-template", + data: function () { + return { showUnimplemented: false }; + }, + }, + }, + + data: function () { + return { + sub: this.$root.sub_tab || "settings", + ridx: this.$root.index, // local copy of the motor index + rail_items: [ + { sub: "settings", href: "#settings", icon: "fa-display", label: "Display & Units" }, + { sub: "admin-network", href: "#admin-network", icon: "fa-network-wired", label: "Network" }, + { sub: "admin-general", href: "#admin-general", icon: "fa-shield-halved", label: "General / Firmware" }, + { sub: "tool", href: "#tool", icon: "fa-bolt", label: "Spindle & Tool" }, + { sub: "io", href: "#io", icon: "fa-plug", label: "I/O" }, + { section: "Motors" }, + { sub: "motor", motor: 0, href: "#motor:0", icon: "fa-arrows-up-down-left-right", label: "Motor 0" }, + { sub: "motor", motor: 1, href: "#motor:1", icon: "fa-arrows-up-down-left-right", label: "Motor 1" }, + { sub: "motor", motor: 2, href: "#motor:2", icon: "fa-arrows-up-down-left-right", label: "Motor 2" }, + { sub: "motor", motor: 3, href: "#motor:3", icon: "fa-arrows-up-down-left-right", label: "Motor 3" }, + { section: " " }, + { sub: "macros", href: "#macros", icon: "fa-keyboard", label: "Macros" }, + { sub: "cheat-sheet", href: "#cheat-sheet", icon: "fa-book", label: "G-code Cheat Sheet" }, + { sub: "help", href: "#help", icon: "fa-circle-question", label: "Help" }, + ], + }; + }, + + ready: function () { + this._onHash = () => this.refresh_from_hash(); + window.addEventListener("hashchange", this._onHash); + this.refresh_from_hash(); + }, + + attached: function () { + // Vue 1 fires `attached` whenever the component is inserted into + // the DOM (which happens on every route change because the outer + // recreates the instance). Re-bind the listener + // here so it works even after detach/attach cycles. + if (!this._onHash) { + this._onHash = () => this.refresh_from_hash(); + } + window.addEventListener("hashchange", this._onHash); + this.refresh_from_hash(); + }, + + detached: function () { + if (this._onHash) { + window.removeEventListener("hashchange", this._onHash); + } + }, + + beforeDestroy: function () { + if (this._onHash) { + window.removeEventListener("hashchange", this._onHash); + } + }, + + methods: { + refresh_from_hash: function () { + const hash = location.hash.substr(1) || "settings"; + const parts = hash.split(":"); + this.sub = parts[0] || "settings"; + this.ridx = parts[1] !== undefined ? parts[1] : -1; + }, + + is_active: function (item) { + if (!item || item.section) return false; + if (item.sub !== this.sub) return false; + if (item.sub === "motor") { + return "" + item.motor === "" + this.ridx; + } + return true; + }, + + showShutdownDialog: function () { + SvelteComponents.showDialog("Shutdown"); + }, + }, +}; diff --git a/src/pug/index.pug b/src/pug/index.pug index 79f1416..b9a65b5 100644 --- a/src/pug/index.pug +++ b/src/pug/index.pug @@ -8,7 +8,6 @@ html(lang="en") style: include ../static/css/pure-min.css - style: include ../static/css/side-menu.css style: include ../static/css/font-awesome.min.css style: include ../static/css/Audiowide.css @@ -23,99 +22,113 @@ html(lang="en") #overlay(v-if="status != 'connected'") span {{status}} - - #layout - a#menuLink.menu-link(href="#menu"): span - #menu - button.save.pure-button.button-success(:disabled="!modified", - @click="save") Save + .app-shell + header.app-head + .brand-blk + .brand-logo + .brand-name ONEFINITY - .pure-menu - ul.pure-menu-list - li.pure-menu-heading - a.pure-menu-link(href="#control") Control + nav.tabs-host(role="tablist") + a.ktab(:class="{active: top_tab === 'control'}", href="#control", + title="Jog, DRO, macros") + .fa.fa-gamepad + span Control + a.ktab(:class="{active: top_tab === 'program'}", href="#program", + title="Run programs, files, toolpath preview") + .fa.fa-list-ol + span Program + a.ktab(:class="{active: top_tab === 'console'}", href="#console", + title="MDI, messages, indicators") + .fa.fa-terminal + span Console + span.ktab-badge(v-if="messages_count") {{messages_count}} + a.ktab(:class="{active: top_tab === 'settings'}", href="#settings", + title="Configuration, network, macros") + .fa.fa-sliders + span Settings - li.pure-menu-heading - a.pure-menu-link(href="#macros") Macros + .head-spacer - li.pure-menu-heading - a.pure-menu-link(href="#settings") Settings + .sys-btn(@click.stop="toggle_sys_popover", :class="{open: sys_open}") + span.pip(:class="sys_class") + span.sys-text {{sys_summary}} + .fa.fa-chevron-down - li.pure-menu-heading - a.pure-menu-link(href="#motor:0") Motors + .pi-temp-warning(v-if="80 <= state.rpi_temp", + title="Raspberry Pi temperature too high.") + .fa.fa-thermometer-full - li.pure-menu-item(v-for="motor in config.motors") - a.pure-menu-link(:href="'#motor:' + $index") Motor {{$index}} + span.state-badge(:class="state_class", :title="mach_state_full") + span.dot + span {{state_label}} - li.pure-menu-heading - a.pure-menu-link(href="#tool") Tool + .estop(:class="{active: state.es}") + estop(@click="estop") - li.pure-menu-heading - a.pure-menu-link(href="#io") I/O - - li.pure-menu-heading - a.pure-menu-link(href="#admin-general") Admin - - li.pure-menu-item - a.pure-menu-link(href="#admin-general") General - - li.pure-menu-item - a.pure-menu-link(href="#admin-network") Network - - li.pure-menu-heading - a.pure-menu-link(href="#cheat-sheet") Cheat Sheet - - li.pure-menu-heading - a.pure-menu-link(href="#help") Help - - button.pure-button.pure-button-primary(@click="showShutdownDialog", style="width: 100%") - .fa.fa-power-off - - #main - .nav-header - .brand - img(src="/images/onefinity_logo.png") - .version - div Version: v{{config.full_version}} - div IP Address: {{config.ip}} - div WiFi: {{config.wifiName}} - a.upgrade-link(v-if="show_upgrade()", href="#admin-general") - | Upgrade to v{{latestVersion}} - .fa.fa-exclamation-circle.upgrade-attention(v-if="show_upgrade()") - - .pi-temp-warning - .fa.fa-thermometer-full(class="error", - v-if="80 <= state.rpi_temp", - title="Raspberry Pi temperature too high.") - - .easy-adapter(v-if="is_easy_adapter_active") - .round-dot - div.easy-adapter-text Easy Adapter - - .whitespace - - div - button.rotary-button(:disabled="!enable_rotary", :class="is_rotary_active && 'active'", @click="showSwitchRotaryModeDialog") - img(src="/images/rotary.svg", alt="rotary", :style="is_rotary_active ? 'width:90%;' : 'width:85%;'") - div.rotary-text Rotary - - .video(title="Plug camera into USB.\n" + - "Left click to toggle video size.\n" + - "Right click to toggle crosshair.", @click="toggle_video", - @contextmenu="toggle_crosshair", :class="video_size") + // System popover (chip-soup destination) + .sys-popover(v-if="sys_open", @click.stop="") + .sp-row + .sp-icon: .fa.fa-microchip + .sp-text + .sp-label Firmware + .sp-val v{{config.full_version}} + a.sp-act(v-if="show_upgrade()", href="#admin-general") + | Upgrade to v{{latestVersion}} + .fa.fa-exclamation-circle.upgrade-attention + .sp-row + .sp-icon: .fa.fa-network-wired + .sp-text + .sp-label IP Address + .sp-val {{config.ip}} + .sp-row + .sp-icon: .fa.fa-wifi(:class="{'sp-warn': config.wifiName === 'not connected'}") + .sp-text + .sp-label WiFi + .sp-val {{config.wifiName}} + a.sp-act(href="#admin-network", @click="sys_open=false") Configure + .sp-row(v-if="enable_rotary") + .sp-icon: img(src="/images/rotary.svg", alt="rotary") + .sp-text + .sp-label Rotary + .sp-val {{is_rotary_active ? 'Active' : 'Inactive'}} + button.sp-act(@click="showSwitchRotaryModeDialog") + | {{is_rotary_active ? 'Disable' : 'Enable'}} + .sp-row(v-if="is_easy_adapter_active") + .sp-icon: .fa.fa-puzzle-piece + .sp-text + .sp-label Easy Adapter + .sp-val Active + .sp-row.video-row + .sp-icon: .fa.fa-video + .sp-text + .sp-label Camera + .sp-val {{has_camera ? 'Live' : 'Plug camera into USB'}} + .sp-act(v-if="has_camera", @click="toggle_video") + | {{video_size === 'small' ? 'Enlarge' : 'Shrink'}} + .video(v-if="sys_open && has_camera", title="Camera feed", + @click="toggle_video", @contextmenu="toggle_crosshair", + :class="video_size") .crosshair(v-if="crosshair") .vertical .horizontal .box - img(src="/api/video") + img(src="/api/video", @error="has_camera=false") + .sp-foot + button.sp-shutdown(@click="showShutdownDialog") + .fa.fa-power-off + |  Shutdown + button.sp-save(:disabled="!modified", @click="save") + .fa.fa-save + |  Save{{modified ? '*' : ''}} - .estop(:class="{active: state.es}") - estop(@click="estop") - - .content(class="{{currentView}}-view") - component(:is="currentView + '-view'", :index="index", - :config="config", :template="template", :state="state", keep-alive) + // Routed view (no keep-alive: Vue 1 has issues re-evaluating + // dynamic :class / v-if bindings on cached components when the + // route changes within the same kept-alive tree) + .app-body + component(:is="currentView + '-view'", :index="index", + :config="config", :template="template", :state="state", + :sub-tab="sub_tab") message.error-message(:show.sync="errorShow") div(slot="header") diff --git a/src/pug/templates/console-view.pug b/src/pug/templates/console-view.pug new file mode 100644 index 0000000..1bd6aa4 --- /dev/null +++ b/src/pug/templates/console-view.pug @@ -0,0 +1,67 @@ +script#console-view-template(type="text/x-template") + .console-page + .console-card + .ptab-bar + button.ptab(:class="{active: sub === 'mdi'}", @click="select_sub('mdi')") + .fa.fa-keyboard + |  MDI + button.ptab(:class="{active: sub === 'messages'}", @click="select_sub('messages')") + .fa.fa-comment-dots + |  Messages + span.ptab-badge(v-if="unread_messages") {{unread_messages}} + button.ptab(:class="{active: sub === 'indicators'}", @click="select_sub('indicators')") + .fa.fa-bell + |  Indicators + + // ----- MDI ----- + .mdi-pane(v-show="sub === 'mdi'") + .mdi-input + span.prompt G> + input(type="text", v-model="mdi", :disabled="!can_mdi", + @keyup.enter="submit_mdi", placeholder="enter a G-code command…") + button.mdi-send(:disabled="!can_mdi || !mdi", @click="submit_mdi") + .fa.fa-paper-plane + |  SEND + .mdi-keys + button.mkey(@click="prepend('G0 ')") G0 + button.mkey(@click="prepend('G1 ')") G1 + button.mkey(@click="prepend('G2 ')") G2 + button.mkey(@click="prepend('G3 ')") G3 + button.mkey(@click="prepend('G28 ')") G28 + button.mkey(@click="prepend('G92 ')") G92 + button.mkey(@click="prepend('M3 ')") M3 + button.mkey(@click="prepend('M5 ')") M5 + button.mkey(@click="append('X')") X + button.mkey(@click="append('Y')") Y + button.mkey(@click="append('Z')") Z + button.mkey(@click="append('W')") W + button.mkey(@click="append('F')") F + button.mkey(@click="append('S')") S + button.mkey.clear(@click="mdi = ''") CLEAR + button.mkey.send(:disabled="!can_mdi || !mdi", @click="submit_mdi") SEND ↵ + + em Machine units: #[strong {{mach_units}}]. G20/G21 to switch. + + .mdi-history(:class="{placeholder: !history.length}") + span.mdi-empty(v-if="!history.length") MDI history will display here. + .h-row(v-for="item in history", @click="load_history($index)", + track-by="$index") + span.h-cmd {{item}} + span.h-status ↻ + + // ----- Messages ----- + .messages-pane(v-show="sub === 'messages'") + .msg-empty(v-if="!$root.messages_log.length") + .fa.fa-check-circle + |  No messages. + .msg(v-for="m in $root.messages_log", + :class="m.level === 'warning' ? 'warn' : 'info'", track-by="$index") + .mi + .fa(:class="m.level === 'warning' ? 'fa-triangle-exclamation' : 'fa-circle-info'") + div + .mtitle {{m.text}} + .mtime ID {{m.id}} + + // ----- Indicators ----- + .indicators-pane(v-show="sub === 'indicators'") + indicators(:state="state", :template="template") diff --git a/src/pug/templates/control-view.pug b/src/pug/templates/control-view.pug index 4393835..f88d0d5 100644 --- a/src/pug/templates/control-view.pug +++ b/src/pug/templates/control-view.pug @@ -1,43 +1,39 @@ script#control-view-template(type="text/x-template") - #control + .control-page + // ----- Modal dialogs (kept verbatim from legacy) ----- message(:show.sync="showGcodeMessage") h3(slot="header") Processing New File - div(slot="body") h3 Please wait.. p Simulating GCode to check for errors, calculate ETA and generate 3D view. - div(slot="footer") label Simulating {{(toolpath_progress || 0) | percent}} - + message(:show.sync="showNoGcodeMessage") - h3(slot="header") GCode Not Set - div(slot="body") - p Configure the GCode for the selected macro to use it - - div(slot="footer") - button.pure-button(@click="showNoGcodeMessage=false") OK + h3(slot="header") GCode Not Set + div(slot="body") + p Configure the GCode for the selected macro to use it + div(slot="footer") + button.pure-button(@click="showNoGcodeMessage=false") OK message(:show.sync="macrosLoading") - h3(slot="header") Run Macro? - div(slot="body") - p - | The macro file - strong {{state.selected}} - | is being loaded. - - div(slot="footer") - button.pure-button(@click="macrosLoading=false") Cancel - button.pure-button.pure-button-primary(@click="start_pause") Run - + h3(slot="header") Run Macro? + div(slot="body") + p + | The macro file + strong {{state.selected}} + | is being loaded. + div(slot="footer") + button.pure-button(@click="macrosLoading=false") Cancel + button.pure-button.pure-button-primary(@click="start_pause") Run + message(:show.sync="GCodeNotFound") h3(slot="header") File not found div(slot="body") - p It seems like the file you selected cannot be found. Try uploading again. + p It seems like the file you selected cannot be found. Try uploading again. div(slot="footer") - button.pure-button.button-error(@click="GCodeNotFound=false") - | OK - + button.pure-button.button-error(@click="GCodeNotFound=false") OK + message(:show.sync="show_probe_dialog") h3(slot="header") Probe Rotary div(slot="body") @@ -46,485 +42,232 @@ script#control-view-template(type="text/x-template") div(slot="footer") button.pure-button(@click="show_probe_dialog=false") Cancel + // ----- Main grid: jog | (DRO + status strip) ----- + .control-grid - table(style="table-layout: fixed; width: 100%;") - tr(style="height: fit-content;") - td(style="white-space: nowrap; width: 410px;", rowspan="2") - table.control-buttons(table-layout="fixed") - colgroup - col(style="width:100px") - col(style="width:100px") - col(style="width:100px") - col(style="width:100px") - tr - td(style="height:100px",align="center") - button(@click="jog_fn(-1,1,0,0)") - .fa.fa-arrow-right(style="transform: rotate(-135deg);") - td(style="height:100px",align="center") - button(@click="jog_fn(0,1,0,0)") Y+ - td(style="height:100px",align="center") - button(@click="jog_fn(1,1,0,0)") - .fa.fa-arrow-right(style="transform: rotate(-45deg);") - td(style="height:100px",align="center") - button(,@click="jog_fn(0,0,1,0)") Z+ - tr - td(style="height:100px",align="center") - button(@click="jog_fn(-1,0,0,0)") X- - td(style="height:100px",align="center") - button(@click="showMoveToZeroDialog('xy')") - | XY - br - | Origin - td(style="height:100px",align="center") - button(@click="jog_fn(1,0,0,0)") X+ - td(style="height:100px",align="center") - button(@click="showMoveToZeroDialog('z')") - | Z - br - | Origin - tr - td(style="height:100px",align="center") - button(@click="jog_fn(-1,-1,0,0)") - .fa.fa-arrow-right(style="transform: rotate(135deg);") - td(style="height:100px",align="center") - button(@click="jog_fn(0,-1,0,0)") Y- - td(style="height:100px",align="center") - button(@click="jog_fn(1,-1,0,0)") - .fa.fa-arrow-right(style="transform: rotate(45deg);") - td(style="height:100px",align="center") - button(@click="jog_fn(0,0,-1,0)") Z- - tr - td(style="height:100px",align="center") - button(:style="getJogIncrStyle('fine')", @click="jog_incr = 'fine'") - span {{jog_incr_amounts[display_units].fine}}#[span.jog-units {{metric ? 'mm' : 'in'}}] - td(style="height:100px",align="center") - button(:style="getJogIncrStyle('small')", @click="jog_incr = 'small'") - span {{jog_incr_amounts[display_units].small}}#[span.jog-units {{metric ? 'mm' : 'in'}}] - td(style="height:100px",align="center") - button(:style="getJogIncrStyle('medium')", @click="jog_incr = 'medium'") - span {{jog_incr_amounts[display_units].medium}}#[span.jog-units {{metric ? 'mm' : 'in'}}] - td(style="height:100px",align="center") - button(:style="getJogIncrStyle('large')", @click="jog_incr = 'large'") - span {{jog_incr_amounts[display_units].large}}#[span.jog-units {{metric ? 'mm' : 'in'}}] + // ===== JOG ===== + .jog-card + .jog-head + .jog-title + | Jog + span.step-pre · step + span.step {{jog_incr_amounts[display_units][jog_incr]}}#[span.unit {{metric ? 'mm' : 'in'}}] + .step-seg + button(:class="{active: jog_incr === 'fine'}", @click="jog_incr = 'fine'") + | {{jog_incr_amounts[display_units].fine}} + button(:class="{active: jog_incr === 'small'}", @click="jog_incr = 'small'") + | {{jog_incr_amounts[display_units].small}} + button(:class="{active: jog_incr === 'medium'}", @click="jog_incr = 'medium'") + | {{jog_incr_amounts[display_units].medium}} + button(:class="{active: jog_incr === 'large'}", @click="jog_incr = 'large'") + | {{jog_incr_amounts[display_units].large}} - // W axis jog row (auxcnc). Only shown when the aux controller - // is enabled in aux.json. We treat home == 0 for the W axis, - // so there is no separate "set zero" / "W origin" button - - // just W-, W+, and Home. - tr(v-if="w.enabled") - td(style="height:100px", align="center", colspan="1") - button(@click="aux_jog_incr(-1)", - :disabled="!w.enabled", - style="display:grid;justify-content:center;align-items:center;padding:14px;") - | W- - .fa.fa-arrow-down + .jog-grid + // Row 1 + button.jbtn.dir(@click="jog_fn(-1, 1, 0, 0)", title="X- Y+") + .fa.fa-arrow-up.ico(style="transform: rotate(-45deg)") + button.jbtn(@click="jog_fn(0, 1, 0, 0)") Y+ + button.jbtn.dir(@click="jog_fn(1, 1, 0, 0)", title="X+ Y+") + .fa.fa-arrow-up.ico(style="transform: rotate(45deg)") + button.jbtn(@click="jog_fn(0, 0, 1, 0)") Z+ - td(style="height:100px", align="center", colspan="1") - button(@click="aux_jog_incr(+1)", - :disabled="!w.enabled", - style="display:grid;justify-content:center;align-items:center;padding:14px;") - | W+ - .fa.fa-arrow-up + // Row 2 + button.jbtn(@click="jog_fn(-1, 0, 0, 0)") X− + button.jbtn.ghost(@click="showMoveToZeroDialog('xy')") + span.lbl XY + span Origin + button.jbtn(@click="jog_fn(1, 0, 0, 0)") X+ + button.jbtn.ghost(@click="showMoveToZeroDialog('z')") + span.lbl Z + span Origin - td(style="height:100px", align="center", colspan="2") - button(@click="aux_home()", :disabled="!w.enabled", - style="height:100px;width:200px") - | Home - br - | W + // Row 3 + button.jbtn.dir(@click="jog_fn(-1, -1, 0, 0)", title="X- Y-") + .fa.fa-arrow-down.ico(style="transform: rotate(45deg)") + button.jbtn(@click="jog_fn(0, -1, 0, 0)") Y− + button.jbtn.dir(@click="jog_fn(1, -1, 0, 0)", title="X+ Y-") + .fa.fa-arrow-down.ico(style="transform: rotate(-45deg)") + button.jbtn(@click="jog_fn(0, 0, -1, 0)") Z− - tr(v-if="state['2an'] == 3") - td(style="height:100px", align="center", colspan="1") - button(@click="show_probe_dialog=true") - | Probe - br - | Rotary + // Row 4 — auxiliary axis (W or A) or probe shortcuts + template(v-if="w.enabled") + button.jbtn(@click="aux_jog_incr(-1)", :disabled="!w.enabled") + .fa.fa-arrow-down.ico + span.lbl W− + button.jbtn.ghost(@click="aux_home()", :disabled="!w.enabled") + span.lbl Home + span W + button.jbtn(@click="aux_jog_incr(+1)", :disabled="!w.enabled") + .fa.fa-arrow-up.ico + span.lbl W+ + button.jbtn(@click="show_probe_dialog=true", + :class="{'load-on': !state['pw']}") + .fa.fa-bullseye.ico + span.lbl Probe + template(v-else-if="state['2an'] == 3") + button.jbtn.dir(@click="jog_fn(0, 0, 0, -1)") + .fa.fa-rotate-left.ico + span.lbl A− + button.jbtn.ghost(@click="showMoveToZeroDialog('a')") + span.lbl A + span Origin + button.jbtn.dir(@click="jog_fn(0, 0, 0, 1)") + .fa.fa-rotate-right.ico + span.lbl A+ + button.jbtn(@click="show_probe_dialog=true", + :class="{'load-on': !state['pw']}") + .fa.fa-bullseye.ico + span.lbl Probe + template(v-else) + button.jbtn(@click="showProbeDialog('xyz')", + :class="{'load-on': !state['pw']}") + .fa.fa-bullseye.ico + span.lbl Probe XYZ + button.jbtn.ghost(@click="zero()", :disabled="!can_set_axis") + .fa.fa-map-marker.ico + span.lbl Zero all + button.jbtn(@click="showProbeDialog('z')", + :class="{'load-on': !state['pw']}") + .fa.fa-bullseye.ico + span.lbl Probe Z + button.jbtn.ghost(@click="home()", :disabled="!is_idle") + .fa.fa-home.ico + span.lbl Home all - td(style="height:100px", align="center", colspan="1") - button(@click="jog_fn(0,0,0,-1)", style="display: grid;justify-content: center;align-items: center;padding: 14px;") - | A- - .fa.fa-rotate-left - - td(style="height:100px", align="center", colspan="1") - button(@click="showMoveToZeroDialog('a')") - | A - br - | Origin + // ===== DRO + status strip ===== + .right-col - td(style="height:100px", align="center", colspan="1") - button(@click="jog_fn(0,0,0,1)", style="display: grid;justify-content: center;align-items: center;padding: 14px;") - | A+ - .fa.fa-rotate-right + .dro-card + .dro-head + div Axis + div Position + div Absolute + div Offset + div State + div Toolpath + div(style="text-align:right") Actions - tr(v-else) - td(style="height:100px", align="center", colspan="2") - button(:class="state['pw'] ? '' : 'load-on'", - style="height:100px;width:200px", - @click="showProbeDialog('xyz')") - | Probe XYZ - - td(style="height:100px", align="center", colspan="2") - button(:class="state['pw'] ? '' : 'load-on'", - style="height:100px;width:200px", - @click="showProbeDialog('z')") - | Probe Z - - td(style="vertical-align: top;") - table.axes - tr(:class="axes.klass") - th.name Axis - th.position Position - th.absolute Absolute - th.offset Offset - th.state State - th.tstate Toolpath - th.actions - button.pure-button(disabled, style="height:60px;width:60px;display:none;") - - button.pure-button(:disabled="!can_set_axis", - title="Zero all axis offsets.", @click="zero()",style="height:60px;width:60px") - .fa.fa-map-marker - - button.pure-button(title="Home all axes.", @click="home()", - :disabled="!is_idle",style="height:60px;width:60px") - .fa.fa-home - - each axis in 'xyzabc' - tr.axis(:class=`${axis}.klass`, v-if=`${axis}.enabled`, - :title=`${axis}.title`) - th.name= axis - td.position: unit-value(:value=`${axis}.pos`, precision=4) - td.absolute: unit-value(:value=`${axis}.abs`, precision=3) - td.offset: unit-value(:value=`${axis}.off`, precision=3) - td.state + // Per-axis rows — keep unit-value + bindings from axis-vars + each axis in 'xyzabc' + .dro-row(:class=`${axis}.klass + ' ' + ${axis}.tklass`, + v-if=`${axis}.enabled`, + :title=`${axis}.title`) + .dro-axis(:class=`'axis-' + '${axis}'`)= axis.toUpperCase() + .dro-pos: unit-value(:value=`${axis}.pos`, precision=4) + .dro-sec: unit-value(:value=`${axis}.abs`, precision=3) + .dro-sec: unit-value(:value=`${axis}.off`, precision=3) + .dro-state + span.chip(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'chip-red' : (${axis}.homed ? 'chip-green' : 'chip-amber')`) .fa(:class=`'fa-' + ${axis}.icon`) - | {{#{axis}.state}} - td.tstate(:class=`${axis}.tklass`, :title=`${axis}.toolmsg`, @click=`showToolpathMessageDialog('${axis}')`) + |  {{#{axis}.state}} + .dro-toolpath + span.chip(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'chip-red' : (${axis}.tklass.indexOf('warn') !== -1 ? 'chip-amber' : 'chip-green')`, + @click=`showToolpathMessageDialog('${axis}')`) .fa(:class=`'fa-' + ${axis}.ticon`) - | {{#{axis}.tstate}} - - th.actions - button.pure-button(:disabled="!can_set_axis", - title=`Set {{'${axis}' | upper}} axis position.`, - @click=`show_set_position('${axis}')`, style="height:60px;width:60px") - .fa.fa-cog - - button.pure-button(:disabled="!can_set_axis", - title=`Zero {{'${axis}' | upper}} axis offset.`, - @click=`zero('${axis}')`, style="height:60px;width:60px") - .fa.fa-map-marker - - button.pure-button(:disabled="!is_idle", @click=`home('${axis}')`, - title=`Home {{'${axis}' | upper}} axis.`, style="height:60px;width:60px") - .fa.fa-home - - // Auxiliary W axis (auxcnc ESP32 over /dev/ttyUSB0) - tr.axis(:class="w.klass", v-if="w.enabled", :title="w.title") - th.name w - td.position: unit-value(:value="w.pos", precision=4) - td.absolute: unit-value(:value="w.abs", precision=3) - td.offset — - td.state - .fa(:class="'fa-' + w.icon") - | {{w.state}} - td.tstate(:class="w.tklass", :title="w.toolmsg") - .fa(:class="'fa-' + w.ticon") - | {{w.tstate}} - - th.actions - // Invisible placeholders so the W home button lines up - // under the X/Y/Z home column. The W axis has no "set - // position" cog and no "zero offset" marker - home == 0. - button.pure-button(disabled, - style="height:60px;width:60px;visibility:hidden") + |  {{#{axis}.tstate}} + .actions-cell + button.icon-btn(:disabled="!can_set_axis", + :title=`'Set ${axis.toUpperCase()} axis position.'`, + @click=`show_set_position('${axis}')`) .fa.fa-cog - - button.pure-button(disabled, - style="height:60px;width:60px;visibility:hidden") + button.icon-btn(:disabled="!can_set_axis", + :title=`'Zero ${axis.toUpperCase()} axis offset.'`, + @click=`zero('${axis}')`) .fa.fa-map-marker - - button.pure-button(:disabled="!w.enabled", @click="aux_home()", - title="Home W axis.", style="height:60px;width:60px") + button.icon-btn(:disabled="!is_idle", + :title=`'Home ${axis.toUpperCase()} axis.'`, + @click=`home('${axis}')`) .fa.fa-home - - tr(style="vertical-align: top;") - td - table(width="100%") - tr - td(style="text-align:center") - table.info - tr - th State - td(:class="{attention: highlight_state}") {{mach_state}} - tr - th Message - td.message(:class="{attention: highlight_state}") - | {{message.replace(/^#/, '')}} + // W axis (auxiliary) — no offset, no set-zero / no set-position + .dro-row(:class="w.klass + ' ' + w.tklass", v-if="w.enabled", + :title="w.title") + .dro-axis.axis-w W + .dro-pos: unit-value(:value="w.pos", precision=4) + .dro-sec: unit-value(:value="w.abs", precision=3) + .dro-sec — + .dro-state + span.chip(:class="w.homed ? 'chip-green' : 'chip-amber'") + .fa(:class="'fa-' + w.icon") + |  {{w.state}} + .dro-toolpath + span.chip.chip-green + .fa(:class="'fa-' + w.ticon") + |  {{w.tstate}} + .actions-cell + button.icon-btn(disabled, style="visibility:hidden") + .fa.fa-cog + button.icon-btn(disabled, style="visibility:hidden") + .fa.fa-map-marker + button.icon-btn(:disabled="!w.enabled", + title="Home W axis.", @click="aux_home()") + .fa.fa-home - tr - th Display Units - td.units - select(v-model="display_units") - option(value="METRIC") METRIC - option(value="IMPERIAL") IMPERIAL + // ----- Status strip ----- + .status-strip + .stat-card + .stat-label State + .stat-val(:class="state_kpi_class") {{mach_state || '--'}} + .stat-sub(v-if="message") {{message.replace(/^#/, '')}} + .stat-sub(v-else) No alerts - tr(title="Active tool") - th Tool - td {{state.tool || 0}} + .stat-card + .stat-label Velocity / Feed + .stat-val + unit-value(:value="state.v", precision="2", unit="", iunit="", + scale="0.0254") + | ·  + unit-value(:value="state.feed", precision="0", unit="", iunit="") + .stat-sub {{metric ? 'm/min · mm/min' : 'IPM · IPM'}} - td - table.info - tr( - title="Current velocity in {{metric ? 'meters' : 'inches'}} per minute") - th Velocity - td - unit-value(:value="state.v", precision="2", unit="", iunit="", - scale="0.0254") - | {{metric ? ' m/min' : ' IPM'}} + .stat-card.stat-tappable(@click="overrides_open = !overrides_open", + :class="{open: overrides_open}", title="Tap to adjust feed/spindle override") + .stat-label Spindle + .stat-val + | {{(state.speed || 0) | fixed 0}} + span(v-if="state.s != null && !isNaN(state.s)") ({{state.s | fixed 0}}) + .stat-sub + | RPM (commanded / actual) + .fa.fa-sliders.tap-hint(title="Open override drawer") - tr(title="Programmed feed rate.") - th Feed - td - unit-value(:value="state.feed", precision="2", unit="", iunit="") - | {{metric ? ' mm/min' : ' IPM'}} + .stat-card + .stat-label Job + .stat-val + | {{0 <= state.line ? state.line : 0 | number}} + span(v-if="toolpath.lines") + | / {{toolpath.lines | number}} + .stat-sub(v-if="plan_time_remaining || toolpath.time") + | Line · {{plan_time_remaining ? (plan_time_remaining | time) : (toolpath.time | time)}} remaining + .stat-sub(v-else) Line · ETA -- - tr(title="Programed and actual speed.") - th Speed - td - | {{state.speed || 0 | fixed 0}} - span(v-if="!isNaN(state.s)")  ({{state.s | fixed 0}}) - = ' RPM' + // ----- Macro row (slice 0..7); full list lives in Settings → Macros ----- + .macro-row(v-if="state.macros && state.macros.length") + button.macro-btn(v-for="(index, macros) in state.macros.slice(0, 8)", + title="Click to run macro", + @click="run_macro(index)", + :disabled="!is_ready", + :style="{ borderLeftColor: macros.color || '#fde047' }") + span.mnum {{index + 1}} + .fa.fa-circle-play.micon + span.mname {{macros.name || ('Macro ' + (index + 1))}} - tr(title="Load switch states.") - th Loads - td - span(:class="state['1oa'] ? 'load-on' : ''") - | 1:{{state['1oa'] ? 'On' : 'Off'}} - |   - span(:class="state['2oa'] ? 'load-on' : ''") - | 2:{{state['2oa'] ? 'On' : 'Off'}} - - td - table.info - tr - th Remaining - td(title="Total run time (days:hours:mins:secs)"). - #[span(v-if="plan_time_remaining") {{plan_time_remaining | time}} of] - {{toolpath.time | time}} - - tr - th ETA - td.eta {{eta}} - - tr - th Line - td - | {{0 <= state.line ? state.line : 0 | number}} - span(v-if="toolpath.lines") - |  of {{toolpath.lines | number}} - - tr - th Progress - td.progress - label {{(progress || 0) | percent}} - .bar(:style="'width:' + (progress || 0) * 100 + '%'") - - .macros-div(class="present") - button.macros-button(title="Click to run Macros",v-for="(index,macros) in state.macros", - @click="run_macro(index)",:disabled="!is_ready",v-bind:style="{ backgroundColor: macros.color }") {{macros.name}} - - .tabs - - input#tab1(type="radio", name="tabs",checked="" @click="tab = 'auto'") - label(for="tab1", title="Run GCode programs",style="height:50px;width:100px") Auto - - input#tab2(type="radio", name="tabs", @click="tab = 'mdi'") - label(for="tab2", title="Manual GCode entry",style="height:50px;width:100px") MDI - - input#tab3(type="radio", name="tabs", @click="tab = 'messages'") - label(for="tab3",style="height:50px;width:100px") Messages - - input#tab4(type="radio", name="tabs", @click="tab = 'indicators'") - label(for="tab4",style="height:50px;width:100px") Indicators - - - - - section#content1.tab-content.pure-form - .toolbar.pure-control-group - button.pure-button(:class="{'attention': is_holding}", - title="{{is_running ? 'Pause' : 'Start'}} program.", - @click="start_pause", :disabled="!state.selected", - style="height:100px;width:100px;font-weight:normal") - img(v-if="is_running" src="images/pause_gcode.png" style="height: 55px;") - img(v-else src="images/play_gcode.png" style="height: 55px;") - - button.pure-button(title="Stop program.", @click="stop", style="height:100px;width:100px;font-weight:normal") - img(src="images/stop.png" style="height: 55px;") - - button.pure-button(title="Pause program at next optional stop (M1).", - @click="optional_pause", v-if="false", style="height:100px;width:100px;font-weight:normal") - .fa.fa-stop-circle-o - - message(:show.sync="uploading_files") - h3(slot="header") Files uploading - div(slot="body") - h3 Please wait... - p - p The files are currently being uploaded. - p Do not close the window. - div(slot="footer") - - button.pure-button(title="Execute one program step.", @click="step", - :disabled="(!is_ready && !is_holding) || !state.selected", - v-if="false", style="height:100px;width:100px;font-weight:normal") - .fa.fa-step-forward - - button.pure-button(title="Upload a new GCode folder.", @click="open_folder", - :disabled="!is_ready",style="height:100px;width:100px;font-weight:normal") - img(src="images/upload_folder.png" style="height: 65px;") - - form.gcode-folder-input.file-upload - input#folderInput(type="file", @change="upload_folder", :disabled="!is_ready", - webkitdirectory, directory) - - button.pure-button(title="Upload a new GCode program.", @click="open_file", - :disabled="!is_ready",style="height:100px;width:100px;font-weight:normal") - img(src="images/upload_gcode.png" style="height: 65px;") - - form.gcode-file-input.file-upload - input(type="file", @change="upload_file", :disabled="!is_ready", - accept=".nc,.ngc,.gcode,.gc", multiple) - - a(:disabled="!state.selected", download, - :href="'/api/file/' + state.selected", - title="Download the selected GCode program.") - button.pure-button(:disabled="!state.selected", style="height:100px;width:100px") - img(src="images/download_gcode.png" style="height: 65px;") - - button.pure-button(title="Delete current GCode program.", - @click="deleteGCode = true", - :disabled="!state.selected || !is_ready",style="height:100px;width:100px;font-weight:normal") - img(src="images/delete_gcode.png" style="height: 55px;") - - message.error-message(:show.sync="deleteGCode") - h3(slot="header") Select files to delete: - div(slot="body") - input.search-bar(type="text", v-model="search_query", placeholder="Search Files...") - .container - .folders - h3 Folders - div(v-for="(index, folder) in state.gcode_list", :key="index", @click="populateFiles(index)", - class="folder-item", :class="{ selected: index === selected_folder_index }") {{ folder.name }} - .files - h3 Files - label.file-item(v-for="item in gcode_filtered_files" :key="item") - input(type="checkbox" :value="item" v-model="selected_items_to_delete") - | {{ item }} - div(slot="footer") - button.pure-button(@click="cancel_delete",style="height:50px") Cancel - //- button.pure-button.button-error(@click="delete_all_except_macros") - //- .fa.fa-trash - //- |  All - button.pure-button.button-success(@click="delete_current",style="height:50px") - .fa.fa-trash - |  Selected - - .drop-down-container - message(:show.sync="create_folder") - h3(slot="header") Enter folder name: - div(slot="body") - input.input-name(type="text",minlength='1',maxlength='15',style ="margin-top:1rem;margin-bottom:2rem;", - id="folder-name" ,v-model="folder_name",@keypress="edited_folder_name") - - div(slot="footer") - button.pure-button(@click="cancel_new_folder") Cancel - button.pure-button.button-success(@click="create_new_folder",:disabled="!edited") - | Create - - message(:show.sync="confirmDelete") - h3(slot="header") Delete Folder? - div(slot="body") - p Are you sure to delete the folder? - - div(slot="footer") - button.pure-button(@click="confirmDelete=false") Cancel - button.pure-button.button-error(@click="delete_folder") Folder only - button.pure-button.button-success(@click="delete_folder_and_files") Folder and files - - button.pure-button(title="Create a new folder.", @click="create_folder=true", - :disabled="!is_ready",style="height:100%") - | Create Folder - - button.pure-button(title="Delete a folder.", @click="confirmDelete=true", - :disabled="!is_ready",style="height:100%;margin-left:5px") - | Delete Folder - - select(title="Select previously uploaded GCode folder.", - v-model="state.folder", @change="reset_gcode", :disabled="!is_ready", - style="max-width:100%;margin-left:5px") - option( selected='' value='default') Default folder - option(v-for="file in gcode_folders", :value="file") {{file}} - - select(title="Select previously uploaded GCode programs.", - v-model="state.selected", @change="load", :disabled="!is_ready", - style="max-width:300px;margin-left:5px") - option(v-for="file in gcode_files", :value="file") {{file}} - - button.pure-button(@click="toggle_sorting", :disabled="!is_ready", - style="height:75%") - | {{files_sortby}} - - .progress(v-if="toolpath_progress && toolpath_progress < 1", - title="Simulating GCode to check for errors, calculate ETA and " + - "generate 3D view. You can run GCode before the simulation " + - "finishes.") - div(:style="'width:' + (toolpath_progress || 0) * 100 + '%'") - label Simulating {{(toolpath_progress || 0) | percent}} - - path-viewer(:toolpath="toolpath", :state="state", :config="config") - gcode-viewer - - section#content2.tab-content - .mdi.pure-form(title="Manual GCode entry.") - button.pure-button(:disabled="!can_mdi", - :class="{'attention': is_holding}", - title="{{is_running ? 'Pause' : 'Start'}} command.", - @click="mdi_start_pause",style="height:100px;width:100px") - .fa(:class="is_running ? 'fa-pause' : 'fa-play'") - - button.pure-button(title="Stop command.", @click="stop",style="height:100px;width:100px") - .fa.fa-stop - - input(v-model="mdi", :disabled="!can_mdi", @keyup.enter="submit_mdi") - - div - em The machine is currently operating in #[strong {{mach_units}}] units. Use G20/G21 to switch units. - - .history(:class="{placeholder: !history}") - span(v-if="!history.length") MDI history displays here. - ul - li(v-for="item in history", @click="load_history($index)", - track-by="$index") - | {{item}} - - section#content3.tab-content - console - - section#content4.tab-content - indicators(:state="state", :template="template") - - .override(title="Feed rate override.") - label Feed - input(type="range", min="0", max="2", step="0.01", - v-model="feed_override", @change="override_feed") - span.percent {{feed_override | percent 0}} - - .override(title="Spindle speed override.") - label Speed - input(type="range", min="0", max="2", step="0.01", - v-model="speed_override", @change="override_speed") - span.percent {{speed_override | percent 0}} - - + // ----- Override drawer (anchored to bottom; toggled by Spindle KPI tile) ----- + .override-drawer(:class="{open: overrides_open}") + .od-head + .od-title + .fa.fa-sliders + |  Overrides + button.od-close(@click="overrides_open = false") ✕ + .od-body + .od-row + label Feed + input(type="range", min="0", max="2", step="0.01", + v-model="feed_override", @change="override_feed") + .od-val {{feed_override | percent 0}} + button.od-reset(@click="feed_override = 1; override_feed()") Reset 100% + .od-row + label Spindle + input(type="range", min="0", max="2", step="0.01", + v-model="speed_override", @change="override_speed") + .od-val {{speed_override | percent 0}} + button.od-reset(@click="speed_override = 1; override_speed()") Reset 100% diff --git a/src/pug/templates/program-view.pug b/src/pug/templates/program-view.pug new file mode 100644 index 0000000..bc4d4c0 --- /dev/null +++ b/src/pug/templates/program-view.pug @@ -0,0 +1,137 @@ +script#program-view-template(type="text/x-template") + .program-page + + // ----- Modal dialogs ----- + message(:show.sync="showGcodeMessage") + h3(slot="header") Processing New File + div(slot="body") + h3 Please wait.. + p Simulating GCode to check for errors, calculate ETA and generate 3D view. + div(slot="footer") + label Simulating {{(toolpath_progress || 0) | percent}} + + message(:show.sync="GCodeNotFound") + h3(slot="header") File not found + div(slot="body") + p It seems like the file you selected cannot be found. Try uploading again. + div(slot="footer") + button.pure-button.button-error(@click="GCodeNotFound=false") OK + + message(:show.sync="uploading_files") + h3(slot="header") Files uploading + div(slot="body") + h3 Please wait... + p + p The files are currently being uploaded. + p Do not close the window. + div(slot="footer") + + message.error-message(:show.sync="deleteGCode") + h3(slot="header") Select files to delete: + div(slot="body") + input.search-bar(type="text", v-model="search_query", placeholder="Search Files...") + .container + .folders + h3 Folders + div(v-for="(index, folder) in state.gcode_list", :key="index", + @click="populateFiles(index)", + class="folder-item", + :class="{ selected: index === selected_folder_index }") {{ folder.name }} + .files + h3 Files + label.file-item(v-for="item in gcode_filtered_files", :key="item") + input(type="checkbox", :value="item", v-model="selected_items_to_delete") + | {{ item }} + div(slot="footer") + button.pure-button(@click="cancel_delete", style="height:50px") Cancel + button.pure-button.button-success(@click="delete_current", style="height:50px") + .fa.fa-trash + |  Selected + + message(:show.sync="create_folder") + h3(slot="header") Enter folder name: + div(slot="body") + input.input-name(type="text", minlength="1", maxlength="15", + style="margin-top:1rem;margin-bottom:2rem;", + id="folder-name", v-model="folder_name", @keypress="edited_folder_name") + div(slot="footer") + button.pure-button(@click="cancel_new_folder") Cancel + button.pure-button.button-success(@click="create_new_folder", :disabled="!edited") Create + + message(:show.sync="confirmDelete") + h3(slot="header") Delete Folder? + div(slot="body") + p Are you sure to delete the folder? + div(slot="footer") + button.pure-button(@click="confirmDelete=false") Cancel + button.pure-button.button-error(@click="delete_folder") Folder only + button.pure-button.button-success(@click="delete_folder_and_files") Folder and files + + .program-card + + // Action bar (RUN / STOP / Upload / Download / Delete) + .action-bar + button.action-btn.run(:class="{'attention': is_holding}", + @click="start_pause", :disabled="!state.selected", + :title="is_running ? 'Pause program.' : 'Start program.'") + .fa.fa-play.ico(v-if="!is_running") + .fa.fa-pause.ico(v-else) + span {{is_running ? 'PAUSE' : 'RUN'}} + button.action-btn.stop(@click="stop", title="Stop program.") + .fa.fa-stop.ico + span STOP + button.action-btn(@click="open_folder", :disabled="!is_ready", + title="Upload a new GCode folder.") + .fa.fa-folder-arrow-up.ico + span UPLOAD FOLDER + form.gcode-folder-input.file-upload + input#folderInput(type="file", @change="upload_folder", + :disabled="!is_ready", webkitdirectory, directory) + button.action-btn(@click="open_file", :disabled="!is_ready", + title="Upload a new GCode program.") + .fa.fa-file-arrow-up.ico + span UPLOAD FILE + form.gcode-file-input.file-upload + input(type="file", @change="upload_file", :disabled="!is_ready", + accept=".nc,.ngc,.gcode,.gc", multiple) + a(:href="state.selected ? '/api/file/' + state.selected : '#'", + download, :class="{disabled: !state.selected}", + title="Download the selected GCode program.") + button.action-btn(:disabled="!state.selected") + .fa.fa-file-arrow-down.ico + span DOWNLOAD FILE + button.action-btn.danger(@click="deleteGCode = true", + :disabled="!state.selected || !is_ready", + title="Delete current GCode program.") + .fa.fa-trash.ico + span DELETE + + // File / folder selectors + .file-bar + button.file-btn(@click="create_folder=true", :disabled="!is_ready") + .fa.fa-folder-plus + |  Create Folder + button.file-btn(@click="confirmDelete=true", :disabled="!is_ready") + .fa.fa-folder-minus + |  Delete Folder + select.file-select(title="Select previously uploaded GCode folder.", + v-model="state.folder", @change="reset_gcode", :disabled="!is_ready") + option(selected, value="default") Default folder + option(v-for="file in gcode_folders", :value="file") {{file}} + select.file-select.primary(title="Select previously uploaded GCode programs.", + v-model="state.selected", @change="load", :disabled="!is_ready") + option(value="") (no file) + option(v-for="file in gcode_files", :value="file") {{file}} + button.file-btn(@click="toggle_sorting", :disabled="!is_ready") + .fa.fa-arrow-down-wide-short + |  {{files_sortby}} + + // Body: gcode listing on the left, 3D viewer on the right + .program-body + gcode-viewer + path-viewer(:toolpath="toolpath", :state="state", :config="config") + + .progress-bar(v-if="toolpath_progress && toolpath_progress < 1", + title="Simulating GCode to check for errors, calculate ETA and generate 3D view.") + div(:style="'width:' + (toolpath_progress || 0) * 100 + '%'") + label Simulating {{(toolpath_progress || 0) | percent}} diff --git a/src/pug/templates/settings-shell.pug b/src/pug/templates/settings-shell.pug new file mode 100644 index 0000000..3c605b1 --- /dev/null +++ b/src/pug/templates/settings-shell.pug @@ -0,0 +1,44 @@ +script#settings-shell-view-template(type="text/x-template") + .settings-shell + aside.settings-rail + // Use a single v-for over a data-driven items array so every + // rail item shares the same compiled :class binding template. + // This sidesteps a Vue 1 quirk where sibling-with-different- + // expression :class bindings sometimes fail to re-evaluate on + // hash navigation, leaving stale `.active` classes. + template(v-for="item in rail_items") + .set-section(v-if="item.section") {{item.section}} + a.set-item(v-if="!item.section", :class="{active: is_active(item)}", + :href="item.href") + .fa(:class="item.icon") + |  {{item.label}} + .set-rail-foot + button.sp-shutdown(@click="showShutdownDialog") + .fa.fa-power-off + |  Shutdown + button.sp-save(:disabled="!$root.modified", @click="$root.save()") + .fa.fa-save + |  Save{{$root.modified ? '*' : ''}} + + .settings-content + // Explicit v-if cascade so the inner template swaps reactively + // when sub changes (Vue 1's `` does not always + // re-evaluate dynamic strings inside a kept-alive parent). + settings-view-inner(v-if="sub === 'settings'", + :index="index", :config="config", :template="template", :state="state") + admin-general-view(v-if="sub === 'admin-general'", + :index="index", :config="config", :template="template", :state="state") + admin-network-view(v-if="sub === 'admin-network'", + :index="index", :config="config", :template="template", :state="state") + motor-view(v-if="sub === 'motor'", + :index="index", :config="config", :template="template", :state="state") + tool-view(v-if="sub === 'tool'", + :index="index", :config="config", :template="template", :state="state") + io-view(v-if="sub === 'io'", + :index="index", :config="config", :template="template", :state="state") + macros-view(v-if="sub === 'macros'", + :index="index", :config="config", :template="template", :state="state") + help-view(v-if="sub === 'help'", + :index="index", :config="config", :template="template", :state="state") + cheat-sheet-view(v-if="sub === 'cheat-sheet'", + :index="index", :config="config", :template="template", :state="state") diff --git a/src/stylus/style.styl b/src/stylus/style.styl index f64568a..007c7da 100644 --- a/src/stylus/style.styl +++ b/src/stylus/style.styl @@ -1,12 +1,44 @@ +// ===================================================================== +// V09 redesign tokens & chrome +// ===================================================================== +// +// The new shell wraps everything in `.app-shell { .app-head | .app-body }`. +// Inner views use the shared tokens below for jog/macro tiles, status +// chips and segmented controls. Anything legacy that's still needed by +// settings/admin/motor/tool/io templates remains lower in this file. + +$ink = #0f172a +$ink-soft = #334155 +$muted = #64748b +$muted-2 = #94a3b8 +$line = #e5e7eb +$line-soft = #f1f5f9 +$bg = #ffffff +$body-bg = #f1f5f9 + +$accent = #fde047 +$accent-ink = $ink +$accent-text = #0ea5e9 + +// Jog tile palette — V09 (flat soft slate, no shadow) +$jog-bg = #3f4b63 +$jog-hover = #4a5777 +$jog-dir = #5b6885 +$jog-dir-hov = #6a779a +$jog-ghost = #8c97ad +$jog-ghost-hov = #9ba6bb +$jog-ink = #fff +$jog-ghost-ink = $ink + body - overflow-y scroll + margin 0 + font-family 'Inter', system-ui, -apple-system, sans-serif + background $body-bg + color $ink [v-cloak] display none -.menu-link - z-index unset - tt color #000 background #eee @@ -32,168 +64,340 @@ tt background-color #0078e7 color #fff - -.header, .content - padding 0 - .clear clear left clear right -.header - height 140px - padding 0 +// ===================================================================== +// App shell +// ===================================================================== +.app-shell + display flex + flex-direction column + min-height 100vh + background $body-bg -.nav-header - padding-left 60px - display flex +.app-body + flex 1 + min-height 0 + display flex + flex-direction column + padding 18px - .brand + > * + flex 1 + min-height 0 + +.app-head + flex 0 0 96px + height 96px + display flex + align-items center + gap 18px + padding 0 24px + background $bg + border-bottom 1px solid $line + position relative + z-index 30 + + .brand-blk display flex - flex-direction row - align-self center - white-space nowrap + align-items center + gap 14px + + .brand-logo + width 42px + height 42px + border-radius 8px + background repeating-linear-gradient(135deg, #a7c7a3 0 6px, transparent 6px 14px) + + .brand-name + font-weight 900 + font-size 22px + letter-spacing -0.01em + + .head-spacer + flex 1 + +// Underline-ribbon tabs +.tabs-host + display inline-flex + gap 0 + margin-right auto + padding-left 18px + align-items stretch + height 96px + +.ktab + position relative + height 96px + padding 0 26px + display inline-flex + align-items center + gap 0.55rem + background transparent + border none + text-decoration none + color $ink-soft + font-size 1.05rem + font-weight 700 + cursor pointer + transition color .15s + + .fa + font-size 1.1rem + color $muted-2 + transition color .15s + + &:hover + color $ink + .fa + color $ink-soft + + &.active + color $ink + .fa + color $ink + + &.active::after + content "" + position absolute + left 14px + right 14px + bottom 0 + height 5px + background $accent + border-radius 5px 5px 0 0 + + .ktab-badge + background #fee2e2 + color #991b1b + font-size 0.7rem + padding 3px 8px + border-radius 9999px + font-weight 800 + line-height 1 + + &.active .ktab-badge + background $accent + color $accent-ink + +// System pill (collapses old chip-soup) +.sys-btn + display inline-flex + align-items center + gap 0.55rem + height 54px + padding 0 1.1rem + border-radius 14px + background $line-soft + border 1px solid $line + color $ink + font-size 0.9rem + font-weight 600 + cursor pointer + user-select none + + &:hover + background #e2e8f0 + + .pip + width 9px + height 9px + border-radius 9999px + background #22c55e + + .pip.amber + background #f59e0b + + .pip.red + background #dc2626 + + .fa-chevron-down + color $muted-2 + font-size 12px + + &.open + background #e2e8f0 + +.pi-temp-warning + align-self center + margin 0 4px + color #dc2626 + font-size 24px + +.state-badge + display inline-flex + align-items center + gap 0.6rem + height 54px + padding 0 1.1rem + border-radius 14px + background #dcfce7 + color #166534 + font-weight 800 + font-size 1rem + letter-spacing 0.04em + + .dot + width 10px + height 10px + border-radius 9999px + background currentColor + position relative + + .dot::after + content "" + position absolute + inset -3px + border-radius 9999px + border 2px solid currentColor + opacity 0.5 + animation pulse-dot 1.6s ease-out infinite + + &.warn + background #fef3c7 + color #92400e + + &.bad + background #fee2e2 + color #991b1b + + &.busy + background #dbeafe + color #1e40af + + &.unknown + background $line-soft + color $muted + + &.unknown .dot::after + display none + +@keyframes pulse-dot + 0% + transform scale(0.7) + opacity 0.6 + 100% + transform scale(2.2) + opacity 0 + +@media (prefers-reduced-motion: reduce) + .state-badge .dot::after + animation none + +// System popover +.sys-popover + position absolute + top 96px + right 240px + width 360px + background $bg + border 1px solid $line + border-radius 14px + box-shadow 0 18px 40px rgba(15, 23, 42, 0.18) + padding 12px + z-index 40 + display flex + flex-direction column + gap 10px + + .sp-row + display grid + grid-template-columns 32px 1fr auto + gap 12px + align-items center + + .sp-icon + width 32px + height 32px + border-radius 8px + background $line-soft + color $ink-soft + display inline-flex + align-items center + justify-content center img - width 300px - height 15% + width 18px + height 18px - .version - font-size 18pt - color #777 - display flex - flex-direction column - justify-content space-evenly - border-left #777 2px solid; - margin-left 15px - padding 0px 10px - font-weight bold + .fa.sp-warn + color #f59e0b - .upgrade-link - margin-left 20px - font-size 16pt - align-self center - color blue + .sp-label + font-size 0.7rem + text-transform uppercase + letter-spacing 0.1em + color $muted-2 + font-weight 800 - .upgrade-attention - color red - font-size 18pt - align-self center - margin-left 5px + .sp-val + font-size 0.95rem + color $ink + font-weight 600 - .pi-temp-warning - align-self center - font-size 30pt - font-family Audiowide - display inline - margin 0 30px - - .left - color #444 - .right - color #e5aa3d - - .easy-adapter - display flex - align-items center - gap 5px - padding 5px - margin 5px - - .round-dot - width 8px - height 8px - border-radius 50% - background-color #000000 - flex-shrink 0 - - .easy-adapter-text - margin 0 - padding 0 - - - .whitespace - flex-grow 1 - - .rotary-button - border 1px solid transparent - background transparent + .sp-act + height 36px + padding 0 12px + border-radius 8px + background $line-soft + border 1px solid $line + color $ink + font-size 0.8rem + font-weight 700 cursor pointer - width 100px - height 100px - border-radius 100px - margin 5px 0 5px 0 - display flex - justify-content center + text-decoration none + display inline-flex align-items center + gap 4px - &.active - border 1px solid #54ed54 - background #ccffcb - - &:focus - outline none - box-shadow none - - &:disabled - opacity 0.5 - background #e0e0e0 - border 1px solid black - cursor not-allowed - - .rotary-text - text-align center + .sp-act:hover + background #e2e8f0 .video + width 100% + height 180px + border-radius 8px + background #000 + overflow hidden position relative - width 174px - height 130px - border 2px solid transparent - border-radius 5px - - &:hover - border-color #aaa - - &.large - width 100% - margin 5px 0 - height inherit - - .crosshair - > * - border 1px dashed #ccc - position absolute - - .vertical - height 100% - width 0 - left 50% - margin-left -1px - - .horizontal - height 0 - width 100% - top 50% - margin-top -1px - - .box - width 16px - height 16px - top 50% - left 50% - margin-top -9px - margin-left -9px img width 100% height 100% + object-fit cover - .estop - align-self center - margin 0 30px + .sp-foot + display flex + gap 8px + margin-top 4px + padding-top 10px + border-top 1px solid $line-soft + + .sp-shutdown, .sp-save + flex 1 + height 40px + border-radius 10px + border 1px solid $line + background $line-soft + color $ink + font-weight 700 + cursor pointer + + .sp-shutdown + background #fef2f2 + color #991b1b + border-color #fecaca + + .sp-save:not([disabled]) + background $accent + color $ink + border-color $accent .error background red + color #fff .warn background orange @@ -268,467 +472,61 @@ span.unit position relative top 50% -#menu - .save - display block - margin 0.25em 0.6em - height 3.5em - width 8em +// Form rules used by the settings/admin/motor/tool/io templates that +// still rely on Pure form classes. +.app-body .pure-control-group + label.units + width 6em + text-align left + textarea + width 24em + height 12em - .pure-menu-heading - background inherit - padding 0 + > select, > input:not([type=checkbox]) + min-width 300px - .pure-menu-link - padding 0.6em - color #fff - - .pure-menu-item .pure-menu-link - padding-left 1.5em - - -#main - margin-left 0.5em - - .content - h2 - text-transform capitalize - - .pure-control-group - label.units - width 6em - text-align left - - textarea - width 24em - height 12em - - > select, > input:not([type=checkbox]) - min-width 300px - - > tt - min-width 15.25em - padding 0.7em 1em - border-radius 3px - display inline-block - + > tt + min-width 15.25em + padding 0.7em 1em + border-radius 3px + display inline-block @keyframes blink 50% fill #ff9d00 -.estop - width 130px - transition 250ms +// Octagonal STOP wrapper around the existing SVG. The SVG +// rules below (`.button`, `.ring`, etc.) keep working unchanged. +.app-head .estop + width 88px + height 88px + background #dc2626 + clip-path polygon(30% 0, 70% 0, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0 70%, 0 30%) + display flex + align-items center + justify-content center + border 3px solid #fff + box-shadow 0 0 0 3px #b91c1c, 0 8px 20px rgba(220, 38, 38, 0.35) + cursor pointer + transition transform 0.06s - &.active .ring - animation blink 2s step-start 0s infinite - - &:hover .button circle - fill #b72424 !important + &:active + transform scale(0.96) svg + width 56px + height 56px cursor pointer .button:hover filter brightness(120%) -.control-view - max-width 95% +.app-head .estop.active .ring + animation blink 2s step-start 0s infinite - .drop-down-container - height 50px - - .container - display flex - width 100% - height 25rem - margin-bottom 30px - - .folders - width 30% - border-right 1px solid #ccc - padding 10px - overflow-y auto - - .files - width 70% - padding 10px - display grid - overflow-y auto - - .search-bar - margin-bottom 10px - width 50% - - .file-item - padding 3px - - .folder-item - padding 10px - - .folder-item.selected - font-weight bold - background-color #add1ad - border-radius 5px - - table - border-collapse collapse - - // Make sure buttons don't turn into circles - button - -webkit-appearance none - border-radius 0px - border-width 1px - border-color darkgrey - - // The jogging buttons, etc. - .control-buttons button - font-size 150% - width 100px - height 100px - - .jog-units - font-size initial - margin-left 5px - - &:first-child - margin 0.5em 0 - - td, th - border none - - .radio-toolbar - input - opacity 0 - position fixed - width 0 - label - display inline-block - background-color #ddd - padding 10px 20px - font-family sans-serif Arial - font-size 16px - border 2px solid #444 - border-radius 4px - - .axes - width 100% - - .axis-x .name - color #f00 - - .axis-y .name - color #0f0 - - .axis-z .name - color #00f - - .axis-a .name - color #f80 - - .axis-b .name - color #0ff - - .axis-c .name - color #f0f - - td, th - padding 2px - white-space nowrap - border 1px solid #ddd - - th - text-align center - vertical-align bottom - - td - text-align right - font-family Courier - - .homed - background-color #ccffcc - color #000 - - .warn - background-color #ffffcc - - .error - background-color #ffcccc - - .axis - .name - text-transform capitalize - vertical-align middle - - .name, .position - font-size 24pt - line-height 24pt - - .position - width 99% - - td.state - text-align left - - .fa - margin-left 2px - margin-right 6px - - .absolute, .offset - min-width 6em - - td.tstate - text-align left - - .fa - margin-left 2px - margin-right 6px - - tr:nth-child(1) th.actions - text-align right - - .jog svg - text - user-select none - font-family Sans - font-weight bold - stroke transparent - fill #444 - - .button - cursor pointer - stroke #4c4c4c - - &:hover - stroke #e55 - - path - overflow visible - - .house - stroke #444 - fill #444 - - .ring - cursor pointer - overflow visible - - .button - stroke transparent - - &:hover - stroke #e55 - - text - font-size 8pt - text-anchor middle - - .info - empty-cells show - width 100% - display inline-block - - th, td - height 1.75em - padding 3px - text-align right - overflow hidden - text-overflow ellipsis - white-space nowrap - border 1px solid #ddd - - th - min-width 5.25em - width 5.25em - - td - min-width 8em - width 100% - - .units - padding 0 - - select - width 100% - height 1.9em - background-color transparent - border 0 - padding 3px - text-align right - - .eta - font-size 90% - - .progress - height 1.75em - - label - float right - - .bar - height 1.75em - background #f2ac45 - - .override - display none /* Hidden for now */ - margin 0.5em 0 - white-space nowrap - - label - font-weight bold - min-width 3.5em - display inline-block - - .percent - display inline-block - width 3em - - input - border-radius 0 - margin -0.4em 0.5em - - .override:nth-of-type(1) - clear left - float left - - .override:nth-of-type(2) - clear right - float right - - .toolbar - clear both - padding 0 0.125em - margin-bottom 50px - - > * - margin 0.25em 0.125em - - select - max-width 11em - min-width inherit !important - - .progress - display inline-block - background #fff - line-height 2em - border 1px solid #aaa - border-radius 3px - width 330px - vertical-align middle - text-align center - - div - height 2em - background #f2ac45 - - label - margin 0 0.125em - white-space nowrap - - .tabs - section - min-height 500px - overflow-x hidden - overflow-y auto - padding 0 - margin 0 - - .path-viewer - width 100% - - .path-viewer-content - height 500px - - .gcode, .history - font-family courier - clear both - overflow auto - width 100% - height 450px - white-space nowrap - - .clusterize-scroll - max-height 450px - - &.placeholder - color #aaa - - .history - padding 0.25em - - .gcode ul, .history ul - margin 0 - padding 0 - list-style none - - .gcode ul - li - line-height 15px - - li:nth-child(even) - background-color #fafafa - - li.highlight - background-color #eaeaea - - li > b - font-weight normal - display inline-block - padding 0 0.25em - color #e5aa3d - min-width 4em - - .history ul li - cursor pointer - - .mdi - clear both - white-space nowrap - padding 0.125em - display flex - - > * - margin 0.125em - - input - flex 2 - - .jog - text-align center - - > svg - margin 1em - - .jog-settings - margin-bottom 1em - - input - margin 0 0.5em - vertical-align middle - - .macros-div - display flex - flex-wrap wrap - justify-content flex-start - margin 10px - margin-left 50px - margin-right 50px - margin-bottom 30px - - .macros-button - height 60px - width 115px - font-weight normal - border-radius 10px - margin-left 1rem - margin-top 1rem - border 0 - overflow-wrap break-word - color #fff - box-shadow rgba(0, 0, 0, 0.3) 0px 0px 5px - text-shadow: rgba(0, 0, 0, 0.8) 0px 0px 3px +.app-head .estop:hover .button circle + fill #b72424 !important #macros width 104% @@ -990,66 +788,12 @@ tt.save background-color #f3f3f3 -.tabs +// Legacy `.tabs` selector retained only for #macros (Settings → Macros) +// which uses .tabs as a content container with a