Skip to content

Commit af35a16

Browse files
committed
feat: background image
1 parent 49108c8 commit af35a16

4 files changed

Lines changed: 182 additions & 7 deletions

File tree

aao/app.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
if _ROOT not in sys.path:
3232
sys.path.insert(0, _ROOT)
3333

34-
from PySide6.QtCore import QThread, QTimer # noqa: E402
34+
from PySide6.QtCore import Qt, QThread, QTimer # noqa: E402
3535
from PySide6.QtWidgets import ( # noqa: E402
3636
QApplication,
3737
QHBoxLayout,
@@ -41,7 +41,6 @@
4141
QMainWindow,
4242
QMessageBox,
4343
QStackedWidget,
44-
QWidget,
4544
)
4645

4746
from aao import config # noqa: E402
@@ -52,6 +51,7 @@
5251
from aao.measure.worker import MeasurementWorker # noqa: E402
5352
from aao.timeline.editor_window import EditorWindow # noqa: E402
5453
from aao.ui.about_page import AboutPage # noqa: E402
54+
from aao.ui.background import BackgroundContainer # noqa: E402
5555
from aao.ui.calibration_page import CalibrationPage # noqa: E402
5656
from aao.ui.farm_page import FarmPage # noqa: E402
5757
from aao.ui.log_handler import QtLogHandler # noqa: E402
@@ -160,6 +160,14 @@ def __init__(
160160
self.tray.quit_requested.connect(self._quit_from_tray)
161161
self.tray.show()
162162

163+
# 背景图(读 settings 启动即应用;后续切换由设置页 background_changed 驱动)
164+
from aao.ui.settings_page import load_settings
165+
166+
bg = load_settings()
167+
self._apply_background(
168+
bg.get("background_image", ""), bg.get("background_opacity", 25) / 100.0
169+
)
170+
163171
# --- 新手引导 ---
164172

165173
@staticmethod
@@ -289,7 +297,8 @@ def _on_profile_saved(self, filename: str) -> None:
289297
self.nav.setCurrentRow(_PAGE_FARM)
290298

291299
def _build_ui(self) -> None:
292-
central = QWidget()
300+
central = BackgroundContainer() # 带 cover 背景图的 central(paintEvent 画图)
301+
self.bg_container = central
293302
root = QHBoxLayout(central)
294303
root.setContentsMargins(0, 0, 0, 0)
295304
root.setSpacing(0)
@@ -329,6 +338,23 @@ def _build_ui(self) -> None:
329338
self.setCentralWidget(central)
330339
self.nav.currentRowChanged.connect(self.stack.setCurrentIndex)
331340

341+
# 背景图从侧栏/内容栈/各页根透出。用 objectName 限定透明:只让这些容器本身透明,
342+
# 不级联到表格表头/下拉项等子控件——否则 "background: transparent" 会破坏子控件
343+
# 渲染(表头变黑、下拉项 focus 时字变白看不清)。控件本身保持 palette 不透明。
344+
self.setStyleSheet("#bg_layer { background: transparent; }")
345+
for w in (
346+
self.nav,
347+
self.stack,
348+
self.farm_page,
349+
self.editor_page,
350+
self.calib_page,
351+
self.settings_page,
352+
self.about_page,
353+
):
354+
w.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
355+
w.setObjectName("bg_layer")
356+
self.settings_page.background_changed.connect(self._apply_background)
357+
332358
# 新手引导:设置页连好窗口 → 引导中则跳校准页;校准页保存 → 跳凹图页 + 标记完成
333359
# 普通 settings_changed(改端口/profile)不触发引导
334360
self.settings_page.window_configured.connect(self._on_window_configured)
@@ -355,6 +381,11 @@ def _set_connection_status(self, text: str, color: str) -> None:
355381
self.status_conn.setText(f"● {text}")
356382
self.status_conn.setStyleSheet(f"color: {color}; font-weight: bold;")
357383

384+
def _apply_background(self, path: str, opacity: float) -> None:
385+
"""应用背景图 + 透明度到 central(设置页 background_changed / 启动时调用)。"""
386+
self.bg_container.set_background(path)
387+
self.bg_container.set_opacity(opacity)
388+
358389
# --- 测量状态更新 ---
359390

360391
def _on_measure_state(self, state: dict) -> None:

aao/ui/background.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""主控台背景图层:cover 裁剪绘制图片 + 可调透明度。
2+
3+
cover:按较大缩放比把图片放大至完全覆盖容器,超出部分裁掉(保持原比例,不拉伸变形)。
4+
透明度:``painter.setOpacity`` 控制,0=不画(纯底色),1=完全不透明;底色取 palette
5+
的 Window 角色,故半透明图片自然叠在主题背景上,切主题时底色跟随。
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from PySide6.QtCore import QEvent, QRectF
11+
from PySide6.QtGui import QPainter, QPaintEvent, QPalette, QPixmap
12+
from PySide6.QtWidgets import QApplication, QWidget
13+
14+
15+
class BackgroundContainer(QWidget):
16+
"""带背景图的容器:paintEvent 画底色 + cover 裁剪图(可调透明度)。"""
17+
18+
def __init__(self, parent: QWidget | None = None) -> None:
19+
super().__init__(parent)
20+
self._pixmap: QPixmap | None = None
21+
self._opacity: float = 0.25
22+
23+
def set_background(self, path: str) -> None:
24+
"""按路径加载背景图;空路径或加载失败则清除。"""
25+
if path:
26+
pm = QPixmap(path)
27+
self._pixmap = pm if not pm.isNull() else None
28+
else:
29+
self._pixmap = None
30+
self.update()
31+
32+
def set_opacity(self, opacity: float) -> None:
33+
self._opacity = max(0.0, min(1.0, opacity))
34+
self.update()
35+
36+
def changeEvent(self, event: QEvent) -> None:
37+
# 主题切换 → palette 变 → 自绘底色需要主动刷新;否则加载背景图时旧底色会滞留,
38+
# 看起来像深/浅主题没有生效,直到清除/重载图片触发 update。
39+
if event.type() == QEvent.Type.PaletteChange:
40+
self.update()
41+
super().changeEvent(event)
42+
43+
def paintEvent(self, _event: QPaintEvent) -> None:
44+
painter = QPainter(self)
45+
# 底色(主题背景)——直接读 QApplication palette,避免 widget 自身 PaletteChange
46+
# 异步传播导致背景层落后一拍(标题栏/状态栏已变,中间仍是上一主题)。
47+
app = QApplication.instance()
48+
pal = app.palette() if isinstance(app, QApplication) else self.palette()
49+
painter.fillRect(self.rect(), pal.color(QPalette.ColorRole.Window))
50+
pm = self._pixmap
51+
if pm is None or pm.isNull() or self._opacity <= 0:
52+
return
53+
cw, ch = self.width(), self.height()
54+
iw, ih = pm.width(), pm.height()
55+
if iw <= 0 or ih <= 0:
56+
return
57+
painter.setOpacity(self._opacity)
58+
# cover:取较大缩放比 → 完全覆盖;居中绘制,超出窗口部分被裁
59+
scale = max(cw / iw, ch / ih)
60+
dw, dh = iw * scale, ih * scale
61+
painter.drawPixmap(QRectF((cw - dw) / 2, (ch - dh) / 2, dw, dh), pm, QRectF(0, 0, iw, ih))

aao/ui/settings_page.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
import json
1010
from typing import TYPE_CHECKING, Any
1111

12-
from PySide6.QtCore import QObject, Qt, QThread, Signal
12+
from PySide6.QtCore import QObject, Qt, QThread, QTimer, Signal
1313
from PySide6.QtWidgets import (
1414
QCheckBox,
1515
QComboBox,
16+
QFileDialog,
1617
QFormLayout,
1718
QGroupBox,
1819
QHBoxLayout,
@@ -22,6 +23,7 @@
2223
QListWidgetItem,
2324
QMessageBox,
2425
QPushButton,
26+
QSlider,
2527
QSpinBox,
2628
QVBoxLayout,
2729
QWidget,
@@ -131,6 +133,7 @@ class SettingsPage(QWidget):
131133

132134
settings_changed = Signal() # 保存后通知 MainWindow(端口/profile 变更需重启生效)
133135
window_configured = Signal() # 设为默认窗口后(新手引导用:连完窗口 → 校准)
136+
background_changed = Signal(str, float) # 背景图路径 + 透明度(0-1),即时应用
134137

135138
def __init__(self):
136139
super().__init__()
@@ -280,6 +283,28 @@ def _build_ui(self) -> None:
280283

281284
root.addWidget(ui_box)
282285

286+
# --- 背景图 ---
287+
bg_box = QGroupBox("背景图")
288+
bg_form = QFormLayout(bg_box)
289+
self.edit_bg = QLineEdit()
290+
self.edit_bg.setPlaceholderText("可选,留空 = 无背景图")
291+
self.btn_bg_pick = QPushButton("📂 选择")
292+
self.btn_bg_clear = QPushButton("✕ 清除")
293+
bg_file_row = QHBoxLayout()
294+
bg_file_row.addWidget(self.edit_bg, 1)
295+
bg_file_row.addWidget(self.btn_bg_pick)
296+
bg_file_row.addWidget(self.btn_bg_clear)
297+
bg_form.addRow("图片:", bg_file_row)
298+
self.slider_bg = QSlider(Qt.Orientation.Horizontal)
299+
self.slider_bg.setRange(0, 100)
300+
self.slider_bg.setValue(25)
301+
self.lbl_bg_val = QLabel("25%")
302+
bg_op_row = QHBoxLayout()
303+
bg_op_row.addWidget(self.slider_bg, 1)
304+
bg_op_row.addWidget(self.lbl_bg_val)
305+
bg_form.addRow("透明度:", bg_op_row)
306+
root.addWidget(bg_box)
307+
283308
root.addStretch()
284309

285310
# 日志
@@ -289,6 +314,9 @@ def _build_ui(self) -> None:
289314
# 信号
290315
self.btn_save.clicked.connect(self._on_save)
291316
self.cb_theme.currentIndexChanged.connect(self._on_theme_changed)
317+
self.btn_bg_pick.clicked.connect(self._on_bg_pick)
318+
self.btn_bg_clear.clicked.connect(self._on_bg_clear)
319+
self.slider_bg.valueChanged.connect(self._on_bg_opacity)
292320
self.btn_sync.clicked.connect(lambda: self._run_resource("sync", False))
293321
self.btn_sync_remote.clicked.connect(lambda: self._run_resource("sync", True))
294322
self.btn_check.clicked.connect(lambda: self._run_resource("check_update", False))
@@ -439,18 +467,55 @@ def _on_theme_changed(self, idx: int) -> None:
439467
"""主题下拉切换:即时应用 + 持久化(纯 UI 偏好,无需重启或点保存按钮)。"""
440468
mode = theme.MODES[idx]
441469
theme.apply_theme(mode)
470+
# 背景层是自绘的:主题切换后在下一轮事件循环刷新一次,等 Qt 完成 palette 传播。
471+
QTimer.singleShot(0, lambda: self._emit_background(save=False))
442472
s = load_settings()
443473
s["theme"] = mode
444474
save_settings(s)
445475
self.lbl_op.setText(f"主题已切换为:{theme.label(mode)}")
446476

477+
def _on_bg_pick(self) -> None:
478+
path, _ = QFileDialog.getOpenFileName(
479+
self, "选择背景图", "", "图片 (*.png *.jpg *.jpeg *.bmp *.webp)"
480+
)
481+
if not path:
482+
return
483+
self.edit_bg.setText(path)
484+
self._emit_background()
485+
486+
def _on_bg_clear(self) -> None:
487+
self.edit_bg.clear()
488+
self._emit_background()
489+
490+
def _on_bg_opacity(self, value: int) -> None:
491+
self.lbl_bg_val.setText(f"{value}%")
492+
self._emit_background()
493+
494+
def _emit_background(self, save: bool = True) -> None:
495+
"""即时应用背景(发信号给 MainWindow);默认同时持久化。"""
496+
path = self.edit_bg.text().strip()
497+
opacity = self.slider_bg.value() / 100.0
498+
self.background_changed.emit(path, opacity)
499+
if not save:
500+
return
501+
s = load_settings()
502+
s["background_image"] = path
503+
s["background_opacity"] = self.slider_bg.value()
504+
save_settings(s)
505+
447506
def _load(self) -> None:
448507
s = load_settings()
449508
# 主题:静默选中(启动时已由 app.py 应用,这里只同步下拉),切换时才即时生效
450509
self.cb_theme.blockSignals(True)
451510
mode = s.get("theme", theme.AUTO)
452511
self.cb_theme.setCurrentIndex(theme.MODES.index(mode) if mode in theme.MODES else 0)
453512
self.cb_theme.blockSignals(False)
513+
# 背景图:同步控件(启动时由 app.py 应用,这里静默,切换时才 emit)
514+
self.edit_bg.setText(s.get("background_image", ""))
515+
self.slider_bg.blockSignals(True)
516+
self.slider_bg.setValue(int(s.get("background_opacity", 25)))
517+
self.slider_bg.blockSignals(False)
518+
self.lbl_bg_val.setText(f"{self.slider_bg.value()}%")
454519
if s.get("profile"):
455520
self.cb_profile.setCurrentText(s["profile"])
456521
if s.get("port"):

aao/ui/theme.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,29 @@ def light_palette() -> QPalette:
9999
return p
100100

101101

102-
def _apply_palette_for_scheme(scheme: Qt.ColorScheme) -> None:
102+
def _refresh_existing_widgets(app: QApplication, palette: QPalette) -> None:
103+
"""同步刷新已存在控件的 palette,避免主题切换时控件外观落后一拍。
104+
105+
Qt 会异步向现有 widget 分发 PaletteChange;对下拉框/表格/按钮这类控件,
106+
视觉上可能要到下一次交互或下一轮切换才更新。这里主动同步 palette + repolish。
107+
"""
108+
for w in app.allWidgets():
109+
w.setPalette(palette)
110+
w.style().unpolish(w)
111+
w.style().polish(w)
112+
w.update()
113+
114+
115+
def _apply_palette(palette: QPalette) -> None:
103116
app = QApplication.instance()
104117
if not isinstance(app, QApplication):
105118
return
106-
app.setPalette(dark_palette() if scheme == Qt.ColorScheme.Dark else light_palette())
119+
app.setPalette(palette)
120+
_refresh_existing_widgets(app, palette)
121+
122+
123+
def _apply_palette_for_scheme(scheme: Qt.ColorScheme) -> None:
124+
_apply_palette(dark_palette() if scheme == Qt.ColorScheme.Dark else light_palette())
107125

108126

109127
def _on_system_scheme_changed(scheme: Qt.ColorScheme) -> None:
@@ -137,4 +155,4 @@ def apply_theme(mode: str) -> None:
137155
_listening = True
138156
_apply_palette_for_scheme(hints.colorScheme())
139157
else:
140-
app.setPalette(dark_palette() if _current_mode == DARK else light_palette())
158+
_apply_palette(dark_palette() if _current_mode == DARK else light_palette())

0 commit comments

Comments
 (0)