From ba030a048cb5543bbdc4413996ce34cbbc6a86ca Mon Sep 17 00:00:00 2001 From: ilya-pevzner Date: Mon, 31 Mar 2025 12:44:11 -0400 Subject: [PATCH] added tree view component --- frontend/code/componentManagement.ts | 2 + frontend/code/components/customListItem.ts | 1 + frontend/code/components/customTreeItem.ts | 172 +++++++++++++++++ frontend/code/components/listView.ts | 32 ++-- .../css/components/list_view_and_items.scss | 8 +- .../css/components/tree_view_and_items.scss | 32 ++++ frontend/css/style.scss | 1 + rio/components/__init__.py | 2 + rio/components/list_view.py | 2 +- rio/components/tree_items.py | 178 ++++++++++++++++++ rio/components/tree_view.py | 32 ++++ 11 files changed, 446 insertions(+), 16 deletions(-) create mode 100644 frontend/code/components/customTreeItem.ts create mode 100644 frontend/css/components/tree_view_and_items.scss create mode 100644 rio/components/tree_items.py create mode 100644 rio/components/tree_view.py diff --git a/frontend/code/componentManagement.ts b/frontend/code/componentManagement.ts index ac3b3a16..44292b75 100644 --- a/frontend/code/componentManagement.ts +++ b/frontend/code/componentManagement.ts @@ -13,6 +13,7 @@ import { ComponentId } from "./dataModels"; import { ComponentPickerComponent } from "./components/componentPicker"; import { ComponentTreeComponent } from "./components/componentTree"; import { CustomListItemComponent } from "./components/customListItem"; +import { CustomTreeItemComponent } from "./components/customTreeItem"; import { devToolsConnector } from "./app"; import { DevToolsConnectorComponent } from "./components/devToolsConnector"; import { DialogContainerComponent } from "./components/dialogContainer"; @@ -80,6 +81,7 @@ const COMPONENT_CLASSES = { "ComponentPicker-builtin": ComponentPickerComponent, "ComponentTree-builtin": ComponentTreeComponent, "CustomListItem-builtin": CustomListItemComponent, + "CustomTreeItem-builtin": CustomTreeItemComponent, "DevToolsConnector-builtin": DevToolsConnectorComponent, "DialogContainer-builtin": DialogContainerComponent, "Drawer-builtin": DrawerComponent, diff --git a/frontend/code/components/customListItem.ts b/frontend/code/components/customListItem.ts index 254db282..e7eff1f8 100644 --- a/frontend/code/components/customListItem.ts +++ b/frontend/code/components/customListItem.ts @@ -16,6 +16,7 @@ export class CustomListItemComponent extends ComponentBase createElement(): HTMLElement { let element = document.createElement("div"); element.classList.add("rio-custom-list-item"); + element.classList.add("rio-selectable-item"); return element; } diff --git a/frontend/code/components/customTreeItem.ts b/frontend/code/components/customTreeItem.ts new file mode 100644 index 00000000..704f653a --- /dev/null +++ b/frontend/code/components/customTreeItem.ts @@ -0,0 +1,172 @@ +import { ComponentBase, ComponentState, DeltaState } from "./componentBase"; +import { ComponentId } from "../dataModels"; +import { componentsById } from "../componentManagement"; + +export type CustomTreeItemState = ComponentState & { + _type_: "CustomTreeItem-builtin"; + expand_button: ComponentId | null; + content: ComponentId; + children_container: ComponentId | null; + is_expanded: boolean; +}; + +export class CustomTreeItemComponent extends ComponentBase { + createElement(): HTMLElement { + const element = document.createElement("div"); + element.classList.add("rio-custom-tree-item"); + + // Header row for expand button and content + const headerRow = document.createElement("div"); + headerRow.classList.add("rio-tree-header-row"); + element.appendChild(headerRow); + + if (this.state.expand_button !== null) { + const buttonElement = + componentsById[this.state.expand_button].element; + if (this.state.children_container !== null) { + buttonElement.classList.add("rio-tree-expand-button"); + buttonElement.addEventListener( + "click", + this._toggleExpansion.bind(this) + ); + } else { + buttonElement.classList.add("rio-tree-expand-placeholder"); + } + headerRow.appendChild(buttonElement); + } + + if (this.state.content !== null) { + const contentContainerElement = + componentsById[this.state.content].element; + contentContainerElement.classList.add("rio-selectable-item"); + headerRow.appendChild(contentContainerElement); + } + + const childrenContainerElement = document.createElement("div"); + childrenContainerElement.classList.add("rio-tree-children"); + element.appendChild(childrenContainerElement); + + if (this.state.children_container !== null) { + childrenContainerElement.style.display = this.state.is_expanded + ? "block" + : "none"; + childrenContainerElement.appendChild( + componentsById[this.state.children_container].element + ); + } + + return element; + } + + updateElement( + deltaState: DeltaState, + latentComponents: Set + ): void { + super.updateElement(deltaState, latentComponents); + + const expandButton = + deltaState.expand_button !== undefined + ? deltaState.expand_button + : this.state.expand_button; + const content = + deltaState.content !== undefined + ? deltaState.content + : this.state.content; + const childrenContainer = + deltaState.children_container !== undefined + ? deltaState.children_container + : this.state.children_container; + + // Update header row if changed + if ( + this.state.expand_button !== expandButton || + this.state.content !== content + ) { + const headerRowElement = this.element.querySelector( + ".rio-tree-header-row" + ) as HTMLElement; + const headerChildren = [expandButton, content].filter( + (id) => id !== null + ) as ComponentId[]; + this.replaceChildren( + latentComponents, + headerChildren, + headerRowElement, + false + ); + } + + // Update expand button listener if changed + if (this.state.expand_button !== expandButton) { + if (this.state.expand_button !== null) { + const oldButtonElement = + componentsById[this.state.expand_button].element; + console.log("oldButton: ", oldButtonElement); + + oldButtonElement.removeEventListener( + "click", + this._toggleExpansion.bind(this) + ); + } + if (expandButton !== null) { + const newButtonElement = componentsById[expandButton].element; + console.log("newButton: ", newButtonElement); + + if (childrenContainer !== null) { + newButtonElement.classList.add("rio-tree-expand-button"); + newButtonElement.addEventListener( + "click", + this._toggleExpansion.bind(this) + ); + } else { + newButtonElement.classList.add( + "rio-tree-expand-placeholder" + ); + } + } + } + + const childrenContainerElement = this.element.querySelector( + ".rio-tree-children" + ) as HTMLElement; + // Update children container if changed + if (this.state.children_container !== childrenContainer) { + const allChildren = + childrenContainer !== null ? [childrenContainer] : []; + this.replaceChildren( + latentComponents, + allChildren, + childrenContainerElement, + false + ); + } + + // Update expansion state if changed + if (deltaState.is_expanded !== undefined) { + childrenContainerElement.style.display = deltaState.is_expanded + ? "block" + : "none"; + } + } + + private _toggleExpansion(): void { + this.state.is_expanded = !this.state.is_expanded; + console.log("Toggling expansion to", this.state.is_expanded); + + const childrenContainerElement = this.element.querySelector( + ".rio-tree-children" + ) as HTMLElement; + childrenContainerElement.style.display = this.state.is_expanded + ? "block" + : "none"; + + const expandButtonElement = + componentsById[this.state.expand_button].element; + expandButtonElement.textContent = this.state.is_expanded ? "▼" : "▶"; + + this.sendMessageToBackend({ + type: "toggleExpansion", + is_expanded: this.state.is_expanded, + }); + } +} diff --git a/frontend/code/components/listView.ts b/frontend/code/components/listView.ts index 27b53c9b..6cf45ac5 100644 --- a/frontend/code/components/listView.ts +++ b/frontend/code/components/listView.ts @@ -170,12 +170,20 @@ export class ListViewComponent extends ComponentBase { } private _itemKey(item: HTMLElement): string | number | null { - const component = componentsByElement.get( - item.firstElementChild as HTMLElement - ); + //const listItem = item.querySelector(".rio-custom-list-item"); + const listItem = item.firstElementChild as HTMLElement; + const component = componentsByElement.get(listItem as HTMLElement); + console.log("_itemKey: item:", item); + console.log("_itemKey: listItem:", listItem); + console.log("_itemKey: component:", component); const key = component?.state._key_ ?? null; if (key === null || key === "") { - console.warn("No key found for item", item); + console.warn( + "_itemKey: No key found for item. found=", + key, + "item=", + item + ); } return key; } @@ -242,13 +250,15 @@ export class ListViewComponent extends ComponentBase { this.element .querySelectorAll(".rio-listview-grouped") .forEach((item) => { - const itemKey = this._itemKey(item); - const listItem = item.querySelector(".rio-custom-list-item"); - if (listItem !== null && itemKey !== null) { - if (this.state.selected_keys.includes(itemKey)) { - listItem.classList.add("selected"); - } else { - listItem.classList.remove("selected"); + const listItem = item.querySelector(".rio-selectable-item"); + if (listItem !== null) { + const itemKey = this._itemKey(item); + if (itemKey !== null) { + if (this.state.selected_keys.includes(itemKey)) { + listItem.classList.add("selected"); + } else { + listItem.classList.remove("selected"); + } } } }); diff --git a/frontend/css/components/list_view_and_items.scss b/frontend/css/components/list_view_and_items.scss index 81a5610b..0c49d6f4 100644 --- a/frontend/css/components/list_view_and_items.scss +++ b/frontend/css/components/list_view_and_items.scss @@ -71,18 +71,18 @@ background: var(--rio-local-bg-active); } -.rio-list-view.selectable .rio-custom-list-item.selected { +.rio-list-view.selectable .rio-selectable-item.selected { background-color: var(--rio-global-secondary-bg); } -.rio-list-view.selectable .rio-custom-list-item.selected:hover { +.rio-list-view.selectable .rio-selectable-item.selected:hover { background: var(--rio-global-secondary-bg-active); } -.rio-list-view.selectable .rio-custom-list-item:hover { +.rio-list-view.selectable .rio-selectable-item:hover { background: var(--rio-local-bg-active); } -.rio-list-view.selectable .rio-custom-list-item { +.rio-list-view.selectable .rio-selectable-item { cursor: pointer; } diff --git a/frontend/css/components/tree_view_and_items.scss b/frontend/css/components/tree_view_and_items.scss new file mode 100644 index 00000000..d39be993 --- /dev/null +++ b/frontend/css/components/tree_view_and_items.scss @@ -0,0 +1,32 @@ +.rio-custom-tree-item { + display: flex; + flex-direction: column; +} + +.rio-tree-header-row { + display: flex; + align-items: center; +} + +.rio-tree-expand-button { + cursor: pointer; + display: inline-block; + width: 1rem; + margin-right: 0.5rem; + font-size: 0.8rem; +} + +.rio-tree-expand-placeholder { + display: inline-block; + width: 1rem; + margin-right: 0.5rem; + font-size: 0.8rem; +} + +.rio-selectable-item { + display: inline-block; +} + +.rio-tree-children { + margin-left: 2rem; +} diff --git a/frontend/css/style.scss b/frontend/css/style.scss index 1db9bb0a..7b6077cc 100644 --- a/frontend/css/style.scss +++ b/frontend/css/style.scss @@ -24,6 +24,7 @@ @use "components/linear_containers.scss"; @use "components/link.scss"; @use "components/list_view_and_items.scss"; +@use "components/tree_view_and_items.scss"; @use "components/markdown.scss"; @use "components/media_player.scss"; @use "components/overlay.scss"; diff --git a/rio/components/__init__.py b/rio/components/__init__.py index b66f3530..cf864455 100644 --- a/rio/components/__init__.py +++ b/rio/components/__init__.py @@ -62,6 +62,8 @@ from .text import * from .text_input import * from .theme_context_switcher import * from .tooltip import * +from .tree_items import * +from .tree_view import * from .website import * from .webview import * diff --git a/rio/components/list_view.py b/rio/components/list_view.py index 3cfb6260..9f799313 100644 --- a/rio/components/list_view.py +++ b/rio/components/list_view.py @@ -8,7 +8,7 @@ import rio from .fundamental_component import FundamentalComponent -__all__ = ["ListView"] +__all__ = ["ListView", "ListViewSelectionChangeEvent"] class ListViewSelectionChangeEvent: diff --git a/rio/components/tree_items.py b/rio/components/tree_items.py new file mode 100644 index 00000000..d6fa6fb9 --- /dev/null +++ b/rio/components/tree_items.py @@ -0,0 +1,178 @@ +import typing as t + +import typing_extensions as te + +from ..utils import EventHandler +from .component import Component +from .fundamental_component import FundamentalComponent +from .linear_containers import Column, Row +from .text import Text + +__all__ = ["CustomTreeItem", "AbstractTreeItem", "SimpleTreeItem"] + + +class CustomTreeItem(FundamentalComponent): + content: Component + expand_button: Component | None = None + children_container: Component | None = None + is_expanded: bool = False + on_expansion_changed: EventHandler[bool] = None + + def __init__( + self, + content: Component, + *, + expand_button: Component | None = None, + children_container: Component | None = None, + is_expanded: bool = False, + on_expansion_changed: EventHandler[bool] = None, + key: str | int | None = None, + ) -> None: + super().__init__(key=key) + self.content = content + self.expand_button = expand_button + self.children_container = children_container + self.is_expanded = is_expanded + self.on_expansion_changed = on_expansion_changed + + async def _on_message_(self, msg: t.Any) -> None: + assert isinstance(msg, dict), f"Invalid message: {msg}" + assert msg.get("type") == "toggleExpansion", ( + f"Invalid message type: {msg.get('type')}" + ) + is_expanded = msg.get("is_expanded") + assert isinstance(is_expanded, bool), ( + f"Invalid is_expanded: {is_expanded}" + ) + + # Update the state + self._apply_delta_state_from_frontend({"is_expanded": is_expanded}) + + if self.on_expansion_changed: + await self.call_event_handler( + self.on_expansion_changed, is_expanded + ) + + # Refresh the session + await self.session._refresh() + + +CustomTreeItem._unique_id_ = "CustomTreeItem-builtin" + + +class AbstractTreeItem(Component): + text: str + children: list[te.Self] = [] + is_expanded: bool = False + on_press: EventHandler[[]] = None + on_expansion_changed: EventHandler[bool] = None + + def __init__( + self, + text: str, + *, + children: list[te.Self] | None = None, + is_expanded: bool = False, + on_press: EventHandler[[]] = None, + on_expansion_changed: EventHandler[bool] = None, + key: str | int | None = None, + ) -> None: + super().__init__(key=key) + self.text = text + self.children = children or [] + self.is_expanded = is_expanded + self.on_press = on_press + self.on_expansion_changed = on_expansion_changed + + def build_content(self) -> Component: + return Text(self.text, justify="left", selectable=False) + + def build(self) -> Component: + expand_button = Text( + ("▶" if not self.is_expanded else "▼") if self.children else "●", + style="plain-text", + key=f"expand_{self.key}", + ) + if self.children: + children_container = Column( + *self.children, + spacing=0.5, + margin_left=2, # Indentation + key=f"children_{self.key}", + ) + else: + children_container = None + + return CustomTreeItem( + expand_button=expand_button, + content=self.build_content(), + children_container=children_container, + is_expanded=self.is_expanded, + on_expansion_changed=self.on_expansion_changed, + key=self.key, + ) + + +class SimpleTreeItem(AbstractTreeItem): + secondary_text: str = "" + left_child: Component | None = None + right_child: Component | None = None + + def __init__( + self, + text: str, + *, + secondary_text: str = "", + left_child: Component | None = None, + right_child: Component | None = None, + children: list[te.Self] | None = None, + is_expanded: bool = False, + on_press: EventHandler[[]] = None, + on_expansion_changed: EventHandler[bool] = None, + key: str | int | None = None, + ) -> None: + super().__init__( + text, + key=key, + children=children, + is_expanded=is_expanded, + on_press=on_press, + on_expansion_changed=on_expansion_changed, + ) + self.secondary_text = secondary_text + self.left_child = left_child + self.right_child = right_child + + def build_content(self) -> Component: + children = [] + if self.left_child: + children.append(self.left_child) + text_children = [super().build_content()] + if self.secondary_text: + text_children.append( + Text( + self.secondary_text, + overflow="wrap", + style="dim", + justify="left", + selectable=False, + ) + ) + children.append( + Column( + *text_children, + spacing=0.5, + grow_x=True, + align_y=0.5, + grow_y=False, + ) + ) + if self.right_child: + children.append(self.right_child) + + return Row( + *children, + spacing=1, + grow_x=True, + key=f"content_{self.key}", + ) diff --git a/rio/components/tree_view.py b/rio/components/tree_view.py new file mode 100644 index 00000000..921a7fe8 --- /dev/null +++ b/rio/components/tree_view.py @@ -0,0 +1,32 @@ +import typing as t + +from .component import Component +from .list_view import ListView +from .tree_items import AbstractTreeItem + +__all__ = ["TreeView"] + + +class TreeView(Component): + root_items: list[AbstractTreeItem] + selection_mode: t.Literal["none", "single", "multiple"] = "none" + selected_keys: list[str | int] = [] + + def __init__( + self, + *root_items: AbstractTreeItem, + selection_mode: t.Literal["none", "single", "multiple"] = "none", + selected_keys: list[str | int] = None, + key: str | int | None = None, + ) -> None: + super().__init__(key=key) + self.root_items = list(root_items) + self.selection_mode = selection_mode + self.selected_keys = selected_keys or [] + + def build(self) -> Component: + return ListView( + *self.root_items, + selection_mode=self.selection_mode, + selected_keys=self.selected_keys, + )