Added TreeView and Panel
This commit is contained in:
56
src/app.py
56
src/app.py
@@ -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")))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
102
src/myfasthtml/controls/Panel.py
Normal file
102
src/myfasthtml/controls/Panel.py
Normal 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()
|
||||
400
src/myfasthtml/controls/TreeView.py
Normal file
400
src/myfasthtml/controls/TreeView.py
Normal 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()
|
||||
@@ -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 '',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user