階層構造(ツリー状)を持つデータを視覚的に表示し、編集できるアプリを作りたいと思い作成しました。
案外面白かったので、その内容をここに残しておく。
アプリ概要
ツリー状の階層構造を表示し、それを編集するためのものです。主な機能は以下の通りです。
- ノードの追加:新しい項目を追加できる
- ディレクトリへの出し入れ:ノードをディレクトリの内外に移動させい、階層構造を変更できる
- ノードの順序変更:ノードの表示順序(ノードの位置)を調整できる
- ノードの削除:不要なノードを削除できる
- 構造の保存:作成した階層構造をファイルに保存し、再利用できる
見た目は以下のようになります。
詳細
コード全体 は最後に掲載します。まずは主要な部分を説明していきます。
ノード情報
ツリーの各要素(ノード)は、クラスの形で保持することにした。
親と子の関係を保持するようにすることで、編集機能の実装が容易になります。
to_dict
は保存時に使用します。
# ノード情報を保持するクラス
class Node:
def __init__(self, name, is_dir=False, parent=None):
self.name = name
self.is_dir = is_dir
self.parent = parent
self.children = []
def to_dict(self):
if self.parent == None:
parent_name = ""
else:
parent_name = self.parent.name
return {"name": self.name, "is_dir": self.is_dir, "parent": parent_name}
保存ファイルの読み込み
保存しておいた階層構造の情報を読み込む関数です。存形式は以下のJSON形式で、名前、タイプ(ディレクトリかファイルか)、親の名前を保持します。
[
{
"name": "main",
"is_dir": true,
"parent": ""
},
{
"name": "bbb.json",
"is_dir": false,
"parent": "main"
},
....
{
"name": "tree.py",
"is_dir": false,
"parent": "new"
}
]
親の名前を基に階層構造を構築します。
def build_tree_data(file_path):
if not os.path.exists(file_path):
return []
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
root_node = Node("", is_dir=True)
for item in data:
if item["parent"] == "":
root_node.children.append(
Node(item["name"], is_dir=item["is_dir"], parent=root_node)
)
else:
node = get_selected_node(root_node, item["parent"])
if node == None:
data.append(item)
else:
node.children.append(
Node(item["name"], is_dir=item["is_dir"], parent=node)
)
return root_node
ポイントは、
if node == None:
data.append(item)
の部分で、親が見つからない場合にその項目を後回しにすることで、ファイルの記載順序に依存せずツリーを構築できるようにしています。
補助関数
階層構造を操作するための補助関数です。ノードの検索や名前の存在チェックを行います。
- ノードの名前からノード情報を取得する関数
- ノードの名前からそのノード情報が存在するか確認する関数
def get_selected_node(node, selected_name):
"""
指定された名前のノードを再帰的に検索します。
"""
if node.name == selected_name:
return node
for child in node.children:
result = get_selected_node(child, selected_name)
if result:
return result
return None
def check_node_name(node, name):
"""
指定された名前のノードが存在するかどうかをチェックします。
"""
if node.name == name:
return True
for child in node.children:
if check_node_name(child, name):
return True # 子ノードに同じ名前のノードが存在する
return False # 同じ名前のノードは存在しない
これらの関数は再帰的に実装されています。個人的には再帰関数は、シンプルに記述できて個人的にとても気持ちがいいです。
階層構造の表示
ツリー構造の表示は本アプリの主要な部分です。
階層感を出すために、インデントさせるのだが、streamlitのボタンの場所を直接変更することが困難だったため、Streamlitのcolumns
機能を利用してインデントを表現し、階層感を演出しました。
def display_node(node, indent_level=[]):
if node.is_dir:
icon = "📁"
else:
icon = "📄"
# ボタンのラベル
button_label = f"{icon} {node.name}"
col = st.columns(
[10 if i == len(indent_level) else 1 for i in range(10)]
) # インデントレベルに応じて列数を増やす
with col[len(indent_level)]:
if node.name == st.session_state.selected_item_name:
button_type = "primary"
else:
button_type = "tertiary"
if st.button(button_label, type=button_type):
st.session_state.selected_item_name = node.name
st.rerun()
if len(indent_level) != 0:
for i in range(len(indent_level) - 1):
with col[i]:
if indent_level[i] == 1:
st.markdown(
"""<div style="text-align: center;">|</div>""",
unsafe_allow_html=True,
)
with col[len(indent_level) - 1]:
if indent_level[-1] == 1:
st.markdown(
"""<div style="text-align: center;">├</div>""",
unsafe_allow_html=True,
)
elif indent_level[-1] == 2:
st.markdown(
"""<div style="text-align: center;">└</div>""",
unsafe_allow_html=True,
)
# 子要素があれば、再帰的に表示
if node.is_dir and node.children:
indent_level.append(1) # インデントレベルを1つ増やす
for idx, child in enumerate(node.children):
if idx == (len(node.children) - 1):
# 最後の子要素の場合は、インデントを調整
indent_level[-1] = 2
# その他の子要素は通常のインデントで表示
display_node(child, indent_level.copy())
また、階層感を出すために、「|、├、└」といった記号を使用してツリー感を演出してみました。
この文字をどうやって出すのかと右往左往してたら、ちゃんとまとめてくれている人がいました。大変助かります。
編集機能
各編集機能の主要な部分を以下に示します。詳細なコードはコード全体 をご参照ください。
ノードの追加
ノード名を入力し、選択ノードの下に新しいノードを追加します。
node = get_selected_node(
st.session_state.tree_data, st.session_state.selected_item_name
)
node.children.append(Node(node_name, is_dir=True, parent=node))
ディレクトリノードへの出し入れ
ノードをディレクトリに入れる時は、選択ノードの親の兄弟の中から、前方直近のディレクトリノードを検索し、そのノードの子に登録します。
ノードを出す時は、選択ノードの親の親の子として登録します。
文章で書くと意味不明ですね。
node = get_selected_node(
st.session_state.tree_data, st.session_state.selected_item_name
)
index = node.parent.children.index(node)
dir_index = None
for i in range(index - 1, -1, -1):
if node.parent.children[i].is_dir == True:
dir_index = i
break
if dir_index != None:
parent = node.parent
new_parent = node.parent.children[dir_index]
node.parent = new_parent
new_parent.children.append(node)
parent.children.remove(node)
ノードの移動
選択ノードの親のchildren
リスト内の要素の順番を入れ替えることで実現しています。
node.parent.children[index], node.parent.children[index - 1] = (
node.parent.children[index - 1],
node.parent.children[index],
)
要はこういうことです。
my_list[index], my_list[index - 1] = my_list[index - 1], my_list[index]
これは、Pyhtonの多重代入を活用した記述方法です。
一回tmpに入れて、、、みたいなことをしなくていいのがpythonのいいところですね。
削除
選択ノードを、その親のchildren
リストから削除します。
node = get_selected_node(
st.session_state.tree_data, st.session_state.selected_item_name
)
node.parent.children.remove(node)
階層構造の保存
保存ファイルの読み込みで示したJSON形式にノード情報を変換し、保存します。
ここで、ノードクラスのto_dict
メソッドが活躍します。とても長い伏線回収でした。
def make_tree_dict(tree_data):
def _explore(node: Node):
children_list = []
for children in node.children:
children_list += _explore(children)
if node.name == "":
return children_list
return [node.to_dict()] + children_list
tree_dict = _explore(tree_data)
return tree_dict
まとめ
Streamlitの標準機能のみでツリー構造の表示と編集ができないかという発想から、本アプリを作成しました。サードパーティ製で、ディレクトリ構造を書くやつがあるみたいだが、なんとなくStreamlitの純粋な機能だけでできないかと思って作りました。
再帰関数を用いたツリー構造の構成や、Streamlitの columns を利用したインデントの表現は、開発を通して得られた面白い点でした。
コード全体
import json
import os
import streamlit as st
# ノード情報を保持するクラス
class Node:
def __init__(self, name, is_dir=False, parent=None):
self.name = name
self.is_dir = is_dir
self.parent = parent
self.children = []
def to_dict(self):
if self.parent == None:
parent_name = ""
else:
parent_name = self.parent.name
return {"name": self.name, "is_dir": self.is_dir, "parent": parent_name}
def build_tree_data(file_path):
if not os.path.exists(file_path):
return []
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
root_node = Node("", is_dir=True)
for item in data:
if item["parent"] == "":
root_node.children.append(
Node(item["name"], is_dir=item["is_dir"], parent=root_node)
)
else:
node = get_selected_node(root_node, item["parent"])
if node == None:
data.append(item)
else:
node.children.append(
Node(item["name"], is_dir=item["is_dir"], parent=node)
)
return root_node
def get_selected_node(node, selected_name):
"""
指定された名前のノードを再帰的に検索します。
"""
if node.name == selected_name:
return node
for child in node.children:
result = get_selected_node(child, selected_name)
if result:
return result
return None
def check_node_name(node, name):
"""
指定された名前のノードが存在するかどうかをチェックします。
"""
if node.name == name:
return True
for child in node.children:
if check_node_name(child, name):
return True # 子ノードに同じ名前のノードが存在する
return False # 同じ名前のノードは存在しない
def display_node(node, indent_level=[]):
if node.is_dir:
icon = "📁"
else:
icon = "📄"
# ボタンのラベル
button_label = f"{icon} {node.name}"
col = st.columns(
[10 if i == len(indent_level) else 1 for i in range(10)]
) # インデントレベルに応じて列数を増やす
with col[len(indent_level)]:
if node.name == st.session_state.selected_item_name:
button_type = "primary"
else:
button_type = "tertiary"
if st.button(button_label, type=button_type):
st.session_state.selected_item_name = node.name
st.rerun()
if len(indent_level) != 0:
for i in range(len(indent_level) - 1):
with col[i]:
if indent_level[i] == 1:
st.markdown(
"""<div style="text-align: center;">|</div>""",
unsafe_allow_html=True,
)
with col[len(indent_level) - 1]:
if indent_level[-1] == 1:
st.markdown(
"""<div style="text-align: center;">├</div>""",
unsafe_allow_html=True,
)
elif indent_level[-1] == 2:
st.markdown(
"""<div style="text-align: center;">└</div>""",
unsafe_allow_html=True,
)
# 子要素があれば、再帰的に表示
if node.is_dir and node.children:
indent_level.append(1) # インデントレベルを1つ増やす
for idx, child in enumerate(node.children):
if idx == (len(node.children) - 1):
# 最後の子要素の場合は、インデントを調整
indent_level[-1] = 2
# その他の子要素は通常のインデントで表示
display_node(child, indent_level.copy())
# --- Streamlitアプリのメイン部分 ---
st.title("階層構造ビューア")
st.markdown("---") # 区切り線
# session_stateの初期化
if "selected_item_name" not in st.session_state:
st.session_state.selected_item_name = "なし"
if "message_status" not in st.session_state:
st.session_state.message_status = ""
st.session_state.message = ""
# テキスト入力が変更されたら、session_stateを更新し、ツリーを再構築
if "tree_data" not in st.session_state:
st.session_state.tree_data = build_tree_data("tree.json")
st.session_state.selected_item_name = "なし"
st.session_state.selected_item_path = ""
st.session_state.status = ""
(
add_sibling_node,
move_node_up,
move_node_down,
delete_node,
collapse_tree,
) = st.columns(5)
# ボタンの配置
with add_sibling_node:
if st.button("Dirに追加", use_container_width=True):
node = get_selected_node(
st.session_state.tree_data, st.session_state.selected_item_name
)
if node != None:
if node.parent != None:
index = node.parent.children.index(node)
dir_index = None
for i in range(index - 1, -1, -1):
if node.parent.children[i].is_dir == True:
dir_index = i
break
if dir_index != None:
parent = node.parent
new_parent = node.parent.children[dir_index]
node.parent = new_parent
new_parent.children.append(node)
parent.children.remove(node)
else:
st.session_state.message_status = "error"
st.session_state.message = "選択ノードより上にDirがありません"
with move_node_up:
if st.button("Dirから出す", use_container_width=True):
node = get_selected_node(
st.session_state.tree_data, st.session_state.selected_item_name
)
if node.parent.parent != None:
if node.parent.parent.is_dir:
parent = node.parent
new_parent = node.parent.parent
node.parent = new_parent
new_parent.children.append(node)
parent.children.remove(node)
else:
st.session_state.message_status = "error"
st.session_state.message = "rootの外には出せません"
with move_node_down:
if st.button("上に移動", use_container_width=True):
node = get_selected_node(
st.session_state.tree_data, st.session_state.selected_item_name
)
index = node.parent.children.index(node)
if index > 0:
node.parent.children[index], node.parent.children[index - 1] = (
node.parent.children[index - 1],
node.parent.children[index],
)
with delete_node:
if st.button("下に移動", use_container_width=True):
node = get_selected_node(
st.session_state.tree_data, st.session_state.selected_item_name
)
index = node.parent.children.index(node)
if index < len(node.parent.children) - 1:
node.parent.children[index], node.parent.children[index + 1] = (
node.parent.children[index + 1],
node.parent.children[index],
)
with collapse_tree:
if st.button("nodeを削除", use_container_width=True):
node = get_selected_node(
st.session_state.tree_data, st.session_state.selected_item_name
)
if len(node.children) == 0:
if node.is_dir:
index = node.parent.children.index(node)
node.parent.children.remove(node)
else:
st.session_state.message_status = "error"
st.session_state.message = "ディレクトリではありません。"
else:
st.session_state.message_status = "error"
st.session_state.message = (
"削除するにはディレクトリが空である必要があります"
)
node_name = st.text_input("新しいノードの名前を入力してください:", "")
if st.button("ノードを追加"):
if check_node_name(st.session_state.tree_data, node_name) == False:
node = get_selected_node(
st.session_state.tree_data, st.session_state.selected_item_name
)
if node:
if node.is_dir:
node.children.append(Node(node_name, is_dir=True, parent=node))
st.session_state.message_status = "success"
st.session_state.message = "新しいノード({node_name})が作成されました。"
else:
st.session_state.message_status = "error"
st.session_state.message = "選択されたノードはフォルダではありません。"
else:
st.error("選択されたノードが見つかりません。")
else:
st.session_state.message_status = "error"
st.session_state.message = (
"同じ名前のノードが既に存在します。別の名前を選択してください。"
)
if st.session_state.message_status == "error":
st.error(st.session_state.message)
if st.session_state.message_status == "normal":
st.write(st.session_state.message)
if st.session_state.message_status == "success":
st.success(st.session_state.message)
# フォルダ構造の表示ロジック
with st.container(height=500):
display_node(st.session_state.tree_data)
st.markdown("---") # 区切り線
def make_tree_dict(tree_data):
def _explore(node: Node):
children_list = []
for children in node.children:
children_list += _explore(children)
if node.name == "":
return children_list
return [node.to_dict()] + children_list
tree_dict = _explore(tree_data)
return tree_dict
if st.button("登録"):
tree_dict = make_tree_dict(st.session_state.tree_data)
with open("tree.json", "w", encoding="utf-8") as file:
json.dump(tree_dict, file, ensure_ascii=False, indent=4)