Added Panel.py

This commit is contained in:
2025-11-29 18:13:46 +01:00
parent 3271aa0d61
commit 659c4e1d4b
8 changed files with 302 additions and 47 deletions

View File

@@ -14,6 +14,7 @@ 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
@@ -73,7 +74,7 @@ def create_sample_treeview(parent):
tree_view.add_node(todo, parent_id=documents.id)
# Expand all nodes to show the full structure
tree_view.expand_all()
#tree_view.expand_all()
return tree_view
@@ -98,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())

View File

@@ -492,6 +492,7 @@
transition: background-color 0.15s ease;
border-radius: 0.25rem;
}
/* Input for Editing */
.mf-treenode-input {
flex: 1;
@@ -503,7 +504,6 @@
}
.mf-treenode:hover {
background-color: var(--color-base-200);
}
@@ -544,4 +544,131 @@
.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

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

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

@@ -409,11 +409,25 @@ def matches(actual, expected, path=""):
assert hasattr(actual, attr), _error_msg(f"'{attr}' is not found in Actual.",
_actual=actual,
_expected=expected)
assert matches(getattr(actual, attr), value), _error_msg(f"The values are different for '{attr}': ",
_actual=getattr(actual, attr),
_expected=value)
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)
@@ -481,7 +495,7 @@ def matches(actual, expected, path=""):
else:
assert actual == expected, _error_msg("The values are different: ",
assert actual == expected, _error_msg("The values are different",
_actual=actual,
_expected=expected)

View File

@@ -93,6 +93,7 @@ class TestMatches:
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr3="value3")), "'attr3' is not found in Actual"),
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "are different for 'attr2'"),
(Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different:"),
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
])
def test_i_can_detect_errors(self, actual, expected, error_message):