用PyQt制作定时提醒休息的小软件

🚕 PyQt5配置教程

🎃 PyQt5知识点

🚌 实战:用PyQt5制作定时休息软件

python和pyqt5学了几天后,为了保护眼睛(整天戴着眼镜太不舒服了),并学以致用,便想到自己做一个定时休息软件。于是便花两天时间写出了这个代码(感觉一半的时间都花在Google、baidu和找bug上),这算是我做出的第一个可视化软件,虽功能简陋,代码混乱,但感觉还算好看,而且毕竟我还是小白。

1. 主要功能

  • 可以隐藏到托盘
  • 可以让用户选择是否开机自动启动
  • 可以保存用户设置,下次重启软件时自动将设置值作为默认值
  • 主窗口显示还剩多少时间休息的倒计时
  • 时间到了后弹出一个全屏覆盖的子窗口
  • 窗口弹出后音乐(音乐目录的路径由用户选择)自动播放,休息完后自动停止
  • 弹出窗口的进度条实现
  • 一直循环直到用户按下Stop Looping按钮

2. 界面图片

主窗口
主窗口
弹出窗口
弹出窗口

3. 最终代码

我先是用Qtdesigner搭好基础界面,并将ui文件转换为py文件,即Eyes_pop_ui.py和Eyes_ui.py,再用以下代码实现所有逻辑和界面美化。

#! -*- encoding=utf-8 -*-
import shutil
from PyQt5.QtGui import QPalette, QPixmap, QBrush, QIcon
from PyQt5.QtWidgets import QApplication, QMainWindow, QFileDialog, QSystemTrayIcon, QMenu, QAction, qApp, QMessageBox
from PyQt5.QtCore import QTimer, Qt, QThread, QTime
import time
import sys
import os
import pygame  # 播放音乐
import random
from win32com.client import Dispatch  # 创建快捷方式
import json  # 保存用户设置
import Eyes_pop_ui
import Eyes_ui
import getpass

# 定义一些全局变量
SEC = 60  # 定义每分几秒钟的常量
interval = 0  # 定义间隔(分钟)
rest = 0  # 休息时间(分钟)
music_path = ''  # 储存播放音乐的目录
settings = {'interval': 0, 'rest': 0, 'music_path': ''}
flag = False  # 用来判断是否该结束线程,True为结束,结束后只剩下初始的主窗口线程
# 一些有关开机自动启动的路径变量
user_name = getpass.getuser()  # 获取当前用户名
target_path = 'C:\\Users\\' + user_name + '\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\Eyes.lnk'
path_created = 'C:\\Users\\' + user_name + '\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\Eyes2.lnk'
source_path = r'D:\code\python\PycharmProjects\pyqttest\dist\Eyes.exe'
wDir = r'D:\code\python\PycharmProjects\pyqttest\dist'


class MyPop(Eyes_pop_ui.Ui_MainWindow, QMainWindow):
    """弹出窗口类,也是继承自QMainWindow"""

    def __init__(self):
        super().__init__()
        self.setupUi(self)
        self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
        palette = QPalette()  # 设置背景
        palette.setBrush(QPalette.Background, QBrush(QPixmap(wDir + r"\images\flower.jpg")))

        self.setPalette(palette)
        self.label.setStyleSheet("QLabel{color:white}""QLabel:hover{color:violet}")
        self.label_2.setStyleSheet("QLabel{color:rgb(209, 186, 116)}""QLabel:hover{color:yellow}")
        self.label_3.setStyleSheet("QLabel{color:rgb(209, 186, 116)}""QLabel:hover{color:white}")
        self.label_4.setStyleSheet("QLabel{color:rgb(209, 186, 116)}""QLabel:hover{color:violet}")
        self.label_5.setStyleSheet("QLabel{color:rgb(209, 186, 116)}""QLabel:hover{color:yellow}")

        self.progressBar.setStyleSheet("QProgressBar::chunk{background-color:#F4606C}""QProgressBar{border: 5px solid "
                                       "grey;border-radius: 10px;color:violet;font:75 14pt 'Comic Sans MS';}")


