added tree view component

This commit is contained in:
ilya-pevzner
2025-03-31 12:44:11 -04:00
parent 39d656ba3c
commit ba030a048c
11 changed files with 446 additions and 16 deletions

View File

@@ -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,

View File

@@ -16,6 +16,7 @@ export class CustomListItemComponent extends ComponentBase<CustomListItemState>
createElement(): HTMLElement {
let element = document.createElement("div");
element.classList.add("rio-custom-list-item");
element.classList.add("rio-selectable-item");
return element;
}

View File

@@ -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<CustomTreeItemState> {
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<CustomTreeItemState>,
latentComponents: Set<ComponentBase>
): 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,
});
}
}

View File

@@ -170,12 +170,20 @@ export class ListViewComponent extends ComponentBase<ListViewState> {
}
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<ListViewState> {
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");
}
}
}
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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 *

View File

@@ -8,7 +8,7 @@ import rio
from .fundamental_component import FundamentalComponent
__all__ = ["ListView"]
__all__ = ["ListView", "ListViewSelectionChangeEvent"]
class ListViewSelectionChangeEvent:

View File

@@ -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}",
)

View File

@@ -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,
)