突如はじまった、オリジナルYuba-Terminal制作 (yuba-term#1)
ChatGpt, Gemini, Claude, DeepSeekなどのAIで遊んでいた時にプログラミングを書いてもらいたくなって、それぞれに小さなプログラミングを書いてもらって遊んでいました。そのうちの一つがYuba-Terminalでした。Terminalを作ったことがないので丁度良いトレーニングになるかと思いある程度形になるまで制作することにしてみました。実用に耐えうるところまで持っていきたいです。
以下のプログラミングコードの多くは、DeepSeek V3で書いてもらいました。プログラム中にコメントをたくさん入れておきましたので、皆様も下記の最低限の機能のTerminalからあなたのオリジナルTermainalを制作しはじめてみてはいかがでしょうか?
import os # オペレーティングシステムに依存する機能を利用するための標準ライブラリ
import sys # Pythonインタプリタやその実行環境に関する情報を提供する標準ライブラリ
import subprocess # 新しいプロセスを生成し、その入出力を制御するための標準ライブラリ
from PyQt5.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QTextEdit, QShortcut
)
from PyQt5.QtCore import Qt # Qtフレームワークのコア機能に関連するクラスや定数
from PyQt5.QtGui import (
QFont, QTextCursor, QTextOption,
QTextCharFormat, QColor, QKeySequence
)
class TerminalWidget(QTextEdit):
"""シンプルなターミナルウィジェット"""
def __init__(self, parent=None):
super().__init__(parent) # 親クラス(QTextEdit)のコンストラクタを呼び出し、初期化を行う
# ─── 見た目の設定 ──────────────────────────────
self.setFont(QFont("Courier New", 12)) # テキストのフォントを等幅フォントであるCourier Newの12ポイントに設定。ターミナル表示の可読性を高める。
self.setStyleSheet("background-color:#1E1E1E; color:#FFFFFF;") # ウィジェットのスタイルシートを設定。背景色を濃いグレー、文字色を白にすることで、一般的なターミナルの配色を再現。
self.setWordWrapMode(QTextOption.NoWrap) # テキストがウィジェットの幅を超えた場合に折り返さない設定。ターミナルの出力は水平方向に伸びることが多いため。
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) # 垂直スクロールバーを常に表示する設定。長い出力を確認する際に便利。
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn) # 水平スクロールバーを常に表示する設定。長いコマンドや出力を確認する際に必要。
# 編集可能にする
self.setReadOnly(False) # ウィジェットを読み取り専用ではなく、編集可能にする。ユーザーがコマンドを入力できるようにするため。
self.setUndoRedoEnabled(False) # アンドゥ/リドゥ機能を無効にする。ターミナル操作においては一般的ではないため。
self.setFocus() # ウィジェットにキーボードフォーカスを設定し、すぐにコマンド入力できるようにする。
# ─── 内部状態 ────────────────────────────────
self.prompt = "" # ターミナルのプロンプト文字列を格納する変数。初期状態は空。
self.update_prompt() # 現在のユーザー名と作業ディレクトリに基づいてプロンプト文字列を生成し、self.promptに格納。
self.insertPlainText(self.prompt) # 生成されたプロンプト文字列をQTextEditウィジェットに挿入し、ユーザーに入力位置を示す。
self.cursor_position = len(self.prompt) # 現在のカーソル位置を、プロンプト文字列の直後の位置に設定。ユーザーはここからコマンドを入力する。
self.set_cursor_position() # QTextEditウィジェットのカーソルを、self.cursor_positionで指定された位置に実際に移動させる。
self.command_history = [] # ユーザーが過去に入力したコマンドを保存するためのリスト。履歴機能の実現に利用。
self.history_index = 0 # コマンド履歴リスト内での現在のインデックスを示す変数。履歴を遡ったり、進んだりする際に使用。
# 文字色フォーマット
self.default_format = QTextCharFormat() # デフォルトの文字表示形式を定義するためのQTextCharFormatオブジェクトを作成。通常の出力に使用。
self.default_format.setForeground(QColor("#FFFFFF")) # デフォルトの文字色を白に設定。
self.error_format = QTextCharFormat() # エラーメッセージの表示形式を定義するためのQTextCharFormatオブジェクトを作成。
self.error_format.setForeground(QColor("#FF5555")) # エラーメッセージの文字色を赤に設定し、視覚的に強調。
self.input_format = QTextCharFormat() # ユーザーが入力したコマンドの表示形式を定義するためのQTextCharFormatオブジェクトを作成。
self.input_format.setForeground(QColor("#FFFF55")) # 入力されたコマンドの文字色を黄色に設定し、出力と区別。
# ────── ユーティリティ ──────────────────────────
def update_prompt(self):
"""プロンプト文字列を更新する"""
user = os.getenv("USER", "user") # 環境変数USERから現在のユーザー名を取得。存在しない場合は"user"をデフォルト値とする。
cwd = os.getcwd().replace(os.path.expanduser("~"), "~") # 現在の作業ディレクトリをos.getcwd()で取得し、ユーザーのホームディレクトリをチルダ(~)に置換して表示を簡潔にする。
self.prompt = f"[{user}@Yuba {cwd}]$ " # 最終的なプロンプト文字列を生成。ユーザー名、ホスト名(ここでは"Yuba")、現在の作業ディレクトリを含む形式。
def set_cursor_position(self):
"""カーソルの位置を設定する"""
cursor = self.textCursor() # QTextEditウィジェットの現在のテキストカーソルを取得。
cursor.setPosition(self.cursor_position) # 取得したカーソルを指定されたself.cursor_positionの位置に移動させる。
self.setTextCursor(cursor) # 移動させたカーソルをQTextEditウィジェットに再設定し、表示を更新。
# ────── キーイベント処理 ────────────────────────
def keyPressEvent(self, event):
"""キーが押された時のイベント処理"""
cursor = self.textCursor() # 現在のテキストカーソルを取得。キー入力に応じてカーソルの状態を操作するため。
key = event.key() # 押されたキーのQt固有のコードを取得。どのキーが押されたかを識別するために使用。
mod = event.modifiers() # 押されたキーと同時に押されていた修飾キー(Ctrl, Shift, Altなど)の情報を取得。ショートカットキーの判定に利用。
# Enter / Return → コマンド実行
if key in (Qt.Key_Return, Qt.Key_Enter): # EnterキーまたはReturnキーが押された場合、execute_commandメソッドを呼び出してコマンドを実行。
self.execute_command()
return
# BackSpace
if key == Qt.Key_Backspace: # BackSpaceキーが押された場合。
if cursor.position() > self.cursor_position: # カーソルの現在位置が、プロンプト文字列の開始位置よりも後にある場合(つまり、ユーザーがコマンドを入力した部分)。
super().keyPressEvent(event) # 親クラス(QTextEdit)のキープレスイベント処理を呼び出す。これにより、テキストが削除される。
return # イベント処理を終了。プロンプトより前の部分は削除できないように制御。
# 左矢印
if key == Qt.Key_Left: # 左矢印キーが押された場合。
if cursor.position() > self.cursor_position: # カーソルの現在位置がプロンプト文字列の開始位置よりも後にある場合。
super().keyPressEvent(event) # 親クラスのキープレスイベント処理を呼び出す。これにより、カーソルが左に移動する。
return # イベント処理を終了。プロンプトより前の部分にはカーソルを移動させない。
# 右矢印
if key == Qt.Key_Right: # 右矢印キーが押された場合。
super().keyPressEvent(event) # 親クラスのキープレスイベント処理を呼び出す。これにより、カーソルが右に移動する。プロンプト位置に関わらず移動可能。
return # イベント処理を終了。
# Home → 行頭ではなくプロンプト後へ
if key == Qt.Key_Home: # Homeキーが押された場合。
cursor.setPosition(self.cursor_position) # カーソルをプロンプト文字列の直後の位置に移動させる。行の先頭ではなく、入力開始位置に移動するのがターミナルの一般的な挙動。
self.setTextCursor(cursor) # 移動させたカーソルをQTextEditウィジェットに再設定。
return # イベント処理を終了。
# ↑ / ↓ で履歴
if key == Qt.Key_Up: # 上矢印キーが押された場合。
self.navigate_history(-1) # コマンド履歴を一つ前のエントリに移動するためのメソッドを呼び出す。
return # イベント処理を終了。
if key == Qt.Key_Down: # 下矢印キーが押された場合。
self.navigate_history(1) # コマンド履歴を一つ後のエントリに移動するためのメソッドを呼び出す。
return # イベント処理を終了。
# Ctrl-C でキャンセル
if key == Qt.Key_C and mod == Qt.ControlModifier: # CtrlキーとCキーが同時に押された場合(一般的なコマンドキャンセルのショートカット)。
self.insertPlainText("^C\n") # "^C"というキャンセルを示す文字列と改行をテキストエディットに挿入。
self.new_prompt() # 新しいプロンプトを表示し、次のコマンド入力を促す。
return # イベント処理を終了。
# Ctrl-L で画面消去
if key == Qt.Key_L and mod == Qt.ControlModifier: # CtrlキーとLキーが同時に押された場合(一般的な画面クリアのショートカット)。
self.clear() # QTextEditウィジェットの内容を全てクリアする。
self.new_prompt() # 新しいプロンプトを表示し、クリアされた画面で次のコマンド入力を促す。
return # イベント処理を終了。
# 通常の文字入力
if event.text() and event.text().isprintable(): # 入力されたテキストが存在し、かつ表示可能な文字である場合。
super().keyPressEvent(event) # 親クラスのキープレスイベント処理を呼び出す。これにより、入力された文字がテキストエディットに表示される。
return # イベント処理を終了。
# それ以外は無視
event.ignore() # 上記のいずれの条件にも当てはまらないキーイベントは無視する。
# ────── 履歴 ────────────────────────────────────
def navigate_history(self, direction: int):
"""コマンド履歴を移動する"""
if not self.command_history: # コマンド履歴が空の場合。
return # 何もしない。
self.history_index = max( # 履歴インデックスを0以上、コマンド履歴のサイズ以下の範囲に制限。
0,
min(self.history_index + direction, len(self.command_history))
)
# 現在行 (プロンプト以降) を一旦削除
cursor = self.textCursor() # 現在のテキストカーソルを取得。
cursor.movePosition(QTextCursor.End) # カーソルをテキストの末尾に移動。
cursor.setPosition(self.cursor_position, QTextCursor.KeepAnchor) # プロンプトの終了位置から現在のカーソル位置までを選択状態にする。
cursor.removeSelectedText() # 選択された(つまり、ユーザーが入力した)テキストを削除する。
# 履歴コマンドを描写
if 0 <= self.history_index < len(self.command_history): # 履歴インデックスが有効な範囲内にある場合。
cmd = self.command_history[self.history_index] # 履歴リストから、現在のインデックスに対応するコマンドを取得。
cursor.insertText(cmd, self.input_format) # 取得したコマンドを入力フォーマット(黄色)でテキストエディットに挿入。
self.setTextCursor(cursor) # 更新されたテキスト(履歴コマンド)を表示し、カーソルを末尾に移動。
# ────── コマンド実行関連 ─────────────────────────
def execute_command(self):
cursor = self.textCursor() # 現在のテキストカーソルを取得
cursor.movePosition(QTextCursor.End) # カーソルをテキストの末尾に移動
cursor.select(QTextCursor.LineUnderCursor) # 現在の行全体を選択
full_line = cursor.selectedText() # 選択された行のテキストを取得
cmd = full_line[len(self.prompt):] # プロンプト部分を除いたコマンド部分を取得
if cmd.strip() and (not self.command_history or self.command_history[-1] != cmd): # コマンドが空でなく、履歴が空または最新のコマンドと異なる場合
self.command_history.append(cmd) # コマンドを履歴に追加
self.history_index = len(self.command_history) # 履歴インデックスを最新に設定
self.insertPlainText("\n") # 改行を挿入
lower = cmd.strip().lower() # コマンドの前後空白を削除し、小文字に変換
if lower == "help": # コマンドが"help"の場合、ヘルプメッセージを表示
self.show_help()
elif lower == "clear": # コマンドが"clear"の場合、画面をクリア
self.clear()
elif lower == "exit": # コマンドが"exit"の場合、親ウィンドウを閉じて終了
self.parent().close()
return
elif lower == "history": # コマンドが"history"の場合、コマンド履歴を表示
self.show_history()
else: # その他のコマンドの場合、システムコマンドとして実行
self.execute_system_command(cmd)
self.new_prompt() # 新しいプロンプトを表示
def new_prompt(self):
self.update_prompt() # プロンプトを更新
self.insertPlainText(self.prompt) # 更新されたプロンプトを挿入
self.cursor_position = len(self.toPlainText()) # カーソル位置をプロンプトの末尾に設定
self.set_cursor_position() # カーソル位置を実際に設定
def execute_system_command(self, cmd: str):
if not cmd.strip(): # コマンドが空の場合
return # 何もしない
try:
proc = subprocess.Popen( # サブプロセスを実行
cmd,
shell=True, # シェル経由
stdout=subprocess.PIPE, # 標準出力をパイプで取得
stderr=subprocess.PIPE, # 標準エラーをパイプで取得
text=True, # テキストモードで入出力
cwd=os.getcwd() # 現在の作業ディレクトリをコマンドの実行ディレクトリとする
)
for line in iter(proc.stdout.readline, ""): # 標準出力から一行ずつ読み込む
if line: # 行が空でない場合
self.insertPlainText(line) # 読み込んだ行をテキストエディットに挿入
self.moveCursor(QTextCursor.End) # カーソルを末尾に移動
proc.wait() # サブプロセスの終了を待つ
err = proc.stderr.read() # 標準エラー出力を読み込む
if err: # 標準エラー出力がある場合
self.textCursor().insertText(err, self.error_format) # エラーメッセージをエラーフォーマットで挿入
except Exception as exc: # 例外が発生した場合
self.textCursor().insertText(f"Error: {exc}\n", self.error_format) # エラーメッセージをエラーフォーマットで挿入
def show_help(self):
txt = (
"Available Commands:\n"
"- help : Show this message\n"
"- clear : Clear screen\n"
"- history : Show command history (latest 20)\n"
"- exit : Close terminal\n"
"- <others> : Execute as a shell command\n"
)
self.insertPlainText(txt) # ヘルプテキストを挿入
def show_history(self):
history_entries = (
f"{i + 1}: {cmd}"
for i, cmd in enumerate(self.command_history[-20:]) # 最新20件のコマンドを列挙
)
history_text = "\n".join(history_entries) # 各履歴エントリを改行で結合
self.insertPlainText(f"Command History:\n{history_text}\n") # 履歴テキストを挿入
class YubaTerminal(QWidget):
def __init__(self):
super().__init__() # 親クラスの__init__メソッドを呼び出す
self.setWindowTitle("Yuba Terminal") # ウィンドウのタイトルを設定
self.resize(800, 600) # ウィンドウの初期サイズを設定
layout = QVBoxLayout(self) # 垂直方向のレイアウトを作成
layout.setContentsMargins(0, 0, 0, 0) # レイアウトの余白を0に設定
layout.setSpacing(0) # レイアウト内のウィジェット間の間隔を0に設定
self.terminal = TerminalWidget(self) # ターミナルウィジェットを作成
layout.addWidget(self.terminal) # レイアウトにターミナルウィジェットを追加
QShortcut(QKeySequence("Ctrl+L"), self, self.terminal.clear) # Ctrl+Lのショートカットキーでターミナルのclearメソッドを呼び出す
if __name__ == "__main__":
app = QApplication(sys.argv) # QApplicationオブジェクトを作成
window = YubaTerminal() # メインウィンドウを作成
window.show() # ウィンドウを表示
sys.exit(app.exec_()) # アプリケーションのイベントループを開始し、終了コードを返す
参考にしたもの
- PyQt5のドキュメント:https://doc.qt.io/qt-5/pyqt5-index.html
- Pythonのsubprocessモジュール:https://docs.python.org/3/library/subprocess.html