From 94072253d44e9456a9520dae12624d8205abed71 Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Sun, 3 May 2026 14:11:29 +0200 Subject: [PATCH] ui: V09 redesign - Control/Program/Console/Settings shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the legacy side-menu chrome with a 4-tab top header. - index.pug: tablet/kiosk fit-to-viewport script, header tab nav, estop/state badges in header. - app.js: route hash to (control|program|console|), multi-section settings shell. - control-view: header DRO, jog grid, MDI/probe/macros panels. - program-view + program-mixin: file browser + toolpath preview + run/pause/stop, replaces the legacy 'macros' tab content. - console-view: MDI shell, message log, indicators. - settings-shell-view: rail-driven inner pages (Display & Units, Probing, G-code & Motion, Macros, Network, etc.). - settings-view: filter Svelte SettingsView to one rail section. - SettingsView.svelte: tag every section with data-sec=… so the filter above can hide non-matching ones. - style.styl: ~2700 lines of V09 layout, DRO, jog grid, status strip, and tablet/kiosk variants. No A-axis / auxiliary-axis content lives on this branch. --- README.md | 111 +- src/js/app.js | 273 +- src/js/console-view.js | 125 + src/js/control-view.js | 704 +---- src/js/program-mixin.js | 607 ++++ src/js/program-view.js | 62 + src/js/settings-shell-view.js | 169 + src/js/settings-view.js | 54 +- src/pug/index.pug | 231 +- src/pug/templates/console-view.pug | 67 + src/pug/templates/control-view.pug | 695 ++--- src/pug/templates/program-view.pug | 142 + src/pug/templates/settings-shell.pug | 56 + src/stylus/style.styl | 2715 +++++++++++++---- .../src/components/SettingsView.svelte | 28 +- .../components/TextFieldWithOptions.svelte | 2 +- 16 files changed, 4165 insertions(+), 1876 deletions(-) create mode 100644 src/js/console-view.js create mode 100644 src/js/program-mixin.js create mode 100644 src/js/program-view.js create mode 100644 src/js/settings-shell-view.js create mode 100644 src/pug/templates/console-view.pug create mode 100644 src/pug/templates/program-view.pug create mode 100644 src/pug/templates/settings-shell.pug diff --git a/README.md b/README.md index 8988acf..1e1bee2 100644 --- a/README.md +++ b/README.md @@ -1 +1,110 @@ -#OneFinity CNC Controller Firmware +# OneFinity CNC Controller Firmware (community fork) + +This is the OneFinity / Buildbotics bbctrl firmware with a redesigned +UI (V09), Font Awesome 6, faster cold boot, and a streamlined macOS +dev / deploy workflow. + +## Layout + +``` +src/avr/ AVR firmware (motion controller, AtxMega) +src/boot/ AVR bootloader +src/bbserial/ Linux kernel module for the bbserial driver +src/py/bbctrl/ Python control daemon (Tornado + websockets) +src/js/ Vue.js UI (legacy) +src/svelte-components/ Newer Svelte UI for dialogs and settings +src/pug/ Pug templates compiled into build/http/index.html +src/resources/ Static assets and config templates +scripts/ Install / update / RPi build helpers +docs/ Architecture, dev setup +``` + +## Build & flash (quick path, macOS or Linux) + +The full build (`make`) requires `avr-gcc`, but the controller and UI +only depend on the Python + web parts. If you're shipping a UI/Python +change you don't need the AVR toolchain. + +### Prerequisites + +- Node.js (any recent LTS) with npm +- Python 3 with setuptools +- `npm install` once at the project root (this is wired into the + `node_modules` Make target, but on a fresh checkout it's clearer to + do it explicitly) + +```bash +npm install +(cd src/svelte-components && npm install) +``` + +#### macOS gotcha: esbuild platform pin + +The Pi build leaves `node_modules/esbuild` pinned to +`linux-arm64`, which won't run on Darwin. If `npm run build` inside +`src/svelte-components` complains about esbuild, reinstall it for the +host: + +```bash +cd src/svelte-components +rm -rf node_modules/esbuild +npm install esbuild@0.14.49 --no-save +``` + +(Use the version that matches `package-lock.json`.) + +### Build the web UI + Python sdist + +```bash +# Build the Svelte components +(cd src/svelte-components && npm run build) + +# Render pug templates and copy assets into build/http +make all # AVR step will fail without avr-gcc; safe to ignore + # if you didn't change anything under src/avr or src/boot + +# Package +./setup.py sdist +ls dist/bbctrl-*.tar.bz2 +``` + +`make pkg` is the canonical target but it tries to build AVR first. On +hosts without avr-gcc, run the steps above directly. + +If `bbctrl-*.tar.bz2` is missing `src/bbserial/bbserial.ko`, copy the +prebuilt `.ko` from a previous official release into `src/bbserial/` +before running `setup.py sdist` (the install script on the controller +just installs the existing module if a newer one isn't shipped). + +### Flash to a controller + +```bash +curl -X PUT -H "Content-Type: multipart/form-data" \ + -F "firmware=@dist/bbctrl-1.6.7.tar.bz2" \ + -F "password=onefinity" \ + http://onefinity.local/api/firmware/update +``` + +…or use the Make target: + +```bash +make update HOST=onefinity.local PASSWORD=onefinity +``` + +The controller stops bbctrl, untars the package, runs +`scripts/install.sh`, and brings the service back up. Total downtime +is ~30-45s. Watch progress at `http:///` (you'll get 404s while +bbctrl restarts, then the new UI). + +### Verify the flash + +```bash +curl -s http://onefinity.local/ | grep -c "OneFinity" +curl -s http://onefinity.local/api/diag/timing | head +``` + +## Build & flash (full path, Debian/Linux) + +For AVR + GPlan rebuilds, see [docs/development.md](docs/development.md). +That path uses qemu + chroot to cross-compile gplan for ARM and needs +the `gcc-avr` / `avr-libc` toolchain. diff --git a/src/js/app.js b/src/js/app.js index ceeb9ef..8fa8c9f 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -4,6 +4,7 @@ const api = require("./api"); const cookie = require("./cookie")("bbctrl-"); const Sock = require("./sock"); const semverLt = require("semver/functions/lt"); +const restartTiming = require("./restart-timing"); if (document.getElementById("svelte-dialog-host") != undefined) { SvelteComponents.createComponent( @@ -103,12 +104,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 +155,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 +171,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: { @@ -227,6 +251,19 @@ module.exports = new Vue({ }, computed: { + // True when the UI is in kiosk mode — i.e. running on the + // controller's own onboard browser (Pi 3B at 1366x768) or + // explicitly forced via ?kiosk=1. Source-of-truth is the + // `kiosk-mode` class added to by the inline script + // in index.pug, which already honors hostname + URL param + + // localStorage. The Pi's VideoCore IV is too slow for the + // three.js toolpath preview, so we suppress that panel in + // kiosk mode and let the gcode listing take the full width. + is_kiosk: function() { + return typeof document !== "undefined" + && document.documentElement.classList.contains("kiosk-mode"); + }, + popupMessages: function() { const msgs = []; @@ -252,18 +289,130 @@ 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(); + + // Embedded Svelte subviews (A axis settings, etc.) signal + // unsaved changes via this event. The master Save button + // highlights when modified is true. + window.addEventListener("onefin:dirty", () => { + this.modified = true; + }); + + + + // 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. Skip routing into the Svelte settings + // family before config has loaded — those components read + // many config keys (settings.units, settings.probing-prompts, + // motion.*, etc.) and would throw on first paint with the + // empty placeholder config. + const settingsFamily = [ + "settings", "probing", "gcode", + "admin-general", "admin-network", + "motor", "tool", "io", "macros", + "help", "cheat-sheet", + ]; + const initialHead = (location.hash || "").replace(/^#/, "").split(":")[0]; + if (settingsFamily.indexOf(initialHead) === -1) { + this.parse_hash(); + } + // else: stay on "loading" until update() completes and calls + // parse_hash() itself. + 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(); @@ -338,6 +487,12 @@ module.exports = new Vue({ toggle_rotary: async function(isActive) { try { await api.put("rotary", {status: isActive}); + // The /api/rotary endpoint rewrites motors[1]/[2] + // in config.json on the server. Refetch so the UI + // reflects the new motor config (otherwise the + // motor settings page keeps showing pre-toggle + // values until the next page reload). + await this.update(); } catch (error) { console.error(error); alert("Error occured"); @@ -372,11 +527,19 @@ module.exports = new Vue({ connect: function() { this.sock = new Sock(`//${location.host}/sockjs`); + let _gotFirstMsg = false; + let _gotFirstState = false; + this.sock.onmessage = (e) => { if (typeof e.data != "object") { return; } + if (!_gotFirstMsg) { + _gotFirstMsg = true; + restartTiming.onWsFirstMessage(); + } + if (e.data.log && e.data.log.msg !== "Switch not found") { this.$broadcast("log", e.data.log); @@ -386,6 +549,11 @@ module.exports = new Vue({ } } + if (!_gotFirstState) { + _gotFirstState = true; + restartTiming.onFirstState(); + } + // Check for session ID change on controller if ("sid" in e.data) { if (typeof this.sid == "undefined") { @@ -410,6 +578,7 @@ module.exports = new Vue({ this.sock.onopen = () => { this.status = "connected"; + restartTiming.onWsOpen(); this.$emit(this.status); this.$broadcast(this.status); }; @@ -421,6 +590,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 +614,57 @@ 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", "probing", "gcode", + "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() { @@ -455,7 +684,7 @@ module.exports = new Vue({ this.config["selected-tool-settings"][selected_tool] = settings; this.display_units = this.config.settings["units"]; - + try { await api.put("config/save", this.config); this.modified = false; 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 a22de0b..4e928ac 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,28 +180,39 @@ 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(";"); }, + // Should the macro row render a colored left stripe for this + // macro? Only when the user has explicitly picked a color. The + // controller seeds new macros with default placeholders like + // "#ffffff" or "#dedede"; treat anything that close to white as + // "no color". + has_macro_color(macros) { + if (!macros || typeof macros.color !== "string") return false; + const c = macros.color.trim().toLowerCase(); + if (!c) return false; + const defaults = [ + "#fff", "#ffffff", "#fefefe", "#fdfdfd", "#fcfcfc", + "#dedede", "#dddddd", "#cccccc", + ]; + if (defaults.indexOf(c) !== -1) return false; + // Fallback: if the color is very close to white (sum of RGB + // > 690), suppress the stripe. + const m = c.match(/^#([0-9a-f]{6})$/); + if (m) { + const v = parseInt(m[1], 16); + const r = (v >> 16) & 0xff; + const g = (v >> 8) & 0xff; + const b = v & 0xff; + if (r + g + b > 690) return false; + } + return true; + }, + jog_fn: function (x_jog, y_jog, z_jog, a_jog) { const amount = this.jog_incr_amounts[this.display_units][this.jog_incr]; @@ -324,426 +228,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; @@ -765,6 +249,15 @@ module.exports = { api.put(`home/${axis}/clear`); }, + home_all: async function () { + this.ask_home = false; + try { + await api.put("home"); + } catch (e) { + console.error("Home all failed:", e); + } + }, + show_set_position: function (axis) { SvelteComponents.showDialog("SetAxisPosition", { axis }); }, @@ -790,93 +283,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/program-mixin.js b/src/js/program-mixin.js new file mode 100644 index 0000000..e8c2aad --- /dev/null +++ b/src/js/program-mixin.js @@ -0,0 +1,607 @@ +"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"; + }, + + // True only while a loaded G-code program is actually being + // executed (running, paused/holding, or stopping). Excludes + // jogging, homing, probing, MDI commands and other one-off + // motion that also leave state.xx == "RUNNING" but must not + // swap the jog grid out for the "Now Running" panel. + // + // Distinguishing signal is state.cycle: + // - "idle" : nothing happening + // - "jogging" : user-initiated jog + // - "homing" : home cycle + // - "probing" : probe cycle + // - "mdi" : single MDI command + // - "running" : an actual loaded program is being run + // Only "running" (combined with a selected file) is what we want. + is_program_executing: function () { + if (!this.state) return false; + const xx = this.state.xx; + const cycle = this.state.cycle; + const isExecState = xx == "RUNNING" || xx == "HOLDING" || xx == "STOPPING"; + if (!isExecState) return false; + // The cycle string narrows it to a real program run; anything + // else (jogging / homing / probing / mdi) is a one-off. + if (cycle && cycle != "running" && cycle != "idle") return false; + return !!this.state.selected; + }, + + 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 list = Array.isArray(this.state.gcode_list) ? this.state.gcode_list : []; + const folder = list.find(item => item.name == this.state.folder); + if (!folder) return []; + const stateFiles = Array.isArray(this.state.files) ? this.state.files : []; + const files = (folder.files || []) + .filter(item => stateFiles.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 () { + const list = Array.isArray(this.state.gcode_list) ? this.state.gcode_list : []; + return 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; + + // state.files can be undefined briefly after connect, before the + // controller has pushed its file list. Skip the existence check + // until we have a list to consult. + const files = Array.isArray(this.state.files) ? this.state.files : null; + if (this.state.selected && files && !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: async function (id) { + if (this.state.macros[id].file_name == "default") { + this.showNoGcodeMessage = true; + return; + } + const file_name = this.state.macros[id].file_name; + try { + // Selecting a file on the server is a side effect of + // GET /api/file/. The macro button used to mutate + // state.selected client-side and immediately call start, which + // raced the file fetch: if the server hadn't seen the new + // selection yet, mach.start() ran whichever file was selected + // last. Do it explicitly and await so start always sees the + // right file. + if (file_name != this.state.selected) { + this.state.selected = file_name; + // GET /api/file/ returns gcode text (not JSON), so use + // fetch directly. The server's FileHandler.get sets + // state.selected as a side effect; we await the response + // before starting so mach.start() reads the right file. + const resp = await fetch( + `/api/file/${encodeURIComponent(file_name)}`, + { cache: "no-cache" } + ); + if (!resp.ok) { + throw new Error(`file fetch failed: ${resp.status}`); + } + await resp.text(); + } + this.load(); + if (this.state.macros[id].alert == true) { + this.macrosLoading = true; + } else { + await this.start_pause(); + } + } catch (error) { + console.warn("Error running macro: ", error); + } + }, + }, +}; diff --git a/src/js/program-view.js b/src/js/program-view.js new file mode 100644 index 0000000..93f1609 --- /dev/null +++ b/src/js/program-view.js @@ -0,0 +1,62 @@ +"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: { + is_kiosk: function () { return !!this.$root.is_kiosk; }, + + 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..1050a8b --- /dev/null +++ b/src/js/settings-shell-view.js @@ -0,0 +1,169 @@ +"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 + // Whether the controller config has streamed in. The Svelte + // settings views crash on first paint with the placeholder + // config (settings.units / settings.easy-adapter / motion.* + // are all undefined). Gate the inner mount on this flag. + config_ready: false, + rail_items: [ + { sub: "settings", href: "#settings", icon: "fa-display", label: "Display & Units" }, + { sub: "probing", href: "#probing", icon: "fa-bullseye", label: "Probing" }, + { sub: "gcode", href: "#gcode", icon: "fa-code", label: "G-code & Motion" }, + { sub: "macros", href: "#macros", icon: "fa-keyboard", label: "Macros" }, + { sub: "cheat-sheet", href: "#cheat-sheet", icon: "fa-book", label: "G-code Cheat Sheet" }, + { 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: "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(); + this._configPoll = setInterval(() => { + const c = this.$root && this.$root.config; + const ready = !!(c && c.full_version && c.full_version !== "" + && c.settings && typeof c.settings === "object"); + if (ready !== this.config_ready) this.config_ready = ready; + }, 200); + }, + + 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); + } + if (this._configPoll) clearInterval(this._configPoll); + }, + + 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; + }, + + on_rail_click: function (item, ev) { + if (!item) return; + // Always preventDefault on rail clicks. Letting the browser + // anchor-scroll to
etc. inside .app-body + // can pull the .app-head out of view; we drive navigation + // ourselves through location.hash and our hashchange handler. + if (ev && ev.preventDefault) ev.preventDefault(); + + if (item.anchor) { + // Soft-link rail items use a #settings hash plus an in-page + // anchor scroll once the Svelte page has mounted. We scroll + // ONLY the .settings-content overflow container by setting + // its scrollTop directly — element.scrollIntoView() walks all + // ancestor scroll containers and can tug the .app-body / html + // layout, which under tablet mode pulls the fixed header out + // of view. + if (location.hash !== item.href) location.hash = item.href; + const reset = () => { + // Force any inadvertent ancestor scroll back to 0 before + // we move .settings-content explicitly. + window.scrollTo(0, 0); + const body = document.querySelector(".app-body"); + if (body) body.scrollTop = 0; + document.documentElement.scrollTop = 0; + }; + setTimeout(() => { + reset(); + const el = document.getElementById(item.anchor); + const scroller = document.querySelector(".settings-content"); + if (el && scroller) { + const elTop = el.getBoundingClientRect().top; + const scTop = scroller.getBoundingClientRect().top; + scroller.scrollTop = scroller.scrollTop + (elTop - scTop) - 12; + } + // Re-assert ancestor scroll = 0 in case the assignment above + // moved things. + requestAnimationFrame(reset); + }, 320); + } else { + if (location.hash !== item.href) location.hash = item.href; + // Reset .app-body scroll so each route starts at the top. + const body = document.querySelector(".app-body"); + if (body) body.scrollTop = 0; + } + }, + + showShutdownDialog: function () { + SvelteComponents.showDialog("Shutdown"); + }, + }, +}; diff --git a/src/js/settings-view.js b/src/js/settings-view.js index 514b42b..ac6b94b 100644 --- a/src/js/settings-view.js +++ b/src/js/settings-view.js @@ -1,14 +1,60 @@ +// V09 wraps the legacy Svelte SettingsView and filters its big page +// down to a single rail section so each rail item shows only the +// relevant controls. The Svelte component is left untouched (it is +// shared with the legacy UI) — we just hide the `

