602 lines
28 KiB
Python
602 lines
28 KiB
Python
"""Top toolbar - connection status, upload, settings."""
|
|
|
|
import os
|
|
import subprocess
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox
|
|
import threading
|
|
from utils.constants import APP_NAME, ORIENTATION_OPTIONS
|
|
|
|
|
|
class Toolbar(tk.Frame):
|
|
"""Top toolbar with device connection and upload controls."""
|
|
|
|
def __init__(self, parent, serial_manager=None, on_settings_change=None,
|
|
on_profile_switch=None, on_profile_new=None, on_profile_delete=None,
|
|
on_ble_variables=None, on_backups=None, on_rs232_terminal=None,
|
|
on_bt_keyboard=None,
|
|
on_settings_open=None, on_settings_close=None,
|
|
profile_manager=None):
|
|
super().__init__(parent, bg="#1A1A2E", height=44)
|
|
self.serial_manager = serial_manager
|
|
self.on_settings_change = on_settings_change
|
|
self.on_profile_switch = on_profile_switch
|
|
self.on_profile_new = on_profile_new
|
|
self.on_profile_delete = on_profile_delete
|
|
self.on_ble_variables = on_ble_variables
|
|
self.on_backups = on_backups
|
|
self.on_rs232_terminal = on_rs232_terminal
|
|
self.on_bt_keyboard = on_bt_keyboard
|
|
self.on_settings_open = on_settings_open
|
|
self.on_settings_close = on_settings_close
|
|
self.profile_manager = profile_manager
|
|
self.project = None
|
|
self._profile_names = []
|
|
|
|
self.pack_propagate(False)
|
|
self._build_ui()
|
|
|
|
def _build_ui(self):
|
|
tk.Label(self, text=APP_NAME, bg="#1A1A2E", fg="#CCCCCC",
|
|
font=("Segoe UI", 10, "bold")).pack(side="left", padx=12)
|
|
|
|
profile_frame = tk.Frame(self, bg="#1A1A2E")
|
|
profile_frame.pack(side="left", padx=(0, 4))
|
|
|
|
tk.Label(profile_frame, text="Profile:", bg="#1A1A2E", fg="#AAAAAA",
|
|
font=("Segoe UI", 9)).pack(side="left", padx=(0, 4))
|
|
|
|
self._profile_var = tk.StringVar()
|
|
self._profile_combo = ttk.Combobox(
|
|
profile_frame, textvariable=self._profile_var,
|
|
state="readonly", width=18, font=("Segoe UI", 9),
|
|
)
|
|
self._profile_combo.pack(side="left")
|
|
self._profile_combo.bind("<<ComboboxSelected>>", self._on_profile_selected)
|
|
|
|
tk.Button(profile_frame, text="+", bg="#3D3D5C", fg="white",
|
|
font=("Segoe UI", 9, "bold"), relief="flat", width=2,
|
|
command=self._new_profile_dialog).pack(side="left", padx=(4, 2))
|
|
|
|
tk.Button(profile_frame, text="\u00d7", bg="#3D3D5C", fg="#FF7777",
|
|
font=("Segoe UI", 9, "bold"), relief="flat", width=2,
|
|
command=self._delete_profile).pack(side="left", padx=(0, 4))
|
|
|
|
tk.Frame(self, bg="#333355", width=1).pack(side="left", fill="y", pady=6)
|
|
|
|
self.status_frame = tk.Frame(self, bg="#1A1A2E")
|
|
self.status_frame.pack(side="left", padx=12)
|
|
|
|
self.status_dot = tk.Canvas(self.status_frame, width=12, height=12,
|
|
bg="#1A1A2E", highlightthickness=0)
|
|
self.status_dot.pack(side="left", padx=(0, 6))
|
|
self._dot_id = self.status_dot.create_oval(2, 2, 10, 10, fill="#FF4444", outline="")
|
|
|
|
self.status_label = tk.Label(self.status_frame, text="Disconnected",
|
|
bg="#1A1A2E", fg="#888888",
|
|
font=("Segoe UI", 9))
|
|
self.status_label.pack(side="left")
|
|
|
|
self.connect_btn = tk.Button(self, text="Connect", bg="#3D3D5C", fg="white",
|
|
font=("Segoe UI", 9), relief="flat", padx=12,
|
|
command=self._toggle_connection)
|
|
self.connect_btn.pack(side="left", padx=4, pady=6)
|
|
|
|
tk.Frame(self, bg="#333355", width=1).pack(side="left", fill="y", pady=6)
|
|
|
|
self.ble_status_frame = tk.Frame(self, bg="#1A1A2E")
|
|
self.ble_status_frame.pack(side="left", padx=8)
|
|
|
|
self.ble_dot = tk.Canvas(self.ble_status_frame, width=12, height=12,
|
|
bg="#1A1A2E", highlightthickness=0)
|
|
self.ble_dot.pack(side="left", padx=(0, 4))
|
|
self._ble_dot_id = self.ble_dot.create_oval(2, 2, 10, 10, fill="#888888", outline="")
|
|
|
|
self.ble_label = tk.Label(self.ble_status_frame, text="BLE: Off",
|
|
bg="#1A1A2E", fg="#888888",
|
|
font=("Segoe UI", 8))
|
|
self.ble_label.pack(side="left")
|
|
|
|
self.settings_btn = tk.Button(self, text="\u2699 Settings", bg="#3D3D5C", fg="white",
|
|
font=("Segoe UI", 9), relief="flat", padx=12,
|
|
command=self._show_settings)
|
|
self.settings_btn.pack(side="right", padx=6, pady=6)
|
|
|
|
# Live multi-device keyboard streaming. Packed to the right of
|
|
# the other action buttons so it sits near the top-right corner
|
|
# without crowding Upload Profile, the canonical primary action.
|
|
self.bt_kbd_btn = tk.Button(self, text="\u2328 Keyboard",
|
|
bg="#7C3AED", fg="white",
|
|
activebackground="#6D28D9",
|
|
font=("Segoe UI", 9), relief="flat", padx=12,
|
|
command=self._on_bt_keyboard_click)
|
|
self.bt_kbd_btn.pack(side="right", padx=4, pady=6)
|
|
|
|
self.ble_btn = tk.Button(self, text="\u25c6 Variables", bg="#005599", fg="white",
|
|
font=("Segoe UI", 9), relief="flat", padx=12,
|
|
command=self._on_ble_variables)
|
|
self.ble_btn.pack(side="right", padx=4, pady=6)
|
|
|
|
self.upload_btn = tk.Button(self, text="\u25b6 Upload Profile", bg="#27AE60", fg="white",
|
|
font=("Segoe UI", 9, "bold"), relief="flat", padx=16,
|
|
command=self._upload_all)
|
|
self.upload_btn.pack(side="right", padx=4, pady=6)
|
|
|
|
self.progress_var = tk.DoubleVar(value=0)
|
|
self.progress_bar = ttk.Progressbar(self, variable=self.progress_var,
|
|
maximum=1.0, length=150)
|
|
|
|
def set_project(self, project):
|
|
self.project = project
|
|
|
|
def set_profiles(self, names: list, active: str):
|
|
"""Repopulate the profile combobox."""
|
|
self._profile_names = names
|
|
self._profile_combo["values"] = names
|
|
self._profile_var.set(active)
|
|
|
|
def _on_profile_selected(self, event=None):
|
|
name = self._profile_var.get()
|
|
if name and self.on_profile_switch:
|
|
self.on_profile_switch(name)
|
|
|
|
def _new_profile_dialog(self):
|
|
dialog = tk.Toplevel(self)
|
|
dialog.title("New Profile")
|
|
dialog.geometry("300x180")
|
|
dialog.resizable(False, False)
|
|
dialog.configure(bg="#2D2D3D")
|
|
dialog.transient(self.winfo_toplevel())
|
|
dialog.grab_set()
|
|
|
|
tk.Label(dialog, text="Name:", bg="#2D2D3D", fg="white",
|
|
font=("Segoe UI", 10)).pack(anchor="w", padx=16, pady=(16, 2))
|
|
name_var = tk.StringVar(value="New Profile")
|
|
name_entry = tk.Entry(dialog, textvariable=name_var, bg="#1E1E2E", fg="white",
|
|
insertbackground="white", font=("Segoe UI", 10), relief="flat")
|
|
name_entry.pack(fill="x", padx=16, pady=(0, 8))
|
|
name_entry.select_range(0, "end")
|
|
name_entry.focus_set()
|
|
|
|
copy_var = tk.BooleanVar(value=True)
|
|
rb_frame = tk.Frame(dialog, bg="#2D2D3D")
|
|
rb_frame.pack(fill="x", padx=16)
|
|
for text, val in [("Duplicate current profile", True), ("Empty profile", False)]:
|
|
tk.Radiobutton(rb_frame, text=text, variable=copy_var, value=val,
|
|
bg="#2D2D3D", fg="white", selectcolor="#1E1E2E",
|
|
activebackground="#2D2D3D", activeforeground="white",
|
|
font=("Segoe UI", 9)).pack(anchor="w")
|
|
|
|
def create():
|
|
name = name_var.get().strip()
|
|
if not name:
|
|
messagebox.showerror("New Profile", "Name cannot be empty.", parent=dialog)
|
|
return
|
|
dialog.destroy()
|
|
if self.on_profile_new:
|
|
self.on_profile_new(name, copy_var.get())
|
|
|
|
btn_frame = tk.Frame(dialog, bg="#2D2D3D")
|
|
btn_frame.pack(side="bottom", pady=12)
|
|
tk.Button(btn_frame, text="Cancel", command=dialog.destroy,
|
|
bg="#3D3D5C", fg="white", relief="flat", padx=12,
|
|
font=("Segoe UI", 9)).pack(side="left", padx=6)
|
|
tk.Button(btn_frame, text="Create", command=create,
|
|
bg="#27AE60", fg="white", relief="flat", padx=12,
|
|
font=("Segoe UI", 9, "bold")).pack(side="left", padx=6)
|
|
|
|
dialog.bind("<Return>", lambda e: create())
|
|
|
|
def _delete_profile(self):
|
|
name = self._profile_var.get()
|
|
if not name:
|
|
return
|
|
if not messagebox.askyesno("Delete Profile",
|
|
f"Delete profile '{name}'?\nThis cannot be undone.",
|
|
parent=self.winfo_toplevel()):
|
|
return
|
|
if self.on_profile_delete:
|
|
self.on_profile_delete(name)
|
|
|
|
def set_connected(self, port: str):
|
|
self.status_dot.itemconfig(self._dot_id, fill="#44FF44")
|
|
# Append the device's live transport (Mesh / BLE) when the firmware
|
|
# reports it, so the user can see each device's mode as it's plugged
|
|
# in. Older firmware omits it and we just show the port.
|
|
mode = None
|
|
if self.serial_manager is not None:
|
|
try:
|
|
mode = self.serial_manager.live_mode_label()
|
|
except Exception:
|
|
mode = None
|
|
text = f"Connected: {port}" + (f" · {mode}" if mode else "")
|
|
self.status_label.config(text=text, fg="white")
|
|
self.connect_btn.config(text="Disconnect")
|
|
|
|
def set_disconnected(self):
|
|
self.status_dot.itemconfig(self._dot_id, fill="#FF4444")
|
|
self.status_label.config(text="Disconnected", fg="#888888")
|
|
self.connect_btn.config(text="Connect")
|
|
|
|
def _toggle_connection(self):
|
|
if not self.serial_manager:
|
|
return
|
|
|
|
if self.serial_manager.connected:
|
|
self.serial_manager.disconnect()
|
|
self.set_disconnected()
|
|
else:
|
|
self.connect_btn.config(text="Scanning...", state="disabled")
|
|
self.update_idletasks()
|
|
|
|
def scan():
|
|
found = self.serial_manager.scan_and_connect()
|
|
self.after(0, lambda: self._scan_complete(found))
|
|
|
|
threading.Thread(target=scan, daemon=True).start()
|
|
|
|
def _scan_complete(self, found):
|
|
self.connect_btn.config(state="normal")
|
|
if found:
|
|
self.set_connected(self.serial_manager.port)
|
|
else:
|
|
self.set_disconnected()
|
|
messagebox.showwarning("Connection", "ATOMS3 MacroPad not found.\n\n"
|
|
"Make sure the device is connected via USB\n"
|
|
"and the firmware is uploaded.")
|
|
|
|
def _upload_all(self):
|
|
if not self.serial_manager or not self.serial_manager.connected:
|
|
messagebox.showwarning("Upload", "Device not connected.")
|
|
return
|
|
if not self.project:
|
|
messagebox.showwarning("Upload", "No project to upload.")
|
|
return
|
|
if not self.project.macros:
|
|
messagebox.showinfo("Upload", "No routines to upload.")
|
|
return
|
|
|
|
self.upload_btn.config(state="disabled", text="Uploading...")
|
|
self.progress_bar.pack(side="right", padx=4, pady=6)
|
|
self.progress_var.set(0)
|
|
self.update_idletasks()
|
|
|
|
def do_upload():
|
|
def progress(p):
|
|
self.after(0, lambda: self.progress_var.set(p))
|
|
|
|
# Flush so the sub-routines profile on disk is current before upload
|
|
if self.profile_manager:
|
|
self.profile_manager.save_current()
|
|
|
|
sub_macros = []
|
|
if self.profile_manager:
|
|
sub_project = self.profile_manager.get_subroutine_project()
|
|
sub_macros = sub_project.macros
|
|
success = self.serial_manager.upload_all(self.project, progress, subroutine_macros=sub_macros)
|
|
self.after(0, lambda: self._upload_complete(success))
|
|
|
|
threading.Thread(target=do_upload, daemon=True).start()
|
|
|
|
def _upload_complete(self, success):
|
|
self.upload_btn.config(state="normal", text="\u25b6 Upload Profile")
|
|
self.progress_bar.pack_forget()
|
|
self.progress_var.set(0)
|
|
|
|
if success:
|
|
messagebox.showinfo("Upload", "Upload complete!")
|
|
else:
|
|
messagebox.showerror("Upload", "Upload failed.\nCheck device connection.")
|
|
|
|
def set_ble_status(self, status: str):
|
|
"""Update the BLE status indicator."""
|
|
dot_colors = {
|
|
"idle": "#44FF44", "connecting": "#44AAFF",
|
|
"awaiting": "#FFCC44",
|
|
"synced": "#44FF44", "not_found": "#FFAA44",
|
|
"error": "#FF4444", "off": "#888888",
|
|
}
|
|
label_texts = {
|
|
"idle": "BLE: Ready", "connecting": "BLE: Connecting...",
|
|
"awaiting": "BLE: Awaiting input",
|
|
"synced": "BLE: Synced", "not_found": "BLE: Not Found",
|
|
"error": "BLE: Error", "off": "BLE: Off",
|
|
}
|
|
dot_color = dot_colors.get(status, "#888888")
|
|
label_text = label_texts.get(status, f"BLE: {status}")
|
|
self.ble_dot.itemconfig(self._ble_dot_id, fill=dot_color)
|
|
self.ble_label.config(text=label_text, fg=dot_color)
|
|
|
|
def _on_ble_variables(self):
|
|
if self.on_ble_variables:
|
|
self.on_ble_variables()
|
|
|
|
def _on_backups_click(self):
|
|
if self.on_backups:
|
|
self.on_backups()
|
|
|
|
def _on_rs232_terminal_click(self):
|
|
if self.on_rs232_terminal:
|
|
self.on_rs232_terminal()
|
|
|
|
def _on_bt_keyboard_click(self):
|
|
if self.on_bt_keyboard:
|
|
self.on_bt_keyboard()
|
|
|
|
def _show_settings(self):
|
|
if not self.project:
|
|
return
|
|
|
|
if self.on_settings_open:
|
|
self.on_settings_open()
|
|
|
|
dialog = tk.Toplevel(self)
|
|
dialog.title("Device Settings")
|
|
dialog.geometry("380x600")
|
|
dialog.resizable(False, True)
|
|
dialog.configure(bg="#2D2D3D")
|
|
dialog.transient(self.winfo_toplevel())
|
|
dialog.grab_set()
|
|
|
|
def _on_dialog_destroy(event, _self=self, _dialog=dialog):
|
|
if event.widget is _dialog and _self.on_settings_close:
|
|
_self.on_settings_close()
|
|
dialog.bind("<Destroy>", _on_dialog_destroy)
|
|
|
|
settings = self.project.settings
|
|
|
|
outer = tk.Frame(dialog, bg="#2D2D3D")
|
|
outer.pack(fill="both", expand=True)
|
|
|
|
canvas = tk.Canvas(outer, bg="#2D2D3D", highlightthickness=0)
|
|
scrollbar = tk.Scrollbar(outer, orient="vertical", command=canvas.yview)
|
|
content = tk.Frame(canvas, bg="#2D2D3D")
|
|
|
|
content.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
|
canvas.create_window((0, 0), window=content, anchor="nw", tags="inner")
|
|
canvas.configure(yscrollcommand=scrollbar.set)
|
|
canvas.bind("<Configure>", lambda e: canvas.itemconfig("inner", width=e.width))
|
|
canvas.bind_all("<MouseWheel>",
|
|
lambda e: canvas.yview_scroll(-1 * (e.delta // 120), "units"))
|
|
|
|
canvas.pack(side="left", fill="both", expand=True)
|
|
scrollbar.pack(side="right", fill="y")
|
|
|
|
lbl_style = {"bg": "#2D2D3D", "fg": "white", "font": ("Segoe UI", 9)}
|
|
hint_style = {"bg": "#2D2D3D", "fg": "#888888", "font": ("Segoe UI", 7), "justify": "left"}
|
|
scale_style = {"orient": "horizontal", "bg": "#2D2D3D", "fg": "white",
|
|
"troughcolor": "#1E1E2E", "highlightthickness": 0,
|
|
"font": ("Segoe UI", 8)}
|
|
|
|
def section_label(text):
|
|
f = tk.Frame(content, bg="#444466", height=1)
|
|
f.pack(fill="x", padx=12, pady=(14, 2))
|
|
tk.Label(content, text=text, bg="#2D2D3D", fg="#AAAAAA",
|
|
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=16, pady=(2, 4))
|
|
|
|
def flash_firmware(variant=""):
|
|
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
bat_path = os.path.join(project_root, "CompileAndUpload", "upload.bat")
|
|
if not os.path.isfile(bat_path):
|
|
messagebox.showerror(
|
|
"Flash Firmware",
|
|
f"upload.bat not found:\n{bat_path}",
|
|
parent=dialog,
|
|
)
|
|
return
|
|
# Release the serial port BEFORE spawning the flasher. The
|
|
# flasher's auto_enter_bootloader.py opens the COM port itself
|
|
# to ping the device and send a "bootloader" command. If the
|
|
# GUI still holds the port, that step silently fails and the
|
|
# flasher falls back to the manual "Unplug → Replug" prompts.
|
|
# The port watcher in app.py is already paused while the
|
|
# settings dialog is open (on_settings_open) and resumes when
|
|
# the dialog closes — it'll re-attach to the M5Stack on its
|
|
# next 3-second poll after the flasher releases the port and
|
|
# the device reboots into the new firmware.
|
|
if self.serial_manager and self.serial_manager.connected:
|
|
try:
|
|
self.serial_manager.disconnect()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
args = ["cmd", "/c", "start", "", bat_path]
|
|
if variant:
|
|
args.append(variant)
|
|
subprocess.Popen(
|
|
args,
|
|
cwd=os.path.dirname(bat_path),
|
|
shell=False,
|
|
)
|
|
except Exception as e:
|
|
messagebox.showerror(
|
|
"Flash Firmware",
|
|
f"Failed to launch upload.bat:\n{e}",
|
|
parent=dialog,
|
|
)
|
|
|
|
# Both buttons flash the SAME universal binary (the AtomS3 and the
|
|
# AtomS3 Lite are the identical ESP32-S3 module; the firmware
|
|
# detects the board at boot). The Lite button only switches
|
|
# upload.bat's manual-recovery instructions to LED-based wording,
|
|
# since the Lite has no screen to watch during flashing.
|
|
tk.Button(content, text="⚡ Flash Firmware — AtomS3",
|
|
command=flash_firmware,
|
|
bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"),
|
|
relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(12, 4))
|
|
|
|
tk.Button(content, text="⚡ Flash Firmware — AtomS3 Lite (no screen)",
|
|
command=lambda: flash_firmware("--lite"),
|
|
bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"),
|
|
relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(4, 4))
|
|
|
|
tk.Button(content, text="🖧 RS232 Terminal",
|
|
command=lambda: (dialog.destroy(), self._on_rs232_terminal_click()),
|
|
bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"),
|
|
relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(4, 4))
|
|
|
|
tk.Button(content, text="⧉ Backups",
|
|
command=lambda: (dialog.destroy(), self._on_backups_click()),
|
|
bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"),
|
|
relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(4, 8))
|
|
|
|
section_label("General")
|
|
|
|
tk.Label(content, text="Long press duration (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
|
hold_var = tk.IntVar(value=settings.hold_ms)
|
|
tk.Scale(content, from_=200, to=2000, variable=hold_var, resolution=50,
|
|
**scale_style).pack(fill="x", padx=16, pady=2)
|
|
|
|
tk.Label(content, text="Display orientation:", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
|
|
orient_var = tk.IntVar(value=settings.orientation)
|
|
orient_frame = tk.Frame(content, bg="#2D2D3D")
|
|
orient_frame.pack(fill="x", padx=16, pady=2)
|
|
for val, label in ORIENTATION_OPTIONS:
|
|
tk.Radiobutton(orient_frame, text=label, variable=orient_var, value=val,
|
|
bg="#2D2D3D", fg="white", selectcolor="#1E1E2E",
|
|
activebackground="#2D2D3D", activeforeground="white",
|
|
font=("Segoe UI", 8)).pack(side="left", padx=4)
|
|
|
|
tk.Label(content, text="Power-on resume delay (seconds):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
|
|
resume_var = tk.IntVar(value=settings.resume_delay)
|
|
tk.Scale(content, from_=0, to=60, variable=resume_var,
|
|
**scale_style).pack(fill="x", padx=16, pady=2)
|
|
tk.Label(content, text="0 = disabled. Auto-resumes routine after power loss.", **hint_style).pack(anchor="w", padx=16)
|
|
|
|
section_label("Typing")
|
|
|
|
tk.Label(content, text="Per-character delay (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
|
delay_var = tk.IntVar(value=settings.type_delay)
|
|
tk.Scale(content, from_=0, to=200, variable=delay_var,
|
|
**scale_style).pack(fill="x", padx=16, pady=2)
|
|
tk.Label(content, text="Base delay between each typed character.\n"
|
|
"Lower = faster typing. Below ~10ms the host may drop keys.",
|
|
**hint_style).pack(anchor="w", padx=16)
|
|
|
|
tk.Label(content, text="Extra delay for shifted chars (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
|
|
shift_extra_var = tk.IntVar(value=settings.type_shift_extra_ms)
|
|
tk.Scale(content, from_=0, to=200, variable=shift_extra_var,
|
|
**scale_style).pack(fill="x", padx=16, pady=2)
|
|
tk.Label(content, text="Added to the per-character delay only for uppercase\n"
|
|
"letters and symbols like !@#$. 0 = uniform rate.",
|
|
**hint_style).pack(anchor="w", padx=16)
|
|
|
|
tk.Label(content, text="End-of-text settle delay (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
|
|
settle_var = tk.IntVar(value=settings.type_settle_ms)
|
|
tk.Scale(content, from_=0, to=1000, variable=settle_var, resolution=10,
|
|
**scale_style).pack(fill="x", padx=16, pady=2)
|
|
tk.Label(content, text="Pause after the last character before moving on,\n"
|
|
"so the last HID reports fully drain to the host.",
|
|
**hint_style).pack(anchor="w", padx=16)
|
|
|
|
section_label("Key Combo Timing")
|
|
|
|
tk.Label(content, text="Delay before combo (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
|
combo_pre_var = tk.IntVar(value=settings.combo_pre_ms)
|
|
tk.Scale(content, from_=0, to=2000, variable=combo_pre_var, resolution=25,
|
|
**scale_style).pack(fill="x", padx=16, pady=2)
|
|
tk.Label(content, text="Wait time before the key combo is sent.", **hint_style).pack(anchor="w", padx=16)
|
|
|
|
tk.Label(content, text="Delay after combo (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
|
|
combo_post_var = tk.IntVar(value=settings.combo_post_ms)
|
|
tk.Scale(content, from_=0, to=2000, variable=combo_post_var, resolution=25,
|
|
**scale_style).pack(fill="x", padx=16, pady=2)
|
|
tk.Label(content, text="Wait time after the key combo is released.", **hint_style).pack(anchor="w", padx=16)
|
|
|
|
section_label("Media Key Timing")
|
|
|
|
tk.Label(content, text="Media key hold time (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
|
media_var = tk.IntVar(value=settings.media_hold_ms)
|
|
tk.Scale(content, from_=25, to=500, variable=media_var, resolution=25,
|
|
**scale_style).pack(fill="x", padx=16, pady=2)
|
|
tk.Label(content, text="How long to hold the media key before releasing.", **hint_style).pack(anchor="w", padx=16)
|
|
|
|
section_label("PC Alive Check")
|
|
|
|
tk.Label(content, text="Probe timeout (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
|
probe_var = tk.IntVar(value=settings.probe_timeout_ms)
|
|
tk.Scale(content, from_=50, to=1000, variable=probe_var, resolution=25,
|
|
**scale_style).pack(fill="x", padx=16, pady=2)
|
|
tk.Label(content, text="How long to wait for the host PC to respond\nto a Num Lock toggle.", **hint_style).pack(anchor="w", padx=16)
|
|
|
|
section_label("Pause Display Margins")
|
|
tk.Label(content,
|
|
text="Pixel padding around pause-screen text (128x128 LCD).\n"
|
|
"Larger = narrower text box, more aggressive truncation.",
|
|
**hint_style).pack(anchor="w", padx=16)
|
|
|
|
tk.Label(content, text="Left margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(6, 0))
|
|
pml_var = tk.IntVar(value=settings.pause_margin_left)
|
|
tk.Scale(content, from_=0, to=48, variable=pml_var,
|
|
**scale_style).pack(fill="x", padx=16, pady=2)
|
|
|
|
tk.Label(content, text="Right margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
|
pmr_var = tk.IntVar(value=settings.pause_margin_right)
|
|
tk.Scale(content, from_=0, to=48, variable=pmr_var,
|
|
**scale_style).pack(fill="x", padx=16, pady=2)
|
|
|
|
tk.Label(content, text="Top margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
|
pmt_var = tk.IntVar(value=settings.pause_margin_top)
|
|
tk.Scale(content, from_=0, to=64, variable=pmt_var,
|
|
**scale_style).pack(fill="x", padx=16, pady=2)
|
|
|
|
tk.Label(content, text="Bottom margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
|
pmb_var = tk.IntVar(value=settings.pause_margin_bottom)
|
|
tk.Scale(content, from_=0, to=48, variable=pmb_var,
|
|
**scale_style).pack(fill="x", padx=16, pady=2)
|
|
tk.Label(content,
|
|
text="Bottom margin also reserves room for the timer\n"
|
|
"progress bar on timed pauses.",
|
|
**hint_style).pack(anchor="w", padx=16)
|
|
|
|
section_label("Mesh (Keyboard hub)")
|
|
tk.Label(content,
|
|
text="Wi-Fi channel (1-13) the ESP-NOW mesh uses. Every "
|
|
"device in the fleet must match. Applied on the device "
|
|
"now; change it on each unit if Lag climbs.",
|
|
**hint_style).pack(anchor="w", padx=16)
|
|
# Seed from the connected device's current channel (best-effort).
|
|
_mesh_ch = 1
|
|
if self.serial_manager and self.serial_manager.connected:
|
|
try:
|
|
_png = self.serial_manager.ping()
|
|
if _png and isinstance(_png.get("mesh_ch"), int):
|
|
_mesh_ch = _png["mesh_ch"]
|
|
except Exception:
|
|
pass
|
|
mesh_ch_var = tk.IntVar(value=_mesh_ch)
|
|
tk.Scale(content, from_=1, to=13, variable=mesh_ch_var,
|
|
**scale_style).pack(fill="x", padx=16, pady=2)
|
|
|
|
def save():
|
|
# Mesh channel is device-local NVS (not part of the project),
|
|
# so push it straight to the device rather than via the project
|
|
# settings upload.
|
|
if self.serial_manager and self.serial_manager.connected:
|
|
try:
|
|
self.serial_manager.set_setting("mesh_ch", mesh_ch_var.get())
|
|
except Exception:
|
|
pass
|
|
settings.hold_ms = hold_var.get()
|
|
settings.type_delay = delay_var.get()
|
|
settings.type_shift_extra_ms = shift_extra_var.get()
|
|
settings.type_settle_ms = settle_var.get()
|
|
settings.orientation = orient_var.get()
|
|
settings.resume_delay = resume_var.get()
|
|
settings.combo_pre_ms = combo_pre_var.get()
|
|
settings.combo_post_ms = combo_post_var.get()
|
|
settings.probe_timeout_ms = probe_var.get()
|
|
settings.media_hold_ms = media_var.get()
|
|
settings.pause_margin_left = pml_var.get()
|
|
settings.pause_margin_right = pmr_var.get()
|
|
settings.pause_margin_top = pmt_var.get()
|
|
settings.pause_margin_bottom = pmb_var.get()
|
|
dialog.destroy()
|
|
if self.on_settings_change:
|
|
self.on_settings_change()
|
|
|
|
btn_frame = tk.Frame(dialog, bg="#2D2D3D")
|
|
btn_frame.pack(fill="x", side="bottom", pady=10)
|
|
tk.Button(btn_frame, text="Save", command=save, bg="#27AE60", fg="white",
|
|
font=("Segoe UI", 10, "bold"), relief="flat", padx=20, pady=6).pack()
|