Added TreeView and Panel

This commit is contained in:
2025-11-29 18:15:20 +01:00
parent ce5328fe34
commit 1d20fb8650
21 changed files with 2343 additions and 366 deletions

View File

@@ -10,9 +10,11 @@ from myfasthtml.controls.InstancesDebugger import InstancesDebugger
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.instances import UniqueInstance
from myfasthtml.icons.carbon import volume_object_storage
from myfasthtml.icons.fluent_p2 import key_command16_regular
from myfasthtml.icons.fluent_p3 import folder_open20_regular
from myfasthtml.myfastapp import create_app
@@ -31,6 +33,52 @@ app, rt = create_app(protect_routes=True,
base_url="http://localhost:5003")
def create_sample_treeview(parent):
"""
Create a sample TreeView with a file structure for testing.
Args:
parent: Parent instance for the TreeView
Returns:
TreeView: Configured TreeView instance with sample data
"""
tree_view = TreeView(parent, _id="-treeview")
# Create sample file structure
projects = TreeNode(label="Projects", type="folder")
tree_view.add_node(projects)
myfasthtml = TreeNode(label="MyFastHtml", type="folder")
tree_view.add_node(myfasthtml, parent_id=projects.id)
app_py = TreeNode(label="app.py", type="file")
tree_view.add_node(app_py, parent_id=myfasthtml.id)
readme = TreeNode(label="README.md", type="file")
tree_view.add_node(readme, parent_id=myfasthtml.id)
src_folder = TreeNode(label="src", type="folder")
tree_view.add_node(src_folder, parent_id=myfasthtml.id)
controls_py = TreeNode(label="controls.py", type="file")
tree_view.add_node(controls_py, parent_id=src_folder.id)
documents = TreeNode(label="Documents", type="folder")
tree_view.add_node(documents, parent_id=projects.id)
notes = TreeNode(label="notes.txt", type="file")
tree_view.add_node(notes, parent_id=documents.id)
todo = TreeNode(label="todo.md", type="file")
tree_view.add_node(todo, parent_id=documents.id)
# Expand all nodes to show the full structure
#tree_view.expand_all()
return tree_view
@rt("/")
def index(session):
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
@@ -51,7 +99,7 @@ def index(session):
commands_debugger = CommandsDebugger(layout)
btn_show_commands_debugger = mk.label("Commands",
icon=None,
icon=key_command16_regular,
command=add_tab("Commands", commands_debugger),
id=commands_debugger.get_id())
@@ -62,13 +110,17 @@ def index(session):
btn_popup = mk.label("Popup",
command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown")))
# Create TreeView with sample data
tree_view = create_sample_treeview(layout)
layout.header_left.add(tabs_manager.add_tab_btn())
layout.header_right.add(btn_show_right_drawer)
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
layout.left_drawer.add(btn_file_upload, "Test")
layout.left_drawer.add(btn_popup, "Test")
layout.left_drawer.add(tree_view, "TreeView")
layout.set_main(tabs_manager)
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
add_tab("File Open", FileUpload(layout, _id="-file_upload")))

View File

