0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Streamlitで階層構造を表示・編集するアプリ

Posted at

階層構造(ツリー状)を持つデータを視覚的に表示し、編集できるアプリを作りたいと思い作成しました。
案外面白かったので、その内容をここに残しておく。

アプリ概要

ツリー状の階層構造を表示し、それを編集するためのものです。主な機能は以下の通りです。

  • ノードの追加:新しい項目を追加できる
  • ディレクトリへの出し入れ:ノードをディレクトリの内外に移動させい、階層構造を変更できる
  • ノードの順序変更:ノードの表示順序(ノードの位置)を調整できる
  • ノードの削除:不要なノードを削除できる
  • 構造の保存:作成した階層構造をファイルに保存し、再利用できる

見た目は以下のようになります。

image.png

詳細

コード全体 は最後に掲載します。まずは主要な部分を説明していきます。

ノード情報

ツリーの各要素(ノード)は、クラスの形で保持することにした。
親と子の関係を保持するようにすることで、編集機能の実装が容易になります。
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)
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?