class MyTray(QSystemTrayIcon):
    """托盘类"""

    def __init__(self):
        super().__init__()
        self.setIcon(QIcon(wDir + r'\images\eye.jpg'))  # 设置系统托盘图标
        self.setToolTip('Eyes')
        self.activated.connect(self.act)  # 设置托盘点击事件处理函数
        self.tray_menu = QMenu(QApplication.desktop())  # 创建菜单
        self.ShowAction = QAction('&show')  # 添加一级菜单动作选项(还原主窗口)
        self.QuitAction = QAction('&exit')  # 添加一级菜单动作选项(退出程序)
        self.ShowAction.triggered.connect(myrest.show)
        self.QuitAction.triggered.connect(qApp.quit)
        self.QuitAction.setToolTip('Exit the software')
        self.ShowAction.setToolTip('show the window')
        self.tray_menu.addAction(self.ShowAction)  # 为菜单添加动作
        self.tray_menu.addAction(self.QuitAction)
        self.setContextMenu(self.tray_menu)  # 设置系统托盘菜单

    def act(self, reason):
        if reason == 2 or reason == 3:  # 单击或双击
            myrest.showNormal()  # 若用show(),窗口最小化时,点击托盘图标不知为何无法显示窗口


class ProgressBar:
    """进度条类"""

    def __init__(self, pb):
        self.pb = pb

    def start(self):
        value = 0
        start_time = time.time()
        n = 100 / (rest * 60)  # 每s递增的量
        while (time.time() - start_time) <= (rest * 60):
            self.pb.setValue(value)
            value += n
            time.sleep(1)


class Music:
    """音乐类"""

    def __init__(self):
        self.li = []  # li中保存所有MP3文件的完整路径
        self.fill_li()  # 填充列表

    def fill_li(self):
        if music_path == '':
            return
        file_list = os.listdir(music_path)  # 获取指定目录下的所有文件的名称(注意包含隐藏文件),返回一个列表
        for original_file in file_list:  # 筛选掉不是mp3结尾的文件
            if original_file[-3:] != 'mp3':
                continue
            self.li.append(music_path + '\\' + original_file)

    def play(self):
        random.shuffle(self.li)  # 每次播放的顺序要不一样
        # 循环播放一首音乐
        pygame.mixer.init()
        pygame.mixer.music.load(self.li[0])
        pygame.mixer.music.play(-1)

    def stop(self):
        if pygame.mixer.music.get_busy():  # 如果还在播放,就停止
            pygame.mixer.music.stop()


class Thread(QThread):
    """线程:检测时间并做出相应反应"""

    def __init__(self):
        super().__init__()
        # 声明并初始化了此线程要用到的属性
        self.start_time = 0
        self.pop = MyPop()
        self.s = SEC
        self.m = 0

    def display_lcd(self):
        self.m = interval - 1
        self.s = SEC
        myrest.lcdNumber.display(str(interval) + ':' + '00')
        self.sleep(1)

    def update_lcd(self):
        self.s -= 1
        myrest.lcdNumber.display('{}:{:0>2d}'.format(self.m, self.s))
        if self.s == 0:
            self.s = SEC
            self.m -= 1
        self.sleep(1)

    def run(self):
        self.display_lcd()  # 显示刚开始的lcd
        music = Music()
        pb = ProgressBar(self.pop.progressBar)
        self.start_time = time.time()  # 设置刚开始的时间戳
        while not flag:
            self.update_lcd()
            if (time.time() - self.start_time) >= interval * 60:  # 若到了休息时间,则执行
                self.pop.progressBar.setValue(0)  # 重新将pb设为0,否则还是100%
                QTimer.singleShot(0, self.pop.showFullScreen)  # 用QTimer类弹出全屏的新窗口
                music.play()
                pb.start()
                music.stop()
                self.pop.hide()  # 休息完后关闭弹出的窗口
                self.start_time = time.time()  # 更新开始计时的时间戳
                self.display_lcd()  # 重设lcd


