"""
Inifile Editor
概要:
簡易的なINI/TOMLファイルエディタです。設定ファイルの編集に特化しており、構文ハイライト、検索・置換、セクションジャンプ、外部コマンド実行などの機能を提供します。
詳細説明:
本エディタは、主にWindows環境でのINIファイルやTOMLファイルの編集を想定しています。
エディタの設定は 'iniedit.ini' に保存され、挿入テキストは 'text.ini'、
構文ハイライトルールは 'color.ini' から読み込まれます。
ファイルのドラッグ&ドロップによるオープン、文字コードの自動判別と指定、
未保存の変更に対する警告、カスタム可能な外部コマンド実行機能などが実装されています。
関連リンク:
:doc:`iniedit_usage`
"""
import tkinter as tk
import tkinter.font as tkFont # フォントオブジェクトを扱うためにインポート
from tkinter import filedialog, messagebox, simpledialog, scrolledtext
import os
import argparse
import chardet
from tkinterdnd2 import DND_FILES, TkinterDnD
import re
import subprocess
from PIL import Image, ImageTk
# --- Constants ---
CONFIG_FILE = "iniedit.ini" # エディタの設定を保存する設定ファイル
TEXT_INI_FILE = "text.ini" # 挿入データを保存するファイル
COLOR_INI_FILE = "color.ini" # 構文ハイライトの設定を保存するファイル
# --- INI File Reading/Writing Functions ---
[ドキュメント]
def read_ini(filepath):
"""
INIファイルから設定を読み込みます。
概要:
シンプルなINIファイル(キー=値形式、セクションは不要)から設定を読み込み、辞書として返します。
詳細説明:
ファイルのエンコーディングを自動判別し、信頼度が80%未満の場合はUTF-8をフォールバックとして使用します。
ファイルが存在しない場合は空の辞書を返します。
行頭が'#'の行と空行はスキップされます。
:param filepath: str: 読み込むINIファイルのパス。
:returns: dict: 読み込んだキーと値のペアを含む辞書。エラーが発生した場合は空の辞書。
"""
settings = {}
if not os.path.exists(filepath):
return settings
detected_encoding = 'utf-8'
try:
with open(filepath, 'rb') as f_raw:
raw_data = f_raw.read()
result = chardet.detect(raw_data)
if result['encoding'] and result['confidence'] > 0.8:
detected_encoding = result['encoding']
except Exception as e:
print(f"Warning: Could not detect encoding for {filepath}: {e}")
try:
with open(filepath, 'r', encoding=detected_encoding, errors='ignore') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' in line:
key, value = line.split('=', 1)
settings[key.strip()] = value.strip()
except Exception as e:
print(f"Error reading INI file {filepath} with encoding {detected_encoding}: {e}")
messagebox.showerror("INIファイル読み込みエラー", f"'{os.path.basename(filepath)}' の読み込み中にエラーが発生しました:\n{e}")
return settings
[ドキュメント]
def write_ini(filepath, settings):
"""
INIファイルに設定を書き込みます。
概要:
辞書形式の設定をシンプルなINIファイル(キー=値形式)に書き込みます。
詳細説明:
すべての設定はUTF-8エンコーディングで書き込まれます。
各キーと値のペアは新しい行に `key=value` の形式で保存されます。
:param filepath: str: 書き込むINIファイルのパス。
:param settings: dict: 書き込むキーと値のペアを含む辞書。
:returns: None
"""
try:
with open(filepath, 'w', encoding='utf-8') as f:
for key, value in settings.items():
f.write(f"{key}={value}\n")
except Exception as e:
print(f"Error writing INI file {filepath}: {e}")
messagebox.showerror("INIファイル書き込みエラー", f"'{os.path.basename(filepath)}' の書き込み中にエラーが発生しました:\n{e}")
[ドキュメント]
def parse_multiline_ini(filepath):
"""
複数行の値を含むINIファイルを解析します。
概要:
`\"\"\"` で囲まれた複数行の値をサポートするINIファイル(キー=値形式)を解析します。
詳細説明:
ファイルのエンコーディングを自動判別し、信頼度が80%未満の場合はUTF-8をフォールバックとして使用します。
`\"\"\"` で開始し `\"\"\"` で終了する値は、内部の改行を含めて1つの値として処理されます。
ファイルが存在しない場合は空の辞書を返します。コメント行はスキップされます。
:param filepath: str: 解析するINIファイルのパス。
:returns: dict: 解析したキーと値のペアを含む辞書。
"""
settings = {}
if not os.path.exists(filepath):
return settings
detected_encoding = 'utf-8'
try:
with open(filepath, 'rb') as f_raw:
raw_data = f_raw.read()
result = chardet.detect(raw_data)
if result['encoding'] and result['confidence'] > 0.8:
detected_encoding = result['encoding']
except Exception as e:
print(f"Warning: Could not detect encoding for {filepath}: {e}")
try:
with open(filepath, 'r', encoding=detected_encoding, errors='ignore') as f:
content = f.read()
in_multiline = False
current_key = None
current_value = []
for line in content.splitlines():
stripped_line = line.strip()
if in_multiline:
if stripped_line.endswith('"""'):
current_value.append(stripped_line.rstrip('"""'))
settings[current_key] = '\n'.join(current_value)
in_multiline = False
current_key = None
current_value = []
else:
current_value.append(line)
elif '=' in line:
if stripped_line.startswith('#'): continue
key, value_part = line.split('=', 1)
key = key.strip()
value_part = value_part.strip()
if value_part.startswith('"""'):
in_multiline = True
current_key = key
if value_part.endswith('"""') and len(value_part) > 5:
settings[key] = value_part[3:-3]
in_multiline = False
current_key = None
else:
current_value = [value_part[3:]]
else:
settings[key] = value_part
except Exception as e:
print(f"Error reading multiline INI file {filepath}: {e}")
messagebox.showerror("INIファイル読み込みエラー", f"'{os.path.basename(filepath)}' の読み込み中にエラーが発生しました:\n{e}")
return settings
# --- Simple TOML/INI Parser Class (for main editor content) ---
[ドキュメント]
class SimpleTomlParser:
"""
簡易的なTOML/INIパーサーです。
概要:
セクション `[section]` と `key=value` のペアを処理するシンプルなパーサーを提供します。
詳細説明:
このパーサーは、複雑なTOML仕様のすべてをサポートしているわけではありませんが、
基本的なセクション定義とキー=値の割り当てを識別し、データを構造化された辞書形式で抽出します。
コメント行 (`#` で始まる行) と空行はスキップされます。
セクション名は、セクションの定義行全体とともに、その開始行番号も保持されます。
"""
def __init__(self):
"""
SimpleTomlParser クラスのコンストラクタです。
概要:
内部データを初期化します。
詳細説明:
解析されたデータを格納する `self.data` と、セクション情報(セクション名、行番号、元の行)を
格納する `self.sections` を空の辞書およびリストとして初期化します。
"""
self.data = {}
self.sections = []
[ドキュメント]
def parse(self, content):
"""
与えられたテキストコンテンツを解析します。
概要:
入力されたINI/TOML形式の文字列を解析し、セクションごとのキーと値のペアを抽出します。
詳細説明:
解析前に `self.data` と `self.sections` をリセットします。
行ごとにコンテンツを走査し、正規表現を使用してセクション定義 (`[section]`) を識別します。
識別されたセクションは `self.sections` に元の行と行番号と共に保存されます。
キー=値のペアは現在のセクションの下に格納されます。セクションが定義されていない場合、
空文字列のキーを持つデフォルトセクションに格納されます。
:param content: str: 解析する文字列コンテンツ。
:returns: tuple:
- dict: 解析されたデータ。セクション名をキーとし、その中にキー=値のペアを持つ辞書。
- list: セクション情報のリスト。各要素は `(元のセクション行文字列, 行番号, 元の行)` のタプル。
"""
self.data = {}
self.sections = []
current_section = ""
lines = content.splitlines()
section_start_pattern = re.compile(r"^\s*\[")
for i, line in enumerate(lines):
stripped_line = line.strip()
if stripped_line.startswith("#") or not stripped_line:
continue
if section_start_pattern.match(stripped_line) and "=" not in stripped_line:
extracted_section_name = stripped_line
self.sections.append((extracted_section_name, i + 1, line))
match_inner_section = re.match(r"^\[([^\]]+)\].*", stripped_line)
parsed_section_key = match_inner_section.group(1).strip() if match_inner_section else ""
if parsed_section_key not in self.data:
self.data[parsed_section_key] = {}
current_section = parsed_section_key
elif "=" in stripped_line:
try:
key, value = stripped_line.split("=", 1)
key = key.strip()
value = value.strip()
target_section_data = self.data.setdefault(current_section, {})
target_section_data[key] = value
except ValueError:
pass
return self.data, self.sections
# --- ToolTip Class ---
# --- Main Editor Class ---
[ドキュメント]
class TomlEditor:
"""
INI/TOMLファイルのためのメインエディタアプリケーションです。
概要:
Tkinterをベースにしたテキストエディタで、設定ファイルの編集に特化した機能を提供します。
詳細説明:
ファイルの開閉、保存、検索・置換、構文ハイライト、ドラッグ&ドロップ、
設定の永続化、セクション一覧表示、外部コマンド実行などの機能を含みます。
`iniedit.ini` からウィンドウサイズや位置、フォント設定を読み込み、
`text.ini` からコンテキストメニューに挿入するテキストを、
`color.ini` から構文ハイライトルールを読み込みます。
"""
def __init__(self, master, initial_file=None, initial_line=None, initial_col=None):
"""
TomlEditor クラスのコンストラクタです。
概要:
エディタのUIを初期化し、設定を読み込み、必要に応じて初期ファイルを開きます。
詳細説明:
Tkinterのルートウィンドウ (`master`) を受け取り、エディタのフレーム、テキストエリア、
ステータスバー、メニュー、ツールバーなどを構築します。
`CONFIG_FILE` から設定を読み込み、ウィンドウのサイズや位置、フォントを適用します。
指定された初期ファイルがあればそれを開き、指定された行・桁にジャンプします。
DND (Drag and Drop) 機能も有効化されます。
:param master: tk.Tk: 親となるTkinterのルートウィンドウ。
:param initial_file: str, optional: エディタ起動時に開くINI/TOMLファイルのパス。デフォルトはNone。
:param initial_line: int, optional: 初期ファイルを開いた際にジャンプする行番号(1から始まる)。デフォルトはNone。
:param initial_col: int, optional: 初期ファイルを開いた際にジャンプする桁番号(0から始まる)。デフォルトは0。
"""
self.master = master
self.loading = True
master.title("Inifile Editor")
self.last_search_term = ""
self.last_search_is_regex = False
self.last_search_case_sensitive = False
self.script_dir = os.path.dirname(os.path.abspath(__file__))
self.config_filepath = os.path.join(self.script_dir, CONFIG_FILE)
self.text_ini_filepath = os.path.join(self.script_dir, TEXT_INI_FILE)
self.color_ini_filepath = os.path.join(self.script_dir, COLOR_INI_FILE)
self.settings = read_ini(self.config_filepath)
self._apply_saved_settings()
# Font setup
font_family = self.settings.get('font_family', 'Consolas')
try: font_size = int(self.settings.get('font_size', 11))
except ValueError: font_size = 11
self.editor_font = tkFont.Font(family=font_family, size=font_size)
self.current_file = initial_file
self.file_encoding = "auto"
self.parser = SimpleTomlParser()
self.text_ini_data = {}
# Highlighting setup
self.highlight_rules = []
self.highlight_fonts = {}
# --- UI Setup ---
self.toolbar = tk.Frame(master, bd=1, relief=tk.RAISED)
self.text_area = scrolledtext.ScrolledText(master, wrap=tk.WORD, undo=True, font=self.editor_font)
self.text_area.bind("<<Modified>>", self.on_text_modified)
self.text_area.bind("<KeyRelease>", self.update_status_bar)
self.text_area.bind("<ButtonRelease-1>", self.update_status_bar)
self.text_area.drop_target_register(DND_FILES)
self.text_area.dnd_bind("<<Drop>>", self.handle_drop)
self.context_menu = tk.Menu(self.text_area, tearoff=0)
self.text_area.bind("<Button-3>", self._show_context_menu)
self.status_bar = tk.Label(master, text="行: 1, 桁: 1 | 文字コード: 自動判別", bd=1, relief=tk.SUNKEN, anchor=tk.W)
self._add_toolbar_buttons()
self._create_menus()
self.toolbar.pack(side=tk.TOP, fill=tk.X)
self.status_bar.pack(side=tk.BOTTOM, fill=tk.X)
self.text_area.pack(expand=True, fill="both")
self._bind_shortcuts()
self.update_status_bar()
if self.current_file:
self._load_initial_file(self.current_file)
if initial_line:
self.master.after(100, lambda: self._jump_to_line_col(initial_line, initial_col))
else:
self.master.title("Inifile Editor - 無題")
self.status_bar.config(text="行: 1, 桁: 1 | 文字コード: 自動判別")
self._load_text_ini_data()
self.master.protocol("WM_DELETE_WINDOW", self._on_closing)
self.master.after(100, self._finish_loading)
def _finish_loading(self):
"""
メインループ開始後に読み込みプロセスを完了させます。
概要:
エディタの初期化が完了した後、`loading` フラグを `False` に設定し、
undo/redo履歴をリセットし、テキスト変更ハンドラを呼び出します。
"""
self.loading = False
self.text_area.edit_reset()
self.on_text_modified()
def _add_toolbar_buttons(self):
"""
ツールバーにボタンを追加します。
概要:
ファイル操作、検索・置換、元に戻す/やり直し、切り取り/コピー/貼り付け、
セクションダイアログ表示のためのボタンをツールバーに配置します。
詳細説明:
アイコンは `icons` ディレクトリから読み込まれ、PIL (Pillow) を使用してリサイズされます。
各ボタンには `ToolTip` が設定され、機能の説明が表示されます。
"""
self.icons = {}
icon_size = (18, 18)
icon_paths = { "new": "icons/new.png", "open": "icons/open.png", "save": "icons/save.png", "find": "icons/find.png", "replace": "icons/replace.png", "undo": "icons/undo.png", "redo": "icons/redo.png", "cut": "icons/cut.png", "copy": "icons/copy.png", "paste": "icons/paste.png", "sections": "icons/sections.png", }
for name, path in icon_paths.items():
path = os.path.join(self.script_dir, path)
try:
img = Image.open(path).resize(icon_size, Image.Resampling.LANCZOS)
self.icons[name] = ImageTk.PhotoImage(img)
except Exception as e:
print(f"Error loading icon {path}: {e}")
self.icons[name] = None
buttons_info = [
("new", self.new_file, "新規ファイル (Ctrl+N)"),
("open", self.open_file, "ファイルを開く (Ctrl+O)"),
("save", self.save_file, "ファイルを保存 (Ctrl+S)"),
("separator",),
("find", self.find_text, "テキストを検索 (Ctrl+F)"),
("replace", self.replace_text, "テキストを置換 (Ctrl+H)"),
("separator",),
("undo", self.text_area.edit_undo, "元に戻す (Ctrl+Z)"),
("redo", self.text_area.edit_redo, "やり直し (Ctrl+Y)"),
("separator",),
("cut", lambda: self.text_area.event_generate("<<Cut>>"), "切り取り (Ctrl+X)"),
("copy", lambda: self.text_area.event_generate("<<Copy>>"), "コピー (Ctrl+C)"),
("paste", lambda: self.text_area.event_generate("<<Paste>>"), "貼り付け (Ctrl+V)"),
("separator",),
("sections", self.show_section_dialog, "セクションダイアログを表示"),
]
for info in buttons_info:
if info[0] == "separator":
tk.Frame(self.toolbar, width=1, bg="gray").pack(side=tk.LEFT, fill=tk.Y, padx=5)
continue
name, command, tooltip = info
btn = tk.Button(self.toolbar, command=command)
if self.icons.get(name): btn.config(image=self.icons[name])
else: btn.config(text=name.capitalize())
btn.pack(side=tk.LEFT, padx=2, pady=2)
ToolTip(btn, tooltip)
def _create_menus(self):
"""
メニューバーとサブメニューを作成します。
概要:
ファイル、編集、表示、ツールなどの標準的なメニュー項目を作成し、
それらを対応するメソッドにバインドします。
詳細説明:
ファイルメニューには新規作成、開く、保存、名前を付けて保存、ファイル場所を開く、
ターミナルを開く、文字コード選択、終了のオプションが含まれます。
編集メニューには検索、置換、元に戻す/やり直し、ジャンプ、切り取り/コピー/貼り付けのオプションが含まれます。
表示メニューには単語折り返しオプションが含まれます。
ツールメニューにはセクションダイアログ、ハイライト適用、外部コマンドのオプションが含まれます。
"""
self.menubar = tk.Menu(self.master)
self.master.config(menu=self.menubar)
file_menu = tk.Menu(self.menubar, tearoff=0)
self.menubar.add_cascade(label="ファイル", menu=file_menu)
file_menu.add_command(label="新規", command=self.new_file, accelerator="Ctrl+N")
file_menu.add_command(label="開く", command=self.open_file, accelerator="Ctrl+O")
file_menu.add_command(label="保存", command=self.save_file, accelerator="Ctrl+S")
file_menu.add_command(label="名前を付けて保存...", command=self.save_file_as, accelerator="Ctrl+Shift+S")
file_menu.add_separator()
file_menu.add_command(label="ファイル場所を開く", command=self._open_file_location)
file_menu.add_command(label="ターミナルを開く", command=self._open_terminal)
file_menu.add_separator()
self.encoding_menu = tk.Menu(file_menu, tearoff=0)
file_menu.add_cascade(label="文字コード", menu=self.encoding_menu)
self.selected_encoding = tk.StringVar(value="auto")
self._add_encoding_options()
file_menu.add_separator()
file_menu.add_command(label="終了", command=self._on_closing, accelerator="Ctrl+Q")
edit_menu = tk.Menu(self.menubar, tearoff=0)
self.menubar.add_cascade(label="編集", menu=edit_menu)
edit_menu.add_command(label="検索...", command=self.find_text, accelerator="Ctrl+F")
edit_menu.add_command(label="次を検索", command=self.find_next_menu, accelerator="F3")
edit_menu.add_command(label="前を検索", command=self.find_previous_menu, accelerator="Shift+F3")
edit_menu.add_command(label="置換...", command=self.replace_text, accelerator="Ctrl+H")
edit_menu.add_separator()
edit_menu.add_command(label="元に戻す", command=self.text_area.edit_undo, accelerator="Ctrl+Z")
edit_menu.add_command(label="やり直し", command=self.text_area.edit_redo, accelerator="Ctrl+Y")
edit_menu.add_separator()
edit_menu.add_command(label="文書先頭へジャンプ", command=self._jump_to_start)
edit_menu.add_command(label="文書末へジャンプ", command=self._jump_to_end)
edit_menu.add_command(label="行番号を指定してジャンプ", command=self._jump_to_line)
edit_menu.add_separator()
edit_menu.add_command(label="切り取り", command=lambda: self.text_area.event_generate("<<Cut>>"), accelerator="Ctrl+X")
edit_menu.add_command(label="コピー", command=lambda: self.text_area.event_generate("<<Copy>>"), accelerator="Ctrl+C")
edit_menu.add_command(label="貼り付け", command=lambda: self.text_area.event_generate("<<Paste>>"), accelerator="Ctrl+V")
view_menu = tk.Menu(self.menubar, tearoff=0)
self.menubar.add_cascade(label="表示", menu=view_menu)
self.word_wrap_var = tk.BooleanVar(value=True)
view_menu.add_checkbutton(label="折り返し", onvalue=True, offvalue=False, variable=self.word_wrap_var, command=self.toggle_word_wrap)
tools_menu = tk.Menu(self.menubar, tearoff=0)
self.menubar.add_cascade(label="ツール", menu=tools_menu)
tools_menu.add_command(label="セクションダイアログ", command=self.show_section_dialog)
tools_menu.add_command(label="ハイライト適用", command=self.manual_apply_highlighting)
self._add_external_commands_to_menu(tools_menu)
def _bind_shortcuts(self):
"""
キーボードショートカットをバインドします。
概要:
一般的なファイル操作、検索、置換、元に戻す/やり直しなどのショートカットを、
エディタの対応する機能にバインドします。
"""
self.master.bind("<Control-n>", lambda event: self.new_file())
self.master.bind("<Control-o>", lambda event: self.open_file())
self.master.bind("<Control-s>", lambda event: self.save_file())
self.master.bind("<Control-Shift-S>", lambda event: self.save_file_as())
self.master.bind("<Control-q>", lambda event: self._on_closing())
self.master.bind("<Control-f>", lambda event: self.find_text())
self.master.bind("<F3>", lambda event: self.find_next_menu())
self.master.bind("<Shift-F3>", lambda event: self.find_previous_menu())
self.master.bind("<Control-h>", lambda event: self.replace_text())
self.master.bind("<Control-z>", lambda event: self.text_area.edit_undo())
self.master.bind("<Control-y>", lambda event: self.text_area.edit_redo())
# --- Highlighting Methods ---
[ドキュメント]
def manual_apply_highlighting(self):
"""
手動で構文ハイライトを適用します。
概要:
`color.ini` からハイライトルールを再読み込みし、テキストエリアのコンテンツに適用します。
詳細説明:
主にデバッグや、`color.ini` を変更した後にエディタを再起動せずにハイライトを更新したい場合に呼び出されます。
ルールを読み込み、タグを設定し、テキストエリア全体にハイライトを適用する一連の処理を実行します。
"""
print("--- Applying Syntax Highlighting ---")
self._setup_highlighting()
self._apply_highlighting()
print("--- Highlighting complete. ---")
def _setup_highlighting(self):
"""
`color.ini` からハイライトルールを読み込み、タグを設定します。
概要:
`color.ini` ファイルから正規表現パターンと対応する表示スタイル(色、太字、斜体)を読み込み、
Tkinterのテキストウィジェットタグとして設定します。
詳細説明:
`color.ini` は `pattern=style:color` の形式でルールを定義します。
例えば `[Section]=bold:#0000FF` は、`[Section]` に一致するテキストを太字の青色で表示します。
各ルールは正規表現としてコンパイルされ、対応するTkinterタグが作成されます。
タグにはフォントスタイル(太字、斜体)と前景色が設定されます。
"""
self.highlight_rules = []
self.highlight_fonts = {}
highlight_data = read_ini(self.color_ini_filepath)
print("Reading color.ini for highlighting rules...")
for i, (pattern_str, format_str) in enumerate(highlight_data.items()):
try:
style, color = format_str.split(':')
style = style.lower().strip()
color = color.strip()
tag_name = f"highlight_{i}"
family = self.editor_font.cget("family")
size = self.editor_font.cget("size")
weight = "normal"
slant = "roman"
if style == 'bold':
weight = 'bold'
elif style == 'italic':
slant = 'italic'
new_font = tkFont.Font(family=family, size=size, weight=weight, slant=slant)
self.highlight_fonts[tag_name] = new_font
self.text_area.tag_configure(tag_name, foreground=color, font=new_font)
self.highlight_rules.append((re.compile(pattern_str), tag_name))
print(f" Rule {i}: Pattern='{pattern_str}', Style='{style}', Color='{color}', Tag='{tag_name}'")
except Exception as e:
print(f" Invalid highlight rule for '{pattern_str}': {e}")
def _apply_highlighting(self):
"""
テキストエリアに構文ハイライトを適用します。
概要:
事前に設定されたハイライトルールに基づいて、テキストエリアのコンテンツに色とフォントスタイルを適用します。
詳細説明:
既存のハイライトタグをすべて削除した後、テキストエリアのコンテンツ全体を対象に、
各ハイライトルール(正規表現パターンとタグ名)を順に適用します。
正規表現に一致するすべてのテキスト範囲に、対応するTkinterタグが追加されます。
"""
for _, tag_name in self.highlight_rules:
self.text_area.tag_remove(tag_name, "1.0", tk.END)
content = self.text_area.get("1.0", tk.END)
for pattern, tag_name in self.highlight_rules:
for match in pattern.finditer(content):
start, end = match.span()
start_index = self.text_area.index(f"1.0 + {start} chars")
end_index = self.text_area.index(f"1.0 + {end} chars")
self.text_area.tag_add(tag_name, start_index, end_index)
# --- Other Methods ---
def _substitute_variables(self, command_template):
"""
コマンドテンプレート内のプレースホルダー変数を置換します。
概要:
`{{var_name}}` の形式で指定された変数を、現在のファイルパスやアプリケーション設定、
または環境変数から取得した値に置換します。
詳細説明:
置換される変数の優先順位は次の通りです:
1. `{{file_path}}`: 現在開いているファイルのフルパス。
2. `{{var_name}}`: `iniedit.ini` から読み込まれたアプリケーション設定内のキー。
3. `{{var_name}}`: 環境変数。
:param command_template: str: 変数を含むコマンド文字列。
:returns: str: 変数置換後のコマンド文字列。
"""
if self.current_file:
command_template = command_template.replace("{{file_path}}", f'"{self.current_file}"')
def replacer(match):
var_name = match.group(1)
if var_name in self.settings:
return self.settings[var_name]
return os.environ.get(var_name, "")
return re.sub(r'\{\{(.*?)\}\}', replacer, command_template)
def _add_external_commands_to_menu(self, menu):
"""
`iniedit.ini` に定義された外部コマンドをツールメニューに追加します。
概要:
`external_cmdX_name` と `external_cmdX_path` の形式で設定ファイルに記述されたコマンドを、
「ツール」メニューの項目として追加します。
詳細説明:
最大9つの外部コマンド(`external_cmd1` から `external_cmd9` まで)をサポートします。
コマンドは `subprocess.Popen` を使用して実行され、`_substitute_variables` を通じて
`{{file_path}}` などの変数が置換されます。
"""
added_any = False
for i in range(1, 10):
cmd_name = self.settings.get(f'external_cmd{i}_name')
cmd_path = self.settings.get(f'external_cmd{i}_path')
if cmd_name and cmd_path:
if not added_any: menu.add_separator(); added_any = True
menu.add_command(label=cmd_name, command=lambda cmd=cmd_path: self._run_external_command(cmd))
if added_any: menu.add_separator()
def _run_external_command(self, command_template):
"""
指定された外部コマンドを実行します。
概要:
コマンドテンプレート内の変数を置換した後、OSのシェルを介してコマンドを実行します。
詳細説明:
コマンドは `_substitute_variables` を使用して、現在のファイルパスや設定値で置換されます。
その後、`subprocess.Popen` を使用して、現在のファイルのディレクトリ(またはスクリプトのディレクトリ)を
カレントワーキングディレクトリとして新しいプロセスで実行されます。
これにより、例えば外部のLinterやFormatterを呼び出すことが可能です。
:param command_template: str: 実行するコマンドのテンプレート文字列。
:returns: None
"""
command = self._substitute_variables(command_template)
try:
# cwd = os.path.dirname(self.current_file) if self.current_file else self.script_dir
if not self.current_file or not os.path.exists(self.current_file):
cwd = self.script_dir
else:
cwd = os.path.dirname(self.current_file)
subprocess.Popen(command, shell=True, cwd=cwd)
except Exception as e:
messagebox.showerror("コマンド実行エラー", f"コマンドの実行に失敗しました:\n{command}\nエラー: {e}")
def _open_file_location(self):
"""
現在のファイルの保存場所をファイルエクスプローラー(またはOSのファイルマネージャー)で開きます。
概要:
現在開いているファイルがある場合、そのファイルが保存されているディレクトリを
OSのデフォルトのファイルマネージャーで表示します。
詳細説明:
ファイルが開かれていない、または存在しない場合は、スクリプトが配置されているディレクトリを開きます。
Windowsでは `explorer`、macOSでは `open`、Linuxでは `xdg-open` を使用します。
"""
if not self.current_file or not os.path.exists(self.current_file):
directory = self.script_dir
else:
directory = os.path.dirname(self.current_file)
directory = os.path.abspath(directory)
directory = os.path.normpath(directory)
if os.name == 'nt': subprocess.Popen(['explorer', directory])
elif os.uname().sysname == 'Darwin': subprocess.Popen(['open', directory])
else: subprocess.Popen(['xdg-open', directory])
def _open_terminal(self):
"""
現在のファイルのディレクトリでターミナル(コマンドプロンプト)を開きます。
概要:
現在開いているファイルのディレクトリをカレントワーキングディレクトリとして、
新しいターミナルウィンドウ(Windowsの場合はコマンドプロンプト)を開きます。
詳細説明:
ファイルが開かれていない、または存在しない場合は、スクリプトが配置されているディレクトリでターミナルを開きます。
Windowsでは `os.startfile('cmd.exe')` を、それ以外のOSでは `TERMINAL` 環境変数を参照するか、
`x-terminal-emulator` を試して `subprocess.Popen` で開きます。
"""
if not self.current_file or not os.path.exists(self.current_file):
directory = self.script_dir
else:
directory = os.path.dirname(self.current_file)
if os.name == 'nt':
os.startfile('cmd.exe', cwd=directory)
else:
terminal_cmd = os.environ.get('TERMINAL', 'x-terminal-emulator')
try:
subprocess.Popen([terminal_cmd], cwd=directory)
except FileNotFoundError:
messagebox.showerror("エラー", f"ターミナル '{terminal_cmd}' が見つかりません。")
def _apply_saved_settings(self):
"""
`iniedit.ini` から読み込んだウィンドウサイズと位置を適用します。
概要:
設定ファイルに保存されているメインウィンドウの幅、高さ、X座標、Y座標を読み込み、
アプリケーションウィンドウに適用します。
詳細説明:
設定値が無効な場合は、デフォルトのサイズと位置 (`800x600+100+100`) が適用されます。
"""
try:
width = int(self.settings.get('main_width', 800))
height = int(self.settings.get('main_height', 600))
pos_x = int(self.settings.get('main_pos_x', 100))
pos_y = int(self.settings.get('main_pos_y', 100))
self.master.geometry(f"{width}x{height}+{pos_x}+{pos_y}")
except ValueError:
self.master.geometry("800x600+100+100")
def _on_closing(self):
"""
ウィンドウが閉じられる際の処理を行います。
概要:
未保存の変更がある場合、ユーザーに保存するかどうかを尋ね、
その後、現在のウィンドウのサイズと位置を設定ファイルに保存し、アプリケーションを終了します。
詳細説明:
ユーザーが保存を選択した場合、`save_file()` が呼び出されます。
キャンセルを選択した場合は、終了処理が中断されます。
ウィンドウの現在の状態(幅、高さ、位置)は `iniedit.ini` に保存され、
次回の起動時に復元されます。
"""
if self.text_area.edit_modified():
response = messagebox.askyesnocancel("終了", "変更が保存されていません。保存しますか?")
if response is True: # Yes
self.save_file()
elif response is None: # Cancel
return
self.settings['main_width'] = self.master.winfo_width()
self.settings['main_height'] = self.master.winfo_height()
self.settings['main_pos_x'] = self.master.winfo_x()
self.settings['main_pos_y'] = self.master.winfo_y()
write_ini(self.config_filepath, self.settings)
self.master.destroy()
[ドキュメント]
def on_text_modified(self, event=None):
"""
テキストエリアの内容が変更されたときにウィンドウタイトルを更新します。
概要:
テキストエリアに変更があった場合、ウィンドウタイトルにファイル名の後にアスタリスク `*` を追加します。
詳細説明:
`self.loading` フラグが `True` の間は、この処理はスキップされます。
これにより、初期ファイル読み込み時の不要なタイトル更新が防がれます。
`edit_modified()` メソッドを使用して変更状態をチェックします。
"""
if self.loading:
return
title = "Inifile Editor - "
filename = os.path.basename(self.current_file) if self.current_file else "無題"
if self.text_area.edit_modified():
title += f"{filename}*"
else:
title += filename
self.master.title(title)
[ドキュメント]
def update_status_bar(self, event=None):
"""
ステータスバーの表示を更新します。
概要:
現在のカーソル位置(行番号、桁番号)とファイルの文字コード情報をステータスバーに表示します。
詳細説明:
カーソルが移動したり、キーがリリースされたり、マウスボタンがリリースされたりするたびに呼び出されます。
文字コードが「auto」の場合は「自動判別」と表示されます。
"""
line, char = self.text_area.index(tk.INSERT).split('.')
encoding_display = self.file_encoding.upper() if self.file_encoding != "auto" else "自動判別"
self.status_bar.config(text=f"行: {line}, 桁: {int(char) + 1} | 文字コード: {encoding_display}")
def _load_text_ini_data(self):
"""
`text.ini` から挿入データを読み込みます。
概要:
`TEXT_INI_FILE` (デフォルトでは 'text.ini') を解析し、その内容を `self.text_ini_data` に格納します。
詳細説明:
このデータは、コンテキストメニューの「挿入」オプションを通じて、
頻繁に使用するテキストスニペットやテンプレートをエディタに簡単に挿入するために使用されます。
`parse_multiline_ini` 関数を使用して、複数行の値をサポートする形式でデータを読み込みます。
"""
self.text_ini_data = parse_multiline_ini(self.text_ini_filepath)
def _show_context_menu(self, event):
"""
右クリックで表示されるコンテキストメニューを生成し、表示します。
概要:
テキストエリアが右クリックされたときに、コピー、切り取り、貼り付けの基本的な操作と、
`text.ini` から読み込んだ挿入テキストのメニュー、`text.ini` 再読み込みオプションを含む
コンテキストメニューを表示します。
詳細説明:
`text.ini` のデータは動的にネストされたサブメニューとして表示され、
ユーザーがキーを選択すると対応する値がカーソル位置に挿入されます。
"""
self.context_menu.delete(0, tk.END)
self.context_menu.add_command(label="切り取り", command=lambda: self.text_area.event_generate("<<Cut>>"))
self.context_menu.add_command(label="コピー", command=lambda: self.text_area.event_generate("<<Copy>>"))
self.context_menu.add_command(label="貼り付け", command=lambda: self.text_area.event_generate("<<Paste>>"))
self.context_menu.add_separator()
insert_ini_menu = tk.Menu(self.context_menu, tearoff=0)
self.context_menu.add_cascade(label=f"{TEXT_INI_FILE}から挿入", menu=insert_ini_menu)
if self.text_ini_data:
nested_data = {}
for key, value in self.text_ini_data.items():
parts = key.split('\\')
d = nested_data
for part in parts[:-1]:
d = d.setdefault(part, {})
d[parts[-1]] = value
self._build_dynamic_menu(insert_ini_menu, nested_data)
else:
insert_ini_menu.add_command(label=f"({TEXT_INI_FILE}が空か見つかりません)", state=tk.DISABLED)
self.context_menu.add_separator()
self.context_menu.add_command(label=f"{TEXT_INI_FILE}再読み込み", command=self._load_text_ini_data)
try: self.context_menu.tk_popup(event.x_root, event.y_root)
finally: self.context_menu.grab_release()
def _build_dynamic_menu(self, parent_menu, data_dict):
"""
辞書データに基づいて動的なメニュー構造を構築します。
概要:
`text.ini` から読み込んだネストされた辞書データを受け取り、
Tkinterのメニューウィジェット内に対応するサブメニューとコマンドを再帰的に作成します。
詳細説明:
`data_dict` の値が別の辞書の場合、それはサブメニューとして処理されます。
値が文字列の場合、それはメニューコマンドとして追加され、選択されると
`_insert_value_from_ini` を呼び出してその値をエディタに挿入します。
:param parent_menu: tk.Menu: メニュー項目を追加する親メニューオブジェクト。
:param data_dict: dict: メニューを構築するための階層的なデータ。
:returns: None
"""
for key, value in sorted(data_dict.items()):
if isinstance(value, dict):
submenu = tk.Menu(parent_menu, tearoff=0)
parent_menu.add_cascade(label=key, menu=submenu)
self._build_dynamic_menu(submenu, value)
else:
parent_menu.add_command(label=key, command=lambda v=value: self._insert_value_from_ini(v))
def _insert_value_from_ini(self, value):
"""
`text.ini` から読み込んだ値をテキストエリアのカーソル位置に挿入します。
概要:
指定されたテキスト値に改行を追加して、現在のカーソル位置に挿入します。
詳細説明:
挿入後、テキストエリアの「変更済み」フラグを設定し、ステータスバーを更新します。
:param value: str: テキストエリアに挿入する文字列。
:returns: None
"""
insert_text = str(value) + '\n'
self.text_area.insert(tk.INSERT, insert_text)
self.text_area.edit_modified(True)
def _jump_to_start(self):
"""
カーソルを文書の先頭に移動させ、表示を更新します。
"""
self.text_area.mark_set(tk.INSERT, "1.0")
self.text_area.see(tk.INSERT)
self.update_status_bar()
def _jump_to_end(self):
"""
カーソルを文書の末尾に移動させ、表示を更新します。
"""
self.text_area.mark_set(tk.INSERT, tk.END)
self.text_area.see(tk.INSERT)
self.update_status_bar()
def _jump_to_line(self):
"""
ユーザーから行番号を入力してもらい、その行へカーソルを移動させます。
概要:
シンプルなダイアログボックスを表示し、ユーザーにジャンプしたい行番号を入力させます。
詳細説明:
入力された値が有効な整数である場合、`_jump_to_line_col` メソッドを呼び出します。
無効な入力があった場合はエラーメッセージを表示します。
"""
line_str = simpledialog.askstring("行へジャンプ", "ジャンプする行番号を入力してください:", parent=self.master)
if line_str:
try: self._jump_to_line_col(int(line_str), 0)
except ValueError: messagebox.showerror("入力エラー", "有効な行番号を入力してください。")
def _jump_to_line_col(self, line, col):
"""
指定された行番号と桁番号へカーソルを移動させます。
概要:
カーソルを文書内の特定の行と桁に移動させ、その位置が見えるようにスクロールします。
詳細説明:
指定された行番号が1未満の場合や、文書の最終行を超える場合は、それぞれ1行目または最終行に調整されます。
カーソル移動後、ステータスバーが更新されます。
:param line: int: ジャンプする行番号(1から始まる)。
:param col: int: ジャンプする桁番号(0から始まる)。
:returns: None
"""
if line is None: return
if line < 1: line = 1
last_line = int(self.text_area.index("end-1c").split('.')[0])
if line > last_line: line = last_line
self.text_area.mark_set(tk.INSERT, f"{line}.{col}")
self.text_area.see(tk.INSERT)
self.update_status_bar()
def _add_encoding_options(self):
"""
文字コードメニューにオプションを追加します。
概要:
「ファイル」メニュー内の「文字コード」サブメニューに、
主要な文字コード(UTF-8、Shift-JISなど)と自動判別のラジオボタンを追加します。
詳細説明:
これらのオプションは `self.selected_encoding` Tkinter StringVar にバインドされ、
選択された文字コードが変更されると `_on_encoding_selected` メソッドが呼び出されます。
"""
encodings = ["utf-8", "shift_jis", "cp932", "euc_jp", "iso2022_jp"]
self.encoding_menu.add_radiobutton(label="自動判別", value="auto", variable=self.selected_encoding, command=lambda: self._on_encoding_selected(is_auto=True))
self.encoding_menu.add_separator()
for enc in encodings:
self.encoding_menu.add_radiobutton(label=enc.upper(), value=enc, variable=self.selected_encoding, command=self._on_encoding_selected)
def _on_encoding_selected(self, is_auto=False):
"""
文字コードが選択されたときに `file_encoding` を更新します。
概要:
文字コードメニューから新しいエンコーディングが選択された際に呼び出され、
`self.file_encoding` プロパティを更新し、ステータスバーを再表示します。
:param is_auto: bool: 選択されたのが「自動判別」オプションであるかを示すブール値。
:returns: None
"""
self.file_encoding = "auto" if is_auto else self.selected_encoding.get()
self.update_status_bar()
def _load_initial_file(self, file_path):
"""
指定されたファイルを読み込み、エディタに表示します。
概要:
エディタのロード状態を設定し、指定されたファイルを読み込み、タイトルバーとステータスバーを更新します。
詳細説明:
`self.loading` フラグを `True` に設定し、ファイルの読み込み中はUIの更新を一時停止します。
`_read_file_with_encoding_detection` を呼び出してファイルコンテンツを読み込み、
ウィンドウタイトルをファイル名に更新します。
読み込みが完了した後、`_finish_loading` が呼び出されます。
:param file_path: str: 読み込むファイルのパス。
:returns: None
"""
self.loading = True
self.current_file = file_path
self.selected_encoding.set("auto")
self._read_file_with_encoding_detection(file_path)
self.master.title(f"Inifile Editor - {os.path.basename(file_path)}")
self.update_status_bar()
self.master.after(50, self._finish_loading)
def _read_file_with_encoding_detection(self, file_path):
"""
ファイルを読み込み、エンコーディングを自動判別または指定されたエンコーディングでデコードします。
概要:
ファイルパスを受け取り、バイナリモードで読み込みます。
選択されたエンコーディングが「自動判別」の場合、`chardet` を使用してエンコーディングを検出し、
そうでない場合は指定されたエンコーディングでデコードします。
詳細説明:
検出された、または指定されたエンコーディングでファイルをテキストとして開き、
その内容をテキストエリアに挿入します。読み込みエラーが発生した場合は、
エラーメッセージを表示し、テキストエリアをクリアします。
`errors='ignore'` を使用して、デコードエラーを回避します。
:param file_path: str: 読み込むファイルのパス。
:returns: None
"""
try:
with open(file_path, "rb") as f: raw_data = f.read()
if self.selected_encoding.get() == "auto":
result = chardet.detect(raw_data)
detected_encoding = result['encoding']
if detected_encoding and result['confidence'] > 0.8: self.file_encoding = detected_encoding
else: self.file_encoding = "utf-8"
else: self.file_encoding = self.selected_encoding.get()
content = raw_data.decode(self.file_encoding, errors='ignore')
self.text_area.delete(1.0, tk.END)
self.text_area.insert(1.0, content)
self.selected_encoding.set(self.file_encoding)
self._apply_highlighting() # ハイライトを再適用
except Exception as e:
messagebox.showerror("エラー", f"ファイルの読み込み中にエラーが発生しました:\n{e}")
self.text_area.delete(1.0, tk.END)
[ドキュメント]
def handle_drop(self, event):
"""
ファイルがエディタにドラッグ&ドロップされたときの処理を行います。
概要:
ドラッグ&ドロップイベントからファイルパスを取得し、そのファイルをエディタに読み込みます。
詳細説明:
ドロップされたファイルパスは `event.data` から取得され、余分な括弧やスペースが除去されます。
もし現在のファイルに未保存の変更がある場合、ユーザーに保存を促します。
ファイルが有効な場合は `_load_initial_file` を呼び出して開きます。
そうでない場合は警告メッセージが表示されます。
:param event: TkinterDnD.Event: ドラッグ&ドロップイベントオブジェクト。
:returns: None
"""
file_path = event.data.strip('{}')
if ' ' in file_path and os.name == 'nt': file_path = file_path.split('} {')[0].strip('{}')
if os.path.isfile(file_path):
if self.text_area.edit_modified():
if messagebox.askyesno("未保存の変更", "現在のファイルを保存しますか?"): self.save_file()
self._load_initial_file(file_path)
else: messagebox.showwarning("D&Dエラー", "ドロップされたファイルが無効です。")
[ドキュメント]
def new_file(self):
"""
新しい空のファイルを作成します。
概要:
現在編集中のファイルに未保存の変更がある場合、保存を促した上で、
テキストエリアの内容をクリアし、新規ファイルとしてエディタをリセットします。
詳細説明:
`self.loading` フラグを `True` に設定し、UIの更新を一時停止します。
`current_file` を `None` に設定し、タイトルバーを「無題」に戻します。
文字コードも「自動判別」にリセットされます。
リセット後、`_finish_loading` が呼び出されます。
:returns: None
"""
if self.text_area.edit_modified():
if not messagebox.askyesno("未保存の変更", "現在のファイルを保存しますか?"): self.save_file()
self.loading = True
self.text_area.delete(1.0, tk.END)
self.current_file = None
self.master.title("Inifile Editor - 無題")
self.file_encoding = "auto"
self.selected_encoding.set(self.file_encoding)
self.update_status_bar()
self.master.after(50, self._finish_loading)
[ドキュメント]
def open_file(self):
"""
ファイルダイアログを開き、ファイルを選択してエディタに読み込みます。
概要:
ユーザーにファイル選択ダイアログを表示し、選択されたファイルをエディタに開きます。
詳細説明:
現在編集中のファイルに未保存の変更がある場合、保存を促します。
`.ini`, `.toml`, `.txt` ファイル、およびすべてのファイルタイプをフィルタリングするダイアログが表示されます。
ファイルが選択された場合、`_load_initial_file` を呼び出してそのファイルを読み込みます。
:returns: None
"""
if self.text_area.edit_modified():
if messagebox.askyesno("未保存の変更", "現在のファイルを保存しますか?"): self.save_file()
file_types = [("INI files", "*.ini"), ("TOML files", "*.toml"), ("Text files", "*.txt"), ("All files", "*.*")]
file_path = filedialog.askopenfilename(filetypes=file_types)
if file_path: self._load_initial_file(file_path)
def _save_content(self, file_path):
"""
指定されたファイルパスにエディタの内容を保存します。
概要:
テキストエリアの現在の内容を指定されたファイルパスに書き込みます。
詳細説明:
保存時のエンコーディングは、現在選択されている `file_encoding`(または自動判別の場合UTF-8)を使用します。
ファイルの末尾の余分な改行は除去されます。
保存後、`current_file` を更新し、ウィンドウタイトルをファイル名に設定し、
エディタの「変更済み」フラグをリセットします。
エラーが発生した場合は、エラーメッセージを表示します。
:param file_path: str: 保存先のファイルのフルパス。
:returns: None
"""
try:
encoding_to_save = self.file_encoding if self.file_encoding != "auto" else "utf-8"
with open(file_path, "w", encoding=encoding_to_save, newline='') as file:
content = self.text_area.get(1.0, tk.END).rstrip('\n')
file.write(content)
self.current_file = file_path
self.master.title(f"Inifile Editor - {os.path.basename(file_path)}")
self.text_area.edit_reset()
except Exception as e:
messagebox.showerror("エラー", f"ファイルの保存中にエラーが発生しました:\n{e}")
[ドキュメント]
def save_file(self):
"""
現在のファイルを保存します。
概要:
現在開いているファイルがある場合、そのファイルに内容を上書き保存します。
新規ファイルの場合(まだファイルパスがない場合)は、`save_file_as` を呼び出して
ユーザーにファイルパスを尋ねます。
:returns: None
"""
if self.current_file: self._save_content(self.current_file)
else: self.save_file_as()
[ドキュメント]
def save_file_as(self):
"""
ファイルダイアログを開き、指定された名前でファイルを保存します。
概要:
「名前を付けて保存」ダイアログをユーザーに表示し、新しいファイル名とパスを選択させ、
現在のエディタの内容をその場所に保存します。
詳細説明:
`.ini`, `.toml`, `.txt` ファイル、およびすべてのファイルタイプをフィルタリングするダイアログが表示されます。
ファイルパスが選択された場合、`_save_content` を呼び出してファイルを保存します。
:returns: None
"""
file_types = [("INI files", "*.ini"), ("TOML files", "*.toml"), ("Text files", "*.txt"), ("All files", "*.*")]
file_path = filedialog.asksaveasfilename(defaultextension=".toml", filetypes=file_types)
if file_path: self._save_content(file_path)
def _on_find_dialog_closing(self):
"""
検索ダイアログが閉じられる際の処理を行います。
概要:
検索ダイアログの現在のサイズと位置を設定ファイルに保存し、ダイアログを破棄します。
"""
self.settings['find_dialog_width'] = self.find_dialog.winfo_width()
self.settings['find_dialog_height'] = self.find_dialog.winfo_height()
write_ini(self.config_filepath, self.settings)
self.find_dialog.destroy()
[ドキュメント]
def find_text(self):
"""
検索ダイアログを表示します。
概要:
テキストを検索するための独立したダイアログウィンドウを作成し、表示します。
詳細説明:
既に検索ダイアログが開いている場合は、既存のダイアログを前面に表示します。
ダイアログには検索文字列の入力フィールド、正規表現オプション、大文字小文字区別オプション、
「前を検索」「次を検索」「閉じる」ボタンが含まれます。
ダイアログのサイズと位置は `iniedit.ini` から読み込まれ、終了時に保存されます。
"""
if hasattr(self, 'find_dialog') and self.find_dialog.winfo_exists():
self.find_dialog.lift(); self.find_dialog.focus_set(); return
self.find_dialog = tk.Toplevel(self.master)
self.find_dialog.title("検索")
try:
width = int(self.settings.get('find_dialog_width', 350))
height = int(self.settings.get('find_dialog_height', 150))
self.find_dialog.geometry(f"{width}x{height}")
except ValueError: self.find_dialog.geometry("350x150")
self.find_dialog.protocol("WM_DELETE_WINDOW", self._on_find_dialog_closing)
self.find_dialog.grid_columnconfigure(1, weight=1)
tk.Label(self.find_dialog, text="検索:").grid(row=0, column=0, padx=5, pady=5, sticky=tk.W)
self.find_entry = tk.Entry(self.find_dialog)
self.find_entry.grid(row=0, column=1, columnspan=2, padx=5, pady=5, sticky="we")
self.find_entry.focus_set()
check_frame = tk.Frame(self.find_dialog)
check_frame.grid(row=1, column=0, columnspan=2, sticky=tk.W, padx=5)
self.find_regex_var = tk.BooleanVar(value=False)
tk.Checkbutton(check_frame, text="正規表現", variable=self.find_regex_var).pack(side=tk.LEFT)
self.find_case_var = tk.BooleanVar(value=False)
tk.Checkbutton(check_frame, text="大文字小文字を区別する", variable=self.find_case_var).pack(side=tk.LEFT)
button_frame = tk.Frame(self.find_dialog)
button_frame.grid(row=2, column=0, columnspan=3, pady=5)
tk.Button(button_frame, text="前を検索", command=self._find_previous).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="次を検索", command=self._find_next).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="閉じる", command=self._on_find_dialog_closing).pack(side=tk.LEFT, padx=5)
self.find_dialog.bind("<Return>", lambda e: self._find_next())
self.find_dialog.bind("<Shift-Return>", lambda e: self._find_previous())
self.find_dialog.bind("<Escape>", lambda e: self._on_find_dialog_closing())
def _find_next(self):
"""
「検索」ダイアログで指定された検索条件に基づいて、テキストエリア内の次の出現箇所を検索します。
概要:
検索文字列、正規表現の使用、大文字小文字の区別といった現在のダイアログ設定を取得し、
それらを使用して前方検索を実行します。
詳細説明:
検索語を `last_search_term` などに保存し、`_perform_find` を呼び出して実際の検索処理を行います。
"""
self.last_search_term = self.find_entry.get()
self.last_search_is_regex = self.find_regex_var.get()
self.last_search_case_sensitive = self.find_case_var.get()
self._perform_find(self.last_search_term, self.last_search_is_regex, self.last_search_case_sensitive, backwards=False)
def _find_previous(self):
"""
「検索」ダイアログで指定された検索条件に基づいて、テキストエリア内の前の出現箇所を検索します。
概要:
検索文字列、正規表現の使用、大文字小文字の区別といった現在のダイアログ設定を取得し、
それらを使用して後方検索を実行します。
詳細説明:
検索語を `last_search_term` などに保存し、`_perform_find` を呼び出して実際の検索処理を行います。
"""
self.last_search_term = self.find_entry.get()
self.last_search_is_regex = self.find_regex_var.get()
self.last_search_case_sensitive = self.find_case_var.get()
self._perform_find(self.last_search_term, self.last_search_is_regex, self.last_search_case_sensitive, backwards=True)
def _perform_find(self, search_text, is_regex, case_sensitive, backwards=False):
"""
実際の検索処理を実行し、見つかったテキストをハイライトします。
概要:
指定された検索条件(検索文字列、正規表現フラグ、大文字小文字区別フラグ、検索方向)に基づいて
テキストエリア内を検索し、最初に見つかった出現箇所をハイライトしてカーソルを移動させます。
詳細説明:
検索前に既存のハイライトを削除します。
現在のカーソル位置から検索を開始し、文書の末尾(または先頭)に達した後、
文書の反対側から再度検索を開始して循環検索を実現します。
検索語が見つからない場合はメッセージを表示します。
見つかったテキストは黄色でハイライトされ、カーソルが見つかったテキストの末尾(または先頭)に移動し、
その位置までスクロールされます。
:param search_text: str: 検索する文字列。
:param is_regex: bool: 検索文字列が正規表現であるかを示すブール値。
:param case_sensitive: bool: 大文字小文字を区別して検索するかを示すブール値。
:param backwards: bool: 逆方向に検索するかを示すブール値。デフォルトはFalse(前方検索)。
:returns: None
"""
if not search_text: return
start_index = self.text_area.index(tk.INSERT)
self.text_area.tag_remove("found", "1.0", tk.END)
try:
nocase = not case_sensitive
pos = self.text_area.search(search_text, start_index, stopindex="1.0" if backwards else tk.END, backwards=backwards, regexp=is_regex, nocase=nocase)
if not pos:
# 循環検索: 末尾または先頭に達したら、反対側から再検索
pos = self.text_area.search(search_text, tk.END if backwards else "1.0", stopindex="1.0" if backwards else tk.END, backwards=backwards, regexp=is_regex, nocase=nocase)
if not pos: messagebox.showinfo("検索", "テキストが見つかりませんでした。"); return
if is_regex:
flags = 0 if case_sensitive else re.IGNORECASE
content_after = self.text_area.get(pos, f"{pos} lineend")
match = re.match(search_text, content_after, flags)
match_len = len(match.group(0)) if match else 0
else: match_len = len(search_text)
end_pos = f"{pos}+{match_len}c"
self.text_area.tag_add("found", pos, end_pos)
self.text_area.tag_config("found", background="yellow")
self.text_area.mark_set(tk.INSERT, pos if backwards else end_pos)
self.text_area.see(tk.INSERT)
except re.error as e: messagebox.showerror("正規表現エラー", f"無効な正規表現です:\n{e}")
def _on_replace_dialog_closing(self):
"""
置換ダイアログが閉じられる際の処理を行います。
概要:
置換ダイアログの現在のサイズと位置を設定ファイルに保存し、ダイアログを破棄します。
"""
self.settings['replace_dialog_width'] = self.replace_dialog.winfo_width()
self.settings['replace_dialog_height'] = self.replace_dialog.winfo_height()
write_ini(self.config_filepath, self.settings)
self.replace_dialog.destroy()
[ドキュメント]
def replace_text(self):
"""
置換ダイアログを表示します。
概要:
テキストを検索し、置換するための独立したダイアログウィンドウを作成し、表示します。
詳細説明:
既に置換ダイアログが開いている場合は、既存のダイアログを前面に表示します。
ダイアログには検索文字列の入力フィールド、置換文字列の入力フィールド、
正規表現オプション、大文字小文字区別オプション、
「次を検索」「置換」「すべて置換」「閉じる」ボタンが含まれます。
ダイアログのサイズと位置は `iniedit.ini` から読み込まれ、終了時に保存されます。
"""
if hasattr(self, 'replace_dialog') and self.replace_dialog.winfo_exists():
self.replace_dialog.lift(); self.replace_dialog.focus_set(); return
self.replace_dialog = tk.Toplevel(self.master)
self.replace_dialog.title("置換")
try:
width = int(self.settings.get('replace_dialog_width', 400))
height = int(self.settings.get('replace_dialog_height', 180))
self.replace_dialog.geometry(f"{width}x{height}")
except ValueError: self.replace_dialog.geometry("400x180")
self.replace_dialog.protocol("WM_DELETE_WINDOW", self._on_replace_dialog_closing)
self.replace_dialog.grid_columnconfigure(1, weight=1)
tk.Label(self.replace_dialog, text="検索:").grid(row=0, column=0, padx=5, pady=2, sticky=tk.W)
self.replace_find_entry = tk.Entry(self.replace_dialog)
self.replace_find_entry.grid(row=0, column=1, padx=5, pady=2, sticky="we")
self.replace_find_entry.focus_set()
tk.Label(self.replace_dialog, text="置換:").grid(row=1, column=0, padx=5, pady=2, sticky=tk.W)
self.replace_entry = tk.Entry(self.replace_dialog)
self.replace_entry.grid(row=1, column=1, padx=5, pady=2, sticky="we")
check_frame = tk.Frame(self.replace_dialog)
check_frame.grid(row=2, column=1, sticky=tk.W, padx=5)
self.replace_regex_var = tk.BooleanVar(value=False)
tk.Checkbutton(check_frame, text="正規表現", variable=self.replace_regex_var).pack(side=tk.LEFT)
self.replace_case_var = tk.BooleanVar(value=False)
tk.Checkbutton(check_frame, text="大文字小文字を区別する", variable=self.replace_case_var).pack(side=tk.LEFT)
button_frame = tk.Frame(self.replace_dialog)
button_frame.grid(row=0, column=2, rowspan=4, padx=5)
tk.Button(button_frame, text="次を検索", command=self._find_next_for_replace).pack(fill=tk.X, pady=2)
tk.Button(button_frame, text="置換", command=self._do_replace).pack(fill=tk.X, pady=2)
tk.Button(button_frame, text="すべて置換", command=self._do_replace_all).pack(fill=tk.X, pady=2)
tk.Button(button_frame, text="閉じる", command=self._on_replace_dialog_closing).pack(fill=tk.X, pady=2)
self.replace_dialog.bind("<Escape>", lambda e: self._on_replace_dialog_closing())
def _find_next_for_replace(self):
"""
置換ダイアログの入力に基づいて、テキストエリア内の次の出現箇所を検索します。
概要:
「置換」ダイアログの「検索」フィールドとオプション設定を使用して、
テキストエリア内の次の出現箇所を見つけてハイライトします。
詳細説明:
このメソッドは `_perform_find` を内部的に呼び出し、置換ダイアログの検索設定を渡します。
"""
search_text = self.replace_find_entry.get()
is_regex = self.replace_regex_var.get()
case_sensitive = self.replace_case_var.get()
self._perform_find(search_text, is_regex, case_sensitive, backwards=False)
def _do_replace(self):
"""
現在選択されているテキストを置換します。
概要:
テキストエリアに選択範囲があり、それが置換ダイアログの検索条件に一致する場合、
その選択範囲を置換文字列で置き換えます。
詳細説明:
選択範囲がない場合、まず `_find_next_for_replace` を呼び出して最初の出現箇所を検索します。
選択されているテキストが検索条件と一致することを確認するために、正規表現または文字列比較が行われます。
置換後、テキストエリアの「変更済み」フラグが設定され、次の出現箇所が検索されます。
正規表現エラーが発生した場合はメッセージボックスで通知します。
"""
if not self.text_area.tag_ranges(tk.SEL):
self._find_next_for_replace()
return
search_text = self.replace_find_entry.get()
replace_with = self.replace_entry.get()
is_regex = self.replace_regex_var.get()
case_sensitive = self.replace_case_var.get()
try:
selected_text = self.text_area.get(tk.SEL_FIRST, tk.SEL_LAST)
match = False
if is_regex:
flags = 0 if case_sensitive else re.IGNORECASE
if re.fullmatch(search_text, selected_text, flags): match = True
elif not case_sensitive and selected_text.lower() == search_text.lower(): match = True
elif case_sensitive and selected_text == search_text: match = True
if match:
self.text_area.delete(tk.SEL_FIRST, tk.SEL_LAST)
self.text_area.insert(tk.SEL_FIRST, replace_with)
self.text_area.edit_modified(True)
self._find_next_for_replace()
except re.error as e: messagebox.showerror("正規表現エラー", f"無効な正規表現です:\n{e}", parent=self.replace_dialog)
def _do_replace_all(self):
"""
ドキュメント全体で指定されたテキストをすべて置換します。
概要:
テキストエリアの内容全体を対象に、検索文字列に一致するすべての箇所を置換文字列で置き換えます。
詳細説明:
検索文字列が空の場合は何もしません。
正規表現の使用と大文字小文字の区別オプションに従って `re.subn` を使用して置換を実行します。
置換が実行された場合、テキストエリアの内容が更新され、「変更済み」フラグが設定されます。
置換された件数がメッセージボックスで通知されます。
正規表現エラーが発生した場合はメッセージボックスで通知します。
"""
search_text = self.replace_find_entry.get()
replace_with = self.replace_entry.get()
if not search_text: return
is_regex = self.replace_regex_var.get()
case_sensitive = self.replace_case_var.get()
content = self.text_area.get(1.0, tk.END)
try:
flags = 0 if case_sensitive else re.IGNORECASE
if is_regex:
new_content, count = re.subn(search_text, replace_with, content, flags=flags)
else:
new_content, count = re.subn(re.escape(search_text), replace_with, content, flags=flags)
if count > 0:
self.text_area.delete(1.0, tk.END)
self.text_area.insert(1.0, new_content)
self.text_area.edit_modified(True)
messagebox.showinfo("置換", f"{count} 件を置換しました。", parent=self.replace_dialog)
except re.error as e: messagebox.showerror("正規表現エラー", f"無効な正規表現です:\n{e}", parent=self.replace_dialog)
[ドキュメント]
def toggle_word_wrap(self):
"""
テキストエリアの単語折り返し設定を切り替えます。
概要:
「表示」メニューの「折り返し」チェックボックスの状態に応じて、
テキストエリアの `wrap` オプションを `tk.WORD` (単語単位で折り返す) または `tk.NONE` (折り返さない) に設定します。
"""
self.text_area.config(wrap=tk.WORD if self.word_wrap_var.get() else tk.NONE)
[ドキュメント]
def show_section_dialog(self):
"""
セクション一覧表示ダイアログを開きます。
概要:
ファイル内のセクション(`[section]` 形式の行)を一覧表示し、
選択するとエディタ内の該当セクションにジャンプできる独立したダイアログウィンドウを作成し、表示します。
詳細説明:
既にセクションダイアログが開いている場合は、既存のダイアログを前面に表示します。
ダイアログには、セクションを検索するための正規表現入力フィールドと、大文字小文字区別のチェックボックスが含まれます。
検索結果はリストボックスに表示され、ユーザーが項目を選択するとエディタが該当行にスクロールし、その行をハイライトします。
ダイアログのサイズと位置は `iniedit.ini` から読み込まれ、終了時に保存されます。
"""
if hasattr(self, 'section_dialog') and self.section_dialog.winfo_exists():
self.section_dialog.lift(); return
self.section_dialog = tk.Toplevel(self.master)
self.section_dialog.title("セクション")
try:
width = int(self.settings.get('section_dialog_width', 300))
height = int(self.settings.get('section_dialog_height', 400))
self.section_dialog.geometry(f"{width}x{height}")
except ValueError: self.section_dialog.geometry("300x400")
top_frame = tk.Frame(self.section_dialog)
top_frame.pack(fill=tk.X, padx=5, pady=5)
self.section_regex_var = tk.StringVar(value=r'^\s*\[.*\]')
regex_entry = tk.Entry(top_frame, textvariable=self.section_regex_var)
regex_entry.pack(side=tk.LEFT, expand=True, fill=tk.X)
search_button = tk.Button(top_frame, text="検索", command=self._search_and_populate_sections)
search_button.pack(side=tk.LEFT, padx=(5, 0))
self.section_case_var = tk.BooleanVar(value=False)
case_check = tk.Checkbutton(self.section_dialog, text="大文字小文字を区別する", variable=self.section_case_var, command=self._search_and_populate_sections)
case_check.pack(anchor=tk.W, padx=5)
self.section_listbox = tk.Listbox(self.section_dialog)
self.section_listbox.pack(expand=True, fill="both", padx=5, pady=(0, 5))
self.section_listbox.bind("<<ListboxSelect>>", self._on_section_select)
self.section_dialog.protocol("WM_DELETE_WINDOW", self._on_section_dialog_closing)
self._search_and_populate_sections()
def _on_section_dialog_closing(self):
"""
セクションダイアログが閉じられる際の処理を行います。
概要:
セクションダイアログの現在のサイズと位置を設定ファイルに保存し、ダイアログを破棄します。
"""
self.settings['section_dialog_width'] = self.section_dialog.winfo_width()
self.settings['section_dialog_height'] = self.section_dialog.winfo_height()
write_ini(self.config_filepath, self.settings)
self.section_dialog.destroy()
def _search_and_populate_sections(self):
"""
テキストからセクションを検索し、セクションリストボックスに表示します。
概要:
ユーザーが指定した正規表現と大文字小文字区別オプションに基づいて、
テキストエリアのコンテンツ内からセクションらしき行を検索し、リストボックスに一覧表示します。
詳細説明:
セクションの定義は通常 `[セクション名]` の形式ですが、カスタム正規表現で柔軟に定義できます。
検索結果には、セクション行の次の行に `caption=` があれば、それをキャプションとして付加して表示します。
検索されたセクション行と行番号は `self.found_sections_list` に保存され、
リストボックスでの選択時にエディタを該当行にジャンプさせるために使用されます。
正規表現が無効な場合はエラーメッセージが表示されます。
"""
self.section_listbox.delete(0, tk.END)
self.found_sections_list = []
regex_str = self.section_regex_var.get()
if not regex_str: return
try:
flags = 0 if self.section_case_var.get() else re.IGNORECASE
pattern = re.compile(regex_str, flags)
except re.error as e:
messagebox.showerror("正規表現エラー", f"無効な正規表現です:\n{e}", parent=self.section_dialog)
return
content = self.text_area.get(1.0, tk.END)
lines = content.splitlines()
nlines = len(lines)
for i, line in enumerate(lines):
if pattern.search(line):
caption = None
if nlines > i+1:
_aa = lines[i+1].split('=', 1)
if len(_aa) > 1 and _aa[0].lower() == 'caption':
caption = _aa[1].strip()
self.found_sections_list.append((line.strip(), i + 1))
if caption is not None:
self.section_listbox.insert(tk.END, f"{line.strip()} ({caption})")
else:
self.section_listbox.insert(tk.END, line.strip())
def _on_section_select(self, event):
"""
セクションリストボックスで項目が選択されたときに呼び出されます。
概要:
リストボックスでセクションが選択されると、エディタ内の対応する行にカーソルを移動させ、
その行をハイライト表示します。
詳細説明:
選択されたリストボックスのインデックスから、`self.found_sections_list` に保存されている
セクションの行番号を取得します。
`text_area.mark_set` でカーソルを該当行の先頭に移動させ、`text_area.see` でその行が見えるようにスクロールします。
選択されたセクション行は青色で一時的にハイライトされます。ステータスバーも更新されます。
:param event: tk.Event: リストボックス選択イベントオブジェクト。
:returns: None
"""
selected_indices = self.section_listbox.curselection()
if selected_indices:
index = selected_indices[0]
_, line_number = self.found_sections_list[index]
self.text_area.mark_set(tk.INSERT, f"{line_number}.0")
self.text_area.see(tk.INSERT)
self.text_area.tag_remove("found_section", "1.0", tk.END)
self.text_area.tag_add("found_section", f"{line_number}.0", f"{line_number}.end")
self.text_area.tag_config("found_section", background="lightblue")
self.update_status_bar()
# --- Main Application Entry Point ---
[ドキュメント]
def main():
"""
アプリケーションのエントリポイントです。
概要:
コマンドライン引数を解析し、Tkinterルートウィンドウと `TomlEditor` インスタンスを作成し、メインループを開始します。
詳細説明:
`argparse` を使用して、起動時に開くINI/TOMLファイル、開始行、開始桁をコマンドライン引数として受け取ります。
`TkinterDnD.Tk()` を使用して、ファイルドラッグ&ドロップ機能をサポートするルートウィンドウを作成します。
`TomlEditor` クラスのインスタンスを生成し、アプリケーションのGUIを構築・表示します。
その後、`root.mainloop()` を呼び出して、Tkinterアプリケーションイベントループを開始します。
:returns: None
"""
parser = argparse.ArgumentParser(description="簡易Iniファイルエディタ")
parser.add_argument("input_file", nargs="?", default=None, help="編集するINI/TOMLファイルのパス (オプション)")
parser.add_argument("--line", type=int, default=None, help="開始行番号 (オプション)")
parser.add_argument("--col", type=int, default=0, help="開始桁番号 (オプション, デフォルトは0)")
args = parser.parse_args()
root = TkinterDnD.Tk()
editor = TomlEditor(root, initial_file=args.input_file, initial_line=args.line, initial_col=args.col)
root.mainloop()
if __name__ == "__main__":
main()