from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFrame, QGridLayout, QSizePolicy, QLineEdit, QApplication, ) from PyQt6.QtCore import Qt, QTimer, QPointF, QRectF, QEvent from PyQt6.QtGui import QFont, QPainter, QColor, QBrush, QPen, QDoubleValidator from utils.config_parse import ConfigParse from utils.floating_keyboard import FloatingKeyboard MOVE_STEP = 10 # 每次点击移动 mm (保留备用) MOVE_DETECT_MS = 300 class JoystickWidget(QWidget): """圆形 XY 摇杆 — 拖动小圆控制方向,松开回中,越远越快""" def __init__(self, parent=None, max_speed=3000): super().__init__(parent) self.setFixedSize(320, 320) self.setMouseTracking(True) self._max_speed = max_speed # 最大进给速度 mm/min self._radius_outer = 140 # 外圈半径 self._radius_inner = 30 # 内圈小圆半径 self._deadzone = 10 # 死区像素 # 当前小圆偏移(相对中心,像素) self._dx = 0.0 self._dy = 0.0 self._pressed = False # 定时发送 GCode self._move_timer = QTimer(self) self._move_timer.setInterval(MOVE_DETECT_MS) self._move_timer.timeout.connect(self._emit_move) # 回调(上层设置) self.on_move = None # callable(dx_norm, dy_norm, feedrate) def _emit_move(self): """定时器触发:计算速度方向并回调""" dist = (self._dx ** 2 + self._dy ** 2) ** 0.5 if dist < self._deadzone: return ratio = min(dist / (self._radius_outer - self._radius_inner), 1.0) # 速度映射:30% ~ 100% * max_speed # speed = int((0.3 + ratio * 0.7) * self._max_speed) # ratio = min(dist / max_dist, 1.0) speed = ratio * self._max_speed / (1000/MOVE_DETECT_MS) if dist > 0: nx = self._dx / dist ny = self._dy / dist if self.on_move: self.on_move(nx, ny, speed) def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) cx, cy = self.width() / 2, self.height() / 2 # 外圈背景 painter.setBrush(QBrush(QColor("#2a2a2a"))) painter.setPen(QPen(QColor("#666666"), 3)) painter.drawEllipse(QPointF(cx, cy), self._radius_outer, self._radius_outer) # 十字准线(浅) painter.setPen(QPen(QColor("#555555"), 1)) painter.drawLine(int(cx - self._radius_outer), int(cy), int(cx + self._radius_outer), int(cy)) painter.drawLine(int(cx), int(cy - self._radius_outer), int(cx), int(cy + self._radius_outer)) # 小圆(可拖动) knob_x = cx + self._dx knob_y = cy + self._dy painter.setBrush(QBrush(QColor("#4a9fc8" if self._pressed else "#6fb8dd"))) painter.setPen(QPen(QColor("#2f6f91"), 2)) painter.drawEllipse(QPointF(knob_x, knob_y), self._radius_inner, self._radius_inner) # 中心小点标记 painter.setBrush(QBrush(QColor("#888888"))) painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(QPointF(cx, cy), 4, 4) def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self._pressed = True self._update_knob(event.position()) self._move_timer.start() def mouseMoveEvent(self, event): if self._pressed: self._update_knob(event.position()) def mouseReleaseEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self._pressed = False self._move_timer.stop() self._dx = 0.0 self._dy = 0.0 self.update() def _update_knob(self, pos): cx, cy = self.width() / 2, self.height() / 2 dx = pos.x() - cx dy = pos.y() - cy max_r = self._radius_outer - self._radius_inner dist = (dx ** 2 + dy ** 2) ** 0.5 if dist > max_r: dx = dx / dist * max_r dy = dy / dist * max_r self._dx = dx self._dy = dy self.update() class ZFaderWidget(QWidget): """Z 轴拨杆 — 上下拖动控制 Z 速度,松开回中,越远越快""" def __init__(self, parent=None, max_speed=200): super().__init__(parent) self.setFixedSize(120, 320) self.setMouseTracking(True) self._max_speed = max_speed self._track_h = 260 # 滑道高度 self._knob_h = 40 # 滑块高度 self._knob_w = 80 # 滑块宽度 self._deadzone = 8 self._dy = 0.0 # 相对中心的偏移 self._pressed = False self._move_timer = QTimer(self) self._move_timer.setInterval(MOVE_DETECT_MS) self._move_timer.timeout.connect(self._emit_move) self.on_move = None # callable(direction, feedrate) direction: +1 or -1 def _emit_move(self): dist = abs(self._dy) if dist < self._deadzone: return max_dist = (self._track_h - self._knob_h) / 2 ratio = min(dist / max_dist, 1.0) # speed = int((0.3 + ratio * 0.7) * self._max_speed / 10) speed = ratio * self._max_speed / (1000/MOVE_DETECT_MS) direction = 1 if self._dy < 0 else -1 # dy<0 → 向上 if self.on_move: self.on_move(direction, speed) def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) cx = self.width() / 2 # 滑道 rect = QRectF(cx - 12, (self.height() - self._track_h) / 2, 24, self._track_h) painter.setBrush(QBrush(QColor("#2a2a2a"))) painter.setPen(QPen(QColor("#666666"), 2)) painter.drawRoundedRect(rect, 12, 12) # 中心刻度线 mid_y = self.height() / 2 painter.setPen(QPen(QColor("#777777"), 2)) painter.drawLine(int(cx - 20), int(mid_y), int(cx + 20), int(mid_y)) # 滑块 knob_center_y = mid_y + self._dy knob_y = knob_center_y - self._knob_h / 2 knob_rect = QRectF(cx - self._knob_w / 2, knob_y, self._knob_w, self._knob_h) color = "#4a9fc8" if self._pressed else "#6fb8dd" painter.setBrush(QBrush(QColor(color))) painter.setPen(QPen(QColor("#2f6f91"), 2)) painter.drawRoundedRect(knob_rect, 10, 10) # Z 标签 painter.setPen(QPen(QColor("#a0a0a0"), 1)) font = QFont("sans-serif", 14, QFont.Weight.Bold) painter.setFont(font) painter.drawText(knob_rect, Qt.AlignmentFlag.AlignCenter, "Z") def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self._pressed = True self._update_knob(event.position().y()) self._move_timer.start() def mouseMoveEvent(self, event): if self._pressed: self._update_knob(event.position().y()) def mouseReleaseEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self._pressed = False self._move_timer.stop() self._dy = 0.0 self.update() def _update_knob(self, py): mid_y = self.height() / 2 max_d = (self._track_h - self._knob_h) / 2 dy = py - mid_y dy = max(-max_d, min(max_d, dy)) self._dy = dy self.update() class ControlPage(QWidget): def __init__(self, api_client, parent=None): super().__init__(parent) self.api_client = api_client self.config_parser = ConfigParse() self.config_parser.config_changed.connect(self._on_config_changed) self._load_limits() self._load_speeds() # 当前坐标(本地跟踪) self.pos_x = 0.0 self.pos_y = 0.0 self.pos_z = 0.0 self._is_printing = False self._homed = False # 是否已轴回零 self._motor_on = True # 电机是否已使能(默认True,关电机后置False) # 归零后的停留位置 hp = self.config_parser.home_positions or {} self._home_x = hp.get("x", 0.0) self._home_y = hp.get("y", 0.0) self._home_z = hp.get("z", 0.0) # 悬浮键盘 self._keyboard = FloatingKeyboard() self._keyboard_attached = False self.init_ui() self._sync_inputs() self._apply_state() self._start_status_poll() def _load_speeds(self): ms = self.config_parser.move_max_speed or {} self.move_speed_xy = ms.get("xy", 3000) self.move_speed_z = ms.get("z", 200) def _start_status_poll(self): self._status_timer = QTimer(self) self._status_timer.timeout.connect(self._poll_status) self._status_timer.start(2000) def _poll_status(self): """定时轮询打印机状态,更新按钮启用状态和坐标""" try: data = self.api_client.get_status() flags = data.get("status", {}).get("state", {}).get("flags", {}) printing = flags.get("printing", False) paused = flags.get("paused", False) self._is_printing = printing or paused except Exception: self._is_printing = False self._update_buttons() def _load_limits(self): ma = self.config_parser.move_axis_area or {} self.x_min = ma.get("x_min", 0) self.x_max = ma.get("x_max", 300) self.y_min = ma.get("y_min", 0) self.y_max = ma.get("y_max", 300) self.z_min = ma.get("z_min", 0) self.z_max = ma.get("z_max", 300) def _on_config_changed(self, config_instance): self._load_limits() self._load_speeds() # ── 状态管理 ────────────────────────────────────────── def _is_control_enabled(self): """摇杆/拨杆/坐标输入是否可用:已回零 + 电机已使能 + 非打印中""" return self._homed and self._motor_on and not self._is_printing def _apply_state(self): """根据回零/电机状态同步所有控件""" enabled = self._is_control_enabled() self.joystick.setEnabled(enabled) self.z_fader.setEnabled(enabled) for inp in (self.input_x, self.input_y, self.input_z): inp.setEnabled(enabled) if not enabled: inp.setText("---") if enabled: self._sync_inputs() def _sync_inputs(self): """将 pos 值同步到输入框""" if self._is_control_enabled(): self.input_x.setText(f"{self.pos_x:.3f}") self.input_y.setText(f"{self.pos_y:.3f}") self.input_z.setText(f"{self.pos_z:.3f}") def _on_coord_changed(self): """输入框手动输入触发运动""" if not self._is_control_enabled(): return try: tx = float(self.input_x.text()) ty = float(self.input_y.text()) tz = float(self.input_z.text()) except ValueError: return tx = max(self.x_min, min(self.x_max, tx)) ty = max(self.y_min, min(self.y_max, ty)) tz = max(self.z_min, min(self.z_max, tz)) gcode = f"G1 X{tx:.3f} Y{ty:.3f} Z{tz:.3f} F3000" self.api_client.send_gcode(gcode) self.pos_x, self.pos_y, self.pos_z = tx, ty, tz self._sync_inputs() def _update_buttons(self): printing = self._is_printing self.btn_pause.setEnabled(printing) self.btn_stop.setEnabled(printing) idle = not printing self.btn_home.setEnabled(idle) self.btn_level.setEnabled(idle) self.btn_motor_off.setEnabled(idle) self._apply_state() # ── GCode 发送 ────────────────────────────────────────── def _send_move(self, x=None, y=None, z=None, feedrate=None): if not self._is_control_enabled(): return target_x = self.pos_x if x is None else x target_y = self.pos_y if y is None else y target_z = self.pos_z if z is None else z target_x = max(self.x_min, min(self.x_max, target_x)) target_y = max(self.y_min, min(self.y_max, target_y)) target_z = max(self.z_min, min(self.z_max, target_z)) f = feedrate or 3000 gcode = f"G1 X{target_x:.3f} Y{target_y:.3f} Z{target_z:.3f} F{f}" self.api_client.send_gcode(gcode) self.pos_x, self.pos_y, self.pos_z = target_x, target_y, target_z self._sync_inputs() # ── 摇杆/拨杆回调 ── def _on_joystick_move(self, nx, ny, speed): if not self._is_control_enabled(): return step = speed * MOVE_DETECT_MS / 1000 dx = nx * step dy = ny * step tx = max(self.x_min, min(self.x_max, self.pos_x + dx)) ty = max(self.y_min, min(self.y_max, self.pos_y + dy)) gcode = f"G1 X{tx:.1f} Y{ty:.1f} F{speed}" print(f"Moving: X{tx:.1f} Y{ty:.1f} F{speed}") self.api_client.send_gcode(gcode) self.pos_x, self.pos_y = tx, ty self._sync_inputs() def _on_fader_move(self, direction, speed): if not self._is_control_enabled(): return step = speed * MOVE_DETECT_MS / 1000 tz = self.pos_z + direction * step tz = max(self.z_min, min(self.z_max, tz)) gcode = f"G1 Z{tz:.3f} F{speed}" print(f"Moving: Z{tz:.3f} F{speed}") self.api_client.send_gcode(gcode) self.pos_z = tz self._sync_inputs() # ── 指令按钮 ── def _cmd_pause(self): self.api_client.pause_print() def _cmd_stop(self): self.api_client.stop_print() def _cmd_home(self): self.api_client.home_axes(["x", "y", "z"]) self.pos_x = self._home_x self.pos_y = self._home_y self.pos_z = self._home_z self._homed = True self._motor_on = True self._sync_inputs() self._apply_state() def _cmd_level(self): self.api_client.auto_leveling() def _cmd_motor_off(self): self.api_client.off_motors() self._motor_on = False self._apply_state() # ── 悬浮键盘 ───────────────────────────────────────────── def _attach_keyboard(self, widget): """将悬浮键盘绑定到指定输入框并显示""" self._keyboard.attach(widget) self._keyboard.show_below(widget) self._keyboard_attached = True def _dismiss_keyboard(self): """关闭悬浮键盘""" self._keyboard.hide() self._keyboard.detach() self._keyboard_attached = False def _on_input_focus_in(self, widget): """输入框获得焦点时的处理""" if self._keyboard_attached: self._keyboard.attach(widget) self._keyboard.show_below(widget) else: self._attach_keyboard(widget) def eventFilter(self, obj, event): if event.type() == QEvent.Type.FocusIn: if obj in (self.input_x, self.input_y, self.input_z): self._on_input_focus_in(obj) elif self._keyboard_attached and not isinstance(obj, QLineEdit): self._dismiss_keyboard() elif event.type() == QEvent.Type.FocusOut: if obj in (self.input_x, self.input_y, self.input_z): QTimer.singleShot(100, self._check_dismiss_keyboard) return super().eventFilter(obj, event) def _check_dismiss_keyboard(self): """检查当前焦点是否还在坐标输入框上,不在则关闭键盘""" w = self.focusWidget() if w not in (self.input_x, self.input_y, self.input_z): self._dismiss_keyboard() # ── UI 构建 ────────────────────────────────────────────── def init_ui(self): self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) main_layout = QHBoxLayout(self) main_layout.setContentsMargins(14, 14, 14, 14) main_layout.setSpacing(14) # =========================== 左栏 =========================== left_panel = QFrame() left_panel.setStyleSheet("background-color: #3f3f3f; border-radius: 10px;") left_panel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) left_layout = QVBoxLayout(left_panel) left_layout.setContentsMargins(16, 14, 16, 14) left_layout.setSpacing(8) left_title = QLabel("控制") left_title.setAlignment(Qt.AlignmentFlag.AlignCenter) left_title.setStyleSheet("color: #efefef; font-size: 22px; font-weight: 700;") left_layout.addWidget(left_title) btn_style = """ QPushButton { min-height: 50px; font-size: 18px; font-weight: 600; color: #f8f8f8; background-color: #555555; border: 2px solid #888888; border-radius: 8px; padding: 4px 12px; } QPushButton:hover { background-color: #636363; border-color: #aaaaaa; } QPushButton:pressed { background-color: #3d3d3d; border-color: #5a9fcf; } QPushButton:disabled { color: #606060; background-color: #3a3a3a; border-color: #505050; } """ grid_btns = QGridLayout() grid_btns.setSpacing(8) self.btn_pause = QPushButton("⏸ 暂停打印") self.btn_pause.setStyleSheet(btn_style) self.btn_pause.clicked.connect(self._cmd_pause) grid_btns.addWidget(self.btn_pause, 0, 0) self.btn_stop = QPushButton("⏹ 停止打印") self.btn_stop.setStyleSheet(btn_style) self.btn_stop.clicked.connect(self._cmd_stop) grid_btns.addWidget(self.btn_stop, 0, 1) self.btn_home = QPushButton("⌂ 轴回零") self.btn_home.setStyleSheet(btn_style) self.btn_home.clicked.connect(self._cmd_home) grid_btns.addWidget(self.btn_home, 1, 0) self.btn_level = QPushButton("◉ 自动调平") self.btn_level.setStyleSheet(btn_style) self.btn_level.clicked.connect(self._cmd_level) grid_btns.addWidget(self.btn_level, 1, 1) self.btn_motor_off = QPushButton("⏻ 关电机") self.btn_motor_off.setStyleSheet(btn_style) self.btn_motor_off.clicked.connect(self._cmd_motor_off) grid_btns.addWidget(self.btn_motor_off, 2, 0, 1, 2) left_layout.addLayout(grid_btns) main_layout.addWidget(left_panel, 2) # =========================== 右栏 =========================== right_panel = QFrame() right_panel.setStyleSheet("background-color: #3f3f3f; border-radius: 10px;") right_panel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(16, 14, 16, 14) right_layout.setSpacing(10) # ── 坐标输入 ── coord_row = QHBoxLayout() coord_row.setSpacing(12) coord_row.setAlignment(Qt.AlignmentFlag.AlignCenter) input_style = """ QLineEdit { min-width: 80px; max-width: 120px; min-height: 44px; font-size: 24px; font-weight: 700; color: #e0e0e0; background-color: #2a2a2a; border: 2px solid #646464; border-radius: 8px; padding: 4px 10px; text-align: center; } QLineEdit:focus { border-color: #5a9fcf; } QLineEdit:disabled { color: #606060; background-color: #222222; border-color: #444444; } """ for axis, val in [("X", 0.0), ("Y", 0.0), ("Z", 0.0)]: lbl = QLabel(f"{axis}:") lbl.setStyleSheet("color: #d0d0d0; font-size: 24px; font-weight: 700;") coord_row.addWidget(lbl) inp = QLineEdit(f"{val:.1f}") inp.setStyleSheet(input_style) inp.setAlignment(Qt.AlignmentFlag.AlignCenter) inp.setValidator(QDoubleValidator(-9999, 9999, 1)) inp.returnPressed.connect(self._on_coord_changed) inp.installEventFilter(self) setattr(self, f"input_{axis.lower()}", inp) coord_row.addWidget(inp) right_layout.addLayout(coord_row) # ── 操作器 ── manip_row = QHBoxLayout() manip_row.setSpacing(14) manip_row.setAlignment(Qt.AlignmentFlag.AlignCenter) self.joystick = JoystickWidget(max_speed=self.move_speed_xy) self.joystick.on_move = self._on_joystick_move manip_row.addWidget(self.joystick) self.z_fader = ZFaderWidget(max_speed=self.move_speed_z) self.z_fader.on_move = self._on_fader_move manip_row.addWidget(self.z_fader) right_layout.addLayout(manip_row) main_layout.addWidget(right_panel, 3) self._update_buttons()