class MyRest(QMainWindow, Eyes_ui.Ui_MainWindow):
    """主窗口"""

    def __init__(self):
        super().__init__()
        self.setupUi(self)
        self.thread = Thread()  # 初始化线程,当按下Start Looping按钮时调用此线程
        self.initUi()  # 初始化设置并开始监听事件

    def initUi(self):
        self.setWindowFlags(Qt.WindowMinimizeButtonHint | Qt.WindowCloseButtonHint)  # 设置窗口样式
        self.setWindowIcon(QIcon(wDir + r'\images\eye.jpg'))  # 设置窗口图标
        self.pushButton.setStyleSheet("QPushButton{color:black}"  # 设置按钮QSS
                                      "QPushButton:hover{color:white}"
                                      "QPushButton{background-color:red}"
                                      "QPushButton{border:2px}"
                                      "QPushButton{border-radius:10px}"
                                      "QPushButton{padding:2px 4px}")
        self.pushButton_2.setStyleSheet("QPushButton{color:black}"
                                        "QPushButton:hover{color:white}"
                                        "QPushButton{background-color:red}"
                                        "QPushButton{border:2px}"
                                        "QPushButton{border-radius:10px}"
                                        "QPushButton{padding:2px 4px}")
        self.pushButton_3.setStyleSheet("QPushButton{color:black}"
                                        "QPushButton:hover{color:white}"
                                        "QPushButton{background-color:red}"
                                        "QPushButton{border:2px}"
                                        "QPushButton{border-radius:10px}"
                                        "QPushButton{padding:2px 4px}")
        self.pushButton_4.setStyleSheet("QPushButton{color:black}"
                                        "QPushButton:hover{color:white}"
                                        "QPushButton{background-color:red}"
                                        "QPushButton{border:2px}"
                                        "QPushButton{border-radius:10px}"
                                        "QPushButton{padding:2px 4px}")
        self.pushButton_5.setStyleSheet("QPushButton{color:black}"
                                        "QPushButton:hover{color:yellow}"
                                        "QPushButton{background-color:#8CC7B5}"
                                        "QPushButton{border:2px}"
                                        "QPushButton{border-radius:10px}"
                                        "QPushButton{padding:2px 4px}")
        self.pushButton_6.setStyleSheet("QPushButton{color:black}"
                                        "QPushButton:hover{color:white}"
                                        "QPushButton{background-color:red}"
                                        "QPushButton{border:2px}"
                                        "QPushButton{border-radius:10px}"
                                        "QPushButton{padding:2px 4px}")
        self.label_3.setStyleSheet("QLabel:hover{color:violet}""QLabel{color:white}")  # 设置标签QSS
        palette = QPalette()  # 设置背景
        palette.setBrush(self.backgroundRole(),
                         QBrush(QPixmap(wDir + r"\images\timg.jpg")))
        self.setPalette(palette)
        self.pushButton_2.setEnabled(False)  # 将Stop Looping按钮设置为不可点击
        if os.path.exists(path_created):  # 记住上次的选择
            self.checkBox.setChecked(True)
        self.action()  # 开始监听事件

    def action(self):
        self.pushButton.clicked.connect(self.start)
        self.pushButton_2.clicked.connect(self.end)  # 结束
        self.pushButton_4.clicked.connect(self.close)  # 关闭主窗口
        self.pushButton_3.clicked.connect(self.hide)
        self.pushButton_6.clicked.connect(self.save_settings)
        self.toolButton.clicked.connect(self.browse)  # 打开对话窗口让用户选目录
        self.checkBox.clicked.connect(self.autorun)  # 如果checkBox被点击,调用self.autorun()来更新是否开机自启设置

    def start(self):
        """每次Start Looping按钮点击便调用此函数"""
        global interval, rest, flag, music_path  # 引入全局变量
        interval = self.timeEdit.time().minute()  # 获取用户输入
        rest = self.timeEdit_2.time().minute()
        music_path = self.lineEdit.text()
        if rest == 0 or interval == 0:
            return
        flag = False  # 每次start前需重设flag
        self.thread.start()  # 线程1启动
        self.pushButton.setEnabled(False)  # 更新按钮状态
        self.pushButton_2.setEnabled(True)

    def browse(self):
        global music_path
        music_path = QFileDialog.getExistingDirectory(self, 'choose a directory', r'C:\Users\don\Music',
                                                      QFileDialog.ShowDirsOnly)
        self.lineEdit.setText(music_path)

    def end(self):
        global flag
        flag = True
        self.pushButton.setEnabled(True)
        self.pushButton_2.setEnabled(False)

    def autorun(self):
        if self.checkBox.isChecked():  # 复制basis快捷方式
            if not os.path.exists(path_created):
                shutil.copy(target_path, path_created)
        else:  # 删除快捷方式
            if os.path.exists(path_created):
                os.remove(path_created)
            else:
                pass

    def save_settings(self):
        # 获取用户输入(新的设置重启软件才有效)
        settings['interval'], settings['rest'], settings[
            'music_path'] = self.timeEdit.time().minute(), self.timeEdit_2.time().minute(), self.lineEdit.text()
        try:
            with open(wDir + r'\settings.json', 'w') as f:
                json.dump(settings, f)
        except FileNotFoundError:
            QMessageBox.information(self, 'Message', 'File is Not Found !')

    def closeEvent(self, e):  # 因为退出后托盘图标不会消失(鼠标移上去才会消失),所以重写一下closeEvent
        mytray.setVisible(False)
        e.accept()


