From 1426a618f47e8a5aff27658e44ccc0653ace7781 Mon Sep 17 00:00:00 2001 From: ken910606 Date: Thu, 30 Apr 2026 06:24:09 +0800 Subject: [PATCH] Update GUI 2.2.0: Settings tab --- src/GUI/comm_panel.py | 163 +++++++++++++++----- src/GUI/drone_panel.py | 151 ++++++++++++++---- src/GUI/gui.py | 323 +++++++++++++++++++++++++++++++++++++-- src/GUI/map_layout.py | 33 ++-- src/GUI/mission_group.py | 47 ++++-- 5 files changed, 614 insertions(+), 103 deletions(-) diff --git a/src/GUI/comm_panel.py b/src/GUI/comm_panel.py index 3a41226..cf79be1 100644 --- a/src/GUI/comm_panel.py +++ b/src/GUI/comm_panel.py @@ -1,10 +1,78 @@ #!/usr/bin/env python3 -from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QPushButton, QLineEdit, QComboBox) +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QLineEdit, QComboBox, QApplication) +from PyQt6.QtGui import QFont from PyQt6.QtCore import pyqtSignal import glob import os + +def _get_font_scale(): + app = QApplication.instance() + if app is None: + return 1.0 + scale = app.property("font_scale") + try: + return float(scale) if scale is not None else 1.0 + except (TypeError, ValueError): + return 1.0 + + +def _scale_stylesheet_font_sizes(stylesheet, scale): + if not stylesheet or 'font-size' not in stylesheet: + return stylesheet + + import re + + def repl(match): + size = float(match.group(1)) + unit = match.group(2) + scaled = max(1.0, size * scale) + text = f"{scaled:.2f}".rstrip('0').rstrip('.') + return f"font-size: {text}{unit}" + + return re.sub(r'font-size\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*(px|pt)', repl, stylesheet) + + +def _set_scaled_stylesheet(widget, stylesheet): + widget._base_stylesheet = stylesheet + scaled = _scale_stylesheet_font_sizes(stylesheet, _get_font_scale()) + widget._applied_stylesheet = scaled + widget.setStyleSheet(scaled) + + +def _reapply_scaled_stylesheet(widget): + current_stylesheet = widget.styleSheet() + base_stylesheet = getattr(widget, '_base_stylesheet', None) + applied_stylesheet = getattr(widget, '_applied_stylesheet', None) + + if current_stylesheet != applied_stylesheet: + base_stylesheet = current_stylesheet + widget._base_stylesheet = base_stylesheet + + if base_stylesheet is not None: + scaled = _scale_stylesheet_font_sizes(base_stylesheet, _get_font_scale()) + widget._applied_stylesheet = scaled + if current_stylesheet != scaled: + widget.setStyleSheet(scaled) + + +def _apply_scaled_font(widget): + base_font = getattr(widget, '_base_font_for_scale', None) + if base_font is None: + app = QApplication.instance() + app_base_font = app.property("base_app_font") if app else None + base_font = QFont(app_base_font) if app_base_font is not None else QFont(widget.font()) + widget._base_font_for_scale = QFont(base_font) + + scaled_font = QFont(base_font) + scale = _get_font_scale() + if base_font.pointSizeF() > 0: + scaled_font.setPointSizeF(max(1.0, base_font.pointSizeF() * scale)) + elif base_font.pointSize() > 0: + scaled_font.setPointSize(max(1, int(round(base_font.pointSize() * scale)))) + widget.setFont(scaled_font) + class CommPanel(QWidget): """通讯设置面板类""" @@ -26,6 +94,7 @@ class CommPanel(QWidget): self.ws_connections = [] self.serial_connections = [] self._init_ui() + self.apply_font_scale() def _init_ui(self): """初始化UI""" @@ -35,7 +104,7 @@ class CommPanel(QWidget): # ========== UDP MAVLink 區域 ========== udp_title = QLabel("UDP") - udp_title.setStyleSheet(""" + _set_scaled_stylesheet(udp_title, """ color: #DDD; font-size: 14px; font-weight: bold; @@ -60,7 +129,7 @@ class CommPanel(QWidget): self.udp_ip_input = QLineEdit() self.udp_ip_input.setText("127.0.0.1") self.udp_ip_input.setPlaceholderText("IP") - self.udp_ip_input.setStyleSheet(""" + _set_scaled_stylesheet(self.udp_ip_input, """ QLineEdit { background-color: #333; color: #DDD; @@ -74,7 +143,7 @@ class CommPanel(QWidget): self.udp_port_input.setText("14550") self.udp_port_input.setPlaceholderText("Port") self.udp_port_input.setFixedWidth(80) - self.udp_port_input.setStyleSheet(""" + _set_scaled_stylesheet(self.udp_port_input, """ QLineEdit { background-color: #333; color: #DDD; @@ -86,7 +155,7 @@ class CommPanel(QWidget): add_udp_btn = QPushButton("添加") add_udp_btn.clicked.connect(self._handle_add_udp) - add_udp_btn.setStyleSheet(""" + _set_scaled_stylesheet(add_udp_btn, """ QPushButton { background-color: #4CAF50; color: white; @@ -98,9 +167,13 @@ class CommPanel(QWidget): QPushButton:hover { background-color: #45a049; } """) - add_udp_layout.addWidget(QLabel("IP:", styleSheet="color: #DDD;")) + ip_label = QLabel("IP:") + _set_scaled_stylesheet(ip_label, "color: #DDD;") + add_udp_layout.addWidget(ip_label) add_udp_layout.addWidget(self.udp_ip_input) - add_udp_layout.addWidget(QLabel("Port:", styleSheet="color: #DDD;")) + port_label = QLabel("Port:") + _set_scaled_stylesheet(port_label, "color: #DDD;") + add_udp_layout.addWidget(port_label) add_udp_layout.addWidget(self.udp_port_input) add_udp_layout.addWidget(add_udp_btn) @@ -113,7 +186,7 @@ class CommPanel(QWidget): # ========== Serial 區域 ========== serial_title = QLabel("Serial") - serial_title.setStyleSheet(""" + _set_scaled_stylesheet(serial_title, """ color: #DDD; font-size: 14px; font-weight: bold; @@ -136,7 +209,7 @@ class CommPanel(QWidget): add_serial_layout.setContentsMargins(0, 0, 0, 0) self.serial_port_combo = QComboBox() - self.serial_port_combo.setStyleSheet(""" + _set_scaled_stylesheet(self.serial_port_combo, """ QComboBox { background-color: #333; color: #DDD; @@ -160,7 +233,7 @@ class CommPanel(QWidget): refresh_ports_btn.setFixedWidth(35) refresh_ports_btn.clicked.connect(self._refresh_serial_ports) refresh_ports_btn.setToolTip("重新掃描串口") - refresh_ports_btn.setStyleSheet(""" + _set_scaled_stylesheet(refresh_ports_btn, """ QPushButton { background-color: #444; color: #DDD; @@ -176,7 +249,7 @@ class CommPanel(QWidget): self.serial_baudrate_combo.addItems(['9600', '19200', '38400', '57600', '115200']) self.serial_baudrate_combo.setCurrentText('57600') self.serial_baudrate_combo.setFixedWidth(100) - self.serial_baudrate_combo.setStyleSheet(""" + _set_scaled_stylesheet(self.serial_baudrate_combo, """ QComboBox { background-color: #333; color: #DDD; @@ -197,7 +270,7 @@ class CommPanel(QWidget): add_serial_btn = QPushButton("添加") add_serial_btn.clicked.connect(self._handle_add_serial) - add_serial_btn.setStyleSheet(""" + _set_scaled_stylesheet(add_serial_btn, """ QPushButton { background-color: #4CAF50; color: white; @@ -209,10 +282,14 @@ class CommPanel(QWidget): QPushButton:hover { background-color: #45a049; } """) - add_serial_layout.addWidget(QLabel("Port:", styleSheet="color: #DDD;")) + serial_port_label = QLabel("Port:") + _set_scaled_stylesheet(serial_port_label, "color: #DDD;") + add_serial_layout.addWidget(serial_port_label) add_serial_layout.addWidget(self.serial_port_combo) add_serial_layout.addWidget(refresh_ports_btn) - add_serial_layout.addWidget(QLabel("Baud:", styleSheet="color: #DDD;")) + baud_label = QLabel("Baud:") + _set_scaled_stylesheet(baud_label, "color: #DDD;") + add_serial_layout.addWidget(baud_label) add_serial_layout.addWidget(self.serial_baudrate_combo) add_serial_layout.addWidget(add_serial_btn) @@ -225,7 +302,7 @@ class CommPanel(QWidget): # ========== WebSocket 區域 ========== ws_title = QLabel("WebSocket") - ws_title.setStyleSheet(""" + _set_scaled_stylesheet(ws_title, """ color: #DDD; font-size: 14px; font-weight: bold; @@ -249,7 +326,7 @@ class CommPanel(QWidget): self.ws_url_input = QLineEdit() self.ws_url_input.setPlaceholderText("host") - self.ws_url_input.setStyleSheet(""" + _set_scaled_stylesheet(self.ws_url_input, """ QLineEdit { background-color: #333; color: #DDD; @@ -261,7 +338,7 @@ class CommPanel(QWidget): add_ws_btn = QPushButton("添加") add_ws_btn.clicked.connect(self._handle_add_ws) - add_ws_btn.setStyleSheet(""" + _set_scaled_stylesheet(add_ws_btn, """ QPushButton { background-color: #4CAF50; color: white; @@ -273,7 +350,9 @@ class CommPanel(QWidget): QPushButton:hover { background-color: #45a049; } """) - add_ws_layout.addWidget(QLabel("URL:", styleSheet="color: #DDD;")) + url_label = QLabel("URL:") + _set_scaled_stylesheet(url_label, "color: #DDD;") + add_ws_layout.addWidget(url_label) add_ws_layout.addWidget(self.ws_url_input) add_ws_layout.addWidget(add_ws_btn) @@ -437,7 +516,7 @@ class CommPanel(QWidget): def create_udp_connection_panel(self, conn): """創建 UDP 連接面板""" panel = QWidget() - panel.setStyleSheet(""" + _set_scaled_stylesheet(panel, """ QWidget { background-color: #2A2A2A; border-radius: 6px; @@ -451,22 +530,22 @@ class CommPanel(QWidget): # 連接資訊 info_label = QLabel(f"{conn['name']} - {conn['ip']}:{conn['port']}") - info_label.setStyleSheet("color: #DDD; font-size: 12px;") + _set_scaled_stylesheet(info_label, "color: #DDD; font-size: 12px;") # 狀態指示器 status_label = QLabel("●") if conn.get('enabled', False): - status_label.setStyleSheet("color: #4CAF50; font-size: 16px;") + _set_scaled_stylesheet(status_label, "color: #4CAF50; font-size: 16px;") status_label.setToolTip("運行中") else: - status_label.setStyleSheet("color: #888; font-size: 16px;") + _set_scaled_stylesheet(status_label, "color: #888; font-size: 16px;") status_label.setToolTip("已停止") # 控制按鈕 toggle_btn = QPushButton("停止" if conn.get('enabled', False) else "啟動") toggle_btn.setFixedWidth(60) toggle_btn.clicked.connect(lambda: self.udp_connection_toggled.emit(conn, toggle_btn, status_label)) - toggle_btn.setStyleSheet(""" + _set_scaled_stylesheet(toggle_btn, """ QPushButton { background-color: #444; color: #DDD; @@ -481,7 +560,7 @@ class CommPanel(QWidget): remove_btn = QPushButton("移除") remove_btn.setFixedWidth(60) remove_btn.clicked.connect(lambda: self.udp_connection_removed.emit(conn, panel)) - remove_btn.setStyleSheet(""" + _set_scaled_stylesheet(remove_btn, """ QPushButton { background-color: #d32f2f; color: white; @@ -509,7 +588,7 @@ class CommPanel(QWidget): def create_ws_connection_panel(self, conn): """創建 WebSocket 連接面板""" panel = QWidget() - panel.setStyleSheet(""" + _set_scaled_stylesheet(panel, """ QWidget { background-color: #2A2A2A; border-radius: 6px; @@ -523,22 +602,22 @@ class CommPanel(QWidget): # 連接資訊 info_label = QLabel(f"{conn['name']} - {conn['url']}") - info_label.setStyleSheet("color: #DDD; font-size: 12px;") + _set_scaled_stylesheet(info_label, "color: #DDD; font-size: 12px;") # 狀態指示器 status_label = QLabel("●") if conn.get('enabled', False): - status_label.setStyleSheet("color: #4CAF50; font-size: 16px;") + _set_scaled_stylesheet(status_label, "color: #4CAF50; font-size: 16px;") status_label.setToolTip("運行中") else: - status_label.setStyleSheet("color: #888; font-size: 16px;") + _set_scaled_stylesheet(status_label, "color: #888; font-size: 16px;") status_label.setToolTip("已停止") # 控制按鈕 toggle_btn = QPushButton("停止" if conn.get('enabled', False) else "啟動") toggle_btn.setFixedWidth(60) toggle_btn.clicked.connect(lambda: self.ws_connection_toggled.emit(conn, toggle_btn, status_label)) - toggle_btn.setStyleSheet(""" + _set_scaled_stylesheet(toggle_btn, """ QPushButton { background-color: #444; color: #DDD; @@ -553,7 +632,7 @@ class CommPanel(QWidget): remove_btn = QPushButton("移除") remove_btn.setFixedWidth(60) remove_btn.clicked.connect(lambda: self.ws_connection_removed.emit(conn, panel)) - remove_btn.setStyleSheet(""" + _set_scaled_stylesheet(remove_btn, """ QPushButton { background-color: #d32f2f; color: white; @@ -605,7 +684,7 @@ class CommPanel(QWidget): def create_serial_connection_panel(self, conn): """創建 Serial 連接面板""" panel = QWidget() - panel.setStyleSheet(""" + _set_scaled_stylesheet(panel, """ QWidget { background-color: #2A2A2A; border-radius: 6px; @@ -619,22 +698,22 @@ class CommPanel(QWidget): # 連接資訊 info_label = QLabel(f"{conn['name']} - {conn['port']} @ {conn['baudrate']}") - info_label.setStyleSheet("color: #DDD; font-size: 12px;") + _set_scaled_stylesheet(info_label, "color: #DDD; font-size: 12px;") # 狀態指示器 status_label = QLabel("●") if conn.get('enabled', False): - status_label.setStyleSheet("color: #4CAF50; font-size: 16px;") + _set_scaled_stylesheet(status_label, "color: #4CAF50; font-size: 16px;") status_label.setToolTip("運行中") else: - status_label.setStyleSheet("color: #888; font-size: 16px;") + _set_scaled_stylesheet(status_label, "color: #888; font-size: 16px;") status_label.setToolTip("已停止") # 控制按鈕 toggle_btn = QPushButton("停止" if conn.get('enabled', False) else "啟動") toggle_btn.setFixedWidth(60) toggle_btn.clicked.connect(lambda: self.serial_connection_toggled.emit(conn, toggle_btn, status_label)) - toggle_btn.setStyleSheet(""" + _set_scaled_stylesheet(toggle_btn, """ QPushButton { background-color: #444; color: #DDD; @@ -649,7 +728,7 @@ class CommPanel(QWidget): remove_btn = QPushButton("移除") remove_btn.setFixedWidth(60) remove_btn.clicked.connect(lambda: self.serial_connection_removed.emit(conn, panel)) - remove_btn.setStyleSheet(""" + _set_scaled_stylesheet(remove_btn, """ QPushButton { background-color: #d32f2f; color: white; @@ -684,4 +763,12 @@ class CommPanel(QWidget): def remove_serial_connection_from_list(self, conn): """從列表中移除 Serial 連接""" if conn in self.serial_connections: - self.serial_connections.remove(conn) \ No newline at end of file + self.serial_connections.remove(conn) + + def apply_font_scale(self): + """重新套用目前字體倍率到通訊面板。""" + _apply_scaled_font(self) + _reapply_scaled_stylesheet(self) + for child in self.findChildren(QWidget): + _apply_scaled_font(child) + _reapply_scaled_stylesheet(child) diff --git a/src/GUI/drone_panel.py b/src/GUI/drone_panel.py index 49e560e..3477126 100644 --- a/src/GUI/drone_panel.py +++ b/src/GUI/drone_panel.py @@ -1,10 +1,77 @@ #!/usr/bin/env python3 -from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox) +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, QApplication) from PyQt6.QtCore import pyqtSignal from PyQt6.QtGui import QPainter, QPen, QColor, QFont, QPolygonF from PyQt6.QtCore import QPointF, Qt import math + +def _get_font_scale(): + app = QApplication.instance() + if app is None: + return 1.0 + scale = app.property("font_scale") + try: + return float(scale) if scale is not None else 1.0 + except (TypeError, ValueError): + return 1.0 + + +def _scale_stylesheet_font_sizes(stylesheet, scale): + if not stylesheet or 'font-size' not in stylesheet: + return stylesheet + + import re + + def repl(match): + size = float(match.group(1)) + unit = match.group(2) + scaled = max(1.0, size * scale) + text = f"{scaled:.2f}".rstrip('0').rstrip('.') + return f"font-size: {text}{unit}" + + return re.sub(r'font-size\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*(px|pt)', repl, stylesheet) + + +def _set_scaled_stylesheet(widget, stylesheet): + widget._base_stylesheet = stylesheet + scaled = _scale_stylesheet_font_sizes(stylesheet, _get_font_scale()) + widget._applied_stylesheet = scaled + widget.setStyleSheet(scaled) + + +def _reapply_scaled_stylesheet(widget): + current_stylesheet = widget.styleSheet() + base_stylesheet = getattr(widget, '_base_stylesheet', None) + applied_stylesheet = getattr(widget, '_applied_stylesheet', None) + + if current_stylesheet != applied_stylesheet: + base_stylesheet = current_stylesheet + widget._base_stylesheet = base_stylesheet + + if base_stylesheet is not None: + scaled = _scale_stylesheet_font_sizes(base_stylesheet, _get_font_scale()) + widget._applied_stylesheet = scaled + if current_stylesheet != scaled: + widget.setStyleSheet(scaled) + + +def _apply_scaled_font(widget): + base_font = getattr(widget, '_base_font_for_scale', None) + if base_font is None: + app = QApplication.instance() + app_base_font = app.property("base_app_font") if app else None + base_font = QFont(app_base_font) if app_base_font is not None else QFont(widget.font()) + widget._base_font_for_scale = QFont(base_font) + + scaled_font = QFont(base_font) + scale = _get_font_scale() + if base_font.pointSizeF() > 0: + scaled_font.setPointSizeF(max(1.0, base_font.pointSizeF() * scale)) + elif base_font.pointSize() > 0: + scaled_font.setPointSize(max(1, int(round(base_font.pointSize() * scale)))) + widget.setFont(scaled_font) + class DronePanel(QWidget): """單個無人機面板類別""" @@ -32,12 +99,13 @@ class DronePanel(QWidget): self.attitude_indicator = None self._init_ui() + self.apply_font_scale() def _init_ui(self): """初始化UI""" self.setObjectName(f"panel_{self.drone_id}") self.setFixedHeight(140) - self.setStyleSheet(""" + _set_scaled_stylesheet(self, """ background-color: #2A2A2A; border-radius: 8px; """) @@ -49,7 +117,7 @@ class DronePanel(QWidget): # 創建內容容器(包含 info 和 control) content_widget = QWidget() - content_widget.setStyleSheet("background-color: #333; border-radius: 6px;") + _set_scaled_stylesheet(content_widget, "background-color: #333; border-radius: 6px;") content_layout = QHBoxLayout(content_widget) content_layout.setContentsMargins(8, 8, 8, 8) content_layout.setSpacing(8) @@ -82,7 +150,7 @@ class DronePanel(QWidget): # 勾選框 self.checkbox = QCheckBox() self.checkbox.setObjectName(f"{self.drone_id}_checkbox") - self.checkbox.setStyleSheet(""" + _set_scaled_stylesheet(self.checkbox, """ QCheckBox { color: #DDD; } @@ -103,8 +171,8 @@ class DronePanel(QWidget): ) # ID 顯示 - id_label = QLabel(self.display_id) - id_label.setStyleSheet(""" + self.id_label = QLabel(self.display_id) + _set_scaled_stylesheet(self.id_label, """ font-weight: bold; font-size: 14px; color: #7FFFD4; @@ -112,7 +180,7 @@ class DronePanel(QWidget): """) header_layout.addWidget(self.checkbox) - header_layout.addWidget(id_label) + header_layout.addWidget(self.id_label) header_layout.addStretch() info_layout.addWidget(header) @@ -148,15 +216,15 @@ class DronePanel(QWidget): status_layout.setContentsMargins(0, 0, 0, 0) status_title = QLabel("狀態:") - status_title.setStyleSheet("color: #888; min-width: 50px;") + _set_scaled_stylesheet(status_title, "color: #888; min-width: 50px;") self.mode_label = QLabel("--") self.mode_label.setObjectName(f"{self.drone_id}_mode") - self.mode_label.setStyleSheet("color: #DDD;") + _set_scaled_stylesheet(self.mode_label, "color: #DDD;") self.armed_label = QLabel("--") self.armed_label.setObjectName(f"{self.drone_id}_armed") - self.armed_label.setStyleSheet("color: #DDD;") + _set_scaled_stylesheet(self.armed_label, "color: #DDD;") status_layout.addWidget(status_title) status_layout.addWidget(self.mode_label) @@ -172,15 +240,15 @@ class DronePanel(QWidget): connection_layout.setContentsMargins(0, 0, 0, 0) connection_title = QLabel("Socket:") - connection_title.setStyleSheet("color: #888; min-width: 50px;") + _set_scaled_stylesheet(connection_title, "color: #888; min-width: 50px;") # 根據解析的 drone_id 資訊設定初始值 self.socket_seq_label = QLabel(self.socket_seq) self.socket_seq_label.setObjectName(f"{self.drone_id}_socket_seq") - self.socket_seq_label.setStyleSheet("color: #DDD;") + _set_scaled_stylesheet(self.socket_seq_label, "color: #DDD;") connection_sep = QLabel(" - ") - connection_sep.setStyleSheet("color: #DDD;") + _set_scaled_stylesheet(connection_sep, "color: #DDD;") # 設定連接方式顯示 connection_type_map = { @@ -193,7 +261,7 @@ class DronePanel(QWidget): self.connection_type_label = QLabel(connection_type) self.connection_type_label.setObjectName(f"{self.drone_id}_connection_type") - self.connection_type_label.setStyleSheet("color: #DDD;") + _set_scaled_stylesheet(self.connection_type_label, "color: #DDD;") connection_layout.addWidget(connection_title) connection_layout.addWidget(self.socket_seq_label) @@ -210,29 +278,29 @@ class DronePanel(QWidget): battery_layout.setContentsMargins(0, 0, 0, 0) # 顯示百分比 battery_title = QLabel("電池:") - battery_title.setStyleSheet("color: #888; min-width: 50px;") + _set_scaled_stylesheet(battery_title, "color: #888; min-width: 50px;") self.battery_pct_label = QLabel("--") self.battery_pct_label.setObjectName(f"{self.drone_id}_battery_pct") - self.battery_pct_label.setStyleSheet("color: #DDD;") + _set_scaled_stylesheet(self.battery_pct_label, "color: #DDD;") # 分隔符 separator1 = QLabel(" - ") - separator1.setStyleSheet("color: #DDD;") + _set_scaled_stylesheet(separator1, "color: #DDD;") # 顯示電壓 self.battery_vol_label = QLabel("--") self.battery_vol_label.setObjectName(f"{self.drone_id}_battery_vol") - self.battery_vol_label.setStyleSheet("color: #DDD;") + _set_scaled_stylesheet(self.battery_vol_label, "color: #DDD;") # 分隔符 separator2 = QLabel(" - ") - separator2.setStyleSheet("color: #DDD;") + _set_scaled_stylesheet(separator2, "color: #DDD;") # 顯示電池節數 (S count) self.battery_cells_label = QLabel("--") self.battery_cells_label.setObjectName(f"{self.drone_id}_battery_cells") - self.battery_cells_label.setStyleSheet("color: #DDD;") + _set_scaled_stylesheet(self.battery_cells_label, "color: #DDD;") battery_layout.addWidget(battery_title) battery_layout.addWidget(self.battery_pct_label) @@ -251,18 +319,18 @@ class DronePanel(QWidget): altitude_layout.setContentsMargins(0, 0, 0, 0) altitude_title = QLabel("高度:") - altitude_title.setStyleSheet("color: #888; min-width: 50px;") + _set_scaled_stylesheet(altitude_title, "color: #888; min-width: 50px;") self.altitude_label = QLabel("--") self.altitude_label.setObjectName(f"{self.drone_id}_altitude") - self.altitude_label.setStyleSheet("color: #DDD;") + _set_scaled_stylesheet(self.altitude_label, "color: #DDD;") speed_title = QLabel("速度:") - speed_title.setStyleSheet("color: #888; margin-left: 10px;") + _set_scaled_stylesheet(speed_title, "color: #888; margin-left: 10px;") self.speed_label = QLabel("--") self.speed_label.setObjectName(f"{self.drone_id}_speed") - self.speed_label.setStyleSheet("color: #DDD;") + _set_scaled_stylesheet(self.speed_label, "color: #DDD;") altitude_layout.addWidget(altitude_title) altitude_layout.addWidget(self.altitude_label) @@ -320,6 +388,16 @@ class DronePanel(QWidget): def is_checked(self): """獲取勾選狀態""" return self.checkbox.isChecked() + + def apply_font_scale(self): + """重新套用目前字體倍率到 panel 內所有元件。""" + _apply_scaled_font(self) + _reapply_scaled_stylesheet(self) + for child in self.findChildren(QWidget): + _apply_scaled_font(child) + _reapply_scaled_stylesheet(child) + if self.attitude_indicator: + self.attitude_indicator.update() class SocketGroupPanel(QWidget): # 定義信號 @@ -331,11 +409,12 @@ class SocketGroupPanel(QWidget): self.color = color self.socket_type = socket_type self._init_ui() + self.apply_font_scale() def _init_ui(self): """初始化UI""" self.setObjectName(f"socket_group_{self.socket_id}") - self.setStyleSheet(""" + _set_scaled_stylesheet(self, """ background-color: #1E1E1E; border-radius: 12px; """) @@ -352,7 +431,7 @@ class SocketGroupPanel(QWidget): # 分組勾選框 self.group_checkbox = QCheckBox() self.group_checkbox.setObjectName(f"socket_{self.socket_id}_checkbox") - self.group_checkbox.setStyleSheet(f""" + _set_scaled_stylesheet(self.group_checkbox, f""" QCheckBox {{ color: #DDD; }} QCheckBox::indicator {{ width: 14px; @@ -380,7 +459,7 @@ class SocketGroupPanel(QWidget): else: title_text = f"Socket {self.socket_id}" self.title_label = QLabel(title_text) - self.title_label.setStyleSheet(f""" + _set_scaled_stylesheet(self.title_label, f""" font-weight: bold; font-size: 16px; color: {self.color}; @@ -430,6 +509,14 @@ class SocketGroupPanel(QWidget): """設置分組勾選狀態(支持半選)""" self.group_checkbox.setCheckState(state) + def apply_font_scale(self): + """重新套用目前字體倍率到 socket 分組面板。""" + _apply_scaled_font(self) + _reapply_scaled_stylesheet(self) + for child in self.findChildren(QWidget): + _apply_scaled_font(child) + _reapply_scaled_stylesheet(child) + class AttitudeIndicator(QWidget): """ @@ -503,7 +590,9 @@ class AttitudeIndicator(QWidget): # pitch ladder (every 10°, ±30°) p.setPen(QPen(QColor(255, 255, 255, 180), 1)) - p.setFont(QFont("Arial", 6)) + ladder_font = QFont("Arial") + ladder_font.setPointSizeF(max(1.0, 6 * _get_font_scale())) + p.setFont(ladder_font) for deg in range(-30, 31, 10): if deg == 0: continue @@ -581,6 +670,8 @@ class AttitudeIndicator(QWidget): # heading text centred (bigger) p.setPen(QPen(QColor("#FFFFFF"))) - p.setFont(QFont("Arial", 10, QFont.Weight.Bold)) + heading_font = QFont("Arial", weight=QFont.Weight.Bold) + heading_font.setPointSizeF(max(1.0, 10 * _get_font_scale())) + p.setFont(heading_font) hdg_str = f"{int(self.heading)}°" - p.drawText(0, int(strip_y), w, strip_h, Qt.AlignmentFlag.AlignCenter, hdg_str) \ No newline at end of file + p.drawText(0, int(strip_y), w, strip_h, Qt.AlignmentFlag.AlignCenter, hdg_str) diff --git a/src/GUI/gui.py b/src/GUI/gui.py index fc63e9d..9b1e34f 100644 --- a/src/GUI/gui.py +++ b/src/GUI/gui.py @@ -4,15 +4,16 @@ from PyQt6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout QWidget, QLabel, QSplitter, QScrollArea, QSizePolicy, QTabWidget, QTableWidget, QTableWidgetItem, QHeaderView, QPushButton, QCheckBox, QLineEdit, - QComboBox, QDialog, QPlainTextEdit) -from PyQt6.QtCore import Qt, QTimer, QObject, pyqtSignal -from PyQt6.QtGui import QColor + QComboBox, QDialog, QPlainTextEdit, QSlider) +from PyQt6.QtCore import Qt, QTimer, QObject, pyqtSignal, QEvent +from PyQt6.QtGui import QColor, QFont import sys import asyncio import json import subprocess import time import traceback +import re def _log(level, message): @@ -33,7 +34,8 @@ from mission_planner import FormationPlanner, MissionType from command_sender import MavlinkSender, Ros2CommandSender from mission_executor import MissionExecutor, MissionState from mission_group import ( - MissionGroup, GroupPanel, DroneAssignDialog, GROUP_COLORS + MissionGroup, GroupPanel, DroneAssignDialog, GROUP_COLORS, + DEFAULT_MISSION_PARAM_VALUES ) # ================================================================================ @@ -71,7 +73,10 @@ class StreamRedirector(QObject): self._buffer = "" class ControlStationUI(QMainWindow): - VERSION = '2.1.0' + VERSION = '2.2.0' + FONT_SCALE_MIN = 70 + FONT_SCALE_MAX = 180 + FONT_SCALE_DEFAULT = 100 def __init__(self): super().__init__() @@ -175,12 +180,22 @@ class ControlStationUI(QMainWindow): self._group_counter = 0 # 用來產生 group_id self._pending_box_assign = None # 框選後直接分配到的 group_id # ================================================================================ + self.global_mission_defaults = dict(DEFAULT_MISSION_PARAM_VALUES) + self.font_scale = self.FONT_SCALE_DEFAULT / 100.0 + self.pending_font_scale = self.font_scale + self._font_scale_applying = False + self._base_app_font = QFont(QApplication.instance().font()) self.init_ui() self._setup_stream_redirector() + app = QApplication.instance() + app.setProperty("font_scale", self.font_scale) + app.setProperty("base_app_font", self._base_app_font) + app.installEventFilter(self) def init_ui(self): main_splitter = QSplitter(Qt.Orientation.Horizontal) + self.main_splitter = main_splitter # 左側 TabWidget self.left_tab = QTabWidget() @@ -220,6 +235,10 @@ class ControlStationUI(QMainWindow): self.left_tab.addTab(self.comm_panel, "通訊") + # — 分頁 5:全域設定 + self.settings_tab = self._create_settings_tab() + self.left_tab.addTab(self.settings_tab, "設定") + # 右侧容器 right_container = QWidget() right_layout = QVBoxLayout(right_container) @@ -227,6 +246,11 @@ class ControlStationUI(QMainWindow): right_layout.setSpacing(10) # ========== 任務群組 Tab ========== + self.group_container = QWidget() + group_container_layout = QVBoxLayout(self.group_container) + group_container_layout.setContentsMargins(0, 0, 0, 0) + group_container_layout.setSpacing(6) + group_header = QHBoxLayout() # 標題 + 收起/展開按鈕 @@ -261,7 +285,7 @@ class ControlStationUI(QMainWindow): clear_traj_btn.clicked.connect(self.drone_map.clear_trajectories) group_header.addWidget(clear_traj_btn) - right_layout.addLayout(group_header) + group_container_layout.addLayout(group_header) self.group_tab_widget = QTabWidget() self.group_tab_widget.setStyleSheet(""" @@ -274,9 +298,9 @@ class ControlStationUI(QMainWindow): QTabBar::tab:selected { background-color: #2B2B2B; color: #FFF; border-bottom-color: #2B2B2B; } QTabBar::tab:hover { background-color: #3A3A3A; } """) - self.group_tab_widget.setFixedHeight(150) + self.group_tab_widget.setMinimumHeight(80) self.group_tab_widget.currentChanged.connect(self._on_group_tab_changed) - right_layout.addWidget(self.group_tab_widget) + group_container_layout.addWidget(self.group_tab_widget) # 🌟 新增:保存群組面板的展開狀態 self.group_panel_expanded = True @@ -284,8 +308,17 @@ class ControlStationUI(QMainWindow): # 預設建立 Group A self._add_mission_group() + # 任務群組與地圖之間使用垂直 splitter,可上下拖曳調整高度 + self.right_vertical_splitter = QSplitter(Qt.Orientation.Vertical) + self.right_vertical_splitter.addWidget(self.group_container) + self.right_vertical_splitter.addWidget(self.drone_map.get_widget()) + self.right_vertical_splitter.setChildrenCollapsible(False) + self.right_vertical_splitter.setStretchFactor(0, 0) + self.right_vertical_splitter.setStretchFactor(1, 1) + self.right_vertical_splitter.setSizes([170, 700]) + right_layout.addWidget(self.right_vertical_splitter) + # 添加地圖 - right_layout.addWidget(self.drone_map.get_widget()) self.drone_map.get_gps_signal().connect(self.handle_map_click) self.drone_map.get_drone_clicked_signal().connect(self.handle_drone_clicked) self.drone_map.get_clear_all_drone_selection_signal().connect(self.handle_clear_all_drone_selection) @@ -347,6 +380,259 @@ class ControlStationUI(QMainWindow): return widget + def _create_settings_tab(self): + """建立字體設定分頁。""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(8) + + scale_panel = QWidget() + scale_panel.setStyleSheet(""" + QWidget { + background-color: #2A2A2A; + border: 1px solid #444; + border-radius: 6px; + } + """) + scale_layout = QVBoxLayout(scale_panel) + scale_layout.setContentsMargins(12, 12, 12, 12) + scale_layout.setSpacing(8) + + self.font_scale_label = QLabel() + self.font_scale_label.setStyleSheet("color: #DDD; font-size: 13px; font-weight: bold;") + scale_layout.addWidget(self.font_scale_label) + + self.font_scale_slider = QSlider(Qt.Orientation.Horizontal) + self.font_scale_slider.setRange(self.FONT_SCALE_MIN, self.FONT_SCALE_MAX) + self.font_scale_slider.setValue(self.FONT_SCALE_DEFAULT) + self.font_scale_slider.setTickInterval(10) + self.font_scale_slider.setTickPosition(QSlider.TickPosition.TicksBelow) + self.font_scale_slider.setStyleSheet(""" + QSlider::groove:horizontal { + border: 1px solid #444; + height: 8px; + background: #333; + border-radius: 4px; + } + QSlider::handle:horizontal { + background: #4A9EFF; + border: 1px solid #3A8EEF; + width: 18px; + margin: -6px 0; + border-radius: 9px; + } + QSlider::sub-page:horizontal { + background: #4A9EFF; + border-radius: 4px; + } + QSlider::add-page:horizontal { + background: #2A2A2A; + border-radius: 4px; + } + """) + self.font_scale_slider.valueChanged.connect(self._handle_font_scale_slider_changed) + scale_layout.addWidget(self.font_scale_slider) + + layout.addWidget(scale_panel) + layout.addStretch() + + settings_button_row = QHBoxLayout() + settings_button_row.addStretch() + apply_btn = QPushButton("套用") + apply_btn.setStyleSheet(""" + QPushButton { + background-color: #4A9EFF; + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: bold; + } + QPushButton:hover { background-color: #3A8EEF; } + """) + apply_btn.clicked.connect(self._apply_pending_font_scale) + settings_button_row.addWidget(apply_btn) + + layout.addLayout(settings_button_row) + self._update_font_scale_label() + return widget + + def _update_font_scale_label(self): + percent = int(round(self.pending_font_scale * 100)) + if hasattr(self, 'font_scale_label'): + self.font_scale_label.setText(f"字體倍率:{percent}%") + + def _handle_font_scale_slider_changed(self, value): + self.pending_font_scale = value / 100.0 + self._update_font_scale_label() + + def _apply_pending_font_scale(self): + self._apply_font_scale(self.pending_font_scale) + + def _scale_stylesheet_font_sizes(self, stylesheet, scale): + if not stylesheet or 'font-size' not in stylesheet: + return stylesheet + + def repl(match): + size = float(match.group(1)) + unit = match.group(2) + scaled = max(1.0, size * scale) + if unit == 'px': + text = f"{scaled:.2f}".rstrip('0').rstrip('.') + else: + text = f"{scaled:.3f}".rstrip('0').rstrip('.') + return f"font-size: {text}{unit}" + + return re.sub(r'font-size\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*(px|pt)', repl, stylesheet) + + def _apply_font_scale_to_widget(self, widget): + if hasattr(widget, 'apply_font_scale'): + widget.apply_font_scale() + return + + if hasattr(widget, '_base_stylesheet') and hasattr(widget, '_applied_stylesheet'): + current_stylesheet = widget.styleSheet() + base_stylesheet = widget._base_stylesheet + if current_stylesheet != widget._applied_stylesheet: + base_stylesheet = current_stylesheet + widget._base_stylesheet = base_stylesheet + + scaled_stylesheet = self._scale_stylesheet_font_sizes(base_stylesheet, self.font_scale) + widget._applied_stylesheet = scaled_stylesheet + if current_stylesheet != scaled_stylesheet: + widget.setStyleSheet(scaled_stylesheet) + return + + current_stylesheet = widget.styleSheet() + source_stylesheet = getattr(widget, '_font_scale_source_stylesheet', current_stylesheet) + applied_stylesheet = getattr(widget, '_font_scale_applied_stylesheet', None) + if current_stylesheet != applied_stylesheet: + source_stylesheet = current_stylesheet + + scaled_stylesheet = self._scale_stylesheet_font_sizes(source_stylesheet, self.font_scale) + widget._font_scale_source_stylesheet = source_stylesheet + widget._font_scale_applied_stylesheet = scaled_stylesheet + + if current_stylesheet != scaled_stylesheet: + widget.setStyleSheet(scaled_stylesheet) + + def _apply_font_scale_to_widget_tree(self, root): + if root is None: + return + if isinstance(root, QWidget): + self._apply_font_scale_to_widget(root) + for child in root.findChildren(QWidget): + self._apply_font_scale_to_widget(child) + + def _apply_font_scale(self, scale): + app = QApplication.instance() + splitter_sizes = None + left_width = None + old_left_min = None + old_left_max = None + old_window_size = self.size() + old_window_min = self.minimumSize() + old_window_max = self.maximumSize() + lock_window_size = not self.isMaximized() and not self.isFullScreen() + if hasattr(self, 'main_splitter') and self.main_splitter: + splitter_sizes = self.main_splitter.sizes() + if hasattr(self, 'left_tab') and self.left_tab: + left_width = self.left_tab.width() + old_left_min = self.left_tab.minimumWidth() + old_left_max = self.left_tab.maximumWidth() + if left_width > 0: + self.left_tab.setMinimumWidth(left_width) + self.left_tab.setMaximumWidth(left_width) + + self._font_scale_applying = True + try: + self.font_scale = scale + app.setProperty("font_scale", scale) + + if hasattr(self, '_base_app_font'): + scaled_font = QFont(self._base_app_font) + if self._base_app_font.pointSizeF() > 0: + scaled_font.setPointSizeF(max(1.0, self._base_app_font.pointSizeF() * scale)) + elif self._base_app_font.pixelSize() > 0: + scaled_font.setPixelSize(max(1, int(round(self._base_app_font.pixelSize() * scale)))) + app.setFont(scaled_font) + + if lock_window_size: + self.setMinimumSize(old_window_size) + self.setMaximumSize(old_window_size) + self._update_font_scale_label() + self._apply_font_scale_to_widget_tree(self) + + if hasattr(self, 'comm_panel') and self.comm_panel: + self.comm_panel.apply_font_scale() + + for panel in getattr(self, 'drones', {}).values(): + if hasattr(panel, 'apply_font_scale'): + panel.apply_font_scale() + + for panel in getattr(self, 'socket_groups', {}).values(): + if hasattr(panel, 'apply_font_scale'): + panel.apply_font_scale() + + for panel in getattr(self, 'group_panels', {}).values(): + if hasattr(panel, 'apply_font_scale'): + panel.apply_font_scale() + + if hasattr(self, 'drone_map') and self.drone_map: + self.drone_map.set_font_scale(scale) + + if splitter_sizes: + self.main_splitter.setSizes(splitter_sizes) + + if lock_window_size: + self.resize(old_window_size) + self.left_tab.updateGeometry() + self.update() + finally: + self._font_scale_applying = False + + def _restore_locked_sizes(): + if lock_window_size: + self.setMinimumSize(old_window_min) + self.setMaximumSize(old_window_max) + self.resize(old_window_size) + + if self.left_tab and left_width and left_width > 0: + self.left_tab.setMinimumWidth(left_width) + self.left_tab.setMaximumWidth(left_width) + + if splitter_sizes and self.main_splitter: + self.main_splitter.setSizes(splitter_sizes) + + def _release_size_locks(): + if self.left_tab and old_left_min is not None and old_left_max is not None: + self.left_tab.setMinimumWidth(old_left_min) + self.left_tab.setMaximumWidth(old_left_max) + + QTimer.singleShot(0, _restore_locked_sizes) + QTimer.singleShot(30, _restore_locked_sizes) + QTimer.singleShot(100, _restore_locked_sizes) + QTimer.singleShot(150, _release_size_locks) + + def eventFilter(self, obj, event): + if self._font_scale_applying: + return super().eventFilter(obj, event) + + if isinstance(obj, QWidget) and event.type() in { + QEvent.Type.StyleChange, + QEvent.Type.Polish, + QEvent.Type.Show, + }: + self._font_scale_applying = True + try: + self._apply_font_scale_to_widget(obj) + finally: + self._font_scale_applying = False + + return super().eventFilter(obj, event) + def _clear_message_history(self): """清空訊息歷史。""" self.message_history.clear() @@ -668,15 +954,28 @@ class ControlStationUI(QMainWindow): """🌟 收起/展開任務群組面板""" if self.group_panel_expanded: # 收起 - self.group_tab_widget.setFixedHeight(0) + if hasattr(self, 'right_vertical_splitter'): + sizes = self.right_vertical_splitter.sizes() + if sizes and sizes[0] > 0: + self._last_group_panel_height = sizes[0] self.group_tab_widget.hide() + header_height = self.group_container.sizeHint().height() + self.group_container.setMaximumHeight(header_height) + if hasattr(self, 'right_vertical_splitter'): + total = sum(self.right_vertical_splitter.sizes()) or self.height() + self.right_vertical_splitter.setSizes([header_height, max(1, total - header_height)]) self.toggle_group_btn.setText("▶") self.toggle_group_btn.setToolTip("展開任務群組") self.group_panel_expanded = False else: # 展開 - self.group_tab_widget.setFixedHeight(150) + self.group_container.setMaximumHeight(16777215) self.group_tab_widget.show() + if hasattr(self, 'right_vertical_splitter'): + total = sum(self.right_vertical_splitter.sizes()) or self.height() + group_height = getattr(self, '_last_group_panel_height', 170) + group_height = min(max(group_height, 120), max(120, total - 120)) + self.right_vertical_splitter.setSizes([group_height, max(1, total - group_height)]) self.toggle_group_btn.setText("▼") self.toggle_group_btn.setToolTip("收起任務群組") self.group_panel_expanded = True @@ -688,7 +987,7 @@ class ControlStationUI(QMainWindow): group = MissionGroup(gid, color) self.mission_groups[gid] = group - panel = GroupPanel(group) + panel = GroupPanel(group, default_params=self.global_mission_defaults) panel.assign_drones_requested.connect(self._handle_assign_drones) panel.mission_type_changed.connect(self._handle_mission_type_changed) panel.start_requested.connect(self._handle_group_start) diff --git a/src/GUI/map_layout.py b/src/GUI/map_layout.py index 4f36017..c0239ff 100644 --- a/src/GUI/map_layout.py +++ b/src/GUI/map_layout.py @@ -16,6 +16,7 @@ class DroneMap: self.map_view = QWebEngineView() self.map_loaded = False self.pending_map_updates = {} + self.font_scale = 1.0 # 創建橋接對象 self.bridge = MapBridge() @@ -36,6 +37,7 @@ class DroneMap: