挫折するかと思っていたGUIの実装、どうやら案外できるっぽい!
きょうはプログラミング初心者がプログラミングで遊んでみた記事第二弾!です!
前回の記事では簡易的なGUIで動かすことが出来る画像ビューワーを作ってみました。
GUIってなに?
『Graphical User Interface(グラフィカルユーザーインターフェース)』の略!
ユーザーがコンピュータやソフトウェアと対話するための視覚的な手段です。
文字だけのコマンドラインインターフェース(CLI)と違って、アイコンやウィンドウ、ボタンなどのグラフィック要素を使って、もっと直感的にコンピュータを操作できるようにしたモノです!
前回の記事!
正直なところプログラミング初心者にはGUIの実装はムリだと思っていて途中で挫折するものだと思っていました。
やってみたら案外できたので、今回はよりかっこいい/かわいいGUIが実装できそうなPyQt5を使って理想のテキストエディタを作ってみます。
PyQt5/Tkinterって?
・Tkinter(ティーケイインター)
Pythonに標準で付属しているGUIツールキット!
簡単に使えて、基本的なGUIアプリケーションをすぐに作れるよ。
PyQt5ほど高度な機能はないけれど、小さなプロジェクトや学習用にはとっても便利!
・PyQt5
非常に強力で、商業的なアプリケーションにも使われる開発フレームワーク!
PyQt5を使えばPythonでリッチなデスクトップアプリケーションを作れるんだよ🌟
高度な機能や美しいUIデザインが可能なのが特徴です!
慣れてしまえばPyQt5のほうがむしろ楽にGUIの実装が出来ましたが、
慣れるまではワケが分からず「このライブラリカスなんだけど!」って泣きついてAIに怒られたりしました。
カスなのはライブラリじゃなくてお前だぜ?って言われなくてよかったね
そんなパンチライン言われたら泣いちゃうよ
クソザコプログラミング、何もかもうまくいかなくなって愚痴言ったらめちゃくちゃ正論なこと言われて爆笑した pic.twitter.com/K8J2tMyf3w
— 🗝九龍 | くりゅろぐ (@_clmel) February 14, 2024
それではさっそく今回作るプログラムの概要や作った動機をご紹介していきます!
進化していくAIちゃん、2024年時点でのできるコトを書き留めておくという意味でも少しは意義のある記事になればとおもいます!
今年の夏にはいよいよGPT5が公開されるとか!
たのしみな反面とってもこわいですね~!
OpenAIの次世代大規模言語モデル「GPT-5」2024年夏に公開との報道
メモアプリとして懇意にしていたevernoteの実質的なサービス終了……!
evernoteを使っていた方には記憶に新しい事件だと思うのですが、無料プランの大幅なサービス縮小によって無料メモアプリとしてevernoteを運用することがとても難しくなってしまいました。
この無料プランの縮小によって代替アプリを探す記事がたくさん公開されたりしたのですが……
行った先で同じことが起きればまたメモアプリを探さなくてはいけません。
それならばいっそ自分でゲーミフィケーション要素を取り入れたたのしいメモアプリを作ってみよう!と思ったのが今回のアプリ製作のきっかけでした。
今回のアプリで実装したい機能とチャレンジはこんなかんじです。
- テキストエディタとして過不足ない機能をきちんとすべて実装する(テキストの自動保存機能、保存/読込機能など!)]
- ゲーミフィケーション要素として面白そうな機能を出来るだけ実装する!※後述
- PyQt5の機能や書き方を実践しながら学んで自分なりに満足のいくGUIを実装する
……まだやりたい機能もありましたがいったん!公開してもいいかなというところまで作れたので書いたコードの機能や大変だったところをサクサク紹介していきます!
使用したライブラリは以下の通りです!
- PyQt5: Python向けのクロスプラットフォームGUIツールキット。このプロジェクトでは、メインのウィンドウ、テキストエディタ、ボタン、ダイアログなどのユーザーインターフェースの構築に使用されています。
- SQLite3: 軽量で自己完結型のデータベースエンジン。テキストの保存、読み込み、削除など、データの永続化を管理するために使用されています。
- Requests: HTTPリクエストを送るためのライブラリ。このプロジェクトでは、ローカルのVoiceVOXエンジンへのリクエストを送るのに使用されています。
- Os & Tempfile: ファイルシステムの操作と一時ファイルの生成に使用。主に音声データの一時保存に利用されています。
- Time & Random: 時間関連の操作とランダムなデータの生成。モチベーションメッセージの表示タイミングやランダムメッセージの選択に使用されています。
- Re (Regular Expressions): 正規表現による文字列の検索やマッチング。入力バリデーションに利用されています。
ゲーム要素を一つ追加するだけの作業がめちゃくちゃ大変すぎた!!
こちらが完成したアプリのビジュアルです!
PyQt5では各ボタンの配置の自由度も高く、思い通りのデザインにまとめることができました。
PyQt5ではHTMLで書かれたコードみたいにCSSを適用できることにもびっくりしました!
今回は実装を見送りましたがCSSでアニメーションを付けたり、もっとかわいい動きのあるものにできるかもしれないですね!
アプリの画面上部から機能を紹介していきます。
機能その①!目標設定ボタン
『もくひょーせってい!』ボタンを押すと、目標設定ウィジットが表示され任意のテキストを入力できます。
セッション中、何を目標にしてテキストを書いているのか、視覚的に表示されているほうがはかどる…気がしますよね!
空入力にも対応しています。
続けて目標文字数を入力すると…
PyQt5のプログレスバーのウィジットはとても扱いやすく、見た目も悪くないのですが受け渡すステータスが迷子になりがちで実装するのにとても手間取りました。
これだけでプログレスバーウィジットが利用できます!
from PyQt5.QtWidgets import (
QProgressBar, QMessageBox, QListWidgetItem
)
self.progress_bar = QProgressBar(self)
self.progress_bar.setMinimum(0)
self.progress_bar.setMaximum(100)
layout.addWidget(self.progress_bar)
子クラスにまとめてプログレスバーの実装をしたかったのですが、
子クラスで定義したプログレスバーでは正しく引数を受け渡せていても表示されない?(真偽不明!)挙動があり、修正に信じられないくらい時間がかかりました。
文章書くにはまず目標から!
リアルタイムで更新されるプログレスバーが見ていて気持ちよくてお気に入りです。
右上に表示されている『現在の文字数』はテキストエディタにはあってほしい機能だとおもいます。
例の文字数カウントサイトが閉鎖されてしまったことを受けて需要も爆増しているのではないでしょうか!!
【文字数カウント】サイト閉鎖 ユーザー「一番使いやすかった」「ありがとうございました」https://t.co/Dx2qrIIEd6
— ITmedia NEWS (@itmedia_news) April 1, 2024
機能その②!DPM表示機能!
これがメインの機能といっても過言ではないです!
Dots Per Minute(分間入力文字数)を60で割って算出されたDPSをランクに振り分け、表示する機能です。
ランク振り分け基準は
みとこん様の制作された"イオリ委員の夏休みっ! ~VS妖怪足ペロ先生~"とほぼ同じものを使用しています!
イオリちゃんに心底同情する神タイピングゲームです
def calculate_rank(self, spm):
# spm に基づいてランクを決定
if spm < 0.5:
return "シルバー", "gray"
elif 0.5 <= spm < 1:
return "プラチナ", "silver"
elif 1 <= spm < 1.5:
return "エメラルド", "green"
else:
return "ダイヤモンド", "blue"
純粋な打鍵数ではなく、入力文字数を基に計算を行う仕様にしてみました。
秒間1.5文字以上のタイピングは相当はやいです!爆速タイピング!
この機能はシンプルに見えていくつか解決しなくちゃいけない課題があり、ぜんぶ解決するのがたいへんでした
- コピーペーストによる不正なランク表示
・長いテキストをペーストすれば一瞬でダイヤモンドランクになれてしまいます!(笑)
・ほぼ同じ性質の問題ですが、テキストエディタ上で修正が必要になり一気にテキストを削除すると、今度は逆にランクがマイナス表示になってしまいます。 - テキストが読み込まれた時に自動的にランクが上がってしまう
・起動時に前回編集していたテキストを読み込む仕様にしているのですが、
起動時に読み込むテキストがある場合、こちらもまたランクが自動的に高くなってしまいます。 - テキストが大量に編集・追加された後ランク表示が機能しなくなってしまう
・分間入力数を基にランク表示をしているため、文字列をペーストしたり一気に削除するなどの変更を行うとランクの計算が意図しないものとなってしまいます。
これらの問題はテキストの変更を定義する関数で対応しました!
def on_text_changed(self):
# 現在のテキストの長さを取得
current_text_length = len(self.textEdit.toPlainText())
self.update_current_character_count()
# テキストがロードされた直後かどうかを確認
if not self.initial_text_loaded:
# ロードされたテキストの長さを記録
self.last_character_count = current_text_length
# テキストロードフラグを設定
self.initial_text_loaded = True
else:
# ユーザーによって入力された文字数を更新
characters_entered = current_text_length - self.last_character_count
# 一秒間の変更量が閾値を超えていない場合にのみDPMを更新
if abs(characters_entered) <= 15: # 15文字を閾値として設定
self.character_count += characters_entered
self.gaming_indicator.update_dpm(self.textEdit.toPlainText())
# 最後の文字数を更新
self.last_character_count = current_text_length
ロードされたテキストがある場合にフラグを発生させて、フラグがあるときは更新しないようにしました。
また秒間の変更量が15文字を閾値として、閾値を超えた場合も変更しないようにしました!
プログラミング初心者のぼくにとってはこういう関数をどう変えたら思い通りの動作になるのか考えているときがいちばんたのしかったです!
機能その他!ぜったいになくちゃいけない機能とか!
テキストエディタアプリとして必要そうな機能は思いつく限りすべて実装してみました。
- 編集中テキストの自動保存機能
- 起動時に前回編集中のテキストがある場合にテキストを自動で読み込み
- 任意のタイミングでの保存機能
- 保存したテキストの編集・削除機能
自分で使えるテキストエディタを目指していたので、これらの基本的な機能もしっかりと実装しています!
class TextEditor(QMainWindow):
def __init__(self):
self.autosave_timer = QTimer(self)
self.autosave_timer.timeout.connect(self.autosave_text)
self.autosave_timer.start(15000) # 25秒ごとに自動保存
self.initial_text_loaded = False
def autosave_text(self):
text = self.textEdit.toPlainText()
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("INSERT OR REPLACE INTO untitled_contents (id, content, last_saved) VALUES (1, ?, ?)",
(text, time.strftime("%Y-%m-%d %H:%M:%S")))
conn.commit()
conn.close()
自分では意図していない挙動だったのですが、テキストエディタ上にコピーペーストを行った場合、コピー元のフォントを維持してくれます。
実用性はあまりないかもしれませんが、この挙動を利用してペーストをした際にコピー元テキストのフォントを表示する仕様にしてみました。
Twitter上のテキストをコピー・ペーストを行った際の表示!
これでTwitterのタイムラインのフォントが「Sagoe UI」だとわかりますね!学び!
全体のコードと今後の課題!
コードが非常に長く少し読みにくくなってしまいますがご了承ください…!
import sys
import sqlite3
import random
import time
import re
import requests
import os
import tempfile
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QTextEdit, QVBoxLayout, QWidget,
QPushButton, QInputDialog, QListWidget, QDialog, QLabel,
QHBoxLayout, QProgressBar, QMessageBox, QListWidgetItem
)
from PyQt5.QtCore import QTimer, QEvent, Qt, QUrl
from PyQt5.QtGui import QFont, QPainter, QColor, QPixmap, QIcon
from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent
from PyQt5.QtChart import QChart, QChartView, QLineSeries, QValueAxis
class TextEditor(QMainWindow):
def __init__(self):
super().__init__()
self.db_path = "text_editor_db.db" # データベースのパスを設定
self.init_ui()
self.init_db()
self.motivation_messages = ["よく頑張っています!", "素晴らしい進捗です!", "その調子で続けましょう!"]
self.gaming_indicator = GamingIndicator(self)
self.goal_word_count = 0 # 目標文字数の初期化を追加
self.last_motivation_point = 0 # 最後にモチベーションメッセージが表示された文字数
self.character_count = 0 # 文字数カウントの初期化を追加
self.activity_data = []
self.last_character_count = 0
self.autosave_timer = QTimer(self)
self.autosave_timer.timeout.connect(self.autosave_text)
self.autosave_timer.start(15000) # 25秒ごとに自動保存
self.initial_text_loaded = False
self.load_last_saved_text() # アプリ起動時に最後に保存されたテキストを読み込む
# アイコンを設定
self.setWindowIcon(QIcon('"E://naifu tools//MEL.APP//icon//2021-06-29__2_-removebg-preview.png"'))
self.init_pomodoro_timer()
self.activity_data = []
def init_ui(self):
# UIの設定
self.central_widget = QWidget(self)
self.setCentralWidget(self.central_widget)
layout = QVBoxLayout(self.central_widget)
self.setWindowTitle('DPMテキストエディタ')
self.setGeometry(300, 300, 800, 600)
self.voice_player = QMediaPlayer()
# ボタンと画像を含む水平レイアウトを作成
horizontal_layout = QHBoxLayout()
# 左側の伸縮スペースを追加
horizontal_layout.addStretch()
# 目標設定ボタンの追加
self.goal_button = QPushButton("もくひょーせってい!", self)
layout.addWidget(self.goal_button)
self.goal_button.setFont(QFont("はらませにゃんこ", 32))
self.goal_button.clicked.connect(self.set_goal) # ボタンのクリックイベントに set_goal メソッドを接続
self.goal_label = QLabel("", self) # 目標を表示するラベル
self.goal_label.hide() # 初期状態では非表示
layout.addWidget(self.goal_label)
# GamingIndicator ウィジェットを最上部に配置
self.gaming_indicator = GamingIndicator(self)
layout.addWidget(self.gaming_indicator)
self.progress_bar = QProgressBar(self)
self.progress_bar.setMinimum(0)
self.progress_bar.setMaximum(100)
layout.addWidget(self.progress_bar)
self.textEdit = QTextEdit(self)
self.textEdit.setFont(QFont("源暎ラテミン v2", 12)) # フォント設定
self.textEdit.installEventFilter(self) # イベントフィルタをインストール
layout.addWidget(self.textEdit)
# ボタンを作成
self.motivation_button = QPushButton("Welcome to the Text Editor!", self)
self.motivation_button.setFont(QFont("めもわーる-しかく", 64)) # 指定されたフォント設定
horizontal_layout.addWidget(self.motivation_button) # レイアウトにボタンを追加
self.motivation_button.setFixedSize(300, 100)
# 画像を表示するためのラベルを作成
image_label = QLabel(self)
pixmap = QPixmap("E:/naifu tools/MEL.APP/ずんだもん/111.png") # 画像のパス
image_label.setPixmap(pixmap)
horizontal_layout.addWidget(image_label) # レイアウトにラベルを追加
# 右側の伸縮スペースを追加
horizontal_layout.addStretch()
# 水平レイアウトをメインレイアウトに追加
layout.addLayout(horizontal_layout)
# 保存ボタンと読み込むボタンを中央に配置するための水平レイアウト
button_layout = QHBoxLayout()
# 左側の伸縮スペースを追加
button_layout.addStretch()
# 保存ボタン
self.save_button = QPushButton("ほぞん!", self)
self.save_button.clicked.connect(self.save_text)
self.save_button.setFixedSize(150, 60) # 幅100px、高さ30pxに設定
button_layout.addWidget(self.save_button)
self.save_button.setFont(QFont("はらませにゃんこ", 32))
# 読み込むボタン
self.load_button = QPushButton("よみこみ!", self)
self.load_button.clicked.connect(self.load_text)
self.load_button.setFixedSize(150, 60) # 幅100px、高さ30pxに設定
button_layout.addWidget(self.load_button)
self.load_button.setFont(QFont("はらませにゃんこ", 32))
# 右側の伸縮スペースを追加
button_layout.addStretch()
# 水平レイアウトをメインレイアウトに追加
layout.addLayout(button_layout)
# フォントプレビューラベルの作成
self.font_preview_label = QLabel("フォントプレビュー", self)
layout.addWidget(self.font_preview_label)
# QTextEdit のカーソル位置変更イベントにメソッドを接続
self.textEdit.cursorPositionChanged.connect(self.update_font_preview)
# QTextEdit の textChanged シグナルに on_text_changed メソッドを接続
self.textEdit.textChanged.connect(self.on_text_changed)
def init_db(self):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''CREATE TABLE IF NOT EXISTS texts (
id INTEGER PRIMARY KEY,
title TEXT,
content TEXT,
date TEXT,
duration INTEGER
);''')
# 未保存テキスト用のテーブル
cursor.execute('''CREATE TABLE IF NOT EXISTS untitled_contents (
id INTEGER PRIMARY KEY,
content TEXT,
last_saved TEXT
);''')
conn.commit()
conn.close()
def autosave_text(self):
text = self.textEdit.toPlainText()
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("INSERT OR REPLACE INTO untitled_contents (id, content, last_saved) VALUES (1, ?, ?)",
(text, time.strftime("%Y-%m-%d %H:%M:%S")))
conn.commit()
conn.close()
def load_last_saved_text(self):
conn = sqlite3.connect(self.db_path)
try:
cursor = conn.cursor()
cursor.execute("SELECT content FROM untitled_contents WHERE id = 1")
row = cursor.fetchone()
if row:
# シグナルを一時的に切断
self.textEdit.textChanged.disconnect(self.on_text_changed)
# テキストを設定
self.textEdit.setPlainText(row[0])
# シグナルを再接続
self.textEdit.textChanged.connect(self.on_text_changed)
# character_count をリセット
self.character_count = 0
self.gaming_indicator.reset_character_count() # GamingIndicator にもリセットさせる
except sqlite3.Error as e:
print(f"データベースエラー: {e}")
finally:
conn.close()
def save_text(self):
text = self.textEdit.toPlainText()
title, ok = QInputDialog.getText(self, "テキストの保存", "タイトルを入力してください:")
if ok and title:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
current_date = time.strftime("%Y-%m-%d %H:%M:%S")
# 既存のタイトルがあるかチェック
cursor.execute("SELECT id FROM texts WHERE title = ?", (title,))
row = cursor.fetchone()
if row:
# 既存のテキストを更新
cursor.execute("UPDATE texts SET content = ?, date = ?, duration = ? WHERE id = ?",
(text, current_date, int(time.time()), row[0]))
else:
# 新しいテキストを挿入
cursor.execute("INSERT INTO texts (title, content, date, duration) VALUES (?, ?, ?, ?)",
(title, text, current_date, int(time.time())))
conn.commit()
conn.close()
def eventFilter(self, source, event):
if (source is self.textEdit and event.type() == QEvent.KeyPress and
event.key() == Qt.Key_Return):
# 'Enter'キーが押された時の処理
self.gaming_indicator.update_dpm(self.textEdit.toPlainText())
return super(TextEditor, self).eventFilter(source, event)
def update_current_character_count(self):
# 現在のテキストの長さを取得
current_text_length = len(self.textEdit.toPlainText())
# GamingIndicatorの文字数表示更新メソッドを呼び出し
self.gaming_indicator.update_character_count_display(current_text_length)
def update_font_preview(self):
# 現在のテキストフォーマットを取得
current_format = self.textEdit.currentCharFormat()
font = current_format.font()
# フォントのポイントサイズが設定されているか確認
if font.pointSize() != -1:
font_size = font.pointSize()
else:
# デフォルトのフォントサイズを取得
font_size = self.textEdit.font().pointSize()
# フォントの色を取得
font_color = current_format.foreground().color().name()
# プレビューラベルを更新
preview_text = f"フォント: {font.family()}, サイズ: {font_size}, 色: {font_color}"
self.font_preview_label.setText(preview_text)
def on_text_changed(self):
# 現在のテキストの長さを取得
current_text_length = len(self.textEdit.toPlainText())
self.update_current_character_count()
# テキストがロードされた直後かどうかを確認
if not self.initial_text_loaded:
# ロードされたテキストの長さを記録
self.last_character_count = current_text_length
# テキストロードフラグを設定
self.initial_text_loaded = True
else:
# ユーザーによって入力された文字数を更新
characters_entered = current_text_length - self.last_character_count
# 一秒間の変更量が閾値を超えていない場合にのみDPMを更新
if abs(characters_entered) <= 15: # 15文字を閾値として設定
self.character_count += characters_entered
self.gaming_indicator.update_dpm(self.textEdit.toPlainText())
# 最後の文字数を更新
self.last_character_count = current_text_length
# プログレスバーと現在の文字数の表示を更新
if self.goal_word_count > 0:
self.update_progress_bar(current_text_length)
self.update_character_count_display(current_text_length)
# その他の更新
self.gaming_indicator.update_status() # ステータスの更新
self.update_motivation_message()
# GamingIndicatorに現在の文字数を更新させる
self.gaming_indicator.update_character_count_display(current_text_length)
self.record_activity()
def update_character_count_display(self, text_length):
# 現在の文字数をステータスバーに表示
self.statusBar().showMessage(f"現在の文字数: {text_length}")
def play_motivation_message(self, message):
# VoiceVOXが起動しているか確認
if not self.is_voicevox_running():
print("VoiceVOXが起動していません。")
# ここでエラーハンドリングや代替処理を行う
return
# VoiceVOXのAPIエンドポイント
url = "http://localhost:50021/audio_query?text={}&speaker=1".format(message)
response = requests.post(url)
audio_query = response.json()
# 音声合成をリクエスト
url_synthesize = "http://localhost:50021/synthesis?speaker=1"
response = requests.post(url_synthesize, json=audio_query)
audio_data = response.content
# 音声データを一時ファイルに保存
temp_fd, temp_path = tempfile.mkstemp(suffix=".wav")
with os.fdopen(temp_fd, 'wb') as file:
file.write(audio_data)
# 音声ファイルを再生
self.voice_player.setMedia(QMediaContent(QUrl.fromLocalFile(temp_path)))
self.voice_player.play()
# 一時ファイルを削除するためにパスを記憶
self.temp_audio_path = temp_path
def is_voicevox_running(self):
try:
response = requests.get("http://localhost:50021/version")
if response.status_code == 200:
return True
else:
return False
except requests.exceptions.ConnectionError:
return False
def load_text(self):
dialog = QDialog(self)
dialog.setWindowTitle("テキストを選択")
layout = QVBoxLayout(dialog)
# ダイアログのウィンドウサイズを設定
dialog.resize(800, 400) # 幅と高さを指定
self.list_widget = QListWidget()
self.populate_list()
layout.addWidget(self.list_widget)
dialog.exec_()
def populate_list(self):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# 'last_saved' の代わりに 'date' カラムを使用
cursor.execute("SELECT id, title, date, content FROM texts")
for row in cursor:
text_id, title, last_saved, content = row
preview_text = content[:50] + "..." if len(content) > 50 else content
# QListWidgetItem の作成
list_widget_item = QListWidgetItem()
self.list_widget.addItem(list_widget_item)
# ListItemWidget の作成(parent として self を渡す)
display_text = f"{title} - 最終更新: {last_saved}\n {preview_text}"
item_widget = ListItemWidget(display_text, text_id, list_widget_item, self)
list_widget_item.setSizeHint(item_widget.sizeHint())
# ListItemWidget を QListWidgetItem に設定
self.list_widget.setItemWidget(list_widget_item, item_widget)
conn.close()
def text_selected(self, item):
print("TextEditor: text_selected")
text_id = item.text().split(":")[0]
print(f"TextEditor: text_id = {text_id}")
edit_window = TextEditWindow(text_id, self)
print("TextEditor: TextEditWindow instance created")
edit_window.exec_()
print("TextEditor: TextEditWindow exec_ called")
def set_goal(self):
# 目標文字列の入力
goal_text, ok = QInputDialog.getText(self, "目標設定", "目標を入力してください:")
if ok and goal_text:
self.goal_label.setText(f"目標: {goal_text}")
self.goal_label.show()
self.goal_button.hide()
# 目標文字数の入力を求める
self.set_goal_word_count_dialog()
def set_goal_word_count_dialog(self):
text, ok = QInputDialog.getText(self, "目標設定", "目標文字数を入力してください(半角/全角数字):")
if ok and text:
# 半角および全角数字のみ許容する
if re.match("^[0-90-9]+$", text):
# 全角数字を半角に変換
text = text.translate(str.maketrans('0123456789', '0123456789'))
self.set_goal_word_count(int(text))
else:
# 数値以外が入力された場合のエラーメッセージ
QMessageBox.warning(self, "入力エラー", "数字を入力してください。")
self.set_goal_word_count_dialog() # 再度入力を求める
elif not ok:
# ユーザーがキャンセルを選んだ場合、何もしない
return
def update_progress_bar(self, text_length):
if self.goal_word_count > 0:
progress = (text_length / self.goal_word_count) * 100
self.progress_bar.setValue(min(int(progress), 100))
def set_goal_word_count(self, goal_count):
self.goal_word_count = goal_count
self.update_progress_bar(len(self.textEdit.toPlainText()))
def update_motivation_message(self):
text_length = len(self.textEdit.toPlainText())
if text_length > self.last_motivation_point and text_length % 500 == 0:
message = random.choice(self.motivation_messages)
self.motivation_button.setText(message)
self.motivation_button.show() # ボタンを表示
self.play_motivation_message(message) # 音声メッセージを再生
self.last_motivation_point = text_length # 更新した文字数を記録
def cleanup_temp_audio(self):
# 一時ファイルがあれば削除
if hasattr(self, 'temp_audio_path') and os.path.exists(self.temp_audio_path):
os.remove(self.temp_audio_path)
del self.temp_audio_path
def on_motivation_button_clicked(self):
# ボタンがクリックされたときの処理(必要に応じて実装)
pass
def init_pomodoro_timer(self):
self.pomodoro_timer = QTimer(self)
self.pomodoro_timer.timeout.connect(self.show_activity_graph)
self.pomodoro_timer.start(25 * 60 * 1000) # 25分をミリ秒に変換
def record_activity(self):
current_count = len(self.textEdit.toPlainText())
characters_entered = current_count - self.last_character_count
self.activity_data.append(characters_entered)
self.last_character_count = current_count
def show_activity_graph(self):
series = QLineSeries()
for i, data in enumerate(self.activity_data):
series.append(i * 300, data) # 5分ごとのデータポイント
chart = QChart()
chart.addSeries(series)
chart.createDefaultAxes()
# 横軸の範囲を0から5に設定(25分間を5分ごとに区切った場合)
axisX = QValueAxis()
axisX.setRange(0, 5)
chart.setAxisX(axisX, series)
# 縦軸の範囲を設定
axisY = QValueAxis()
axisY.setRange(0, 9) # 秒間9文字を最大値とする
chart.setAxisY(axisY, series)
# グラフのタイトルとスタイルを設定
chart.setTitle("Activity Over Last 25 Minutes")
chart.setTitleFont(QFont("Arial", 12))
chart.setBackgroundBrush(QColor("lightgray"))
# 軸のフォントと色を設定
axisX = chart.axisX()
axisX.setLabelsFont(QFont("Arial", 10))
axisX.setLabelsColor(QColor("darkblue"))
axisY = chart.axisY()
axisY.setLabelsFont(QFont("Arial", 10))
axisY.setLabelsColor(QColor("darkgreen"))
# グラフの背景色とプロットエリアの背景色を設定
chart.setBackgroundBrush(QColor("lightgray"))
chart.setPlotAreaBackgroundBrush(QColor("white"))
chart.setPlotAreaBackgroundVisible(True)
chart_view = QChartView(chart)
chart_view.setRenderHint(QPainter.Antialiasing)
graph_window = QMainWindow(self)
graph_window.setCentralWidget(chart_view)
graph_window.resize(400, 300)
graph_window.show() # この行で新しいウィンドウを表示
class TextEditWindow(QDialog):
def __init__(self, text_id, parent=None):
super().__init__(parent)
self.text_id = text_id
self.db_path = parent.db_path # データベースのパスを親ウィンドウから取得
self.init_ui()
self.load_text() # 既存のテキストを読み込む
def init_ui(self):
self.layout = QVBoxLayout(self)
self.textEdit = QTextEdit(self)
self.layout.addWidget(self.textEdit)
self.textEdit.setFont(QFont("Segoe UI", 17))
print("Current font:", self.textEdit.font().family(), self.textEdit.font().pointSize())
self.textEdit.update() # UIを更新
self.save_button = QPushButton("保存", self)
self.save_button.clicked.connect(self.save_text) # 保存ボタンのクリックイベントを接続
self.layout.addWidget(self.save_button)
# レイアウトに基づいてウィンドウサイズを設定
self.resize(664, 858) # ウィンドウの初期サイズを設定
def load_text(self):
conn = sqlite3.connect(self.db_path)
try:
cursor = conn.cursor()
cursor.execute("SELECT content FROM texts WHERE id = ?", (self.text_id,))
row = cursor.fetchone()
if row:
# プレーンテキストとしてテキストを設定
self.textEdit.setPlainText(row[0]) # テキストエディタに内容を設定
except sqlite3.Error as e:
print(f"データベースエラー: {e}")
finally:
conn.close()
def save_text(self):
updated_content = self.textEdit.toPlainText()
conn = sqlite3.connect(self.db_path)
try:
cursor = conn.cursor()
cursor.execute("UPDATE texts SET content = ? WHERE id = ?", (updated_content, self.text_id))
conn.commit()
except sqlite3.Error as e:
print(f"データベースエラー: {e}")
finally:
conn.close()
self.accept() # ダイアログを閉じる
class GamingIndicator(QWidget):
def __init__(self, editor, parent=None):
super().__init__(parent)
self.editor = editor
self.init_ui()
self.goal_word_count = 0
self.start_time = time.time()
self.elapsed_time = 0 # ここで初期化
self.timer = QTimer(self)
self.timer.timeout.connect(self.refresh_dpm)
self.timer.start(1000)
def init_ui(self):
layout = QVBoxLayout(self)
self.dpm_label = QLabel("DPS: 0", self)
layout.addWidget(self.dpm_label)
self.elapsed_time_label = QLabel("経過時間: 0秒")
# 現在の文字数表示用のラベルを追加
self.character_count_label = QLabel("現在の文字数: 0")
self.elapsed_time_label.setFont(QFont("GN-きんいろサンセリフ", 20))
self.character_count_label.setFont(QFont("GN-きんいろサンセリフ", 15))
# 水平レイアウトの設定
self.status_bar_layout = QHBoxLayout()
layout.addLayout(self.status_bar_layout)
self.status_bar_layout.addWidget(self.elapsed_time_label)
self.status_bar_layout.addStretch() # スペーサーを追加
self.status_bar_layout.addWidget(self.character_count_label)
def refresh_dpm(self):
text = self.editor.textEdit.toPlainText()
self.update_dpm(text) # DPM を更新
self.update_status() # ステータス(ランクとSPM)を更新
def update_dpm(self, text):
elapsed_time = time.time() - self.start_time
characters_entered = len(text)
dpm = (characters_entered / elapsed_time) * 60
self.dpm_label.setText(f"DPM: {int(dpm)}")
def update_status(self):
elapsed_time = time.time() - self.start_time
self.elapsed_time_label.setText(f"経過時間: {int(elapsed_time)}秒")
if elapsed_time > 0:
dpm = (self.editor.character_count / elapsed_time) * 60
spm = dpm / 60 # 秒間打鍵数に変換
rank, color = self.calculate_rank(spm) # spm を使ってランクを決定
self.dpm_label.setText(f"現在のランク: {rank} (DPM: {int(dpm)})")
self.dpm_label.setStyleSheet(f"color: {color};")
# 現在の文字数をラベルに設定
current_text_length = len(self.editor.textEdit.toPlainText())
self.character_count_label.setText(f"現在の文字数: {current_text_length}")
else:
self.dpm_label.setText("DPM: 計算中")
# ラベルのUIを更新する
self.character_count_label.update()
def calculate_rank(self, spm):
# spm に基づいてランクを決定
if spm < 0.5:
return "シルバー", "gray"
elif 0.5 <= spm < 1:
return "プラチナ", "silver"
elif 1 <= spm < 1.5:
return "エメラルド", "green"
else:
return "ダイヤモンド", "blue"
def reset_timer_and_characters(self):
# 現在時刻を start_time として設定
self.start_time = time.time()
# 文字数をリセット
self.editor.character_count = 0
# 経過時間の表示をリセットするために update_status を呼び出す
self.update_status()
# タイマーを再開始する
self.timer.start(1000)
def reset_character_count(self):
# character_count をリセット
self.editor.character_count = 0
print("GamingIndicator: character_count reset.")
def update_character_count_display(self, character_count):
# 現在の文字数をラベルに設定
self.character_count_label.setText(f"現在の文字数: {character_count}")
# ラベルのUIを更新する
self.character_count_label.update()
class ListItemWidget(QWidget):
def __init__(self, text, text_id, list_widget_item, parent=None):
super().__init__(parent)
self.text_id = text_id
self.parent = parent
self.list_widget_item = list_widget_item
self.layout = QHBoxLayout(self)
self.label = QLabel(text)
self.layout.addWidget(self.label)
self.label.setFont(QFont("Noto Sans JP SemiBold", 10))
self.delete_button = QPushButton("削除")
self.delete_button.setFixedSize(120, 50) # 幅70px、高さ30pxに設定
self.delete_button.clicked.connect(self.confirm_deletion)
self.layout.addWidget(self.delete_button)
def confirm_deletion(self):
reply = QMessageBox.question(self, '削除確認', 'このテキストを削除してもよろしいですか?',
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.Yes:
self.delete_item()
def delete_item(self):
# データベースからテキストを削除する処理
conn = sqlite3.connect(self.parent.db_path)
cursor = conn.cursor()
cursor.execute("DELETE FROM texts WHERE id = ?", (self.text_id,))
conn.commit()
conn.close()
# リストウィジェットからアイテムを削除する処理
self.parent.list_widget.takeItem(self.parent.list_widget.row(self.list_widget_item))
def mouseDoubleClickEvent(self, event):
"""
リストアイテムがダブルクリックされたときに実行されるメソッド。
"""
self.openTextEditWindow()
def openTextEditWindow(self):
"""
テキスト編集ウィンドウを開く。
"""
edit_window = TextEditWindow(self.text_id, self.parent)
edit_window.exec_()
app = QApplication(sys.argv)
app.setStyleSheet("""
QPushButton {
background-color: #84BF04;
border: none;
color: #4F7302;
padding: 10px 15px;
text-align: center;
text-decoration: none;
display: inline-block;
margin: 4px 2px;
transition-duration: 0.4s;
cursor: pointer;
border-radius: 10px;
border: 2px solid #BFD989;
font-size: 14px;
background-color: #F2F2F2;
}
QPushButton:hover {
background-color: #BFD989;
color: #2C4001;
}
QTextEdit {
background-color: #FFFFFF;
border-radius: 10px;
border: 2px solid #84BF04;
}
QProgressBar {
background-color: #BFD989;
color: #2C4001;
border-radius: 5px;
border: 2px solid #84BF04;
}
""")
def main():
app = QApplication(sys.argv)
editor = TextEditor()
editor.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
ほんとうはやりたかった機能のひとつに、25分ごとの「文字入力グラフ」を実装したかったのですが、グラフのレイアウトが思うようにいかず中途半端なものになってしまいました。
実装したかったグラフのイメージはブルアカやNIKKEのようなダメージグラフ!
どこかで一度、グラフのレイアウトを突き詰める作業もやってみたいところですね。
ブラッシュアップしてみたいところもまだありますが、自分でもメールの返信を考えたり、ふとした時のメモとして使えるアプリが作れたのでかなり満足しています。
PythonでならこれぐらいのGUIを備えたアプリが作れるという自信にもなったので、第2弾のPythonアプリケーション開発としてはとても上手くいったと思います。
このアプリケーション開発を経て、ひとが書いたqiitaのエントリーを読むのが楽しくなりました!人生が豊かになったきもちです
次に何に挑戦するのか悩ましいですが、せっかくPythonで何かをやるならということで機械学習にチャレンジしてみたいと思っています。
bitcoinをはじめとした仮想通貨のトレードが趣味程度にすきなのでインジケータを利用した自分なりの価格予測モデルを作ってみたり、トレードロジックのバックテストなどがやってみたいです!
機械学習はもちろんのこと、統計みたいなものにめちゃくちゃ疎いので楽しく学習してみたいですね…!
『こういうことやってみたらいいのに!』的なアドバイスいただけたりするととてもうれしいです!
プログラミング初心者があたふたしているだけの記事を最後までお読みいただきありがとうございました!