if __name__ == '__main__':
    app = QApplication(sys.argv)

    if not os.path.exists(target_path):  # 第一次启动程序时先创建一个快捷方式,当作basis
        shell = Dispatch('WScript.Shell')
        shortcut = shell.CreateShortCut(target_path)
        shortcut.Targetpath = source_path
        shortcut.WorkingDirectory = wDir
        shortcut.save()

    myrest = MyRest()
    mytray = MyTray()
    # 读取用户上一次保存的数据并显示为默认值(第一次运行之前若没有json文件,则需手动创建json文件并存入初始数据)
    with open(wDir + r'\settings.json', 'r') as f:
        settings = json.load(f)
    myrest.timeEdit.setTime(QTime(0, settings['interval']))
    myrest.timeEdit_2.setTime(QTime(0, settings['rest']))
    myrest.lineEdit.setText(settings['music_path'])

    myrest.show()

    # 若以快捷方式打开(命令行会传入'-minimized'参数),则隐藏到托盘,并自动开始Loop
    if len(sys.argv) == 2 and sys.argv[1] == '-minimized':
        myrest.hide()
        myrest.pushButton.clicked.emit()

    mytray.show()

    sys.exit(app.exec_())  # THE END. 指程序一直循环运行直到主窗口被关闭终止进程(如果没有这句话,程序运行时会一闪而过)

4. 注意点

4.1. 播放音乐

  • pygame.init() 进行全部模块的初始化,
  • pygame.mixer.init() 或者只初始化音频部分
  • pygame.mixer.music.load('xx.mp3') 使用文件名作为参数载入音乐 ,音乐可以是ogg、mp3等格式。载入的音乐不会全部放到内容中,而是以流的形式播放的,即在播放的时候才会一点点从文件中读取,一次只能载一个
  • pygame.mixer.music.play()播放载入的音乐,假如里面有数字n是说明播放n+1次(即播放一次后循环n次,若-1即循环播放)。该函数立即返回,音乐播放在后台进行
  • pygame.mixer.music.stop() 停止播放
  • pygame.mixer.music.pause() 暂停播放
  • pygame.mixer.music.unpause() 取消暂停
  • pygame.mixer.music.queue('xx.mp3') 将音乐文件加入队列,等当前音乐播放完后自动播放,注意排队等待的音乐文件只能有一个(为什么我加上去不会播放...我只能单曲循环了)
  • pygame.mixer.quit() 退出音乐播放
  • 还试了导入from win32com.client import Dispatch,然后用COM组件打开Windows Media Player:
    mp = Dispatch("WMPlayer.OCX")  # 遗憾的是,这一行发生了错误...
    tune = mp.newMedia("..path..")
    mp.currentPlaylist.appendItem(tune)
    mp.controls.play()
    mp.controls.stop()
    
  • 注:还可用pyglet pyaudio playsound等模块。

4.2. 设置窗口背景

  • 最简单:用QSS样式表的方式设置窗口背景,这种方法会让所有子控件都继承 self.setStyleSheet("MainWindow{border-image:url(..path..)}")
  • QPallete:
    palette = QPalette()
    palette.setBrush(QPalette.Background, QBrush(QPixmap("..path..")))
    win.setPalette(palette)
    
    当背景图片的宽度高度大于窗口的宽度高度时,背景图片会平铺整个背景; 当背景图片宽度高度小于窗口的宽度高度时,则会加载多个背景图片
  • 重写窗体对象的paintEvent()方法:
    def paintEvent(self, event):
          painter = QPainter(self)
          # 设置背景颜色
          painter.setBrush(Qt.green)
          painter.drawRect(self.rect())
          # 设置背景图片,平铺到整个窗口,随着窗口改变而改变
          # pixmap = QPixmap(r"..path..")
          # painter.drawPixmap(self.rect(), pixmap)
    

4.3. 用Python隐藏和显示windows任务栏:

