Compare commits
1 Commits
main
...
CustomTkin
| Author | SHA1 | Date | |
|---|---|---|---|
| 98832cd765 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
#Folders
|
#Folders
|
||||||
/node_modules/
|
/node_modules/
|
||||||
|
/.venv/
|
||||||
|
|
||||||
#Files
|
#Files
|
||||||
/config.json
|
/config.json
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
const { SerialPort } = require('serialport');
|
|
||||||
const fs = require('fs');
|
|
||||||
const EventEmitter = require('events');
|
|
||||||
// add parser import
|
|
||||||
const { ReadlineParser } = require('@serialport/parser-readline');
|
|
||||||
|
|
||||||
class ArduinoThread extends EventEmitter {
|
|
||||||
constructor(comPort, baudRate = 9600, sliderCount = 3, yamlPath = 'config.json', verbose = false) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
if (typeof comPort !== 'string') throw new Error('comPort must be a string');
|
|
||||||
if (typeof baudRate !== 'number') throw new Error('baudRate must be a number');
|
|
||||||
if (typeof sliderCount !== 'number') throw new Error('sliderCount must be a number');
|
|
||||||
|
|
||||||
this.port = new SerialPort({ path: comPort, baudRate, autoOpen: true });
|
|
||||||
this.sliderCount = sliderCount;
|
|
||||||
this.yamlPath = yamlPath; // now points to config.json by default
|
|
||||||
this.verbose = !!verbose;
|
|
||||||
|
|
||||||
this.sendBuffer = [];
|
|
||||||
this.updateList = Array(sliderCount).fill(0);
|
|
||||||
this.isRunning = true;
|
|
||||||
this.pendingMessage = null;
|
|
||||||
|
|
||||||
// pipe through a newline-based parser so we always get full lines
|
|
||||||
try {
|
|
||||||
this.parser = this.port.pipe(new ReadlineParser({ delimiter: '\n' }));
|
|
||||||
this.parser.on('data', (line) => this._handleData(line));
|
|
||||||
} catch (err) {
|
|
||||||
// fallback to raw data event if parser unavailable
|
|
||||||
this.port.on('data', (data) => this._handleData(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
// keep error/close handlers on the port
|
|
||||||
this.port.on('error', (err) => {
|
|
||||||
this.emit('error', err);
|
|
||||||
if (this.verbose) console.error('Serial error:', err);
|
|
||||||
});
|
|
||||||
this.port.on('close', () => {
|
|
||||||
this.isRunning = false;
|
|
||||||
if (this.verbose) console.log('Serial port closed');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleData(data) {
|
|
||||||
// accept string or Buffer; trim newline/whitespace
|
|
||||||
const input = (typeof data === 'string') ? data.trim() : data.toString().trim();
|
|
||||||
|
|
||||||
// acknowledgement handling
|
|
||||||
if (input === 'OK' && this.pendingMessage) {
|
|
||||||
if (this.verbose) console.log(`Message confirmed: ${this.pendingMessage}`);
|
|
||||||
this.pendingMessage = null;
|
|
||||||
// drain buffer if present
|
|
||||||
if (this.sendBuffer.length > 0) {
|
|
||||||
const next = this.sendBuffer.shift();
|
|
||||||
this._writeMessage(next);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const numbers = input.split('|');
|
|
||||||
if (numbers.length === this.sliderCount) {
|
|
||||||
try {
|
|
||||||
const values = numbers.map((n) => parseInt(n, 10));
|
|
||||||
values.forEach((val, i) => {
|
|
||||||
if (!Number.isFinite(val)) throw new Error('parse error');
|
|
||||||
if (val !== this.updateList[i]) {
|
|
||||||
this._setVolume(val, i);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.updateList = values;
|
|
||||||
this.emit('update', values); // notify listeners (GUI)
|
|
||||||
if (this.verbose) console.log('Slider update ->', values);
|
|
||||||
} catch (err) {
|
|
||||||
this.emit('warn', { msg: 'Invalid slider data', raw: input });
|
|
||||||
if (this.verbose) console.warn('Invalid slider data:', input);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Not an update; emit for consumers
|
|
||||||
this.emit('raw', input);
|
|
||||||
if (this.verbose) console.warn('Unexpected input:', input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
send(message) {
|
|
||||||
if (!message || typeof message !== 'string') return;
|
|
||||||
if (this.pendingMessage) {
|
|
||||||
// queue if another message is awaiting ACK
|
|
||||||
this.sendBuffer.push(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._writeMessage(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
_writeMessage(message) {
|
|
||||||
if (!this.port || !this.port.writable) {
|
|
||||||
const err = new Error('Serial port not writable');
|
|
||||||
this.emit('error', err);
|
|
||||||
if (this.verbose) console.error(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pendingMessage = message;
|
|
||||||
this.port.write(message + '\n', (err) => {
|
|
||||||
if (err) {
|
|
||||||
this.emit('error', err);
|
|
||||||
if (this.verbose) console.error('Error writing:', err.message);
|
|
||||||
this.pendingMessage = null;
|
|
||||||
} else if (this.verbose) {
|
|
||||||
console.log('Wrote message:', message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// fallback timeout if device never replies OK
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.pendingMessage) {
|
|
||||||
this.emit('warn', { msg: "Did not receive OK from device", message: this.pendingMessage });
|
|
||||||
if (this.verbose) console.warn('Did not receive OK from device for', this.pendingMessage);
|
|
||||||
this.pendingMessage = null;
|
|
||||||
// try to drain next queued message if any
|
|
||||||
if (this.sendBuffer.length > 0) {
|
|
||||||
const next = this.sendBuffer.shift();
|
|
||||||
this._writeMessage(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100); // adjustable timeout
|
|
||||||
}
|
|
||||||
|
|
||||||
_setVolume(value, sliderNumber) {
|
|
||||||
const normalized = value / 1023;
|
|
||||||
|
|
||||||
if (!fs.existsSync(this.yamlPath)) {
|
|
||||||
this.emit('warn', { msg: 'Config file missing', path: this.yamlPath });
|
|
||||||
if (this.verbose) console.warn('Config file missing:', this.yamlPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const file = fs.readFileSync(this.yamlPath, 'utf8');
|
|
||||||
const config = JSON.parse(file || '{}'); // parse JSON config.json
|
|
||||||
const processes = config[sliderNumber] || [];
|
|
||||||
|
|
||||||
processes.forEach((proc) => {
|
|
||||||
// Emit an event so the application can perform the platform-specific change.
|
|
||||||
this.emit('set-volume', { process: proc, value: normalized, slider: sliderNumber });
|
|
||||||
if (this.verbose) {
|
|
||||||
console.log(`Would set volume for "${proc}" to ${normalized.toFixed(2)} (slider ${sliderNumber})`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.emit('error', err);
|
|
||||||
if (this.verbose) console.error('Config JSON error:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this.isRunning = false;
|
|
||||||
try {
|
|
||||||
if (this.port && this.port.isOpen) this.port.close();
|
|
||||||
} catch (e) {
|
|
||||||
if (this.verbose) console.warn('Error closing port', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getValues() {
|
|
||||||
return this.updateList.slice();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = ArduinoThread;
|
|
||||||
543
index.html
543
index.html
@@ -1,543 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Dreckshub</title>
|
|
||||||
<link rel="stylesheet" href="styles.css">
|
|
||||||
<!-- Tighter vertical spacing for customisation options -->
|
|
||||||
<style>
|
|
||||||
/* predictable sizing */
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
|
||||||
|
|
||||||
/* very tight vertical spacing */
|
|
||||||
.customisation-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px; /* minimal vertical gap */
|
|
||||||
width: 100%;
|
|
||||||
max-width: 720px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customisation-column {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px; /* minimal gap between rows */
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* compact horizontal row: label | color wheel | reset button */
|
|
||||||
.customisation-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px; /* tight horizontal spacing */
|
|
||||||
padding: 1px 0; /* minimal vertical padding */
|
|
||||||
min-height: 26px; /* compact row height */
|
|
||||||
}
|
|
||||||
|
|
||||||
.customisation-row label {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
min-width: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* tighten spacing inside the control group so wheel is very close to name */
|
|
||||||
.color-control {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px; /* very tight */
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* rectangular swatch with rounded corners (replaces circular variant) */
|
|
||||||
input[type="color"]{
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 40px; /* slightly wider rectangle */
|
|
||||||
height: 26px;
|
|
||||||
padding: 0;
|
|
||||||
border: none; /* remove default box */
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px; /* rounded rectangle (not pill) */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* WebKit browsers: remove wrapper padding & match rounded corners */
|
|
||||||
input[type="color"]::-webkit-color-swatch-wrapper {
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
input[type="color"]::-webkit-color-swatch {
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.12); /* subtle inset ring */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Firefox swatch styling with rounded corners */
|
|
||||||
input[type="color"]::-moz-color-swatch {
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* nicer focus state (matches rectangle radius) */
|
|
||||||
input[type="color"]:focus {
|
|
||||||
outline: 2px solid rgba(15,102,214,0.9); /* example accent */
|
|
||||||
outline-offset: 2px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* slider accent: use CSS variable so we can update from JS */
|
|
||||||
input[type="range"],
|
|
||||||
.vertical-slider {
|
|
||||||
accent-color: var(--accent-color, #0f66d6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* media query variant (keep sizes in sync with tightened layout) */
|
|
||||||
@media (max-width: 520px) {
|
|
||||||
.customisation-grid { gap: 2px; }
|
|
||||||
.customisation-column { gap: 2px; }
|
|
||||||
.customisation-row { gap: 3px; min-height: 24px; padding: 1px 0; }
|
|
||||||
input[type="color"] { width: 32px; height: 24px; border-radius:5px; }
|
|
||||||
input[type="color"]::-webkit-color-swatch-wrapper { border-radius:5px; }
|
|
||||||
input[type="color"]::-webkit-color-swatch { border-radius:5px; }
|
|
||||||
input[type="color"]::-moz-color-swatch { border-radius:5px; }
|
|
||||||
.reset-default { width: 26px; height: 26px; font-size:11px; }
|
|
||||||
.reset-default .reset-arrow { font-size:14px; } /* increased from 13px */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* General settings: Device, COM Port and Auto-connect — normal weight */
|
|
||||||
.settings-left-column .settings-row label[for="device-select"],
|
|
||||||
.settings-left-column .settings-row label[for="com-port-select"],
|
|
||||||
.settings-left-column .settings-row label[for="autoconnect"] {
|
|
||||||
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 400; /* normal */
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ensure selects and option text match (not bold) */
|
|
||||||
.settings-left-column .settings-row .settings-select,
|
|
||||||
#device-select,
|
|
||||||
#com-port-select,
|
|
||||||
.settings-left-column .settings-row .settings-select option {
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 400;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* keep checkbox sizing unchanged but use the same font metrics for adjacent label */
|
|
||||||
.settings-left-column .settings-row .settings-checkbox {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ensure potentiometer ring color can be overridden by JS and isn't forced to blue
|
|
||||||
include pseudo-elements so gradient/overlay layers are covered */
|
|
||||||
.pot-ring,
|
|
||||||
.pot-ring::before,
|
|
||||||
.pot-ring::after {
|
|
||||||
background: var(--pot-ring-color, #007bff) !important;
|
|
||||||
background-image: none !important;
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.12) !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ghostwire button-grid: ensure first-row spacing below the first 4 items */
|
|
||||||
.device-content[data-device="makropad ghostwire"] .button-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, auto); /* 3 buttons + pot in first row */
|
|
||||||
column-gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
/* clear default margins on children, then add vertical gap under first row only */
|
|
||||||
.device-content[data-device="makropad ghostwire"] .button-grid > * {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.device-content[data-device="makropad ghostwire"] .button-grid > *:nth-child(-n+4) {
|
|
||||||
margin-bottom: 12px; /* increased space under first row */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* responsive fallback: collapse to 2 columns on narrow widths */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.device-content[data-device="makropad ghostwire"] .button-grid {
|
|
||||||
grid-template-columns: repeat(2, auto);
|
|
||||||
}
|
|
||||||
.device-content[data-device="makropad ghostwire"] .button-grid > *:nth-child(-n+2) {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-wrapper">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<div class="sidebar-top">
|
|
||||||
<div class="logo" role="banner" aria-label="Dreckshub">
|
|
||||||
<span class="logo-text">
|
|
||||||
<span class="logo-base">Drecks</span><span class="logo-highlight">hub</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<nav class="tabs">
|
|
||||||
<button class="tab-button active" data-target="tab-1">Main</button>
|
|
||||||
<button class="tab-button" data-target="tab-2">Placeholder 1</button>
|
|
||||||
<button class="tab-button" data-target="tab-3">Sliders</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sidebar-bottom">
|
|
||||||
<button id="settings-btn" class="settings-btn">⚙ Settings</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="content-area">
|
|
||||||
<section id="tab-1" class="tab-content active">
|
|
||||||
<!-- Makropad-specific content -->
|
|
||||||
<div class="device-content" data-device="makropad">
|
|
||||||
<h2>Makropad — Main</h2>
|
|
||||||
<p>Content for Makropad on the first tab.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Makropad Ghostwire-specific content -->
|
|
||||||
<div class="device-content" data-device="makropad ghostwire" style="display:none;">
|
|
||||||
<h2>Makropad Ghostwire — Main</h2>
|
|
||||||
<p>Alternate content for Makropad Ghostwire on the first tab.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="tab-2" class="tab-content">
|
|
||||||
<!-- Makropad-specific two-column layout -->
|
|
||||||
<div class="device-content" data-device="makropad">
|
|
||||||
<div class="makropad-two-col" aria-hidden="false">
|
|
||||||
<div class="left-group">
|
|
||||||
<div class="grid-3cols">
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
</div>
|
|
||||||
<!-- spacer row (empty) to match the requested layout) -->
|
|
||||||
<div class="grid-3cols spacer-row">
|
|
||||||
<div></div><div></div><div></div>
|
|
||||||
</div>
|
|
||||||
<div class="grid-3cols">
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
</div>
|
|
||||||
<div class="grid-3cols">
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="vertical-sep" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<div class="right-group">
|
|
||||||
<div class="grid-3cols">
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
</div>
|
|
||||||
<div class="grid-3cols">
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
</div>
|
|
||||||
<div class="grid-3cols">
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
</div>
|
|
||||||
<div class="grid-3cols">
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
<button class="grid-button">b</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Makropad Ghostwire-specific grid -->
|
|
||||||
<div class="device-content" data-device="makropad ghostwire" style="display:none;">
|
|
||||||
<div class="button-grid" aria-hidden="false">
|
|
||||||
<!-- different arrangement / labels for Ghostwire -->
|
|
||||||
<button class="grid-button">1</button>
|
|
||||||
<button class="grid-button">2</button>
|
|
||||||
<button class="grid-button">3</button>
|
|
||||||
<div class="potentiometer" role="img" aria-label="ghost pot" tabindex="-1">
|
|
||||||
<div class="pot-ring" style="--deg: 90deg;"></div>
|
|
||||||
<div class="pot-inner"><span class="pot-value">50</span></div>
|
|
||||||
</div>
|
|
||||||
<!-- remaining placeholders -->
|
|
||||||
<button class="grid-button">4</button>
|
|
||||||
<button class="grid-button">5</button>
|
|
||||||
<button class="grid-button">6</button>
|
|
||||||
<button class="grid-button">7</button>
|
|
||||||
<button class="grid-button">8</button>
|
|
||||||
<button class="grid-button">9</button>
|
|
||||||
<button class="grid-button">10</button>
|
|
||||||
<button class="grid-button">11</button>
|
|
||||||
<button class="grid-button">12</button>
|
|
||||||
<button class="grid-button">13</button>
|
|
||||||
<button class="grid-button">14</button>
|
|
||||||
<button class="grid-button">15</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="tab-3" class="tab-content">
|
|
||||||
<div class="main-layout">
|
|
||||||
<!-- Sliders Panel for vertical sliders -->
|
|
||||||
<div class="sliders-panel">
|
|
||||||
<div class="slider-group">
|
|
||||||
<div class="slider-row">
|
|
||||||
<div class="slider-tag-generator">
|
|
||||||
<input type="text" id="tag-input-slider0" class="tag-input" placeholder="Enter Program Name">
|
|
||||||
<div id="tag-container-slider0" class="tag-container"></div>
|
|
||||||
</div>
|
|
||||||
<div class="slider-controls">
|
|
||||||
<input type="range" min="0" max="100" value="0" id="slider0" class="vertical-slider">
|
|
||||||
<span id="slider0-value-bubble" class="value-bubble" aria-hidden="true">50</span>
|
|
||||||
<button class="slider-btn" id="slider0-btn"></button>
|
|
||||||
</div>
|
|
||||||
<div class="slider-separator" aria-hidden="true"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="slider-group">
|
|
||||||
<div class="slider-row">
|
|
||||||
<div class="slider-tag-generator">
|
|
||||||
<input type="text" id="tag-input-slider1" class="tag-input" placeholder="Enter Program Name">
|
|
||||||
<div id="tag-container-slider1" class="tag-container"></div>
|
|
||||||
</div>
|
|
||||||
<div class="slider-controls">
|
|
||||||
<input type="range" min="0" max="100" value="0" id="slider1" class="vertical-slider">
|
|
||||||
<span id="slider1-value-bubble" class="value-bubble" aria-hidden="true">0</span>
|
|
||||||
<button class="slider-btn" id="slider1-btn"></button>
|
|
||||||
</div>
|
|
||||||
<div class="slider-separator" aria-hidden="true"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="slider-group">
|
|
||||||
<div class="slider-row">
|
|
||||||
<div class="slider-tag-generator">
|
|
||||||
<input type="text" id="tag-input-slider2" class="tag-input" placeholder="Enter Program Name">
|
|
||||||
<div id="tag-container-slider2" class="tag-container"></div>
|
|
||||||
</div>
|
|
||||||
<div class="slider-controls">
|
|
||||||
<input type="range" min="0" max="100" value="0" id="slider2" class="vertical-slider">
|
|
||||||
<span id="slider2-value-bubble" class="value-bubble" aria-hidden="true">0</span>
|
|
||||||
<button class="slider-btn" id="slider2-btn"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Simple settings panel toggled by settings button -->
|
|
||||||
<div id="settings-overlay" class="settings-overlay" aria-hidden="true">
|
|
||||||
<div class="settings-window" role="dialog" aria-modal="true" aria-labelledby="settings-title">
|
|
||||||
<div class="settings-topbar">
|
|
||||||
<div id="settings-title" class="settings-title">Settings</div>
|
|
||||||
<nav class="settings-tabs">
|
|
||||||
<button class="settings-tab-button active" data-target="settings-tab-1">General</button>
|
|
||||||
<button class="settings-tab-button" data-target="settings-tab-2">Customisation</button>
|
|
||||||
<button class="settings-tab-button" data-target="settings-tab-3">Placeholder B</button>
|
|
||||||
</nav>
|
|
||||||
<button class="settings-close" id="close-settings">✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-content">
|
|
||||||
<section id="settings-tab-1" class="settings-tab-content active">
|
|
||||||
<!-- left column: keep both dropdowns on the left stacked -->
|
|
||||||
<div class="settings-left-column">
|
|
||||||
<div class="settings-row">
|
|
||||||
<label for="device-select">Device</label>
|
|
||||||
<select id="device-select" class="settings-select" aria-label="Device selection">
|
|
||||||
<option value="Makropad">Makropad</option>
|
|
||||||
<option value="Makropad Ghostwire">Makropad Ghostwire</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="settings-row" style="margin-top:8px;">
|
|
||||||
<label for="com-port-select">COM Port</label>
|
|
||||||
<select id="com-port-select" class="settings-select" aria-label="COM port selection">
|
|
||||||
<option value="">(none)</option>
|
|
||||||
</select>
|
|
||||||
<button id="com-refresh-btn" class="settings-close" title="Refresh COM ports">⟳</button>
|
|
||||||
|
|
||||||
<!-- Connect / Disconnect controls -->
|
|
||||||
<button id="connect-com-btn" class="assign-btn connect-state" style="margin-left:8px;">Connect</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- new: autoconnect checkbox under the two dropdowns -->
|
|
||||||
<div class="settings-row" style="margin-top:8px;">
|
|
||||||
<label for="autoconnect">Auto-connect</label>
|
|
||||||
<input type="checkbox" id="autoconnect" class="settings-checkbox" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- right column (can hold extra controls later) -->
|
|
||||||
<div class="settings-right-column">
|
|
||||||
<div class="settings-checkbox-row">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" id="startup-with-system" class="settings-checkbox" />
|
|
||||||
Startup with system
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="settings-checkbox-row" style="margin-top:8px;">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" id="close-to-tray" class="settings-checkbox" />
|
|
||||||
Close to tray
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section id="settings-tab-2" class="settings-tab-content">
|
|
||||||
<div class="customisation-grid">
|
|
||||||
<!-- new layout: 3 columns for controls -->
|
|
||||||
<div class="customisation-column">
|
|
||||||
<div class="customisation-row">
|
|
||||||
<label for="cust-bg">Background</label>
|
|
||||||
<div class="color-control">
|
|
||||||
<input type="color" id="cust-bg" name="cust-bg" value="#0d0d0f" data-default="#0d0d0f" />
|
|
||||||
<button type="button" class="reset-default" data-target="cust-bg" title="Reset to default"><span class="reset-arrow">↩︎</span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="customisation-row">
|
|
||||||
<label for="cust-sidebar">Sidebar</label>
|
|
||||||
<div class="color-control">
|
|
||||||
<input type="color" id="cust-sidebar" name="cust-sidebar" value="#111111" data-default="#111111" />
|
|
||||||
<button type="button" class="reset-default" data-target="cust-sidebar" title="Reset to default"><span class="reset-arrow">↩︎</span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="customisation-column">
|
|
||||||
<div class="customisation-row">
|
|
||||||
<label for="cust-button">Button (grid)</label>
|
|
||||||
<div class="color-control">
|
|
||||||
<input type="color" id="cust-button" name="cust-button" value="#1b1b1b" data-default="#1b1b1b" />
|
|
||||||
<button type="button" class="reset-default" data-target="cust-button" title="Reset to default"><span class="reset-arrow">↩︎</span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="customisation-row">
|
|
||||||
<label for="cust-pot">Potentiometer ring</label>
|
|
||||||
<div class="color-control">
|
|
||||||
<input type="color" id="cust-pot" name="cust-pot" value="#007bff" data-default="#007bff" />
|
|
||||||
<button type="button" class="reset-default" data-target="cust-pot" title="Reset to default"><span class="reset-arrow">↩︎</span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="customisation-column">
|
|
||||||
<div class="customisation-row">
|
|
||||||
<label for="cust-accent">Accent (links / highlights)</label>
|
|
||||||
<div class="color-control">
|
|
||||||
<input type="color" id="cust-accent" name="cust-accent" value="#0f66d6" data-default="#0f66d6" />
|
|
||||||
<button type="button" class="reset-default" data-target="cust-accent" title="Reset to default"><span class="reset-arrow">↩︎</span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section id="settings-tab-3" class="settings-tab-content">
|
|
||||||
<h4>Placeholder B</h4>
|
|
||||||
<p>Content for placeholder B.</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- wiring for colour inputs + reset buttons (applies cust-pot to .pot-ring elements) -->
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
function applyColorToPotRing(color) {
|
|
||||||
// set on :root so pseudoelements that read the CSS var update
|
|
||||||
document.documentElement.style.setProperty('--pot-ring-color', color);
|
|
||||||
|
|
||||||
document.querySelectorAll('.pot-ring').forEach(el => {
|
|
||||||
// also set the custom property on the element itself (higher specificity)
|
|
||||||
el.style.setProperty('--pot-ring-color', color, '');
|
|
||||||
|
|
||||||
// clear any background-image and force a plain background color on the element
|
|
||||||
el.style.setProperty('background-image', 'none', 'important');
|
|
||||||
el.style.setProperty('background-color', color, 'important');
|
|
||||||
el.style.setProperty('background', color, 'important');
|
|
||||||
|
|
||||||
// ensure inline box-shadow as fallback
|
|
||||||
el.style.boxShadow = 'inset 0 0 0 1px rgba(0,0,0,0.12)';
|
|
||||||
|
|
||||||
// small toggle to force paint/reflow for stubborn cases
|
|
||||||
el.dataset.potRepaint = '1';
|
|
||||||
window.requestAnimationFrame(() => { delete el.dataset.potRepaint; });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAccentColor(color) {
|
|
||||||
// set on :root so all inputs using var(--accent-color) update
|
|
||||||
document.documentElement.style.setProperty('--accent-color', color);
|
|
||||||
// also ensure existing range inputs get the accent color (fallback)
|
|
||||||
document.querySelectorAll('input[type="range"]').forEach(r => {
|
|
||||||
r.style.setProperty('accent-color', color);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyInputValue(input) {
|
|
||||||
if (!input) return;
|
|
||||||
const id = input.id;
|
|
||||||
const val = input.value;
|
|
||||||
if (id === 'cust-pot') {
|
|
||||||
applyColorToPotRing(val);
|
|
||||||
}
|
|
||||||
// add more mappings here if you want other inputs to affect other UI parts
|
|
||||||
}
|
|
||||||
|
|
||||||
// wire color inputs that include data-default
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// initialize all color inputs
|
|
||||||
document.querySelectorAll('input[type="color"][id]').forEach(input => {
|
|
||||||
applyInputValue(input);
|
|
||||||
// also initialize accent if this is the cust-accent input
|
|
||||||
if (input.id === 'cust-accent') applyAccentColor(input.value);
|
|
||||||
input.addEventListener('input', () => applyInputValue(input));
|
|
||||||
input.addEventListener('change', () => applyInputValue(input));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ensure range inputs reflect the accent variable initially
|
|
||||||
const custAccent = document.getElementById('cust-accent');
|
|
||||||
if (custAccent) applyAccentColor(custAccent.value);
|
|
||||||
|
|
||||||
// wire reset buttons
|
|
||||||
document.querySelectorAll('.reset-default[data-target]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
const targetId = btn.getAttribute('data-target');
|
|
||||||
const input = document.getElementById(targetId);
|
|
||||||
if (!input) return;
|
|
||||||
const def = input.getAttribute('data-default') || '#000000';
|
|
||||||
input.value = def;
|
|
||||||
// trigger input/change handlers
|
|
||||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// reapply cust-pot when new .pot-ring elements appear
|
|
||||||
const obs = new MutationObserver(() => {
|
|
||||||
const custPot = document.getElementById('cust-pot');
|
|
||||||
if (custPot) applyInputValue(custPot);
|
|
||||||
});
|
|
||||||
obs.observe(document.body, { childList: true, subtree: true });
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script src="renderer.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
708
main.js
708
main.js
@@ -1,708 +0,0 @@
|
|||||||
const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } = require("electron");
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
const { exec } = require("child_process");
|
|
||||||
const { execFile } = require("child_process");
|
|
||||||
const { spawn } = require("child_process");
|
|
||||||
|
|
||||||
// add ArduinoThread module and instance holder
|
|
||||||
const ArduinoThread = require("./arduino_thread");
|
|
||||||
let arduinoInstance = null;
|
|
||||||
|
|
||||||
// NEW: persistent python server helpers (globals)
|
|
||||||
let pythonServer = null;
|
|
||||||
let pythonServerStdoutBuffer = "";
|
|
||||||
let pythonServerReady = false;
|
|
||||||
|
|
||||||
let mainWindow;
|
|
||||||
let tray = null;
|
|
||||||
let isQuiting = false;
|
|
||||||
const tagsFilePath = path.join(__dirname, "config.json");
|
|
||||||
|
|
||||||
// load a single app/tray icon to reuse (tray.png expected next to index.html/main.js)
|
|
||||||
const iconPath = path.join(__dirname, "tray.png");
|
|
||||||
let appIcon;
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(iconPath)) {
|
|
||||||
const img = nativeImage.createFromPath(iconPath);
|
|
||||||
// prefer a multi-size icon where possible; keep as-is if empty
|
|
||||||
appIcon = img && !img.isEmpty() ? img : undefined;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
appIcon = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readConfig() {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(tagsFilePath)) {
|
|
||||||
return JSON.parse(fs.readFileSync(tagsFilePath, "utf8"));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("readConfig error:", err);
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeConfig(obj) {
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(tagsFilePath, JSON.stringify(obj, null, 2));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("writeConfig error:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// helper to run a command and return stdout
|
|
||||||
function execPromise(cmd) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
exec(cmd, { encoding: "utf8", windowsHide: true }, (err, stdout, stderr) => {
|
|
||||||
if (err) return reject(err);
|
|
||||||
resolve(stdout || "");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- NEW: tray helpers ---
|
|
||||||
function createTray() {
|
|
||||||
try {
|
|
||||||
if (tray) return;
|
|
||||||
// use the shared appIcon if available
|
|
||||||
tray = new Tray(appIcon || undefined);
|
|
||||||
const contextMenu = Menu.buildFromTemplate([
|
|
||||||
{
|
|
||||||
label: "Show",
|
|
||||||
click: () => {
|
|
||||||
if (mainWindow) {
|
|
||||||
mainWindow.show();
|
|
||||||
mainWindow.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Quit",
|
|
||||||
click: () => {
|
|
||||||
// user explicitly wants to quit
|
|
||||||
isQuiting = true;
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
tray.setToolTip("Dreckshub");
|
|
||||||
tray.setContextMenu(contextMenu);
|
|
||||||
tray.on("double-click", () => {
|
|
||||||
if (mainWindow) {
|
|
||||||
mainWindow.show();
|
|
||||||
mainWindow.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error("createTray failed:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroyTray() {
|
|
||||||
try {
|
|
||||||
if (tray) {
|
|
||||||
tray.destroy();
|
|
||||||
tray = null;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("destroyTray failed:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure tray state matches config.closeToTray */
|
|
||||||
function updateTrayState(enable) {
|
|
||||||
if (enable) {
|
|
||||||
createTray();
|
|
||||||
} else {
|
|
||||||
destroyTray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
|
||||||
// set macOS dock icon early so the app uses tray.png instead of default electron icon
|
|
||||||
if (process.platform === "darwin" && appIcon) {
|
|
||||||
try { app.dock && app.dock.setIcon && app.dock.setIcon(appIcon); } catch (err) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// restore saved window bounds if present
|
|
||||||
const cfg = readConfig();
|
|
||||||
const wb = cfg.windowBounds || null;
|
|
||||||
const winOptions = {
|
|
||||||
// start with the typical fixed size used previously
|
|
||||||
width: 1466,
|
|
||||||
height: 642,
|
|
||||||
// prevent user resizing
|
|
||||||
resizable: false,
|
|
||||||
// set window/taskbar icon (Windows/Linux)
|
|
||||||
icon: appIcon || undefined,
|
|
||||||
webPreferences: {
|
|
||||||
preload: path.join(__dirname, "preload.js"),
|
|
||||||
},
|
|
||||||
autoHideMenuBar: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (wb && typeof wb.width === "number" && typeof wb.height === "number") {
|
|
||||||
winOptions.width = wb.width;
|
|
||||||
winOptions.height = wb.height;
|
|
||||||
if (typeof wb.x === "number" && typeof wb.y === "number") {
|
|
||||||
winOptions.x = wb.x;
|
|
||||||
winOptions.y = wb.y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mainWindow = new BrowserWindow(winOptions);
|
|
||||||
// window is non-resizable; no minimum size call needed
|
|
||||||
|
|
||||||
mainWindow.loadFile("index.html");
|
|
||||||
|
|
||||||
// after content loads, apply maximized state if saved
|
|
||||||
mainWindow.webContents.once("did-finish-load", () => {
|
|
||||||
try {
|
|
||||||
if (wb && wb.isMaximized) {
|
|
||||||
mainWindow.maximize();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("apply maximize failed:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// create tray if setting enabled in config
|
|
||||||
updateTrayState(Boolean(cfg && cfg.closeToTray));
|
|
||||||
|
|
||||||
// save bounds when window is closing
|
|
||||||
mainWindow.on("close", (e) => {
|
|
||||||
try {
|
|
||||||
// If close-to-tray is enabled and user didn't explicitly choose to quit, hide instead of closing
|
|
||||||
const cfgNow = readConfig();
|
|
||||||
const useTray = Boolean(cfgNow && cfgNow.closeToTray);
|
|
||||||
if (useTray && !isQuiting) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (mainWindow) mainWindow.hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// allow closing — save bounds as before
|
|
||||||
// getBounds returns the un-maximized bounds; good for restoring windowed state
|
|
||||||
const bounds = mainWindow.getBounds();
|
|
||||||
const isMax = mainWindow.isMaximized();
|
|
||||||
const curCfg = readConfig();
|
|
||||||
curCfg.windowBounds = {
|
|
||||||
x: bounds.x,
|
|
||||||
y: bounds.y,
|
|
||||||
width: bounds.width,
|
|
||||||
height: bounds.height,
|
|
||||||
isMaximized: isMax,
|
|
||||||
};
|
|
||||||
writeConfig(curCfg);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("save window bounds failed:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
mainWindow.on("closed", () => {
|
|
||||||
mainWindow = null;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.on("save-tags", (event, data) => {
|
|
||||||
// merge incoming tag data with existing config so windowBounds (and future fields) are preserved
|
|
||||||
try {
|
|
||||||
const cfg = readConfig() || {};
|
|
||||||
// If data is an array => legacy behavior for slider0
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
cfg.slider0 = data;
|
|
||||||
} else if (data && typeof data === "object") {
|
|
||||||
// copy all keys provided (slider0, slider1, slider2, sliderAssignments, etc.)
|
|
||||||
Object.keys(data).forEach((k) => {
|
|
||||||
cfg[k] = data[k];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
writeConfig(cfg);
|
|
||||||
|
|
||||||
// react to closeToTray changes immediately
|
|
||||||
try {
|
|
||||||
updateTrayState(Boolean(cfg.closeToTray));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("updateTrayState failed after save-tags:", err);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("save-tags merge failed:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle("load-tags", () => {
|
|
||||||
const cfg = readConfig() || {};
|
|
||||||
// ensure defaults for all three sliders for backward compatibility
|
|
||||||
if (!Array.isArray(cfg.slider0)) cfg.slider0 = [];
|
|
||||||
if (!Array.isArray(cfg.slider1)) cfg.slider1 = [];
|
|
||||||
if (!Array.isArray(cfg.slider2)) cfg.slider2 = [];
|
|
||||||
return cfg;
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle("list-serial-ports", async () => {
|
|
||||||
try {
|
|
||||||
// Windows: prefer .NET SerialPort.GetPortNames() via PowerShell (reliable, returns COM names)
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
try {
|
|
||||||
const psCmd = `powershell -NoProfile -Command "[System.IO.Ports.SerialPort]::GetPortNames() | ForEach-Object { $_ }"`;
|
|
||||||
const out = await execPromise(psCmd);
|
|
||||||
const lines = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
||||||
if (lines.length > 0) {
|
|
||||||
// Ensure unique, return objects with label=value
|
|
||||||
const uniq = Array.from(new Set(lines));
|
|
||||||
return uniq.map(p => ({ value: p, label: p }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback 1: Get-PnpDevice friendly names containing (COMx)
|
|
||||||
const psFallback = `powershell -NoProfile -Command "Get-PnpDevice -Status OK | Where-Object { $_.FriendlyName -match '\\(COM\\d+\\)' -or $_.Name -match '\\(COM\\d+\\)' } | ForEach-Object { ($_.FriendlyName -or $_.Name) }"`;
|
|
||||||
const out2 = await execPromise(psFallback);
|
|
||||||
const lines2 = out2.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
||||||
const ports = [];
|
|
||||||
const re = /(COM\d+)/i;
|
|
||||||
for (const l of lines2) {
|
|
||||||
const m = l.match(re);
|
|
||||||
if (m) ports.push({ value: m[1], label: l });
|
|
||||||
}
|
|
||||||
if (ports.length > 0) {
|
|
||||||
// dedupe by value
|
|
||||||
const seen = new Set();
|
|
||||||
return ports.filter(p => {
|
|
||||||
if (seen.has(p.value)) return false;
|
|
||||||
seen.add(p.value);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback 2: 'mode' command parsing
|
|
||||||
const out3 = await execPromise("mode");
|
|
||||||
const lines3 = out3.split(/\r?\n/);
|
|
||||||
const reMode = /(COM\d+)/gi;
|
|
||||||
const portsMode = [];
|
|
||||||
for (const l of lines3) {
|
|
||||||
const m = l.match(reMode);
|
|
||||||
if (m) {
|
|
||||||
for (const token of m) portsMode.push({ value: token, label: token });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return portsMode;
|
|
||||||
} catch (err) {
|
|
||||||
console.error("list-serial-ports (win) primary/fallback failed:", err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// macOS / Linux: scan common device files
|
|
||||||
try {
|
|
||||||
const out = await execPromise("ls /dev/tty.* /dev/ttyUSB* /dev/ttyACM* 2>/dev/null || true");
|
|
||||||
const lines = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
||||||
return lines.map(p => ({ value: p, label: p }));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("list-serial-ports (unix) failed:", err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("list-serial-ports failed:", err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- new IPC handlers to start/stop and forward serial updates ---
|
|
||||||
// Ensure we don't double-register handlers if the file is reloaded in dev reloads:
|
|
||||||
if (ipcMain.removeHandler) {
|
|
||||||
try { ipcMain.removeHandler("connect-com"); } catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
ipcMain.handle("connect-com", async (event, { port, baudRate = 9600 }) => {
|
|
||||||
try {
|
|
||||||
// if already connected, return info
|
|
||||||
if (arduinoInstance) {
|
|
||||||
return { ok: false, message: "Already connected" };
|
|
||||||
}
|
|
||||||
if (!port) return { ok: false, message: "No port specified" };
|
|
||||||
|
|
||||||
// create and wire instance (verbose disabled to avoid noisy slider logs)
|
|
||||||
arduinoInstance = new ArduinoThread(port, Number(baudRate) || 9600, 3, "config.json", false);
|
|
||||||
|
|
||||||
// Keep last normalized value per slider so we only act when the slider actually changed.
|
|
||||||
// Index = slider number (0..2). null means "unknown / not initialized".
|
|
||||||
let _lastSliderNormalized = [null, null, null];
|
|
||||||
const _minSliderDeltaForChange = 0.01; // 1% change required to be considered different
|
|
||||||
|
|
||||||
arduinoInstance.on("update", (values) => {
|
|
||||||
// forward to renderer
|
|
||||||
try {
|
|
||||||
if (mainWindow && mainWindow.webContents) {
|
|
||||||
mainWindow.webContents.send("serial-update", values);
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
|
|
||||||
// Also ensure the Python helper is invoked for each slider value.
|
|
||||||
// This is a fallback in case the ArduinoThread did not emit 'set-volume'.
|
|
||||||
try {
|
|
||||||
const cfg = readConfig() || {};
|
|
||||||
// values expected as integers (0..1023)
|
|
||||||
if (Array.isArray(values)) {
|
|
||||||
// ensure we have a session cache and refresher running
|
|
||||||
startSessionRefresher();
|
|
||||||
values.forEach((raw, idx) => {
|
|
||||||
const num = Number(raw);
|
|
||||||
if (!Number.isFinite(num)) return;
|
|
||||||
let normalized = Math.max(0, Math.min(1, num / 1023));
|
|
||||||
// snap near-zero to exact 0 to avoid tiny audible residuals
|
|
||||||
if (normalized < 0.02) normalized = 0;
|
|
||||||
|
|
||||||
// only continue if the slider value changed enough since last time
|
|
||||||
const prev = _lastSliderNormalized[idx];
|
|
||||||
const changed = prev === null || Math.abs(prev - normalized) > _minSliderDeltaForChange;
|
|
||||||
if (!changed) return; // skip unchanged slider
|
|
||||||
_lastSliderNormalized[idx] = normalized;
|
|
||||||
|
|
||||||
const key = `slider${idx}`;
|
|
||||||
const procs = Array.isArray(cfg[key]) ? cfg[key] : [];
|
|
||||||
if (!procs || procs.length === 0) return;
|
|
||||||
|
|
||||||
// match configured process names against running sessions (case-insensitive)
|
|
||||||
procs.forEach((proc) => {
|
|
||||||
if (!proc) return;
|
|
||||||
const procLow = String(proc).toLowerCase();
|
|
||||||
const matched = _runningSessions.some(s => {
|
|
||||||
return s === procLow || s.includes(procLow) || procLow.includes(s);
|
|
||||||
});
|
|
||||||
if (matched) {
|
|
||||||
// enqueue only when matched; DO NOT produce per-update verbose logs
|
|
||||||
runPythonSetVolume(proc, normalized, idx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("update handler fallback failed:", err);
|
|
||||||
sendVolumeLog(`update handler fallback failed: ${String(err)}`, true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
arduinoInstance.on("raw", (raw) => {
|
|
||||||
try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("serial-raw", raw); } catch (e) {}
|
|
||||||
});
|
|
||||||
arduinoInstance.on("error", (err) => {
|
|
||||||
try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("serial-error", String(err)); } catch (e) {}
|
|
||||||
});
|
|
||||||
arduinoInstance.on("warn", (w) => {
|
|
||||||
try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("serial-warn", w); } catch (e) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- new helper to call the python volume setter and emit logs to renderer ---
|
|
||||||
// send only concise important logs to renderer to avoid flooding DevTools
|
|
||||||
const _verboseVolumeLogs = false;
|
|
||||||
function sendVolumeLog(msg, force = false) {
|
|
||||||
try {
|
|
||||||
// keep main-process console for troubleshooting but avoid massive logs
|
|
||||||
if (_verboseVolumeLogs || force || /\b(error|failed|succeed|succeeded)\b/i.test(String(msg))) {
|
|
||||||
console.log(msg);
|
|
||||||
try { if (mainWindow && mainWindow.webContents) mainWindow.webContents.send("volume-log", String(msg)); } catch (e) {}
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// cached running audio sessions (lowercase strings)
|
|
||||||
let _runningSessions = [];
|
|
||||||
let _sessionRefreshTimer = null;
|
|
||||||
const _sessionRefreshIntervalMs = 5000; // less frequent to avoid many spawns
|
|
||||||
|
|
||||||
// ask set_volume.py to list sessions (tries several invocation patterns)
|
|
||||||
async function refreshRunningSessions() {
|
|
||||||
const script = path.join(__dirname, "set_volume.py");
|
|
||||||
// Try invocation patterns; prefer "python <script>" as you run manually
|
|
||||||
const attempts = process.platform === "win32"
|
|
||||||
? [ ["python", script, "--list"], ["python3", script, "--list"], ["py", "-3", script, "--list"], ["py", script, "--list"] ]
|
|
||||||
: [ ["python3", script, "--list"], ["python", script, "--list"] ];
|
|
||||||
|
|
||||||
for (const att of attempts) {
|
|
||||||
const exe = att[0];
|
|
||||||
const args = att.slice(1);
|
|
||||||
try {
|
|
||||||
// removed verbose per-attempt logging to avoid noise
|
|
||||||
const out = await new Promise((resolve, reject) => {
|
|
||||||
const child = execFile(exe, args, { windowsHide: true }, (err, stdout, stderr) => {
|
|
||||||
if (err) return reject({ err, stderr });
|
|
||||||
resolve((stdout || "").toString());
|
|
||||||
});
|
|
||||||
child.on("error", (e) => reject({ err: e, stderr: "" }));
|
|
||||||
});
|
|
||||||
const lines = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
||||||
_runningSessions = lines.map(l => l.toLowerCase());
|
|
||||||
// previously sent a refreshRunningSessions: found ... log here — removed to reduce noise
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
// intentionally quiet on per-attempt failures; continue trying others
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// all attempts failed — keep previous cache. Do not spam logs; only keep a very concise console hint.
|
|
||||||
// (If you want visible errors for troubleshooting, reintroduce a targeted sendVolumeLog or console.warn here.)
|
|
||||||
// sendVolumeLog("refreshRunningSessions: all interpreter attempts failed. Install Python or ensure 'python'/'py' is on PATH.");
|
|
||||||
}
|
|
||||||
|
|
||||||
function startSessionRefresher() {
|
|
||||||
if (_sessionRefreshTimer) return;
|
|
||||||
// initial immediate refresh
|
|
||||||
refreshRunningSessions();
|
|
||||||
_sessionRefreshTimer = setInterval(refreshRunningSessions, _sessionRefreshIntervalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopSessionRefresher() {
|
|
||||||
if (_sessionRefreshTimer) {
|
|
||||||
clearInterval(_sessionRefreshTimer);
|
|
||||||
_sessionRefreshTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Queue + rate-limit for invoking set_volume.py to avoid process storming
|
|
||||||
const _volumeCallQueue = [];
|
|
||||||
let _volumeActive = 0;
|
|
||||||
const _volumeMaxConcurrent = 2; // small concurrency to avoid overload
|
|
||||||
const _perProcLast = {}; // procName -> { lastValue, lastTimeMillis }
|
|
||||||
const _minValueDelta = 0.01; // only call if value changed by > 1%
|
|
||||||
const _minIntervalMs = 0; // at least 200ms between calls per proc
|
|
||||||
|
|
||||||
function enqueueVolumeCall(procName, value, slider) {
|
|
||||||
if (!procName) return;
|
|
||||||
const now = Date.now();
|
|
||||||
const key = String(procName).toLowerCase();
|
|
||||||
const prev = _perProcLast[key] || { lastValue: null, lastTime: 0 };
|
|
||||||
const deltaOk = prev.lastValue === null || Math.abs(prev.lastValue - value) > _minValueDelta;
|
|
||||||
const timeOk = (now - prev.lastTime) >= _minIntervalMs;
|
|
||||||
// Only enqueue if value significantly changed or interval passed
|
|
||||||
if (!deltaOk && !timeOk) {
|
|
||||||
// skip noisy updates
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// optimistic update to prevent duplicate rapid enqueues
|
|
||||||
_perProcLast[key] = { lastValue: value, lastTime: now };
|
|
||||||
|
|
||||||
// dedupe: if a queued entry for same proc exists, replace its value with the newest
|
|
||||||
const existingIndex = _volumeCallQueue.findIndex(it => String(it.procName).toLowerCase() === key);
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
_volumeCallQueue[existingIndex].value = Number(value);
|
|
||||||
_volumeCallQueue[existingIndex].slider = slider || null;
|
|
||||||
} else {
|
|
||||||
_volumeCallQueue.push({ procName: String(procName), value: Number(value), slider: slider || null });
|
|
||||||
}
|
|
||||||
|
|
||||||
// cap queue size to avoid runaway growth
|
|
||||||
const maxQueue = 800;
|
|
||||||
if (_volumeCallQueue.length > maxQueue) {
|
|
||||||
_volumeCallQueue.splice(0, _volumeCallQueue.length - maxQueue);
|
|
||||||
}
|
|
||||||
|
|
||||||
processVolumeQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace processVolumeQueue() internals to prefer persistent python server
|
|
||||||
function processVolumeQueue() {
|
|
||||||
if (_volumeActive >= _volumeMaxConcurrent) return;
|
|
||||||
if (_volumeCallQueue.length === 0) return;
|
|
||||||
|
|
||||||
// Attempt to send to persistent python server first
|
|
||||||
(async () => {
|
|
||||||
const item = _volumeCallQueue.shift();
|
|
||||||
_volumeActive++;
|
|
||||||
|
|
||||||
// Ensure server is running (best-effort)
|
|
||||||
const haveServer = await ensurePythonServer();
|
|
||||||
if (haveServer && pythonServer && pythonServer.stdin && !pythonServer.killed) {
|
|
||||||
try {
|
|
||||||
const payload = JSON.stringify({ cmd: "set", process: item.procName, value: Number(item.value) }) + "\n";
|
|
||||||
pythonServer.stdin.write(payload, "utf8", (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error("pythonServer write error:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// we assume the Python server will apply the change quickly; log concisely
|
|
||||||
sendVolumeLog(`sent to python-server: ${item.procName}=${item.value}`, false);
|
|
||||||
} catch (err) {
|
|
||||||
// fall back to spawning a process if write fails
|
|
||||||
sendVolumeLog(`python-server write failed, falling back: ${err}`, true);
|
|
||||||
// fallback to per-call attempts (existing execFile logic)
|
|
||||||
trySpawnFallback(item).catch(e => {
|
|
||||||
sendVolumeLog(`fallback spawn failed: ${e}`, true);
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
_volumeActive = Math.max(0, _volumeActive - 1);
|
|
||||||
setImmediate(processVolumeQueue);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no server, use original spawn attempts (keeps previous behavior)
|
|
||||||
try {
|
|
||||||
await trySpawnFallback(item);
|
|
||||||
} catch (e) {
|
|
||||||
sendVolumeLog(`All interpreter attempts failed for '${item.procName}'.`, true);
|
|
||||||
} finally {
|
|
||||||
_volumeActive = Math.max(0, _volumeActive - 1);
|
|
||||||
setImmediate(processVolumeQueue);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to start a persistent python server (tries several interpreters).
|
|
||||||
function ensurePythonServer() {
|
|
||||||
if (pythonServer && !pythonServer.killed) return Promise.resolve(true);
|
|
||||||
const script = path.join(__dirname, "set_volume.py");
|
|
||||||
const interpreters = process.platform === "win32"
|
|
||||||
? ["python", "python3", "py"]
|
|
||||||
: ["python3", "python"];
|
|
||||||
let lastErr = null;
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
(function tryInterp(i) {
|
|
||||||
if (i >= interpreters.length) {
|
|
||||||
console.error("Could not start python server:", lastErr);
|
|
||||||
pythonServer = null;
|
|
||||||
pythonServerReady = false;
|
|
||||||
return resolve(false);
|
|
||||||
}
|
|
||||||
const exe = interpreters[i];
|
|
||||||
try {
|
|
||||||
const child = spawn(exe, [script, "--server-stdin"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
||||||
let started = false;
|
|
||||||
child.on("error", (err) => {
|
|
||||||
lastErr = err;
|
|
||||||
// try next interpreter
|
|
||||||
tryInterp(i + 1);
|
|
||||||
});
|
|
||||||
child.stdout.on("data", (chunk) => {
|
|
||||||
// accumulate or just log; we don't need to parse every response here
|
|
||||||
try {
|
|
||||||
const s = chunk.toString();
|
|
||||||
pythonServerStdoutBuffer += s;
|
|
||||||
// optionally parse complete lines if needed
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
});
|
|
||||||
child.stderr.on("data", (chunk) => {
|
|
||||||
console.error("python server stderr:", chunk.toString());
|
|
||||||
});
|
|
||||||
child.on("close", (code) => {
|
|
||||||
pythonServer = null;
|
|
||||||
pythonServerReady = false;
|
|
||||||
console.warn("python server closed, code=", code);
|
|
||||||
});
|
|
||||||
// treat as started
|
|
||||||
pythonServer = child;
|
|
||||||
pythonServerReady = true;
|
|
||||||
return resolve(true);
|
|
||||||
} catch (e) {
|
|
||||||
lastErr = e;
|
|
||||||
tryInterp(i + 1);
|
|
||||||
}
|
|
||||||
})(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// helper: existing per-call spawn attempts factored out so we can reuse it as fallback
|
|
||||||
async function trySpawnFallback(item) {
|
|
||||||
const script = path.join(__dirname, "set_volume.py");
|
|
||||||
const attempts = process.platform === "win32"
|
|
||||||
? [ ["python", script, String(item.procName), String(item.value)], ["python3", script, String(item.procName), String(item.value)], ["py", "-3", script, String(item.procName), String(item.value)], ["py", script, String(item.procName), String(item.value)] ]
|
|
||||||
: [ ["python3", script, String(item.procName), String(item.value)], ["python", script, String(item.procName), String(item.value)] ];
|
|
||||||
|
|
||||||
for (const att of attempts) {
|
|
||||||
const exe = att[0];
|
|
||||||
const args = att.slice(1);
|
|
||||||
try {
|
|
||||||
sendVolumeLog(`exec ${exe} ${args.join(" ")} -> ${item.procName} value=${item.value}`, false);
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const child = execFile(exe, args, { windowsHide: true }, (err, stdout, stderr) => {
|
|
||||||
if (err) {
|
|
||||||
sendVolumeLog(`${exe} error: ${err.message}. stderr: ${stderr ? stderr.trim() : ""}`, true);
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
return resolve();
|
|
||||||
});
|
|
||||||
child.on("error", (spawnErr) => {
|
|
||||||
sendVolumeLog(`${exe} spawn error: ${spawnErr.message}`, true);
|
|
||||||
reject(spawnErr);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
sendVolumeLog(`set_volume succeeded for '${item.procName}'`, true);
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
sendVolumeLog(`${exe} failed for '${item.procName}': ${String(e)}`, true);
|
|
||||||
// try next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("All attempts failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// expose a simpler API used by other handlers
|
|
||||||
function runPythonSetVolume(procName, value, slider) {
|
|
||||||
enqueueVolumeCall(procName, value, slider);
|
|
||||||
}
|
|
||||||
|
|
||||||
// start session refresher once (do not start it repeatedly per update)
|
|
||||||
startSessionRefresher();
|
|
||||||
|
|
||||||
// throttle serial updates processed (avoid queueing on every single sample)
|
|
||||||
let _lastUpdateAt = 0;
|
|
||||||
const _updateThrottleMs = 120; // process ~16 updates/sec
|
|
||||||
|
|
||||||
// add listener to actually apply per-process volume changes via Python helper
|
|
||||||
arduinoInstance.on("set-volume", (info) => {
|
|
||||||
try {
|
|
||||||
// info: { process: procName, value: normalizedFloat, slider: sliderNumber }
|
|
||||||
const procName = info && info.process ? info.process : "";
|
|
||||||
const value = (typeof info.value === "number") ? info.value : 0;
|
|
||||||
const slider = typeof info.slider !== "undefined" ? info.slider : null;
|
|
||||||
if (!procName) {
|
|
||||||
sendVolumeLog(`set-volume received but no process name in info: ${JSON.stringify(info)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sendVolumeLog(`set-volume received -> process="${procName}" value=${value} slider=${slider}`);
|
|
||||||
// call Python helper (now queued/rate-limited)
|
|
||||||
runPythonSetVolume(procName, value, slider);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("set-volume handler failed:", err);
|
|
||||||
sendVolumeLog(`set-volume handler failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// no explicit start() needed — ArduinoThread constructor wires port and events
|
|
||||||
return { ok: true };
|
|
||||||
} catch (err) {
|
|
||||||
console.error("connect-com failed:", err);
|
|
||||||
arduinoInstance = null;
|
|
||||||
return { ok: false, message: String(err) };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// before registering disconnect, remove any previous handler
|
|
||||||
if (ipcMain.removeHandler) {
|
|
||||||
try { ipcMain.removeHandler("disconnect-com"); } catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
ipcMain.handle("disconnect-com", async () => {
|
|
||||||
try {
|
|
||||||
if (!arduinoInstance) return { ok: false, message: "Not connected" };
|
|
||||||
// stop session refresher if running
|
|
||||||
try { stopSessionRefresher(); } catch (e) { /* ignore */ }
|
|
||||||
try { arduinoInstance.stop(); } catch (e) { /* ignore */ }
|
|
||||||
arduinoInstance = null;
|
|
||||||
return { ok: true };
|
|
||||||
} catch (err) {
|
|
||||||
console.error("disconnect-com failed:", err);
|
|
||||||
return { ok: false, message: String(err) };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
|
||||||
// destroy tray if present
|
|
||||||
try { destroyTray(); } catch (e) { /* ignore */ }
|
|
||||||
|
|
||||||
if (process.platform !== "darwin") {
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// destroy tray if present
|
|
||||||
try { destroyTray(); } catch (e) { /* ignore */ }
|
|
||||||
|
|
||||||
if (process.platform !== "darwin") {
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
4278
package-lock.json
generated
4278
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
63
package.json
63
package.json
@@ -1,63 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "dreckshub",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Dreckshub",
|
|
||||||
"main": "main.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "electron .",
|
|
||||||
"dist": "electron-builder",
|
|
||||||
"dist:win": "electron-builder --win --x64",
|
|
||||||
"dist:win-elevate": "powershell -Command \"Start-Process -Verb runAs npm -ArgumentList 'run','dist:win'\"",
|
|
||||||
"dist:linux": "electron-builder --linux --x64",
|
|
||||||
"dist:mac": "electron-builder --mac",
|
|
||||||
"postinstall": "electron-builder install-app-deps"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"devDependencies": {
|
|
||||||
"electron": "^35.1.2",
|
|
||||||
"electron-builder": "^24.13.3"
|
|
||||||
},
|
|
||||||
"build": {
|
|
||||||
"asar": false,
|
|
||||||
"appId": "com.example.dreckshub",
|
|
||||||
"productName": "Dreckshub",
|
|
||||||
"directories": {
|
|
||||||
"output": "releases",
|
|
||||||
"buildResources": "build"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"**/*"
|
|
||||||
],
|
|
||||||
"win": {
|
|
||||||
"target": [
|
|
||||||
"nsis",
|
|
||||||
"portable"
|
|
||||||
],
|
|
||||||
"publisherName": "Drecksladen",
|
|
||||||
"icon": "build/app.ico"
|
|
||||||
},
|
|
||||||
"nsis": {
|
|
||||||
"oneClick": false,
|
|
||||||
"perMachine": false,
|
|
||||||
"allowToChangeInstallationDirectory": true,
|
|
||||||
"createDesktopShortcut": "always",
|
|
||||||
"createStartMenuShortcut": true,
|
|
||||||
"shortcutName": "Dreckshub"
|
|
||||||
},
|
|
||||||
"linux": {
|
|
||||||
"target": [
|
|
||||||
"AppImage",
|
|
||||||
"deb"
|
|
||||||
],
|
|
||||||
"icon": "build/app.png"
|
|
||||||
},
|
|
||||||
"mac": {
|
|
||||||
"icon": "build/app.icns"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"serialport": "^13.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
27
preload.js
27
preload.js
@@ -1,27 +0,0 @@
|
|||||||
const { contextBridge, ipcRenderer } = require("electron");
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("api", {
|
|
||||||
saveTags: (tags) => ipcRenderer.send("save-tags", tags),
|
|
||||||
loadTags: () => ipcRenderer.invoke("load-tags"),
|
|
||||||
listSerialPorts: () => ipcRenderer.invoke("list-serial-ports"),
|
|
||||||
|
|
||||||
// connect/disconnect + subscription API
|
|
||||||
connectCom: (port, baudRate) => ipcRenderer.invoke("connect-com", { port, baudRate }),
|
|
||||||
disconnectCom: () => ipcRenderer.invoke("disconnect-com"),
|
|
||||||
onSerialUpdate: (cb) => {
|
|
||||||
const listener = (event, data) => { cb(data); };
|
|
||||||
ipcRenderer.on("serial-update", listener);
|
|
||||||
// return unregister function
|
|
||||||
return () => ipcRenderer.removeListener("serial-update", listener);
|
|
||||||
},
|
|
||||||
onSerialRaw: (cb) => {
|
|
||||||
const listener = (event, data) => { cb(data); };
|
|
||||||
ipcRenderer.on("serial-raw", listener);
|
|
||||||
return () => ipcRenderer.removeListener("serial-raw", listener);
|
|
||||||
},
|
|
||||||
onVolumeLog: (cb) => {
|
|
||||||
const listener = (event, data) => { cb(data); };
|
|
||||||
ipcRenderer.on("volume-log", listener);
|
|
||||||
return () => ipcRenderer.removeListener("volume-log", listener);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
814
renderer.js
814
renderer.js
@@ -1,814 +0,0 @@
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
// per-slider tag storage
|
|
||||||
const sliderIds = ["slider0", "slider1", "slider2"];
|
|
||||||
let tags = { slider0: [], slider1: [], slider2: [] };
|
|
||||||
|
|
||||||
// slider values (existing logic) - start at 0
|
|
||||||
const sliderValues = [0, 0, 0];
|
|
||||||
|
|
||||||
// receive volume helper logs from main early so they're always visible in DevTools
|
|
||||||
if (window.api && window.api.onVolumeLog) {
|
|
||||||
let _lastVolumeLogAt = 0;
|
|
||||||
const _volumeLogThrottleMs = 200; // show at most 5 logs/sec to keep DevTools responsive
|
|
||||||
window.api.onVolumeLog((msg) => {
|
|
||||||
try {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - _lastVolumeLogAt < _volumeLogThrottleMs) return;
|
|
||||||
_lastVolumeLogAt = now;
|
|
||||||
console.log("Volume log:", msg);
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- helper: apply device-specific view mapping (show/hide device-content blocks) ---
|
|
||||||
function applyDeviceView(device) {
|
|
||||||
try {
|
|
||||||
const key = device ? String(device).trim().toLowerCase() : "";
|
|
||||||
// show matching device-content blocks, hide the rest
|
|
||||||
document.querySelectorAll(".device-content").forEach(el => {
|
|
||||||
const elDev = (el.getAttribute("data-device") || "").toLowerCase();
|
|
||||||
if (elDev === key) {
|
|
||||||
el.style.display = ""; // let CSS decide (block/inline)
|
|
||||||
} else {
|
|
||||||
el.style.display = "none";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// also store current device as a body data attribute for optional CSS usage
|
|
||||||
if (key) document.body.setAttribute("data-device", key);
|
|
||||||
else document.body.removeAttribute("data-device");
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// load config (tags per slider)
|
|
||||||
window.api.loadTags().then((data) => {
|
|
||||||
tags.slider0 = (data && Array.isArray(data.slider0)) ? data.slider0.slice() : [];
|
|
||||||
tags.slider1 = (data && Array.isArray(data.slider1)) ? data.slider1.slice() : [];
|
|
||||||
tags.slider2 = (data && Array.isArray(data.slider2)) ? data.slider2.slice() : [];
|
|
||||||
// initial render for each slider's tag container
|
|
||||||
sliderIds.forEach(id => renderTags(id));
|
|
||||||
|
|
||||||
// set initial device selection if present
|
|
||||||
try {
|
|
||||||
const deviceSelect = document.getElementById("device-select");
|
|
||||||
if (deviceSelect && data && typeof data.device === "string") {
|
|
||||||
deviceSelect.value = data.device;
|
|
||||||
// APPLY saved device-specific content visibility
|
|
||||||
applyDeviceView(deviceSelect.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// populate COM port select and restore previously saved selection
|
|
||||||
const comSelect = document.getElementById("com-port-select");
|
|
||||||
const comRefreshBtn = document.getElementById("com-refresh-btn");
|
|
||||||
async function populateComPorts(selected) {
|
|
||||||
if (!window.api || !window.api.listSerialPorts) return;
|
|
||||||
if (!comSelect) return;
|
|
||||||
// show searching placeholder
|
|
||||||
comSelect.innerHTML = '<option value="">Searching...</option>';
|
|
||||||
try {
|
|
||||||
const ports = await window.api.listSerialPorts(); // can be array of strings or {value,label}
|
|
||||||
console.log("listSerialPorts ->", ports);
|
|
||||||
// reset select, always keep explicit "(none)"
|
|
||||||
comSelect.innerHTML = '<option value="">(none)</option>';
|
|
||||||
if (!ports || ports.length === 0) {
|
|
||||||
// leave only (none)
|
|
||||||
if (selected) {
|
|
||||||
// if user had a saved selection that didn't appear, re-add it so it can be restored
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = String(selected);
|
|
||||||
opt.textContent = String(selected);
|
|
||||||
comSelect.appendChild(opt);
|
|
||||||
comSelect.value = selected;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ports.forEach(p => {
|
|
||||||
let value, label;
|
|
||||||
if (typeof p === "string") {
|
|
||||||
value = label = p;
|
|
||||||
} else if (p && typeof p === "object") {
|
|
||||||
value = String(p.value);
|
|
||||||
label = p.label || p.value;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = value;
|
|
||||||
opt.textContent = label;
|
|
||||||
comSelect.appendChild(opt);
|
|
||||||
});
|
|
||||||
if (selected) {
|
|
||||||
// restore if present; otherwise keep default
|
|
||||||
try { comSelect.value = selected; } catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("populateComPorts failed", err);
|
|
||||||
// show fallback
|
|
||||||
comSelect.innerHTML = '<option value="">(none)</option>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (comRefreshBtn) {
|
|
||||||
comRefreshBtn.addEventListener("click", () => {
|
|
||||||
populateComPorts(comSelect ? comSelect.value : undefined);
|
|
||||||
}, { passive: true });
|
|
||||||
}
|
|
||||||
// restore saved COM selection if present
|
|
||||||
if (data && typeof data.comPort === "string") {
|
|
||||||
populateComPorts(data.comPort);
|
|
||||||
} else {
|
|
||||||
populateComPorts();
|
|
||||||
}
|
|
||||||
// wire change to save configuration
|
|
||||||
if (comSelect) {
|
|
||||||
comSelect.addEventListener("change", () => saveConfig());
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- NEW: initialize the three checkboxes from loaded config and wire them to save ---
|
|
||||||
const autoEl = document.getElementById("autoconnect");
|
|
||||||
const startEl = document.getElementById("startup-with-system");
|
|
||||||
const trayEl = document.getElementById("close-to-tray");
|
|
||||||
if (autoEl) autoEl.checked = !!(data && data.autoconnect);
|
|
||||||
if (startEl) startEl.checked = !!(data && data.startupWithSystem);
|
|
||||||
if (trayEl) trayEl.checked = !!(data && data.closeToTray);
|
|
||||||
|
|
||||||
if (autoEl) autoEl.addEventListener("change", () => saveConfig());
|
|
||||||
if (startEl) startEl.addEventListener("change", () => saveConfig());
|
|
||||||
if (trayEl) trayEl.addEventListener("change", () => saveConfig());
|
|
||||||
|
|
||||||
// --- RESTORE customisation values into color inputs (if present) ---
|
|
||||||
try {
|
|
||||||
const cust = data && data.customisation ? data.customisation : {};
|
|
||||||
const colorIds = ['cust-bg','cust-sidebar','cust-button','cust-pot','cust-accent'];
|
|
||||||
colorIds.forEach(id => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
if (cust && typeof cust[id] === 'string') {
|
|
||||||
try { el.value = cust[id]; } catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// apply any restored customisation immediately
|
|
||||||
if (window.applyCustomisation && typeof window.applyCustomisation === 'function') {
|
|
||||||
const payload = {};
|
|
||||||
if (cust['cust-bg']) payload.background = cust['cust-bg'];
|
|
||||||
if (cust['cust-sidebar']) payload.sidebar = cust['cust-sidebar'];
|
|
||||||
if (cust['cust-button']) payload.button = cust['cust-button'];
|
|
||||||
if (cust['cust-pot']) payload.potentiometer = cust['cust-pot'];
|
|
||||||
if (cust['cust-accent']) payload.accent = cust['cust-accent'];
|
|
||||||
applyCustomisation(payload);
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
|
|
||||||
// existing device select is already wired above
|
|
||||||
} catch (err) { /* ignore */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
function saveConfig() {
|
|
||||||
// persist tags under slider0/slider1/slider2 and current device selection
|
|
||||||
const deviceEl = document.getElementById("device-select");
|
|
||||||
const deviceVal = deviceEl ? String(deviceEl.value) : undefined;
|
|
||||||
const comEl = document.getElementById("com-port-select");
|
|
||||||
const comVal = comEl ? String(comEl.value) : undefined;
|
|
||||||
|
|
||||||
// read new checkbox states
|
|
||||||
const autoEl = document.getElementById("autoconnect");
|
|
||||||
const startEl = document.getElementById("startup-with-system");
|
|
||||||
const trayEl = document.getElementById("close-to-tray");
|
|
||||||
const autoVal = autoEl ? Boolean(autoEl.checked) : false;
|
|
||||||
const startVal = startEl ? Boolean(startEl.checked) : false;
|
|
||||||
const trayVal = trayEl ? Boolean(trayEl.checked) : false;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
slider0: tags.slider0,
|
|
||||||
slider1: tags.slider1,
|
|
||||||
slider2: tags.slider2
|
|
||||||
};
|
|
||||||
if (deviceVal !== undefined) payload.device = deviceVal;
|
|
||||||
if (comVal !== undefined) payload.comPort = comVal;
|
|
||||||
// include new checkbox values
|
|
||||||
payload.autoconnect = autoVal;
|
|
||||||
payload.startupWithSystem = startVal;
|
|
||||||
payload.closeToTray = trayVal;
|
|
||||||
|
|
||||||
// --- NEW: read customisation inputs and include in payload ---
|
|
||||||
try {
|
|
||||||
const colorIds = ['cust-bg','cust-sidebar','cust-button','cust-pot','cust-accent'];
|
|
||||||
const cust = {};
|
|
||||||
colorIds.forEach(id => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
// store by element id for straightforward restore (keeps mapping simple)
|
|
||||||
cust[id] = String(el.value || el.getAttribute('data-default') || '');
|
|
||||||
});
|
|
||||||
// only include if any value present
|
|
||||||
if (Object.keys(cust).length > 0) payload.customisation = cust;
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
|
|
||||||
window.api.saveTags(payload);
|
|
||||||
}
|
|
||||||
// wire device dropdown to save on change
|
|
||||||
(function wireDeviceSelect() {
|
|
||||||
const deviceEl = document.getElementById("device-select");
|
|
||||||
if (!deviceEl) return;
|
|
||||||
deviceEl.addEventListener("change", () => {
|
|
||||||
// update visible device-specific fragments (do not switch tabs)
|
|
||||||
applyDeviceView(deviceEl.value);
|
|
||||||
saveConfig();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
function addTag(sliderId, value) {
|
|
||||||
// support passing an array or a comma-separated string
|
|
||||||
if (!value) return;
|
|
||||||
const arr = tags[sliderId] || [];
|
|
||||||
|
|
||||||
// normalize incoming values into an array of trimmed non-empty strings
|
|
||||||
let items = [];
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
items = value.map(v => String(v).trim()).filter(v => v !== "");
|
|
||||||
} else {
|
|
||||||
items = String(value)
|
|
||||||
.split(",")
|
|
||||||
.map(s => s.trim())
|
|
||||||
.filter(s => s !== "");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.length === 0) return;
|
|
||||||
|
|
||||||
// insert each item at the beginning, preserving the order from left-to-right
|
|
||||||
// (so we reverse before unshifting)
|
|
||||||
items.slice().reverse().forEach(item => {
|
|
||||||
if (!arr.includes(item)) {
|
|
||||||
arr.unshift(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tags[sliderId] = arr;
|
|
||||||
renderTags(sliderId);
|
|
||||||
saveConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTag(sliderId, index) {
|
|
||||||
const arr = tags[sliderId] || [];
|
|
||||||
arr.splice(index, 1);
|
|
||||||
tags[sliderId] = arr;
|
|
||||||
renderTags(sliderId);
|
|
||||||
saveConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTags(sliderId) {
|
|
||||||
const container = document.getElementById(`tag-container-${sliderId}`);
|
|
||||||
if (!container) return;
|
|
||||||
container.innerHTML = "";
|
|
||||||
const arr = tags[sliderId] || [];
|
|
||||||
arr.forEach((tag, idx) => {
|
|
||||||
const tagElement = document.createElement("div");
|
|
||||||
tagElement.classList.add("tag");
|
|
||||||
|
|
||||||
// create remove button on the LEFT (now includes a separator after the X)
|
|
||||||
const removeBtn = document.createElement("span");
|
|
||||||
removeBtn.classList.add("remove-tag");
|
|
||||||
removeBtn.setAttribute("data-slider", sliderId);
|
|
||||||
removeBtn.setAttribute("data-index", String(idx));
|
|
||||||
// keep the button content minimal; separator will come from CSS ::after
|
|
||||||
removeBtn.textContent = "\u00D7"; // "×"
|
|
||||||
|
|
||||||
// create text node (safe) for the tag label
|
|
||||||
const textSpan = document.createElement("span");
|
|
||||||
textSpan.classList.add("tag-text");
|
|
||||||
textSpan.textContent = tag;
|
|
||||||
|
|
||||||
// append remove button first, then text
|
|
||||||
tagElement.appendChild(removeBtn);
|
|
||||||
tagElement.appendChild(textSpan);
|
|
||||||
|
|
||||||
container.appendChild(tagElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// wire remove buttons
|
|
||||||
container.querySelectorAll(".remove-tag").forEach(btn => {
|
|
||||||
btn.addEventListener("click", (e) => {
|
|
||||||
const s = e.target.getAttribute("data-slider");
|
|
||||||
const i = Number(e.target.getAttribute("data-index"));
|
|
||||||
removeTag(s, i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// wire per-slider inputs/buttons (improved: supports Enter/comma split, blur)
|
|
||||||
sliderIds.forEach((id) => {
|
|
||||||
const input = document.getElementById(`tag-input-${id}`);
|
|
||||||
const addBtn = document.getElementById(`tag-add-${id}`);
|
|
||||||
|
|
||||||
if (input) {
|
|
||||||
// add on Enter or comma key (prevents the comma char from being inserted)
|
|
||||||
input.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Enter" || e.key === ",") {
|
|
||||||
e.preventDefault();
|
|
||||||
addTag(id, input.value);
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// also add remaining text when the field loses focus
|
|
||||||
input.addEventListener("blur", () => {
|
|
||||||
if (input.value && input.value.trim() !== "") {
|
|
||||||
addTag(id, input.value);
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addBtn) {
|
|
||||||
addBtn.addEventListener("click", () => {
|
|
||||||
if (input && input.value && input.value.trim() !== "") {
|
|
||||||
addTag(id, input.value);
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- existing slider logic (wire vertical sliders) ---
|
|
||||||
// keep original slider wiring so UI updates values
|
|
||||||
sliderIds.forEach((id, idx) => {
|
|
||||||
const slider = document.getElementById(id);
|
|
||||||
const valueBubble = document.getElementById(`${id}-value-bubble`);
|
|
||||||
if (slider && valueBubble) {
|
|
||||||
// helper: update bubble text and position
|
|
||||||
function updateBubble(val) {
|
|
||||||
const v = Number(val);
|
|
||||||
sliderValues[idx] = v;
|
|
||||||
if (valueBubble) valueBubble.textContent = String(v);
|
|
||||||
|
|
||||||
// position bubble next to the slider thumb using bounding rects and bubble height
|
|
||||||
try {
|
|
||||||
const min = Number(slider.min) || 0;
|
|
||||||
const max = Number(slider.max) || 100;
|
|
||||||
const pct = (v - min) / (max - min || 100);
|
|
||||||
|
|
||||||
// estimate thumb height (matches CSS .vertical-slider thumb)
|
|
||||||
const thumbH = 28;
|
|
||||||
|
|
||||||
// extension in pixels to lengthen the bubble rail beyond actual thumb travel
|
|
||||||
const ext = 4; // reduced by 4px as requested
|
|
||||||
|
|
||||||
const sliderRect = slider.getBoundingClientRect();
|
|
||||||
const parentRect = slider.parentElement.getBoundingClientRect();
|
|
||||||
|
|
||||||
// compute new min/max for thumb center including extension
|
|
||||||
const centerMin = (thumbH / 2) - ext;
|
|
||||||
const centerMax = (sliderRect.height - thumbH / 2) + ext;
|
|
||||||
const available = Math.max(0, centerMax - centerMin);
|
|
||||||
|
|
||||||
// thumb center within the slider rect including extension
|
|
||||||
const yWithinSliderRect = centerMin + available * (1 - pct);
|
|
||||||
|
|
||||||
// convert to parent (.slider-controls) coordinate and center the bubble by subtracting half its height
|
|
||||||
const bubbleHalf = valueBubble.offsetHeight ? (valueBubble.offsetHeight / 2) : 0;
|
|
||||||
const top = (sliderRect.top - parentRect.top) + yWithinSliderRect - bubbleHalf;
|
|
||||||
|
|
||||||
// Use transform for positioning (GPU-accelerated). Keep top at 0 in CSS.
|
|
||||||
valueBubble.style.transform = `translateY(${Math.round(top)}px)`;
|
|
||||||
} catch (err) {
|
|
||||||
// ignore position errors
|
|
||||||
}
|
|
||||||
// removed per-input console.log to keep UI responsive
|
|
||||||
}
|
|
||||||
|
|
||||||
slider.addEventListener("input", (e) => {
|
|
||||||
updateBubble(e.target.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// set initial position/value once DOM is ready
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
updateBubble(slider.value);
|
|
||||||
requestAnimationFrame(() => updateBubble(slider.value));
|
|
||||||
setTimeout(() => updateBubble(slider.value), 60);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// recompute bubble positions on resize (keeps them aligned if layout changes)
|
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
sliderIds.forEach((id, idx) => {
|
|
||||||
const slider = document.getElementById(id);
|
|
||||||
const valueBubble = document.getElementById(`${id}-value-bubble`);
|
|
||||||
if (!slider || !valueBubble) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const min = Number(slider.min) || 0;
|
|
||||||
const max = Number(slider.max) || 100;
|
|
||||||
const v = Number(slider.value);
|
|
||||||
const pct = (v - min) / (max - min || 100);
|
|
||||||
|
|
||||||
const thumbH = 28;
|
|
||||||
const ext = 4; // keep same extension as updateBubble (reduced)
|
|
||||||
const sliderRect = slider.getBoundingClientRect();
|
|
||||||
const parentRect = slider.parentElement.getBoundingClientRect();
|
|
||||||
|
|
||||||
const centerMin = (thumbH / 2) - ext;
|
|
||||||
const centerMax = (sliderRect.height - thumbH / 2) + ext;
|
|
||||||
const available = Math.max(0, centerMax - centerMin);
|
|
||||||
const yWithinSliderRect = centerMin + available * (1 - pct);
|
|
||||||
|
|
||||||
const bubbleHalf = valueBubble.offsetHeight ? (valueBubble.offsetHeight / 2) : 0;
|
|
||||||
const top = (sliderRect.top - parentRect.top) + yWithinSliderRect - bubbleHalf;
|
|
||||||
|
|
||||||
// Use transform to reposition instantly and smoothly
|
|
||||||
valueBubble.style.transform = `translateY(${Math.round(top)}px)`;
|
|
||||||
} catch (err) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, { passive: true });
|
|
||||||
|
|
||||||
// --- add thumb dragging class while a slider is being grabbed ---
|
|
||||||
(function wireThumbDragging() {
|
|
||||||
// remove class on global pointerup/leave to ensure cleanup
|
|
||||||
function clearAll() {
|
|
||||||
sliderIds.forEach(id => {
|
|
||||||
const s = document.getElementById(id);
|
|
||||||
if (s && s.classList.contains("thumb-dragging")) s.classList.remove("thumb-dragging");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
sliderIds.forEach((id) => {
|
|
||||||
const slider = document.getElementById(id);
|
|
||||||
if (!slider) return;
|
|
||||||
|
|
||||||
slider.addEventListener("pointerdown", (e) => {
|
|
||||||
// add class immediately when pointer interacts with the slider
|
|
||||||
slider.classList.add("thumb-dragging");
|
|
||||||
// capture pointer so we still receive the up event even if pointer leaves the thumb
|
|
||||||
try { slider.setPointerCapture && slider.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
|
|
||||||
}, { passive: true });
|
|
||||||
|
|
||||||
// remove when pointer is released on the slider itself
|
|
||||||
slider.addEventListener("pointerup", () => {
|
|
||||||
slider.classList.remove("thumb-dragging");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ensure we clear if pointer is canceled
|
|
||||||
slider.addEventListener("pointercancel", () => {
|
|
||||||
slider.classList.remove("thumb-dragging");
|
|
||||||
});
|
|
||||||
|
|
||||||
// also clear on global pointerup (in case pointerup happens outside element)
|
|
||||||
window.addEventListener("pointerup", () => {
|
|
||||||
slider.classList.remove("thumb-dragging");
|
|
||||||
}, { passive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// clear on leave/unload to avoid stuck state
|
|
||||||
window.addEventListener("blur", clearAll);
|
|
||||||
window.addEventListener("pointerleave", clearAll);
|
|
||||||
})();
|
|
||||||
|
|
||||||
// --- Tab switching logic (unchanged) ---
|
|
||||||
const tabButtons = document.querySelectorAll(".tab-button");
|
|
||||||
const tabPanels = document.querySelectorAll(".tab-content");
|
|
||||||
function showTab(targetId) {
|
|
||||||
tabPanels.forEach(p => p.classList.toggle("active", p.id === targetId));
|
|
||||||
tabButtons.forEach(b => b.classList.toggle("active", b.getAttribute("data-target") === targetId));
|
|
||||||
|
|
||||||
// If the Sliders tab becomes visible, force a layout-stable recompute of bubble positions.
|
|
||||||
// We reuse the existing resize handler logic by dispatching a synthetic resize event
|
|
||||||
// after layout has had a chance to settle (double rAF + small timeout fallback).
|
|
||||||
if (targetId === "tab-3") {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
window.dispatchEvent(new Event("resize"));
|
|
||||||
});
|
|
||||||
setTimeout(() => window.dispatchEvent(new Event("resize")), 80);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tabButtons.forEach(btn => btn.addEventListener("click", () => showTab(btn.getAttribute("data-target"))));
|
|
||||||
showTab("tab-1");
|
|
||||||
|
|
||||||
// --- Settings overlay logic (unchanged) ---
|
|
||||||
const settingsBtn = document.getElementById("settings-btn");
|
|
||||||
const settingsOverlay = document.getElementById("settings-overlay");
|
|
||||||
const closeSettings = document.getElementById("close-settings");
|
|
||||||
function toggleSettingsOverlay(show) {
|
|
||||||
if (!settingsOverlay) return;
|
|
||||||
settingsOverlay.setAttribute("aria-hidden", String(!show));
|
|
||||||
if (show) {
|
|
||||||
// show first settings tab by default
|
|
||||||
showSettingsTab("settings-tab-1");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (settingsBtn) settingsBtn.addEventListener("click", () => toggleSettingsOverlay(true));
|
|
||||||
if (closeSettings) closeSettings.addEventListener("click", () => toggleSettingsOverlay(false));
|
|
||||||
const settingsTabButtons = document.querySelectorAll(".settings-tab-button");
|
|
||||||
const settingsTabContents = document.querySelectorAll(".settings-tab-content");
|
|
||||||
function showSettingsTab(targetId) {
|
|
||||||
settingsTabContents.forEach(p => p.classList.toggle("active", p.id === targetId));
|
|
||||||
settingsTabButtons.forEach(b => b.classList.toggle("active", b.getAttribute("data-target") === targetId));
|
|
||||||
}
|
|
||||||
settingsTabButtons.forEach(btn => btn.addEventListener("click", () => showSettingsTab(btn.getAttribute("data-target"))));
|
|
||||||
toggleSettingsOverlay(false);
|
|
||||||
|
|
||||||
// --- Potentiometer visual (unchanged) ---
|
|
||||||
let potValue = 50;
|
|
||||||
const potEl = document.getElementById("pot-0");
|
|
||||||
const potValueEl = potEl ? potEl.querySelector(".pot-value") : null;
|
|
||||||
const potRingEl = potEl ? potEl.querySelector(".pot-ring") : null;
|
|
||||||
function updatePot(value) {
|
|
||||||
if (!potEl) return;
|
|
||||||
potValue = Math.max(0, Math.min(100, Number(value) || 0));
|
|
||||||
if (potValueEl) potValueEl.textContent = String(potValue);
|
|
||||||
if (potRingEl) potRingEl.style.setProperty("--deg", `${potValue * 3.6}deg`);
|
|
||||||
console.log(`pot updated -> ${potValue}`);
|
|
||||||
}
|
|
||||||
window.setPotValue = function(v) { updatePot(v); };
|
|
||||||
window.getPotValue = function() { return potValue; };
|
|
||||||
updatePot(potValue);
|
|
||||||
|
|
||||||
// --- Connect / Disconnect wiring ---
|
|
||||||
const connectBtn = document.getElementById("connect-com-btn");
|
|
||||||
let unregisterSerial = null;
|
|
||||||
let isConnected = false;
|
|
||||||
|
|
||||||
function setConnectedUI(connected) {
|
|
||||||
isConnected = !!connected;
|
|
||||||
if (!connectBtn) return;
|
|
||||||
if (connected) {
|
|
||||||
connectBtn.textContent = "Disconnect";
|
|
||||||
connectBtn.classList.remove("connect-state");
|
|
||||||
connectBtn.classList.add("disconnect-state");
|
|
||||||
} else {
|
|
||||||
connectBtn.textContent = "Connect";
|
|
||||||
connectBtn.classList.remove("disconnect-state");
|
|
||||||
connectBtn.classList.add("connect-state");
|
|
||||||
// ensure UI sliders reset to 0 on disconnect / initial state
|
|
||||||
sliderIds.forEach((sid, idx) => {
|
|
||||||
const sliderEl = document.getElementById(sid);
|
|
||||||
if (!sliderEl) return;
|
|
||||||
sliderEl.value = "0";
|
|
||||||
// update bubble/position via input event
|
|
||||||
sliderEl.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
// update internal state array
|
|
||||||
sliderValues[idx] = 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectBtn) {
|
|
||||||
connectBtn.addEventListener("click", async () => {
|
|
||||||
const comSelect = document.getElementById("com-port-select");
|
|
||||||
const port = comSelect ? comSelect.value : "";
|
|
||||||
if (!port) {
|
|
||||||
console.warn("No COM port selected");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isConnected) {
|
|
||||||
await doConnect(port);
|
|
||||||
} else {
|
|
||||||
await doDisconnect();
|
|
||||||
}
|
|
||||||
}, { passive: true });
|
|
||||||
// initialize UI state
|
|
||||||
setConnectedUI(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- AUTOCONNECT: after COM list population, try to connect when configured ---
|
|
||||||
(async function maybeAutoConnect() {
|
|
||||||
try {
|
|
||||||
const cfg = await window.api.loadTags();
|
|
||||||
if (cfg && cfg.autoconnect && cfg.comPort) {
|
|
||||||
setTimeout(() => {
|
|
||||||
const comSelect = document.getElementById("com-port-select");
|
|
||||||
if (comSelect) {
|
|
||||||
try { comSelect.value = cfg.comPort; } catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
doConnect(cfg.comPort);
|
|
||||||
}, 120);
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
})();
|
|
||||||
|
|
||||||
// --- NEW: shared connect/disconnect helpers for autoconnect and button click ---
|
|
||||||
async function doConnect(port) {
|
|
||||||
if (!port) {
|
|
||||||
console.warn("doConnect: no port specified");
|
|
||||||
return { ok: false, message: "No port" };
|
|
||||||
}
|
|
||||||
if (isConnected) {
|
|
||||||
return { ok: false, message: "Already connected" };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await window.api.connectCom(port, 9600);
|
|
||||||
if (result && result.ok) {
|
|
||||||
// subscribe to serial updates exactly as the original click handler did
|
|
||||||
unregisterSerial = window.api.onSerialUpdate((values) => {
|
|
||||||
if (!Array.isArray(values)) return;
|
|
||||||
sliderIds.forEach((sid, idx) => {
|
|
||||||
const sliderEl = document.getElementById(sid);
|
|
||||||
if (!sliderEl) return;
|
|
||||||
const raw = values[idx];
|
|
||||||
if (typeof raw !== "number") return;
|
|
||||||
const pct = Math.round((raw * 100) / 1023);
|
|
||||||
const clamped = Math.max(0, Math.min(100, pct));
|
|
||||||
sliderEl.value = String(clamped);
|
|
||||||
sliderEl.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
sliderValues[idx] = clamped;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
setConnectedUI(true);
|
|
||||||
return { ok: true };
|
|
||||||
} else {
|
|
||||||
console.warn("Connect failed:", result && result.message);
|
|
||||||
return { ok: false, message: result && result.message };
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("doConnect error:", err);
|
|
||||||
return { ok: false, message: String(err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doDisconnect() {
|
|
||||||
if (!isConnected) {
|
|
||||||
return { ok: false, message: "Not connected" };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await window.api.disconnectCom();
|
|
||||||
if (res && res.ok) {
|
|
||||||
if (typeof unregisterSerial === "function") unregisterSerial();
|
|
||||||
unregisterSerial = null;
|
|
||||||
setConnectedUI(false);
|
|
||||||
return { ok: true };
|
|
||||||
} else {
|
|
||||||
console.warn("Disconnect returned:", res && res.message);
|
|
||||||
return { ok: false, message: res && res.message };
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("doDisconnect error:", err);
|
|
||||||
return { ok: false, message: String(err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- NEW: utility to rebuild the layered background using current CSS vars and set inline style ---
|
|
||||||
function setBodyBackgroundFromVars() {
|
|
||||||
const root = document.documentElement;
|
|
||||||
const body = document.body;
|
|
||||||
if (!root || !body) return;
|
|
||||||
|
|
||||||
// read variables (fall back to existing defaults)
|
|
||||||
const bg1 = (getComputedStyle(root).getPropertyValue('--bg-base-1') || '#1a1a1a').trim();
|
|
||||||
const bg2 = (getComputedStyle(root).getPropertyValue('--bg-base-2') || '#0d0d0f').trim();
|
|
||||||
const stripe = (getComputedStyle(root).getPropertyValue('--stripe') || 'rgba(255,255,255,0.012)').trim();
|
|
||||||
|
|
||||||
// Recreate the layered background used in CSS, but inline so changes are immediate.
|
|
||||||
const layers = [
|
|
||||||
`radial-gradient(600px 360px at 10% 10%, rgba(255,255,255,0.03), transparent 18%)`,
|
|
||||||
`radial-gradient(700px 420px at 92% 86%, rgba(0,0,0,0.12), transparent 45%)`,
|
|
||||||
`linear-gradient(180deg, ${bg1}, ${bg2})`,
|
|
||||||
`repeating-linear-gradient(135deg, ${stripe} 0 2px, transparent 2px 8px)`
|
|
||||||
];
|
|
||||||
|
|
||||||
body.style.backgroundImage = layers.join(',\n ');
|
|
||||||
// keep background-color in sync as a fallback
|
|
||||||
body.style.backgroundColor = bg2;
|
|
||||||
body.style.backgroundRepeat = 'no-repeat';
|
|
||||||
body.style.backgroundSize = 'cover';
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- NEW: apply a customisation payload (keys: background, sidebar, button, potentiometer, accent) ---
|
|
||||||
function applyCustomisation(payload) {
|
|
||||||
try {
|
|
||||||
const root = document.documentElement;
|
|
||||||
if (!root) return;
|
|
||||||
|
|
||||||
// helper: simple hex lighten for bg1 derivation
|
|
||||||
function lightenHex(hex, pct) {
|
|
||||||
try {
|
|
||||||
const h = hex.replace('#','');
|
|
||||||
const num = parseInt(h,16);
|
|
||||||
let r = (num >> 16) & 0xFF;
|
|
||||||
let g = (num >> 8) & 0xFF;
|
|
||||||
let b = num & 0xFF;
|
|
||||||
r = Math.min(255, Math.round(r + (255 - r) * pct));
|
|
||||||
g = Math.min(255, Math.round(g + (255 - g) * pct));
|
|
||||||
b = Math.min(255, Math.round(b + (255 - b) * pct));
|
|
||||||
const res = '#' + ((1<<24) + (r<<16) + (g<<8) + b).toString(16).slice(1);
|
|
||||||
return res;
|
|
||||||
} catch (e) { return hex; }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.background) {
|
|
||||||
root.style.setProperty('--bg-base-2', payload.background);
|
|
||||||
// derive a slightly lighter variant for the top of the gradient
|
|
||||||
root.style.setProperty('--bg-base-1', lightenHex(payload.background, 0.08));
|
|
||||||
}
|
|
||||||
if (payload.sidebar) root.style.setProperty('--sidebar-bg', payload.sidebar);
|
|
||||||
if (payload.button) root.style.setProperty('--button-bg', payload.button);
|
|
||||||
if (payload.potentiometer) root.style.setProperty('--pot-color', payload.potentiometer);
|
|
||||||
if (payload.accent) {
|
|
||||||
root.style.setProperty('--accent-color', payload.accent);
|
|
||||||
// create a subtle stripe using accent color with low alpha
|
|
||||||
try {
|
|
||||||
const rgb = payload.accent.replace('#','');
|
|
||||||
const r = parseInt(rgb.slice(0,2),16);
|
|
||||||
const g = parseInt(rgb.slice(2,4),16);
|
|
||||||
const b = parseInt(rgb.slice(4,6),16);
|
|
||||||
root.style.setProperty('--stripe', `rgba(${r},${g},${b},0.012)`);
|
|
||||||
} catch (e) {
|
|
||||||
// fallback stripe
|
|
||||||
root.style.setProperty('--stripe', 'rgba(255,255,255,0.012)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// rebuild inline layered body background for immediate result
|
|
||||||
setBodyBackgroundFromVars();
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- NEW: re-sync all customisation inputs (applies their values to CSS variables) ---
|
|
||||||
function syncCustomInputs() {
|
|
||||||
const idToKey = {
|
|
||||||
'cust-bg': 'background',
|
|
||||||
'cust-sidebar': 'sidebar',
|
|
||||||
'cust-button': 'button',
|
|
||||||
'cust-pot': 'potentiometer',
|
|
||||||
'cust-accent': 'accent'
|
|
||||||
};
|
|
||||||
const inputs = Array.from(document.querySelectorAll('#settings-tab-2 input[type="color"][data-default]'));
|
|
||||||
const payload = {};
|
|
||||||
inputs.forEach(inp => {
|
|
||||||
const key = idToKey[inp.id];
|
|
||||||
if (key && inp.value) payload[key] = inp.value;
|
|
||||||
});
|
|
||||||
if (Object.keys(payload).length) applyCustomisation(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- NEW: wire reset-default buttons and live update on color inputs ---
|
|
||||||
(function wireCustomisationInputs() {
|
|
||||||
try {
|
|
||||||
// helper: update visibility of reset button for a given input
|
|
||||||
function updateResetVisibilityForInput(inp) {
|
|
||||||
if (!inp) return;
|
|
||||||
const btn = document.querySelector(`#settings-tab-2 .reset-default[data-target="${inp.id}"]`);
|
|
||||||
if (!btn) return;
|
|
||||||
const def = (inp.getAttribute('data-default') || '').toLowerCase();
|
|
||||||
const cur = (inp.value || '').toLowerCase();
|
|
||||||
if (cur !== def && cur !== '') {
|
|
||||||
btn.classList.add('dirty');
|
|
||||||
btn.setAttribute('aria-hidden', 'false');
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('dirty');
|
|
||||||
btn.setAttribute('aria-hidden', 'true');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// color inputs change -> apply + save + toggle reset visibility
|
|
||||||
document.querySelectorAll('#settings-tab-2 input[type="color"][data-default]').forEach(inp => {
|
|
||||||
// set initial visibility on setup
|
|
||||||
try { updateResetVisibilityForInput(inp); } catch (e) { /* ignore */ }
|
|
||||||
|
|
||||||
inp.addEventListener('input', () => {
|
|
||||||
syncCustomInputs();
|
|
||||||
// update associated reset button visibility immediately
|
|
||||||
try { updateResetVisibilityForInput(inp); } catch (e) { /* ignore */ }
|
|
||||||
|
|
||||||
// debounce save to avoid excessive disk writes (coarse)
|
|
||||||
if (window._custSaveTimer) clearTimeout(window._custSaveTimer);
|
|
||||||
window._custSaveTimer = setTimeout(() => saveConfig(), 220);
|
|
||||||
}, { passive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// reset buttons: restore default, hide arrow, apply + save
|
|
||||||
document.querySelectorAll('#settings-tab-2 .reset-default').forEach(btn => {
|
|
||||||
btn.classList.remove('dirty'); // ensure hidden by default
|
|
||||||
btn.setAttribute('aria-hidden', 'true');
|
|
||||||
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
const target = btn.getAttribute('data-target');
|
|
||||||
if (!target) return;
|
|
||||||
const inp = document.getElementById(target);
|
|
||||||
if (!inp) return;
|
|
||||||
const def = inp.getAttribute('data-default') || inp.value || '';
|
|
||||||
try { inp.value = def; } catch (er) { /* ignore */ }
|
|
||||||
// hide the button immediately
|
|
||||||
try {
|
|
||||||
btn.classList.remove('dirty');
|
|
||||||
btn.setAttribute('aria-hidden', 'true');
|
|
||||||
} catch (er) { /* ignore */ }
|
|
||||||
|
|
||||||
// apply changes and persist
|
|
||||||
syncCustomInputs();
|
|
||||||
saveConfig();
|
|
||||||
}, { passive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ensure initial sync and reset-visibility for all inputs now
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
document.querySelectorAll('#settings-tab-2 input[type="color"][data-default]').forEach(inp => updateResetVisibilityForInput(inp));
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
syncCustomInputs();
|
|
||||||
}, 40);
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
1221
styles.css
1221
styles.css
File diff suppressed because it is too large
Load Diff
189
ui.py
Normal file
189
ui.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import customtkinter
|
||||||
|
|
||||||
|
class MyTabView(customtkinter.CTkTabview):
|
||||||
|
def __init__(self, master, **kwargs):
|
||||||
|
super().__init__(master, **kwargs)
|
||||||
|
|
||||||
|
# create tabs
|
||||||
|
self.add("tab 1")
|
||||||
|
self.add("tab 2")
|
||||||
|
|
||||||
|
# add widgets on tabs
|
||||||
|
self.label = customtkinter.CTkLabel(master=self.tab("tab 1"))
|
||||||
|
self.label.grid(row=0, column=0, padx=20, pady=10)
|
||||||
|
|
||||||
|
class App(customtkinter.CTk):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.geometry("1466x642")
|
||||||
|
self.title("Dreckshub")
|
||||||
|
|
||||||
|
# add widgets to app
|
||||||
|
self.button = customtkinter.CTkButton(self, command=self.button_click)
|
||||||
|
self.button.grid(row=0, column=0, padx=20, pady=10)
|
||||||
|
|
||||||
|
# Slider Menu, will be dynamic per device
|
||||||
|
|
||||||
|
self.slider0 = customtkinter.CTkSlider(self, from_=0, to=100, height=470, number_of_steps=100, width=20, orientation="vertical", command=self.slider_event)
|
||||||
|
self.slider0.grid(row=0, column=3, padx=20, pady=10)
|
||||||
|
self.slider0.set(0)
|
||||||
|
|
||||||
|
self.entry0 = customtkinter.CTkEntry(self, placeholder_text="Slider0")
|
||||||
|
|
||||||
|
self.slider1 = customtkinter.CTkSlider(self, from_=0, to=100, height=470, number_of_steps=100, width=20, orientation="vertical", command=self.slider_event)
|
||||||
|
self.slider1.grid(row=0, column=4, padx=20, pady=10)
|
||||||
|
self.slider1.set(0)
|
||||||
|
|
||||||
|
self.entry1 = customtkinter.CTkEntry(self, placeholder_text="Slider1")
|
||||||
|
|
||||||
|
self.slider2 = customtkinter.CTkSlider(self, from_=0, to=100, height=470, number_of_steps=100, width=20, orientation="vertical", command=self.slider_event)
|
||||||
|
self.slider2.grid(row=0, column=5, padx=20, pady=10)
|
||||||
|
self.slider2.set(0)
|
||||||
|
|
||||||
|
self.entry2 = customtkinter.CTkEntry(self, placeholder_text="Slider2")
|
||||||
|
|
||||||
|
|
||||||
|
# add methods to app
|
||||||
|
def button_click(self):
|
||||||
|
print("button click")
|
||||||
|
|
||||||
|
def slider_event(self, value):
|
||||||
|
slider0 = self.slider0.get() / 100
|
||||||
|
slider1 = self.slider1.get() / 100
|
||||||
|
slider2 = self.slider2.get() / 100
|
||||||
|
|
||||||
|
#format = self.slider0.get() + " | " + self.slider1.get() + " | " + self.slider2.get()
|
||||||
|
print(str(slider0) + " | " + str(slider1) + " | " + str(slider2))
|
||||||
|
|
||||||
|
|
||||||
|
app = App()
|
||||||
|
app.mainloop()
|
||||||
|
|
||||||
|
#
|
||||||
|
#import customtkinter as ctk
|
||||||
|
#
|
||||||
|
#class MyTabView(ctk.CTkTabview):
|
||||||
|
# def __init__(self, master, **kwargs):
|
||||||
|
# super().__init__(master, **kwargs)
|
||||||
|
#
|
||||||
|
# # create tabs
|
||||||
|
# self.add("tab 1")
|
||||||
|
# self.add("tab 2")
|
||||||
|
#
|
||||||
|
# # add widgets on tabs
|
||||||
|
# self.label = ctk.CTkLabel(master=self.tab("tab 1"), text="Hello from Tab 1")
|
||||||
|
# self.label.grid(row=0, column=0, padx=20, pady=10)
|
||||||
|
#
|
||||||
|
#class App(ctk.CTk):
|
||||||
|
# def __init__(self):
|
||||||
|
# super().__init__()
|
||||||
|
# self.geometry("1466x642")
|
||||||
|
# self.title("Dreckshub")
|
||||||
|
#
|
||||||
|
# # ---------------------------
|
||||||
|
# # Device List / Sidebar Setup
|
||||||
|
# # ---------------------------
|
||||||
|
# self.devices = ["Device 1"] # Initial connected device
|
||||||
|
# self.MAX_DEVICES = 5
|
||||||
|
# self.pages = {} # Pages for each device
|
||||||
|
#
|
||||||
|
# self.sidebar = ctk.CTkFrame(self, width=150)
|
||||||
|
# self.sidebar.grid(row=0, column=0, rowspan=6, sticky="ns", padx=10, pady=10)
|
||||||
|
#
|
||||||
|
# # Content frame for device pages
|
||||||
|
# self.device_content = ctk.CTkFrame(self)
|
||||||
|
# self.device_content.grid(row=0, column=1, rowspan=6, sticky="nsew", padx=10, pady=10)
|
||||||
|
#
|
||||||
|
# # Configure grid weights for proper expansion
|
||||||
|
# self.grid_columnconfigure(1, weight=1)
|
||||||
|
# self.grid_rowconfigure(0, weight=1)
|
||||||
|
#
|
||||||
|
# self.refresh_sidebar()
|
||||||
|
#
|
||||||
|
# # ---------------------------
|
||||||
|
# # Other Widgets
|
||||||
|
# # ---------------------------
|
||||||
|
# self.button = ctk.CTkButton(self, text="Button", command=self.button_click)
|
||||||
|
# self.button.grid(row=0, column=2, padx=20, pady=10)
|
||||||
|
#
|
||||||
|
# # Sliders
|
||||||
|
# self.slider0 = ctk.CTkSlider(self, from_=0, to=100, height=470, number_of_steps=100, width=20, orientation="vertical", command=self.slider_event)
|
||||||
|
# self.slider0.grid(row=0, column=3, padx=20, pady=10)
|
||||||
|
# self.slider0.set(0)
|
||||||
|
#
|
||||||
|
# self.slider1 = ctk.CTkSlider(self, from_=0, to=100, height=470, number_of_steps=100, width=20, orientation="vertical", command=self.slider_event)
|
||||||
|
# self.slider1.grid(row=0, column=4, padx=20, pady=10)
|
||||||
|
# self.slider1.set(0)
|
||||||
|
#
|
||||||
|
# self.slider2 = ctk.CTkSlider(self, from_=0, to=100, height=470, number_of_steps=100, width=20, orientation="vertical", command=self.slider_event)
|
||||||
|
# self.slider2.grid(row=0, column=5, padx=20, pady=10)
|
||||||
|
# self.slider2.set(0)
|
||||||
|
#
|
||||||
|
# # TabView
|
||||||
|
# self.tab_view = MyTabView(master=self)
|
||||||
|
# self.tab_view.grid(row=1, column=2, padx=20, pady=20, columnspan=1)
|
||||||
|
#
|
||||||
|
# # ---------------------------
|
||||||
|
# # Device Sidebar Methods
|
||||||
|
# # ---------------------------
|
||||||
|
# def refresh_sidebar(self):
|
||||||
|
# # Clear existing sidebar buttons
|
||||||
|
# for widget in self.sidebar.winfo_children():
|
||||||
|
# widget.destroy()
|
||||||
|
#
|
||||||
|
# # Add a button for each device
|
||||||
|
# for device in self.devices:
|
||||||
|
# if device not in self.pages:
|
||||||
|
# page = ctk.CTkFrame(self.device_content)
|
||||||
|
# page.place(relwidth=1, relheight=1)
|
||||||
|
# label = ctk.CTkLabel(page, text=f"Settings for {device}")
|
||||||
|
# label.pack(padx=20, pady=20)
|
||||||
|
# self.pages[device] = page
|
||||||
|
#
|
||||||
|
# ctk.CTkButton(
|
||||||
|
# self.sidebar,
|
||||||
|
# text=device,
|
||||||
|
# command=lambda d=device: self.show_device_page(d)
|
||||||
|
# ).pack(pady=5, fill="x")
|
||||||
|
#
|
||||||
|
# # Add "+ Add Device" button if max devices not reached
|
||||||
|
# if len(self.devices) < self.MAX_DEVICES:
|
||||||
|
# ctk.CTkButton(
|
||||||
|
# self.sidebar,
|
||||||
|
# text="+ Add Device",
|
||||||
|
# fg_color="green",
|
||||||
|
# hover_color="lightgreen",
|
||||||
|
# command=self.add_device
|
||||||
|
# ).pack(pady=20, fill="x")
|
||||||
|
#
|
||||||
|
# # Show first device page by default
|
||||||
|
# if self.devices:
|
||||||
|
# self.show_device_page(self.devices[0])
|
||||||
|
#
|
||||||
|
# def show_device_page(self, device_name):
|
||||||
|
# for page in self.pages.values():
|
||||||
|
# page.lower()
|
||||||
|
# self.pages[device_name].lift()
|
||||||
|
#
|
||||||
|
# def add_device(self):
|
||||||
|
# new_device_name = f"Device {len(self.devices)+1}"
|
||||||
|
# self.devices.append(new_device_name)
|
||||||
|
# self.refresh_sidebar()
|
||||||
|
# self.show_device_page(new_device_name)
|
||||||
|
#
|
||||||
|
# # ---------------------------
|
||||||
|
# # Other Methods
|
||||||
|
# # ---------------------------
|
||||||
|
# def button_click(self):
|
||||||
|
# print("button click")
|
||||||
|
#
|
||||||
|
# def slider_event(self, value):
|
||||||
|
# slider0 = self.slider0.get() / 100
|
||||||
|
# slider1 = self.slider1.get() / 100
|
||||||
|
# slider2 = self.slider2.get() / 100
|
||||||
|
# print(str(slider0) + " | " + str(slider1) + " | " + str(slider2))
|
||||||
|
#
|
||||||
|
## Run the app
|
||||||
|
#app = App()
|
||||||
|
#app.mainloop()
|
||||||
|
#
|
||||||
Reference in New Issue
Block a user