diff --git a/src/app.py b/src/app.py index 1cee4da..d605152 100644 --- a/src/app.py +++ b/src/app.py @@ -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()) diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css index 3e7568f..9bfe73f 100644 --- a/src/myfasthtml/assets/myfasthtml.css +++ b/src/myfasthtml/assets/myfasthtml.css @@ -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; -} \ No newline at end of file +} + +/* *********************************************** */ +/* ********** 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); +} diff --git a/src/myfasthtml/assets/myfasthtml.js b/src/myfasthtml/assets/myfasthtml.js index d0b0e5f..41715a9 100644 --- a/src/myfasthtml/assets/myfasthtml.js +++ b/src/myfasthtml/assets/myfasthtml.js @@ -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(); }); } diff --git a/src/myfasthtml/controls/InstancesDebugger.py b/src/myfasthtml/controls/InstancesDebugger.py index fcec443..cda50e1 100644 --- a/src/myfasthtml/controls/InstancesDebugger.py +++ b/src/myfasthtml/controls/InstancesDebugger.py @@ -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()) diff --git a/src/myfasthtml/controls/Layout.py b/src/myfasthtml/controls/Layout.py index 264329f..0c00d0a 100644 --- a/src/myfasthtml/controls/Layout.py +++ b/src/myfasthtml/controls/Layout.py @@ -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", ) diff --git a/src/myfasthtml/controls/Panel.py b/src/myfasthtml/controls/Panel.py new file mode 100644 index 0000000..66a2bbf --- /dev/null +++ b/src/myfasthtml/controls/Panel.py @@ -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() diff --git a/src/myfasthtml/test/matcher.py b/src/myfasthtml/test/matcher.py index b127fcb..5b313cb 100644 --- a/src/myfasthtml/test/matcher.py +++ b/src/myfasthtml/test/matcher.py @@ -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) diff --git a/tests/testclient/test_matches.py b/tests/testclient/test_matches.py index b4db012..b24fdf5 100644 --- a/tests/testclient/test_matches.py +++ b/tests/testclient/test_matches.py @@ -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):