虽然没用到(本来是为了全屏),但既然了解了还是在此记录一下。

  1. 下载pywin32模块,此模块封装了部分windowsAPI。手动下载(python3.7以上无法pip):https://github.com/mhammond/pywin32/releases我的是pywin32-224.win32-py3.6.exe。若下载时无法识别正确路径,注意选择版本,可能python版本和pywin32版本不匹配。
  2. import win32gui
    fd = win32gui.FindWindow("Shell_TrayWnd",None) # 任务栏类名为Shell_TrayWnd
    win32gui.ShowWindow(fd,0) # SW_HIDE = 0
    win32gui.ShowWindow(fd,5) # SW_SHOW = 5
    

4.4. 获取当前用户名

import getpass
user_name = getpass.getuser()

4.5. 如何保存用户设置

  • 保存用户数据:用json模块,用户点击Save Settings按钮时便把数据保存进json文件,当用户打开软件时,将json里面的数据读出,从而自动显示保存值作为默认值

4.6. 最小化到托盘

创建一个托盘类,继承自QSystemTrayIcon,详见代码

4.7. 如何开机自动启动

这方面我不太会...我用了一个蹩脚的办法:

首次运行程序时,程序会在windows的C:\Users\username\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup目录下创建一个当作“basis”的Eyes.lnk快捷方式,然后手动修改快捷方式的属性(怎么也找不到可以将文件的目标添加另一个参数的方法),将目标改为 "..path.." -minimized ,这样打开此快捷方式的时候,便会向程序传入两个命令行参数,用sys.argv获取,若第二个的参数是-minimized,则自动隐藏到托盘,这样就可以实现开机自动启动并隐藏到托盘的功能了。

但是,我们要让用户自己选择,也就意味着要删除和创建这个Eyes.lnk,然鹅它是basis,是不能动它的,因为删除后它“目标”里面的内容就跑掉了。于是在用户打上开机自启的checkbox的√时,我让程序复制那个basis,生成Eyes2.lnk就好了,取消√就删除它。这里我用shutil.copy()方法复制,用os.move()删除。这种方法好stupid。

4.8. 在别的(Windows系统)电脑上运行方法

  • 需修改代码中的一些路径才能正常运行(取决于你要将exe文件和相关资源放在哪里)
  • 再打包:pyinstaller -F -w Eyes.py Eyes_pop_ui.py Eyes_ui.py
  • 打包后:需在第一次运行exe后找到创建的Eyes.lnk然后修改其“目标”属性,在路径后加一个空格再加上-minimized即可;

4.9. 其它

  • 为了防止和字符串本身的引号冲突,使用 \ 来转义,一般情况下这个也不会引起什么问题,但是当你要使用 \ 来转义 \ 的时候,就比较混乱了,比如我们想要输出一个 \ ,得写两个 \ ,否则会报语法错误,因为 \ 把后面的引号给转义了,必须使用 \
  • 获取目录下所有文件的方法:用allpath = os.listdir(path); os.listdir()返回指定路径下所有的文件和文件夹列表,但是子目录下文件不遍历
  • 关于打包:
  • pyinstaller -F -w Eyes.py Eyes_pop_ui.py Eyes_ui.py -i dist/images/eye.ico
  • 需将相关资源(json、txt、img)放到dist目录下(不知为何用绝对路径也要放到这个目录下),否则exe文件无法执行,弹出“Failed to execute Eyes script”。我找了好久的原因,比如可能要用--hidden-import导入隐藏包,还有可能pyinstaller打包参数不对...最后发现原因只是json文件没放到dist目录下...
  • pyinstaller打包坑是相当的多
  • 打包后会在build和dist目录下生成相应文件,build文件夹保存的是临时文件目录可以安全删除,最终的打包程序在dist文件夹中
  • 一开始,我发现运行程序的时候,弹出窗口自动关闭后,程序会不正常退出,仔细看了看代码也没有什么错误。网上查了一下,说可能是显存不足,加了 os.environ["CUDA_VISIBLE_DEVICES"] = "-1" ,也就是不用gpu而用cpu。再运行了一下真的可以!但是我这个小小小程序要多大的显存啊,然后我再把它注释掉,发现居然还是可以。既然不是显存问题,那是为什么呢?然后考虑到动态语言多线程的不稳定性,我去掉了两个线程(原来用了好几个线程,发现其实没必要)后便不会异常退出了。

20190809

results matching ""

    No results matching ""