` and `
` +// elements whose `data-sec` does not match the active section. + module.exports = { template: "#settings-view-template", - attached: function() { + props: { + // "display" | "probing" | "gcode". Default is "display" which + // keeps the rail's "Display & Units" item working unchanged. + section: { default: "display" }, + }, + + attached: function () { this.svelteComponent = SvelteComponents.createComponent( "SettingsView", document.getElementById("settings") ); + // Defer one tick so Svelte has rendered the section markup. + setTimeout(() => this.apply_section_filter(), 0); }, - detached: function() { - this.svelteComponent.$destroy(); - } + detached: function () { + if (this.svelteComponent) this.svelteComponent.$destroy(); + }, + + watch: { + section: function () { + this.apply_section_filter(); + }, + }, + + methods: { + apply_section_filter: function () { + const root = document.getElementById("settings"); + if (!root) return; + const want = this.section || "display"; + // Hide every section block that does not match. + root.querySelectorAll("[data-sec]").forEach(el => { + el.style.display = el.dataset.sec === want ? "" : "none"; + }); + // Hide the global

Settings

on subsections so the + // page reads as a focused panel. + const h1 = root.querySelector(".settings-view > h1"); + if (h1) { + if (want === "display") { + h1.textContent = "Display & Units"; + } else if (want === "probing") { + h1.textContent = "Probing"; + } else if (want === "gcode") { + h1.textContent = "G-code & Motion"; + } else { + h1.textContent = "Settings"; + } + } + }, + }, }; diff --git a/src/pug/index.pug b/src/pug/index.pug index 79f1416..97bc500 100644 --- a/src/pug/index.pug +++ b/src/pug/index.pug @@ -8,9 +8,8 @@ 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/fa6.min.css style: include ../static/css/Audiowide.css style: include ../static/css/clusterize.css style: include ../svelte-components/node_modules/svelte-material-ui/bare.css @@ -19,103 +18,171 @@ html(lang="en") style: include:stylus ../stylus/style.styl body(v-cloak) + // Tablet (kiosk) mode — pins the .app-shell to 1920x1080 and + // scales it to fit the actual viewport so the UI always looks + // exactly like the 10.8" 1920x1080 portable monitor. + // + // Toggle: ?tablet=1 to enable + // ?tablet=0 to disable + // Sticky in localStorage; once set, no querystring is needed. + script. + (function () { + try { + var p = new URLSearchParams(location.search); + if (p.has("tablet")) { + var on = p.get("tablet") !== "0" && p.get("tablet") !== "false"; + localStorage.setItem("ui-tablet-mode", on ? "1" : "0"); + } + if (localStorage.getItem("ui-tablet-mode") === "1") { + document.documentElement.classList.add("tablet-mode"); + } + function fit() { + if (!document.documentElement.classList.contains("tablet-mode")) return; + var s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080); + document.documentElement.style.setProperty("--tablet-scale", s); + } + fit(); + window.addEventListener("resize", fit); + + // Kiosk mode: when the UI is loaded by the controller's + // own onboard browser (Chromium pointing at localhost on + // the Pi 3B at 1366x768), apply a tighter layout that + // packs the V09 UI into the smaller, slower display. + // Override with ?kiosk=0 to force the desktop layout. + if (p.has("kiosk")) { + var k = p.get("kiosk") !== "0" && p.get("kiosk") !== "false"; + localStorage.setItem("ui-kiosk-mode", k ? "1" : "0"); + } + var stored = localStorage.getItem("ui-kiosk-mode"); + var auto = location.hostname === "localhost" + || location.hostname === "127.0.0.1" + || location.hostname === "::1"; + if (stored === "1" || (stored !== "0" && auto)) { + document.documentElement.classList.add("kiosk-mode"); + } + } catch (_e) {} + })(); + #svelte-dialog-host #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-temperature-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-circle-exclamation.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. We keep instances alive across tab swaps so: + // - The Program tab's WebGL canvas does not + // get destroyed and recreated each time (which caused a + // dark flash as the GL context cleared the new canvas + // before its first frame). + // - The Program tab's clusterize.js gcode list does not + // re-virtualize from scratch on every visit. + // - The Settings shell's child Svelte components stay + // mounted, preserving any in-flight form state. + // The settings-shell handles its own inner v-if cascade so + // the Vue 1 reactivity quirk that motivated removing + // keep-alive earlier no longer applies here. + .app-body + component(:is="currentView + '-view'", :index="index", + :config="config", :template="template", :state="state", + :sub-tab="sub_tab", keep-alive) 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..5c63342 --- /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-circle-check + |  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 06077d3..f250def 100644 --- a/src/pug/templates/control-view.pug +++ b/src/pug/templates/control-view.pug @@ -1,475 +1,296 @@ 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 + h3(slot="header") Choose probe type div(slot="body") + p Pick which probe routine to run. button.pure-button(:class="state['pw'] ? '' : 'load-on'", @click="showProbeDialog('xyz')") Probe XYZ button.pure-button(:class="state['pw'] ? '' : 'load-on'", @click="showProbeDialog('z')") Probe Z 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 ===== + // Hidden only while a G-code program is running / paused / + // stopping. Jogging / homing / MDI moves do not hide it. + .jog-card(v-if="!is_program_executing") + .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}} - tr(v-if="state['2an'] == 3") - td(style="height:100px", align="center", colspan="1") - button(@click="show_probe_dialog=true") - | Probe - br - | Rotary + .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="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 + // Row 2 + button.jbtn(@click="jog_fn(-1, 0, 0, 0)") X− + button.jbtn(@click="showMoveToZeroDialog('xy')") + span.lbl XY + span Origin + button.jbtn(@click="jog_fn(1, 0, 0, 0)") X+ + button.jbtn(@click="showMoveToZeroDialog('z')") + span.lbl Z + span Origin - 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 + // 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-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 + // Row 4 — A axis (rotary) when rotary is enabled. + template(v-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 - td(style="height:100px", align="center", colspan="2") - button(:class="state['pw'] ? '' : 'load-on'", - style="height:100px;width:200px", - @click="showProbeDialog('z')") - | Probe Z + // Row 4 — fallback probe / zero / home shortcuts + template(v-if="state['2an'] != 3") + 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-location-dot.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()") + .fa.fa-home.ico + span.lbl Home all - 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 - .fa(:class=`'fa-' + ${axis}.icon`) - | {{#{axis}.state}} - td.tstate(:class=`${axis}.tklass`, :title=`${axis}.toolmsg`, @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 - - 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(/^#/, '')}} - - tr - th Display Units - td.units - select(v-model="display_units") - option(value="METRIC") METRIC - option(value="IMPERIAL") IMPERIAL - - tr(title="Active tool") - th Tool - td {{state.tool || 0}} - - 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'}} - - tr(title="Programmed feed rate.") - th Feed - td - unit-value(:value="state.feed", precision="2", unit="", iunit="") - | {{metric ? ' mm/min' : ' IPM'}} - - 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' - - 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") + // ===== NOW RUNNING (replaces jog grid only while a G-code + // program is actually executing). Jogging is excluded. + .running-panel(v-if="is_program_executing") + .running-top + div + .running-file + .fa.fa-file-code + span(v-if="state.selected")  {{state.selected}} + span(v-else)  {{(mach_state || 'BUSY').toLowerCase()}} + .running-meta + span(v-if="is_running") {{ (mach_state || 'RUNNING').toLowerCase() }} + span(v-if="is_holding") paused + span(v-if="is_holding && pause_reason") · {{pause_reason}} + span(v-if="is_stopping") stopping + span(v-if="toolpath.lines") · line {{state.line || 0 | number}} / {{toolpath.lines | number}} + span(v-if="plan_time_remaining") · ETA {{plan_time_remaining | time}} + .running-pct + | {{((progress || 0) * 100) | fixed 0}} + span % + .running-progress + div(:style="'width:' + ((progress || 0) * 100) + '%'") + .running-stats + .running-stat + .lbl Velocity + .val + unit-value(:value="state.v", precision="2", unit="", iunit="", scale="0.0254") + |  {{metric ? 'm/min' : 'IPM'}} + .running-stat + .lbl Feed + .val + unit-value(:value="state.feed", precision="0", unit="", iunit="") + |  {{metric ? 'mm/min' : 'IPM'}} + .running-stat + .lbl Spindle + .val + | {{(state.speed || 0) | fixed 0}} + span(v-if="state.s != null && !isNaN(state.s)") ({{state.s | fixed 0}}) + |  RPM + .running-stat + .lbl Tool + .val T{{state.tool || 0}} + .running-row + // While RUNNING the primary action is Pause; while HOLDING / STOPPING it's Resume. + button.tx-btn.pause(v-if="is_running", @click="pause()") + .fa.fa-pause + span.lbl PAUSE + button.tx-btn.run(v-if="is_holding || is_stopping", @click="unpause()") + .fa.fa-play + span.lbl RESUME + button.tx-btn.stop(@click="stop()") .fa.fa-stop + span.lbl STOP + button.tx-btn.step(v-if="is_holding", @click="step()") + .fa.fa-forward-step + span.lbl STEP - input(v-model="mdi", :disabled="!can_mdi", @keyup.enter="submit_mdi") + // ===== DRO + status strip ===== + .right-col - div - em The machine is currently operating in #[strong {{mach_units}}] units. Use G20/G21 to switch units. + .dro-card + .dro-head + div Axis + div Position + div Absolute + div Offset + .actions-cell + // Master Home All. Each row's Actions cell has a per-axis + // home button; this header-level button homes every + // enabled axis (legacy Onefinity behavior). + button.icon-btn(:disabled="!is_idle", + title="Home all axes.", @click="home_all()") + .fa.fa-house-chimney - .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}} + // 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}.toolmsg ? (${axis}.title + ' — ' + ${axis}.toolmsg) : ${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) + .actions-cell + button.icon-btn(:disabled="!can_set_axis", + :title=`'Set ${axis.toUpperCase()} axis position.'`, + @click=`show_set_position('${axis}')`) + .fa.fa-gear + button.icon-btn(:class=`${axis}.tklass.indexOf('error') !== -1 ? 'state-red' : (${axis}.tklass.indexOf('warn') !== -1 ? 'state-amber' : 'state-green')`, + :disabled="!can_set_axis", + :title=`${axis}.toolmsg || ('Zero ${axis.toUpperCase()} axis offset.')`, + @click=`zero('${axis}')`) + .fa.fa-location-dot + button.icon-btn(:class=`${axis}.klass.indexOf('error') !== -1 ? 'state-red' : (${axis}.homed ? 'state-green' : 'state-amber')`, + :disabled="!is_idle", + :title=`${axis}.title`, + @click=`home('${axis}')`) + .fa.fa-home - section#content3.tab-content - console + // ----- 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 - section#content4.tab-content - indicators(:state="state", :template="template") + .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'}} - .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}} + .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") - .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}} + .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 -- - + // ----- Macro row (slice 0..7); full list lives in Settings → Macros ----- + // The colored left stripe (.has-color) is suppressed for white, + // near-white and other default placeholder colors so unconfigured + // macros render as clean slate tiles instead of looking lopsided. + .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", + :class="{'has-color': has_macro_color(macros)}", + :style="has_macro_color(macros) ? {borderLeftColor: macros.color} : {}") + span.mnum {{index + 1}} + span.mname {{macros.name || ('Macro ' + (index + 1))}} + + // ----- 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..c11c4e7 --- /dev/null +++ b/src/pug/templates/program-view.pug @@ -0,0 +1,142 @@ +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-plus.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. + // The 3D path-viewer is suppressed when the UI is loaded by + // the Pi's onboard kiosk browser — the VideoCore IV cannot + // run three.js at a usable frame rate. Off-Pi clients still + // see the full split. + .program-body(:class="{'no-preview': is_kiosk}") + gcode-viewer + path-viewer(v-if="!is_kiosk", :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..b7de0ff --- /dev/null +++ b/src/pug/templates/settings-shell.pug @@ -0,0 +1,56 @@ +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", @click="on_rail_click(item, $event)") + .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). + // The Svelte settings views read many config keys eagerly on + // attach (settings.units, settings.easy-adapter, motion.*), + // so we gate the inner mount on config_ready. + settings-view-inner(v-if="sub === 'settings' && config_ready", + section="display", + :index="index", :config="config", :template="template", :state="state") + settings-view-inner(v-if="sub === 'probing' && config_ready", + section="probing", + :index="index", :config="config", :template="template", :state="state") + settings-view-inner(v-if="sub === 'gcode' && config_ready", + section="gcode", + :index="index", :config="config", :template="template", :state="state") + admin-general-view(v-if="sub === 'admin-general' && config_ready", + :index="index", :config="config", :template="template", :state="state") + admin-network-view(v-if="sub === 'admin-network' && config_ready", + :index="index", :config="config", :template="template", :state="state") + motor-view(v-if="sub === 'motor' && config_ready", + :index="index", :config="config", :template="template", :state="state") + tool-view(v-if="sub === 'tool' && config_ready", + :index="index", :config="config", :template="template", :state="state") + io-view(v-if="sub === 'io' && config_ready", + :index="index", :config="config", :template="template", :state="state") + macros-view(v-if="sub === 'macros' && config_ready", + :index="index", :config="config", :template="template", :state="state") + help-view(v-if="sub === 'help' && config_ready", + :index="index", :config="config", :template="template", :state="state") + cheat-sheet-view(v-if="sub === 'cheat-sheet' && config_ready", + :index="index", :config="config", :template="template", :state="state") + .settings-loading(v-if="!config_ready") + | Loading configuration… diff --git a/src/stylus/style.styl b/src/stylus/style.styl index f64568a..5915641 100644 --- a/src/stylus/style.styl +++ b/src/stylus/style.styl @@ -1,12 +1,54 @@ +// ===================================================================== +// 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 + +// Lock html + body so nothing other than the explicit .app-body or +// inner scroll containers can scroll. Without this, autofocus inside +// nested Svelte components (Settings, Admin Network, etc.) can call +// scrollIntoView() on the html element and push our fixed header off +// the top of the viewport. +html, body + height 100% + overflow hidden + overscroll-behavior none + 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 +74,406 @@ 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 + height 100vh // cap at viewport so children that ask for 1fr/flex:1 + width 100% + overflow hidden + background $body-bg + // Hint to the browser that this layer is a stable composited + // surface so tab swaps inside it don't invalidate the whole page. + contain layout paint + isolation isolate -.nav-header - padding-left 60px - display flex +// Program tab pre-warmer. Mounts the program-view at app start so +// the WebGL canvas is already initialized when the user first +// clicks the Program tab — avoids the first-time dark flash that +// happens when WebGL is created on demand. We render the component +// at full size off-screen with the same width as the live tab so +// the canvas dims match. +.program-warmer + position absolute + left -10000px + top 0 + width 1920px + height 980px + pointer-events none + visibility hidden - .brand +// ===================================================================== +// Tablet / kiosk mode +// +// When is set (via ?tablet=1, sticky in +// localStorage), pin the app shell to a fixed 1920 x 1080 box and +// scale it to fit the real viewport. The scale ratio is published as +// the --tablet-scale CSS variable by the inline script in index.pug. +// +// On the actual 10.8" 1920x1080 portable monitor the scale is 1; on +// a desktop browser at e.g. 1440x900 you get a faithfully shrunk +// preview of exactly what the kiosk renders. +// ===================================================================== +html.tablet-mode + background #0f172a + overflow hidden + +html.tablet-mode body + margin 0 + width 100vw + height 100vh + overflow hidden + display flex + align-items center + justify-content center + +html.tablet-mode .app-shell + width 1920px + height 1080px + flex 0 0 auto + transform scale(var(--tablet-scale, 1)) + transform-origin center center + box-shadow 0 30px 60px rgba(0, 0, 0, 0.45) + border-radius 14px + +// Keep dialog hosts above the scaled shell. +html.tablet-mode #svelte-dialog-host + position fixed + top 0 + left 0 + z-index 10000 + +.app-body + flex 1 + min-height 0 + display flex + flex-direction column + padding 18px + overflow auto // settings/motor pages can scroll inside the body + + > * + flex 1 1 auto + 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 + // sticky so the header stays visible even if a nested scroll + // container manages to move under it. + position sticky + top 0 + 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 +548,67 @@ 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 +// E-Stop in the header — wraps the legacy SVG component. +// Sized to fit the 96px header with breathing room. The SVG carries +// its own yellow safety ring and EMERGENCY/STOP text; we only frame +// it with a soft drop shadow and a hover/active hit target. +.app-head .estop + width 80px + height 80px + display inline-flex + align-items center + justify-content center + border-radius 9999px + cursor pointer + transition transform 0.06s, filter 0.15s + flex 0 0 auto + // Make sure the SVG's internal coordinate space scales correctly + overflow visible - &.active .ring - animation blink 2s step-start 0s infinite + &:hover + filter brightness(1.05) - &:hover .button circle - fill #b72424 !important + &:active + transform scale(0.96) svg + width 80px + height 80px cursor pointer + display block .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% @@ -837,8 +717,12 @@ span.unit text-transform capitalize .path-viewer-content - background-color #333 - background linear-gradient(to bottom, #666 0%, #222 100%); + // Solid dark background matching the WebGL renderer's clear + // colour. We used to use a gradient (#666 -> #222) but the + // visible-during-mount color difference between the gradient + // top and the GL clear bottom caused a brief dark flash when + // the canvas was reattached on every tab switch. + background-color #222 margin-bottom 0.5em &.small @@ -990,66 +874,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