@@ -465,4 +465,210 @@
.mf-dropdown.is-visible {
display: block;
opacity: 1;
}
}
/* *********************************************** */
/* ************** TreeView Component ************* */
/* *********************************************** */
/* TreeView Container */
.mf-treeview {
width: 100%;
user-select: none;
}
/* TreeNode Container */
.mf-treenode-container {
width: 100%;
}
/* TreeNode Element */
.mf-treenode {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 2px 0.5rem;
cursor: pointer;
transition: background-color 0.15s ease;
border-radius: 0.25rem;
}
/* Input for Editing */
.mf-treenode-input {
flex: 1;
padding: 2px 0.25rem;
border: 1px solid var(--color-primary);
border-radius: 0.25rem;
background-color: var(--color-base-100);
outline: none;
}
.mf-treenode:hover {
background-color: var(--color-base-200);
}
.mf-treenode.selected {
background-color: var(--color-primary);
color: var(--color-primary-content);
}
/* Toggle Icon */
.mf-treenode-toggle {
flex-shrink: 0;
width: 20px;
text-align: center;
font-size: 0.75rem;
}
/* Node Label */
.mf-treenode-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mf-treenode-input:focus {
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 25%, transparent);
}
/* Action Buttons - Hidden by default, shown on hover */
.mf-treenode-actions {
display: none;
gap: 0.1rem;
white-space: nowrap;
margin-left: 0.5rem;
}
.mf-treenode:hover .mf-treenode-actions {
display: flex;
}
/* *********************************************** */
/* ********** Generic Resizer Classes ************ */
/* *********************************************** */
/* Generic resizer - used by both Layout and Panel */
.mf-resizer {
position: absolute;
width: 4px;
cursor: col-resize;
background-color: transparent;
transition: background-color 0.2s ease;
z-index: 100;
top: 0;
bottom: 0;
}
.mf-resizer:hover {
background-color: rgba(59, 130, 246, 0.3);
}
/* Active state during resize */
.mf-resizing .mf-resizer {
background-color: rgba(59, 130, 246, 0.5);
}
/* Prevent text selection during resize */
.mf-resizing {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Cursor override for entire body during resize */
.mf-resizing * {
cursor: col-resize !important;
}
/* Visual indicator for resizer on hover - subtle border */
.mf-resizer::before {
content: '';
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 40px;
background-color: rgba(156, 163, 175, 0.4);
border-radius: 2px;
opacity: 0;
transition: opacity 0.2s ease;
}
.mf-resizer:hover::before,
.mf-resizing .mf-resizer::before {
opacity: 1;
}
/* Resizer positioning */
/* Left resizer is on the right side of the left panel */
.mf-resizer-left {
right: 0;
}
/* Right resizer is on the left side of the right panel */
.mf-resizer-right {
left: 0;
}
/* Position indicator for resizer */
.mf-resizer-left::before {
right: 1px;
}
.mf-resizer-right::before {
left: 1px;
}
/* Disable transitions during resize for smooth dragging */
.mf-item-resizing {
transition: none !important;
}
/* *********************************************** */
/* *************** Panel Component *************** */
/* *********************************************** */
/* Container principal du panel */
.mf-panel {
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
/* Panel gauche */
.mf-panel-left {
position: relative;
flex-shrink: 0;
width: 250px;
min-width: 150px;
max-width: 400px;
height: 100%;
overflow: auto;
border-right: 1px solid var(--color-border-primary);
}
/* Panel principal (centre) */
.mf-panel-main {
flex: 1;
height: 100%;
overflow: auto;
min-width: 0; /* Important pour permettre le shrink du flexbox */
}
/* Panel droit */
.mf-panel-right {
position: relative;
flex-shrink: 0;
width: 300px;
min-width: 150px;
max-width: 500px;
height: 100%;
overflow: auto;
border-left: 1px solid var(--color-border-primary);
}

View File

@@ -1,40 +1,43 @@
/**
* Layout Drawer Resizer
* Generic Resizer
*
* Handles resizing of left and right drawers with drag functionality.
* Handles resizing of elements with drag functionality.
* Communicates with server via HTMX to persist width changes.
* Works for both Layout drawers and Panel sides.
*/
/**
* Initialize drawer resizer functionality for a specific layout instance
* Initialize resizer functionality for a specific container
*
* @param {string} layoutId - The ID of the layout instance to initialize
* @param {string} containerId - The ID of the container instance to initialize
* @param {Object} options - Configuration options
* @param {number} options.minWidth - Minimum width in pixels (default: 150)
* @param {number} options.maxWidth - Maximum width in pixels (default: 600)
*/
function initLayoutResizer(layoutId) {
'use strict';
function initResizer(containerId, options = {}) {
const MIN_WIDTH = 150;
const MAX_WIDTH = 600;
const MIN_WIDTH = options.minWidth || 150;
const MAX_WIDTH = options.maxWidth || 600;
let isResizing = false;
let currentResizer = null;
let currentDrawer = null;
let currentItem = null;
let startX = 0;
let startWidth = 0;
let side = null;
const layoutElement = document.getElementById(layoutId);
const containerElement = document.getElementById(containerId);
if (!layoutElement) {
console.error(`Layout element with ID "${layoutId}" not found`);
if (!containerElement) {
console.error(`Container element with ID "${containerId}" not found`);
return;
}
/**
* Initialize resizer functionality for this layout instance
* Initialize resizer functionality for this container instance
*/
function initResizers() {
const resizers = layoutElement.querySelectorAll('.mf-layout-resizer');
const resizers = containerElement.querySelectorAll('.mf-resizer');
resizers.forEach(resizer => {
// Remove existing listener if any to avoid duplicates
@@ -51,24 +54,24 @@ function initLayoutResizer(layoutId) {
currentResizer = e.target;
side = currentResizer.dataset.side;
currentDrawer = currentResizer.closest('.mf-layout-drawer');
currentItem = currentResizer.parentElement;
if (!currentDrawer) {
console.error('Could not find drawer element');
if (!currentItem) {
console.error('Could not find item element');
return;
}
isResizing = true;
startX = e.clientX;
startWidth = currentDrawer.offsetWidth;
startWidth = currentItem.offsetWidth;
// Add event listeners for mouse move and up
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// Add resizing class for visual feedback
document.body.classList.add('mf-layout-resizing');
currentDrawer.classList.add('mf-layout-drawer-resizing');
document.body.classList.add('mf-resizing');
currentItem.classList.add('mf-item-resizing');
}
/**
@@ -92,8 +95,8 @@ function initLayoutResizer(layoutId) {
// Constrain width between min and max
newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth));
// Update drawer width visually
currentDrawer.style.width = `${newWidth}px`;
// Update item width visually
currentItem.style.width = `${newWidth}px`;
}
/**
@@ -109,11 +112,11 @@ function initLayoutResizer(layoutId) {
document.removeEventListener('mouseup', handleMouseUp);
// Remove resizing classes
document.body.classList.remove('mf-layout-resizing');
currentDrawer.classList.remove('mf-layout-drawer-resizing');
document.body.classList.remove('mf-resizing');
currentItem.classList.remove('mf-item-resizing');
// Get final width
const finalWidth = currentDrawer.offsetWidth;
const finalWidth = currentItem.offsetWidth;
const commandId = currentResizer.dataset.commandId;
if (!commandId) {
@@ -122,24 +125,24 @@ function initLayoutResizer(layoutId) {
}
// Send width update to server
saveDrawerWidth(commandId, finalWidth);
saveWidth(commandId, finalWidth);
// Reset state
currentResizer = null;
currentDrawer = null;
currentItem = null;
side = null;
}
/**
* Save drawer width to server via HTMX
* Save width to server via HTMX
*/
function saveDrawerWidth(commandId, width) {
function saveWidth(commandId, width) {
htmx.ajax('POST', '/myfasthtml/commands', {
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
swap: "outerHTML",
target: `#${currentDrawer.id}`,
target: `#${currentItem.id}`,
values: {
c_id: commandId,
width: width
@@ -150,8 +153,8 @@ function initLayoutResizer(layoutId) {
// Initialize resizers
initResizers();
// Re-initialize after HTMX swaps within this layout
layoutElement.addEventListener('htmx:afterSwap', function (event) {
// Re-initialize after HTMX swaps within this container
containerElement.addEventListener('htmx:afterSwap', function (event) {
initResizers();
});
}

View File

@@ -28,7 +28,6 @@ class Dropdown(MultipleInstance):
self.content = content
self.commands = Commands(self)
self._state = DropdownState()
self._toggle_command = self.commands.toggle()
def toggle(self):
self._state.opened = not self._state.opened
@@ -55,7 +54,7 @@ class Dropdown(MultipleInstance):
self._mk_content(),
cls="mf-dropdown-wrapper"
),
Keyboard(self, "-keyboard").add("esc", self.commands.close()),
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
Mouse(self, "-mouse").add("click", self.commands.click()),
id=self._id
)

View File

@@ -1,3 +1,4 @@
from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.network_utils import from_parent_child_list
@@ -6,9 +7,14 @@ from myfasthtml.core.network_utils import from_parent_child_list
class InstancesDebugger(SingleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self._panel = Panel(self, _id="-panel")
def render(self):
s_name = InstancesManager.get_session_user_name
nodes, edges = self._get_nodes_and_edges()
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
return self._panel.set_main(vis_network)
def _get_nodes_and_edges(self):
instances = self._get_instances()
nodes, edges = from_parent_child_list(
instances,
@@ -23,9 +29,7 @@ class InstancesDebugger(SingleInstance):
for node in nodes:
node["shape"] = "box"
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
# vis_network.add_to_options(physics={"wind": {"x": 0, "y": 1}})
return vis_network
return nodes, edges
def _get_instances(self):
return list(InstancesManager.instances.values())

View File

@@ -7,7 +7,7 @@ from myfasthtml.core.instances import MultipleInstance
class Keyboard(MultipleInstance):
def __init__(self, parent, _id=None, combinations=None):
def __init__(self, parent, combinations=None, _id=None):
super().__init__(parent, _id=_id)
self.combinations = combinations or {}

View File

@@ -123,6 +123,7 @@ class Layout(SingleInstance):
self.header_right = self.Content(self)
self.footer_left = self.Content(self)
self.footer_right = self.Content(self)
self._footer_content = None
def set_footer(self, content):
"""
@@ -141,6 +142,7 @@ class Layout(SingleInstance):
content: FastHTML component(s) or content for the main area
"""
self._main_content = content
return self
def toggle_drawer(self, side: Literal["left", "right"]):
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
@@ -233,7 +235,7 @@ class Layout(SingleInstance):
Div: FastHTML Div component for left drawer
"""
resizer = Div(
cls="mf-layout-resizer mf-layout-resizer-right",
cls="mf-resizer mf-resizer-left",
data_command_id=self.commands.update_drawer_width("left").id,
data_side="left"
)
@@ -266,8 +268,9 @@ class Layout(SingleInstance):
Returns:
Div: FastHTML Div component for right drawer
"""
resizer = Div(
cls="mf-layout-resizer mf-layout-resizer-left",
cls="mf-resizer mf-resizer-right",
data_command_id=self.commands.update_drawer_width("right").id,
data_side="right"
)
@@ -311,7 +314,7 @@ class Layout(SingleInstance):
self._mk_main(),
self._mk_right_drawer(),
self._mk_footer(),
Script(f"initLayoutResizer('{self._id}');"),
Script(f"initResizer('{self._id}');"),
id=self._id,
cls="mf-layout",
)

View File

@@ -0,0 +1,102 @@
from dataclasses import dataclass
from typing import Literal
from fasthtml.components import Div
from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance
@dataclass
class PanelConf:
left: bool = False
right: bool = True
class Commands(BaseCommands):
def toggle_side(self, side: Literal["left", "right"]):
return Command("TogglePanelSide", f"Toggle {side} side panel", self._owner.toggle_side, side)
def update_side_width(self, side: Literal["left", "right"]):
"""
Create a command to update panel's side width.
Args:
side: Which panel's side to update ("left" or "right")
Returns:
Command: Command object for updating panel's side width
"""
return Command(
f"UpdatePanelSideWidth_{side}",
f"Update {side} side panel width",
self._owner.update_side_width,
side
)
class Panel(MultipleInstance):
def __init__(self, parent, conf=None, _id=None):
super().__init__(parent, _id=_id)
self.conf = conf or PanelConf()
self.commands = Commands(self)
self._main = None
self._right = None
self._left = None
def update_side_width(self, side, width):
pass
def toggle_side(self, side):
pass
def set_main(self, main):
self._main = main
return self
def set_right(self, right):
self._right = right
return self
def set_left(self, left):
self._left = left
return self
def _mk_right(self):
if not self.conf.right:
return None
resizer = Div(
cls="mf-resizer mf-resizer-right",
data_command_id=self.commands.update_side_width("right").id,
data_side="right"
)
return Div(resizer, self._right, cls="mf-panel-right")
def _mk_left(self):
if not self.conf.left:
return None
resizer = Div(
cls="mf-resizer mf-resizer-left",
data_command_id=self.commands.update_side_width("left").id,
data_side="left"
)
return Div(self._left, resizer, cls="mf-panel-left")
def render(self):
return Div(
self._mk_left(),
Div(self._main, cls="mf-panel-main"),
self._mk_right(),
Script(f"initResizer('{self._id}');"),
cls="mf-panel",
id=self._id,
)
def __ft__(self):
return self.render()

View File

@@ -0,0 +1,400 @@
"""
TreeView component for hierarchical data visualization with inline editing.
This component provides an interactive tree structure with expand/collapse,
selection, and inline editing capabilities.
"""
import uuid
from dataclasses import dataclass, field
from typing import Optional
from fasthtml.components import Div, Input, Span
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, edit20_regular
from myfasthtml.icons.fluent_p2 import chevron_down20_regular, add_circle20_regular, delete20_regular
@dataclass
class TreeNode:
"""
Represents a node in the tree structure.
Attributes:
id: Unique identifier (auto-generated UUID if not provided)
label: Display text for the node
type: Node type for icon mapping
parent: ID of parent node (None for root)
children: List of child node IDs
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
label: str = ""
type: str = "default"
parent: Optional[str] = None
children: list[str] = field(default_factory=list)
class TreeViewState(DbObject):
"""
Persistent state for TreeView component.
Attributes:
items: Dictionary mapping node IDs to TreeNode instances
opened: List of expanded node IDs
selected: Currently selected node ID
editing: Node ID currently being edited (None if not editing)
icon_config: Mapping of node types to icon identifiers
"""
def __init__(self, owner):
super().__init__(owner)
with self.initializing():
self.items: dict[str, TreeNode] = {}
self.opened: list[str] = []
self.selected: Optional[str] = None
self.editing: Optional[str] = None
self.icon_config: dict[str, str] = {}
class Commands(BaseCommands):
"""Command handlers for TreeView actions."""
def toggle_node(self, node_id: str):
"""Create command to expand/collapse a node."""
return Command(
"ToggleNode",
f"Toggle node {node_id}",
self._owner._toggle_node,
node_id
).htmx(target=f"#{self._owner.get_id()}")
def add_child(self, parent_id: str):
"""Create command to add a child node."""
return Command(
"AddChild",
f"Add child to {parent_id}",
self._owner._add_child,
parent_id
).htmx(target=f"#{self._owner.get_id()}")
def add_sibling(self, node_id: str):
"""Create command to add a sibling node."""
return Command(
"AddSibling",
f"Add sibling to {node_id}",
self._owner._add_sibling,
node_id
).htmx(target=f"#{self._owner.get_id()}")
def start_rename(self, node_id: str):
"""Create command to start renaming a node."""
return Command(
"StartRename",
f"Start renaming {node_id}",
self._owner._start_rename,
node_id
).htmx(target=f"#{self._owner.get_id()}")
def save_rename(self, node_id: str):
"""Create command to save renamed node."""
return Command(
"SaveRename",
f"Save rename for {node_id}",
self._owner._save_rename,
node_id
).htmx(target=f"#{self._owner.get_id()}")
def cancel_rename(self):
"""Create command to cancel renaming."""
return Command(
"CancelRename",
"Cancel rename",
self._owner._cancel_rename
).htmx(target=f"#{self._owner.get_id()}")
def delete_node(self, node_id: str):
"""Create command to delete a node."""
return Command(
"DeleteNode",
f"Delete node {node_id}",
self._owner._delete_node,
node_id
).htmx(target=f"#{self._owner.get_id()}")
def select_node(self, node_id: str):
"""Create command to select a node."""
return Command(
"SelectNode",
f"Select node {node_id}",
self._owner._select_node,
node_id
).htmx(target=f"#{self._owner.get_id()}")
class TreeView(MultipleInstance):
"""
Interactive TreeView component with hierarchical data visualization.
Supports:
- Expand/collapse nodes
- Add child/sibling nodes
- Inline rename
- Delete nodes
- Node selection
"""
def __init__(self, parent, items: Optional[dict] = None, _id: Optional[str] = None):
"""
Initialize TreeView component.
Args:
parent: Parent instance
items: Optional initial items dictionary {node_id: TreeNode}
_id: Optional custom ID
"""
super().__init__(parent, _id=_id)
self._state = TreeViewState(self)
self.commands = Commands(self)
if items:
self._state.items = items
def set_icon_config(self, config: dict[str, str]):
"""
Set icon configuration for node types.
Args:
config: Dictionary mapping node types to icon identifiers
Format: {type: "provider.icon_name"}
"""
self._state.icon_config = config
def add_node(self, node: TreeNode, parent_id: Optional[str] = None, insert_index: Optional[int] = None):
"""
Add a node to the tree.
Args:
node: TreeNode instance to add
parent_id: Optional parent node ID (None for root)
insert_index: Optional index to insert at in parent's children list.
If None, appends to end. If provided, inserts at that position.
"""
self._state.items[node.id] = node
node.parent = parent_id
if parent_id and parent_id in self._state.items:
parent = self._state.items[parent_id]
if node.id not in parent.children:
if insert_index is not None:
parent.children.insert(insert_index, node.id)
else:
parent.children.append(node.id)
def expand_all(self):
"""Expand all nodes that have children."""
for node_id, node in self._state.items.items():
if node.children and node_id not in self._state.opened:
self._state.opened.append(node_id)
def _toggle_node(self, node_id: str):
"""Toggle expand/collapse state of a node."""
if node_id in self._state.opened:
self._state.opened.remove(node_id)
else:
self._state.opened.append(node_id)
return self
def _add_child(self, parent_id: str, new_label: Optional[str] = None):
"""Add a child node to a parent."""
if parent_id not in self._state.items:
raise ValueError(f"Parent node {parent_id} does not exist")
parent = self._state.items[parent_id]
new_node = TreeNode(
label=new_label or "New Node",
type=parent.type
)
self.add_node(new_node, parent_id=parent_id)
# Auto-expand parent
if parent_id not in self._state.opened:
self._state.opened.append(parent_id)
return self
def _add_sibling(self, node_id: str, new_label: Optional[str] = None):
"""Add a sibling node next to a node."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
node = self._state.items[node_id]
if node.parent is None:
raise ValueError("Cannot add sibling to root node")
parent = self._state.items[node.parent]
new_node = TreeNode(
label=new_label or "New Node",
type=node.type
)
# Insert after current node
insert_idx = parent.children.index(node_id) + 1
self.add_node(new_node, parent_id=node.parent, insert_index=insert_idx)
return self
def _start_rename(self, node_id: str):
"""Start renaming a node (sets editing state)."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
self._state.editing = node_id
return self
def _save_rename(self, node_id: str, node_label: str):
"""Save renamed node with new label."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
self._state.items[node_id].label = node_label
self._state.editing = None
return self
def _cancel_rename(self):
"""Cancel renaming operation."""
self._state.editing = None
return self
def _delete_node(self, node_id: str):
"""Delete a node (only if it has no children)."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
node = self._state.items[node_id]
if node.children:
raise ValueError(f"Cannot delete node {node_id} with children")
# Remove from parent's children list
if node.parent and node.parent in self._state.items:
parent = self._state.items[node.parent]
parent.children.remove(node_id)
# Remove from state
del self._state.items[node_id]
if node_id in self._state.opened:
self._state.opened.remove(node_id)
if self._state.selected == node_id:
self._state.selected = None
return self
def _select_node(self, node_id: str):
"""Select a node."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
self._state.selected = node_id
return self
def _render_action_buttons(self, node_id: str):
"""Render action buttons for a node (visible on hover)."""
return Div(
mk.icon(add_circle20_regular, command=self.commands.add_child(node_id)),
mk.icon(edit20_regular, command=self.commands.start_rename(node_id)),
mk.icon(delete20_regular, command=self.commands.delete_node(node_id)),
cls="mf-treenode-actions"
)
def _render_node(self, node_id: str, level: int = 0):
"""
Render a single node and its children recursively.
Args:
node_id: ID of node to render
level: Indentation level
Returns:
Div containing the node and its children
"""
node = self._state.items[node_id]
is_expanded = node_id in self._state.opened
is_selected = node_id == self._state.selected
is_editing = node_id == self._state.editing
has_children = len(node.children) > 0
# Toggle icon
toggle = mk.icon(
chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else " ",
command=self.commands.toggle_node(node_id))
# Label or input for editing
if is_editing:
# TODO: Bind input to save_rename (Enter) and cancel_rename (Escape)
label_element = mk.mk(Input(
name="node_label",
value=node.label,
cls="mf-treenode-input input input-sm"
), command=self.commands.save_rename(node_id))
else:
label_element = mk.mk(
Span(node.label, cls="mf-treenode-label text-sm"),
command=self.commands.select_node(node_id)
)
# Node element
node_element = Div(
toggle,
label_element,
self._render_action_buttons(node_id),
cls=f"mf-treenode flex {'selected' if is_selected and not is_editing else ''}",
data_node_id=node_id,
style=f"padding-left: {level * 20}px"
)
# Children (if expanded)
children_elements = []
if is_expanded and has_children:
for child_id in node.children:
children_elements.append(
self._render_node(child_id, level + 1)
)
return Div(
node_element,
*children_elements,
cls="mf-treenode-container"
)
def render(self):
"""
Render the complete TreeView.
Returns:
Div: Complete TreeView HTML structure
"""
# Find root nodes (nodes without parent)
root_nodes = [
node_id for node_id, node in self._state.items.items()
if node.parent is None
]
return Div(
*[self._render_node(node_id) for node_id in root_nodes],
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="_keyboard"),
id=self._id,
cls="mf-treeview"
)
def __ft__(self):
"""FastHTML magic method for rendering."""
return self.render()

View File

@@ -15,6 +15,19 @@ class mk:
@staticmethod
def button(element, command: Command = None, binding: Binding = None, **kwargs):
"""
Defines a static method for creating a Button object with specific configurations.
This method constructs a Button instance by wrapping an element with
additional configurations such as commands and bindings. Any extra keyword
arguments are passed when creating the Button.
:param element: The underlying widget or element to be wrapped in a Button.
:param command: An optional command to associate with the Button. Defaults to None.
:param binding: An optional event binding to associate with the Button. Defaults to None.
:param kwargs: Additional keyword arguments to further configure the Button.
:return: A fully constructed Button instance with the specified configurations.
"""
return mk.mk(Button(element, **kwargs), command=command, binding=binding)
@staticmethod
@@ -33,13 +46,33 @@ class mk:
)
@staticmethod
def icon(icon, size=20,
def icon(icon,
size=20,
can_select=True,
can_hover=False,
cls='',
command: Command = None,
binding: Binding = None,
**kwargs):
"""
Generates an icon element with customizable properties for size, class, and interactivity.
This method creates an icon element wrapped in a container with optional classes
and event bindings. The icon can be styled and its behavior defined using the parameters
provided, allowing for dynamic and reusable UI components.
:param icon: The icon to display inside the container.
:param size: The size of the icon, specified in pixels. Defaults to 20.
:param can_select: Indicates whether the icon can be selected. Defaults to True.
:param can_hover: Indicates whether the icon reacts to hovering. Defaults to False.
:param cls: A string of custom CSS classes to be added to the icon container.
:param command: The command object defining the function to be executed on icon interaction.
:param binding: The binding object for configuring additional event listeners on the icon.
:param kwargs: Additional keyword arguments for configuring attributes and behaviors of the
icon element.
:return: A styled and interactive icon element embedded inside a container, configured
with the defined classes, size, and behaviors.
"""
merged_cls = merge_classes(f"mf-icon-{size}",
'icon-btn' if can_select else '',
'mmt-btn' if can_hover else '',

View File

@@ -10,6 +10,7 @@ from myfasthtml.core.utils import retrieve_user_info
class DbManager(SingleInstance):
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
super().__init__(parent, auto_register=auto_register)
self.db = DbEngine(root=root)
def save(self, entry, obj):

View File

@@ -6,6 +6,8 @@ from fastcore.basics import NotStr
from myfasthtml.core.utils import quoted_str
from myfasthtml.test.testclient import MyFT
MISSING_ATTR = "** MISSING **"
class Predicate:
def __init__(self, value):
@@ -114,6 +116,18 @@ class AttributeForbidden(ChildrenPredicate):
return element
class TestObject:
def __init__(self, cls, **kwargs):
self.cls = cls
self.attrs = kwargs
class TestCommand(TestObject):
def __init__(self, name, **kwargs):
super().__init__("Command", **kwargs)
self.attrs = {"name": name} | kwargs # name should be first
@dataclass
class DoNotCheck:
desc: str = None
@@ -187,6 +201,16 @@ class ErrorOutput:
self.indent = self.indent[:-2]
self._add_to_output(")")
elif isinstance(self.expected, TestObject):
cls = _mytype(self.element)
attrs = {attr_name: _mygetattr(self.element, attr_name) for attr_name in self.expected.attrs}
self._add_to_output(f"({cls} {_str_attrs(attrs)})")
# Try to show where the differences are
error_str = self._detect_error_2(self.element, self.expected)
if error_str:
self._add_to_output(error_str)
else:
self._add_to_output(str(self.element))
# Try to show where the differences are
@@ -205,7 +229,7 @@ class ErrorOutput:
if hasattr(element, "tag"):
# the attributes are compared to the expected element
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in
[attr_name for attr_name in expected.attrs if attr_name is not None]}
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
@@ -228,7 +252,7 @@ class ErrorOutput:
def _detect_error(self, element, expected):
if hasattr(expected, "tag") and hasattr(element, "tag"):
tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^")
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in expected.attrs}
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in expected.attrs}
attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if
not self._matches(attr_value, expected.attrs[attr_name])]
if attrs_in_error:
@@ -242,6 +266,35 @@ class ErrorOutput:
return None
def _detect_error_2(self, element, expected):
"""
Too lazy to refactor original _detect_error
:param element:
:param expected:
:return:
"""
if hasattr(expected, "tag") or isinstance(expected, TestObject):
element_cls = _mytype(element)
expected_cls = _mytype(expected)
str_tag_error = (" " if self._matches(element_cls, expected_cls) else "^") * len(element_cls)
element_attrs = {attr_name: _mygetattr(element, attr_name) for attr_name in expected.attrs}
expected_attrs = {attr_name: _mygetattr(expected, attr_name) for attr_name in expected.attrs}
attrs_in_error = {attr_name for attr_name, attr_value in element_attrs.items() if
not self._matches(attr_value, expected_attrs[attr_name])}
str_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
for name, value in element_attrs.items())
if str_attrs_error.strip() or str_tag_error.strip():
return f" {str_tag_error} {str_attrs_error}"
else:
return None
else:
if not self._matches(element, expected):
return len(str(element)) * "^"
return None
@staticmethod
def _matches(element, expected):
if element == expected:
@@ -347,6 +400,34 @@ def matches(actual, expected, path=""):
# set the path
path += "." + _get_current_path(actual) if path else _get_current_path(actual)
if isinstance(expected, TestObject):
assert _mytype(actual) == _mytype(expected), _error_msg("The types are different: ",
_actual=actual,
_expected=expected)
for attr, value in expected.attrs.items():
assert hasattr(actual, attr), _error_msg(f"'{attr}' is not found in Actual.",
_actual=actual,
_expected=expected)
try:
matches(getattr(actual, attr), value)
except AssertionError as e:
match = re.search(r"Error : (.+?)\n", str(e))
if match:
assert False, _error_msg(f"{match.group(1)} for '{attr}':",
_actual=getattr(actual, attr),
_expected=value)
assert False, _error_msg(f"The values are different for '{attr}': ",
_actual=getattr(actual, attr),
_expected=value)
return True
if isinstance(expected, Predicate):
assert expected.validate(actual), \
_error_msg(f"The condition '{expected}' is not satisfied.",
_actual=actual,
_expected=expected)
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
_error_msg("The types are different: ", _actual=actual, _expected=expected)
@@ -359,6 +440,14 @@ def matches(actual, expected, path=""):
for actual_child, expected_child in zip(actual, expected):
assert matches(actual_child, expected_child, path=path)
elif isinstance(expected, dict):
if len(actual) < len(expected):
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
if len(actual) > len(expected):
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
for k, v in expected.items():
assert matches(actual[k], v, path=f"{path}[{k}={v}]")
elif isinstance(expected, NotStr):
to_compare = actual.s.lstrip('\n').lstrip()
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
@@ -404,8 +493,9 @@ def matches(actual, expected, path=""):
for actual_child, expected_child in zip(actual.children, expected_children):
assert matches(actual_child, expected_child, path=path)
else:
assert actual == expected, _error_msg("The values are different: ",
assert actual == expected, _error_msg("The values are different",
_actual=actual,
_expected=expected)
@@ -466,3 +556,25 @@ def find(ft, expected):
raise AssertionError(f"No element found for '{expected}'")
return res
def _mytype(x):
if hasattr(x, "tag"):
return x.tag
if isinstance(x, TestObject):
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
return type(x).__name__
def _mygetattr(x, attr):
if hasattr(x, "attrs"):
return x.attrs.get(attr, MISSING_ATTR)
if not hasattr(x, attr):
return MISSING_ATTR
return getattr(x, attr, MISSING_ATTR)
def _str_attrs(attrs: dict):
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())