"""
INIファイルやテキストファイルを閲覧するためのGUIアプリケーション。
本アプリケーションは、INIファイルや一般的なテキストファイルの内容を直感的に表示・操作するためのGUIツールです。
左ペインのツリービューでINIのキー(またはファイルの内容全体)を階層的に表示し、
右ペインのテキストエディタで選択したキーに対応する値を閲覧できます。
多行のINI値の解析、検索、コピー、ウィンドウ設定の保存、および外部コマンドの実行機能も提供します。
:doc:'iniview_usage'
"""
import os
import sys
import traceback
import re
import subprocess
import tkinter.messagebox as msgbox
import ttkbootstrap
missing = []
err = ""
for lib in ["chardet", "configparser", "tkinter", "ttkbootstrap"]:
try:
__import__(lib)
except ImportError:
missing.append(lib)
err += "\n" + traceback.format_exc()
if missing:
msgbox.showerror("ライブラリエラー",
f"以下のライブラリが不足しています:\n{', '.join(missing)}"
+ "\n\n詳細:\n{err}")
sys.exit(1)
import chardet
import configparser
import tkinter as tk
import ttkbootstrap
from tkinter import filedialog, simpledialog
from tkinter import ttk
from ttkbootstrap import Window
SETTINGS_FILE = "iniview.ini"
[ドキュメント]
def parse_multiline_ini(filepath):
"""
指定されたファイルパスからINI形式のデータを解析し、辞書として返す。
ファイルのエンコーディングをchardetで自動検出します。
'.ini' 拡張子以外のファイルは、その内容全体を''all''キーの値として読み込みます。
'.ini' ファイルの場合、コメント行 ('#' で始まる行) をスキップし、多行の値をサポートします。
多行値は '\"\"\"' で囲まれた範囲として扱われます。
:param filepath: str
解析対象のファイルパス。
:returns: dict
解析された設定の辞書。キーはINIファイルのキー、値はINIファイルの値。
ファイルが存在しない場合や解析エラーの場合は空の辞書を返します。
"""
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}")
_, ext = os.path.splitext(filepath)
if ext.lower() != '.ini':
try:
with open(filepath, 'r', encoding=detected_encoding, errors='ignore') as f:
content = f.read()
settings['all'] = content
return settings
except Exception as e:
print(f"読み込みエラー: {e}")
return {}
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"INIファイル読み込みエラー: {filepath}: {e}")
return dict(sorted(settings.items()))
[ドキュメント]
class IniViewer:
"""
INIファイルやテキストファイルをGUIで表示・操作するためのビューアアプリケーションクラス。
'ttkbootstrap' を利用したGUIで、左ペインにファイルの内容をツリービューで表示し、
右ペインで選択された項目の値をテキストエディタで表示します。
ファイルの読み込み、検索、値のコピー、ウィンドウ設定の保存・復元、外部コマンド実行などの機能を提供します。
"""
def __init__(self, root, ini_path=None):
"""
'IniViewer' クラスのコンストラクタ。GUI要素の初期化とイベントバインディングを行う。
メインウィンドウのタイトル設定、左右ペインの分割、ツリービューとテキストエディタのセットアップを行う。
ステータスバー、メニューバー、コンテキストメニューを作成し、イベントハンドラをバインドする。
'SETTINGS_FILE' からウィンドウ設定や前回開いたファイルパスをロードし、
必要に応じて外部コマンドをメニューに追加します。
:param root: ttkbootstrap.Window
Tkinterのルートウィンドウオブジェクト。
:param ini_path: str, optional
起動時に読み込むINIファイルのパス。デフォルトはNone。
:returns: None
"""
self.root = root
self.root.title("INI Viewer")
# ペイン分割
self.pane = tk.PanedWindow(root, orient="horizontal")
self.pane.pack(fill="both", expand=True)
# --- 左ペイン(TreeView + Scrollbar) ---
tree_frame = tk.Frame(self.pane)
self.tree = ttk.Treeview(tree_frame)
yscroll_tree = tk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
xscroll_tree = tk.Scrollbar(tree_frame, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=yscroll_tree.set, xscrollcommand=xscroll_tree.set)
self.tree.grid(row=0, column=0, sticky="nsew")
yscroll_tree.grid(row=0, column=1, sticky="ns")
xscroll_tree.grid(row=1, column=0, sticky="ew")
tree_frame.rowconfigure(0, weight=1)
tree_frame.columnconfigure(0, weight=1)
self.pane.add(tree_frame)
# --- 右ペイン(Editor + Scrollbar) ---
editor_frame = tk.Frame(self.pane)
self.editor = tk.Text(editor_frame, wrap="word")
yscroll_editor = tk.Scrollbar(editor_frame, orient="vertical", command=self.editor.yview)
xscroll_editor = tk.Scrollbar(editor_frame, orient="horizontal", command=self.editor.xview)
self.editor.configure(yscrollcommand=yscroll_editor.set, xscrollcommand=xscroll_editor.set)
self.editor.grid(row=0, column=0, sticky="nsew")
yscroll_editor.grid(row=0, column=1, sticky="ns")
xscroll_editor.grid(row=1, column=0, sticky="ew")
editor_frame.rowconfigure(0, weight=1)
editor_frame.columnconfigure(0, weight=1)
self.pane.add(editor_frame)
# Editorコンテキストメニュー
self.editor_menu = tk.Menu(self.root, tearoff=0)
self.editor_menu.add_command(label="コピー", command=self.copy_from_editor)
self.editor.bind("<Button-3>", self.show_editor_menu)
# ステータスバー
self.status = tk.Label(root, text="", anchor="w")
self.status.pack(fill="x", side="bottom")
self.settings = {}
self.item_map = {}
self.last_query = None
self.search_results = []
self.search_index = -1
self.last_opened_file = None
self.create_menus()
self.create_context_menu()
# 起動時のウィンドウ設定をロード
self.load_window_settings()
self._add_external_commands_to_menu(self.tools_menu)
# 引数でファイルを渡された場合
if ini_path:
self.load_ini(ini_path)
self.last_opened_file = ini_path
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
self.tree.bind("<<TreeviewSelect>>", self.on_select)
# --- メニュー生成 ---
def _add_external_commands_to_menu(self, menu):
"""
設定ファイルから外部コマンドを読み込み、ツールメニューに追加する。
'SETTINGS_FILE' の '[paths]' セクションから 'external_cmdX_name' と 'external_cmdX_path' の形式で
定義された外部コマンドを読み込みます。
読み込んだコマンドはツールメニューに区切り線と共に表示され、クリックすると対応するコマンドが実行されます。
:param menu: tk.Menu
外部コマンドを追加するTkinterメニューオブジェクト。
:returns: None
"""
added_any = False
i = 1
while True:
cmd_name = self.paths.get(f'external_cmd{i}_name', None)
cmd_path = self.paths.get(f'external_cmd{i}_path', None)
if not cmd_name or not cmd_path: break
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))
i += 1
if added_any: menu.add_separator()
def _substitute_variables(self, command_template):
"""
コマンドテンプレート内のプレースホルダーを変数に置換する。
'{{file_path}}' は現在開いているファイルのパスに置換されます。
'{{VAR_NAME}}' の形式は、まずINIファイル内の同名のキーの値に置換を試み、
見つからなければ環境変数から値を検索します。
:param command_template: str
変数を含むコマンド文字列テンプレート。
:returns: str
変数が置換されたコマンド文字列。
"""
if self.last_opened_file:
command_template = command_template.replace("{{file_path}}", f'"{self.last_opened_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 _run_external_command(self, command_template):
"""
指定された外部コマンドを実行する。
コマンドテンプレート内の変数を置換した後、'subprocess.Popen' を使用してコマンドを非同期で実行します。
コマンドの実行ディレクトリは、現在開いているファイルのディレクトリ、またはスクリプトの実行ディレクトリとなります。
実行中にエラーが発生した場合はエラーメッセージボックスを表示します。
:param command_template: str
実行する外部コマンドのテンプレート文字列。
:returns: None
"""
command = self._substitute_variables(command_template)
try:
# self.script_dir は関数内で定義されているため、ここでは直接使用
script_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
if not self.last_opened_file or not os.path.exists(self.last_opened_file):
cwd = script_dir
else:
cwd = os.path.dirname(self.last_opened_file)
subprocess.Popen(command, shell=True, cwd=cwd)
except Exception as e:
msgbox.showerror("コマンド実行エラー", f"コマンドの実行に失敗しました:\n{command}\nエラー: {e}")
def _open_file_location(self):
"""
現在開いているファイルの場所をOSのファイルエクスプローラー(または同等ツール)で開く。
'last_opened_file' が設定されている場合、そのファイルのディレクトリを開きます。
設定されていない場合やファイルが存在しない場合は、スクリプトの実行ディレクトリを開きます。
OSに応じて 'explorer' (Windows), 'open' (macOS), 'xdg-open' (Linux) を使用します。
:param: None
:returns: None
"""
script_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
if not self.last_opened_file or not os.path.exists(self.last_opened_file):
directory = script_dir
else:
directory = os.path.dirname(self.last_opened_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):
"""
現在開いているファイルのディレクトリでターミナルを開く。
'last_opened_file' が設定されている場合、そのファイルのディレクトリをカレントディレクトリとしてターミナルを開きます。
設定されていない場合やファイルが存在しない場合は、スクリプトの実行ディレクトリで開きます。
Windowsでは 'cmd.exe' を、それ以外のOSでは 'TERMINAL' 環境変数または 'x-terminal-emulator' を使用します。
:param: None
:returns: None
"""
script_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
if not self.last_opened_file or not os.path.exists(self.last_opened_file):
directory = script_dir
else:
directory = os.path.dirname(self.last_opened_file)
if os.name == 'nt':
subprocess.Popen(['cmd.exe'], cwd=directory)
else:
terminal_cmd = os.environ.get('TERMINAL', 'x-terminal-emulator')
try:
subprocess.Popen([terminal_cmd], cwd=directory)
except FileNotFoundError:
msgbox.showerror("エラー", f"ターミナル '{terminal_cmd}' が見つかりません。")
# --- 折り返し切替 ---
[ドキュメント]
def toggle_wrap(self):
"""
エディタのテキスト折り返し設定を切り替える。
'wrap_var' BooleanVar の値に応じて、エディタの 'wrap' オプションを
'"word"' (折り返す) または '"none"' (折り返さない) に設定します。
:param: None
:returns: None
"""
if self.wrap_var.get():
self.editor.config(wrap="word")
else:
self.editor.config(wrap="none")
# --- ステータスバー更新 ---
[ドキュメント]
def set_status(self, msg):
"""
ステータスバーのテキストを更新する。
アプリケーションの下部にあるステータスバーに指定されたメッセージを表示します。
:param msg: str
ステータスバーに表示するメッセージ。
:returns: None
"""
self.status.config(text=msg)
[ドキュメント]
def select_file(self):
"""
ファイル選択ダイアログを開き、ユーザーが選択したファイルを読み込む。
'tk.filedialog.askopenfilename' を使用してファイル選択ダイアログを表示します。
直前に開いたファイルのディレクトリを初期ディレクトリとします。
選択されたファイルパスがあれば、'load_ini' メソッドでファイルを読み込み、
'last_opened_file' を更新し、ステータスバーにメッセージを表示します。
:param: None
:returns: None
"""
# 直前に開いたファイルのディレクトリを既定にする
script_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
if self.last_opened_file and os.path.exists(self.last_opened_file):
initial_dir = os.path.abspath(os.path.dirname(self.last_opened_file))
else:
initial_dir = os.path.join(script_dir, "iniview")
filetypes = [("Readable files", "*.ini;*.p?;*.c*;*.f*"),
("INI files", "*.ini"),
("Python/Perl files", "*.p?"),
("All files", "*.*")]
filepath = filedialog.askopenfilename(
filetypes = filetypes,
initialdir = initial_dir)
if filepath:
self.load_ini(filepath)
self.last_opened_file = filepath
self.set_status(f"{filepath} を読み込みました")
[ドキュメント]
def load_ini(self, filepath):
"""
指定されたINIファイルを読み込み、ツリービューに表示する。
'parse_multiline_ini' 関数を使用してファイルを解析します。
既存のツリービューの内容をクリアし、解析結果のキーと値をツリービューに階層的に挿入します。
ウィンドウタイトルも更新します。
:param filepath: str
読み込むINIファイルのパス。
:returns: None
"""
self.settings = parse_multiline_ini(filepath)
filename = os.path.basename(filepath)
self.root.title(f"IniView.py: {filename}")
self.tree.delete(*self.tree.get_children())
self.item_map.clear()
for key, value in self.settings.items():
parts = key.split("\\")
parent = ""
for i, part in enumerate(parts):
path = "\\".join(parts[:i+1])
if path not in self.item_map:
item = self.tree.insert(parent, "end", text=part, open=True)
self.item_map[path] = item
parent = self.item_map[path]
self.tree.item(self.item_map[key], values=(value,))
[ドキュメント]
def search_item(self):
"""
ユーザーからの入力に基づいて、ツリービュー内の項目を検索する。
'simpledialog.askstring' を使用して検索文字列をユーザーに入力させます。
検索文字列がキーまたは値に含まれるすべての項目を検索し、結果を 'search_results' に保存します。
最初の検索結果に移動します。
:param: None
:returns: None
"""
query = simpledialog.askstring("検索", "検索する文字列を入力してください")
if not query:
return
self.last_query = query
self.search_results = [
(key, item_id) for key, item_id in self.item_map.items()
if query in key or query in self.settings.get(key, "")
]
self.search_index = -1
self.navigate_search(1)
[ドキュメント]
def navigate_search(self, direction):
"""
検索結果を前後に移動し、対応する項目をツリービューで選択・表示する。
'search_results' リスト内の 'search_index' を 'direction' に応じて更新し、
次の(または前の)検索結果に移動します。
移動先の項目をツリービューで選択し、可視領域にスクロールします。
現在の検索位置をステータスバーに表示します。
:param direction: int
検索方向 ('1' で次へ、'-1' で前へ)。
:returns: None
"""
if not self.search_results:
self.set_status("検索結果なし")
return
self.search_index = (self.search_index + direction) % len(self.search_results)
_, item_id = self.search_results[self.search_index]
self.tree.selection_set(item_id)
self.tree.see(item_id)
self.set_status(f"{self.search_index+1}/{len(self.search_results)} 件目")
# --- コピー機能 ---
[ドキュメント]
def copy_value(self):
"""
ツリービューで選択されている項目の値をクリップボードにコピーする。
選択された項目のキーに対応する値を 'settings' 辞書から取得し、
ルートウィンドウのクリップボードにコピーします。
ステータスバーにコピー完了メッセージを表示します。
:param: None
:returns: None
"""
selected = self.tree.selection()
if not selected:
return
for item_id in selected:
for key, id_ in self.item_map.items():
if id_ == item_id:
value = self.settings.get(key, "")
self.root.clipboard_clear()
self.root.clipboard_append(value)
self.set_status("コピーしました")
break
[ドキュメント]
def copy_from_editor(self):
"""
エディタで選択されているテキスト、またはエディタ全体の内容をクリップボードにコピーする。
エディタでテキストが選択されていればその部分を、選択されていなければエディタ全体のテキストを
クリップボードにコピーします。
ステータスバーにコピー完了メッセージを表示します。
:param: None
:returns: None
"""
try:
text = self.editor.get("sel.first", "sel.last")
except tk.TclError:
text = self.editor.get("1.0", "end-1c")
self.root.clipboard_clear()
self.root.clipboard_append(text)
self.set_status("コピーしました")
# --- Tree選択時に右ペインに表示 ---
[ドキュメント]
def on_select(self, event):
"""
ツリービューで項目が選択されたときに呼び出されるイベントハンドラ。
選択された項目のキーに対応する値を 'settings' 辞書から取得し、
右ペインのテキストエディタに表示します。
ステータスバーをクリアします。
:param event: tk.Event
Tkinterのイベントオブジェクト。
:returns: None
"""
self.set_status("") # ステータスバーをクリア
selected = self.tree.selection()
if not selected:
return
for item_id in selected:
for key, id_ in self.item_map.items():
if id_ == item_id:
value = self.settings.get(key, "")
self.editor.delete("1.0", "end")
self.editor.insert("1.0", value)
break
# --- 設定保存 ---
[ドキュメント]
def load_window_settings(self):
"""
'iniview.ini' ファイルからウィンドウの位置とサイズ、最後のファイルパス、およびパス設定をロードする。
'configparser' を使用して 'SETTINGS_FILE' を読み込みます。
以前保存されたウィンドウのジオメトリ、ペインのサッシュ位置、最後に開いたファイルパスを適用します。
外部コマンドやファイルマネージャー、ターミナルのパス設定も読み込みます。
最後に開いたファイルが存在し、パスが有効であれば、そのファイルを自動的に読み込みます。
:param: None
:returns: None
"""
cfg = configparser.ConfigParser()
if os.path.exists(SETTINGS_FILE):
cfg.read(SETTINGS_FILE)
if "Window" in cfg:
geom = cfg["Window"].get("geometry")
if geom:
self.root.geometry(geom)
sash = cfg["Window"].get("sash", 100)
if sash:
try:
pos = int(sash)
self.root.after(100, lambda: self.pane.sash_place(0, pos, 0))
except Exception:
pass
self.last_opened_file = cfg["Window"].get("last_file", None)
if self.last_opened_file and os.path.exists(self.last_opened_file):
self.load_ini(self.last_opened_file)
self.paths = {}
# script_dir は _run_external_command などで毎回計算されるため、__init__ では定義不要
self.script_dir = os.path.dirname(os.path.abspath(sys.argv[0])) # ここで定義しておくと便利だが、既存コード変更不可のためそのまま
if cfg.has_section("paths"):
self.paths["file_manager_path"] = cfg["paths"].get("file_manager_path")
self.paths["terminal_path"] = cfg["paths"].get("terminal_path")
else:
self.paths["file_manager_path"] = None
self.paths["terminal_path"] = None
i = 1
while True:
name_key = f"external_cmd{i}_name"
name_val = cfg["paths"].get(name_key, None)
path_key = f"external_cmd{i}_path"
path_val = cfg["paths"].get(path_key, None)
if path_val is None or name_val is None: break
self.paths[name_key] = name_val
self.paths[path_key] = path_val
i += 1
[ドキュメント]
def on_close(self):
"""
アプリケーションが閉じられる際に呼び出されるイベントハンドラ。ウィンドウ設定を保存する。
現在のウィンドウのジオメトリ、最後に開いたファイルパス、ペインのサッシュ位置、
および外部コマンドパスを 'SETTINGS_FILE' に保存します。
その後、ルートウィンドウを破棄してアプリケーションを終了します。
:param: None
:returns: None
"""
cfg = configparser.ConfigParser()
sash = self.pane.sash_coord(0)[0] if self.pane.sash_coord(0) else 200
cfg["Window"] = {
"geometry": self.root.geometry(),
"last_file": getattr(self, "last_opened_file", ""),
"sash": str(sash)
}
cfg["paths"] = {
"file_manager_path": self.paths["file_manager_path"],
"terminal_path": self.paths["terminal_path"],
}
i = 1
while True:
name_key = f"external_cmd{i}_name"
name_val = self.paths.get(name_key, None)
path_key = f"external_cmd{i}_path"
path_val = self.paths.get(path_key, None)
if path_val is None or name_val is None: break
cfg["paths"][name_key] = name_val
cfg["paths"][path_key] = path_val
i += 1
with open(SETTINGS_FILE, "w") as f:
cfg.write(f)
self.root.destroy()
if __name__ == "__main__":
"""
アプリケーションのエントリポイント。
必要なライブラリのインポートチェックを行い、不足している場合はエラーメッセージを表示して終了します。
コマンドライン引数からINIファイルのパスを取得します。
'ttkbootstrap.Window' を使ってルートウィンドウを作成し、
'IniViewer' クラスのインスタンスを生成してメインループを開始します。
:param: None
:returns: None (アプリケーションの実行)
"""
ini_path = sys.argv[1] if len(sys.argv) > 1 else None
app = Window(themename="flatly")
viewer = IniViewer(app, ini_path)
app.mainloop()