I can add and show tabs with lazy loading and content management
This commit is contained in:
18
src/app.py
18
src/app.py
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging.config
|
||||||
|
|
||||||
|
import yaml
|
||||||
from fasthtml import serve
|
from fasthtml import serve
|
||||||
from fasthtml.components import *
|
|
||||||
|
|
||||||
from myfasthtml.controls.Layout import Layout
|
from myfasthtml.controls.Layout import Layout
|
||||||
from myfasthtml.controls.TabsManager import TabsManager
|
from myfasthtml.controls.TabsManager import TabsManager
|
||||||
@@ -10,15 +10,16 @@ from myfasthtml.core.commands import Command
|
|||||||
from myfasthtml.core.instances import InstancesManager
|
from myfasthtml.core.instances import InstancesManager
|
||||||
from myfasthtml.myfastapp import create_app
|
from myfasthtml.myfastapp import create_app
|
||||||
|
|
||||||
logging.basicConfig(
|
with open('logging.yaml', 'r') as f:
|
||||||
level=logging.DEBUG, # Set logging level to DEBUG
|
config = yaml.safe_load(f)
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format
|
|
||||||
datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format
|
# At the top of your script or module
|
||||||
)
|
logging.config.dictConfig(config)
|
||||||
|
|
||||||
app, rt = create_app(protect_routes=True,
|
app, rt = create_app(protect_routes=True,
|
||||||
mount_auth_app=True,
|
mount_auth_app=True,
|
||||||
pico=False,
|
pico=False,
|
||||||
|
vis=True,
|
||||||
title="MyFastHtml",
|
title="MyFastHtml",
|
||||||
live=True,
|
live=True,
|
||||||
base_url="http://localhost:5003")
|
base_url="http://localhost:5003")
|
||||||
@@ -28,9 +29,6 @@ app, rt = create_app(protect_routes=True,
|
|||||||
def index(session):
|
def index(session):
|
||||||
layout = InstancesManager.get(session, Ids.Layout, Layout, "Testing Layout")
|
layout = InstancesManager.get(session, Ids.Layout, Layout, "Testing Layout")
|
||||||
layout.set_footer("Goodbye World")
|
layout.set_footer("Goodbye World")
|
||||||
for i in range(50):
|
|
||||||
layout.left_drawer.add(Div(f"Left Drawer Item {i}"))
|
|
||||||
layout.right_drawer.add(Div(f"Left Drawer Item {i}"))
|
|
||||||
|
|
||||||
tabs_manager = TabsManager(session, _id="main")
|
tabs_manager = TabsManager(session, _id="main")
|
||||||
|
|
||||||
|
|||||||
54
src/logging.yaml
Normal file
54
src/logging.yaml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
version: 1
|
||||||
|
disable_existing_loggers: False
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
default:
|
||||||
|
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
|
||||||
|
handlers:
|
||||||
|
console:
|
||||||
|
class: logging.StreamHandler
|
||||||
|
formatter: default
|
||||||
|
|
||||||
|
root:
|
||||||
|
level: DEBUG
|
||||||
|
handlers: [console]
|
||||||
|
|
||||||
|
|
||||||
|
loggers:
|
||||||
|
# Explicit logger configuration (example)
|
||||||
|
multipart.multipart:
|
||||||
|
level: INFO
|
||||||
|
handlers: [console]
|
||||||
|
propagate: False
|
||||||
|
python_multipart.multipart:
|
||||||
|
level: INFO
|
||||||
|
handlers: [ console ]
|
||||||
|
propagate: False
|
||||||
|
httpcore:
|
||||||
|
level: ERROR
|
||||||
|
handlers: [ console ]
|
||||||
|
propagate: False
|
||||||
|
httpx:
|
||||||
|
level: INFO
|
||||||
|
handlers: [ console ]
|
||||||
|
propagate: False
|
||||||
|
watchfiles.main:
|
||||||
|
level: ERROR
|
||||||
|
handlers: [console]
|
||||||
|
propagate: False
|
||||||
|
|
||||||
|
dbengine.dbengine:
|
||||||
|
level: ERROR
|
||||||
|
handlers: [console]
|
||||||
|
propagate: False
|
||||||
|
|
||||||
|
passlib.registry:
|
||||||
|
level: ERROR
|
||||||
|
handlers: [console]
|
||||||
|
propagate: False
|
||||||
|
|
||||||
|
socket.socket:
|
||||||
|
level: ERROR
|
||||||
|
handlers: [ console ]
|
||||||
|
propagate: False
|
||||||
@@ -380,6 +380,16 @@
|
|||||||
|
|
||||||
/* Tab Content Area */
|
/* Tab Content Area */
|
||||||
.mf-tab-content {
|
.mf-tab-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
height: 100%;
|
||||||
|
border: 2px solid black;
|
||||||
|
/*background-color: var(--color-base-100);*/
|
||||||
|
/*padding: 1rem;*/
|
||||||
|
/*border-top: 1px solid var(--color-border-primary);*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-tab-content-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
@@ -396,3 +406,8 @@
|
|||||||
@apply text-base-content/50;
|
@apply text-base-content/50;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mf-vis {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -207,3 +207,63 @@ function initBoundaries(elementId, updateUrl) {
|
|||||||
observer.observe(container.parentNode, {childList: true});
|
observer.observe(container.parentNode, {childList: true});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the tabs display by showing the active tab content and scrolling to make it visible.
|
||||||
|
* This function is called when switching between tabs to update both the content visibility
|
||||||
|
* and the tab button states.
|
||||||
|
*
|
||||||
|
* @param {string} controllerId - The ID of the tabs controller element (format: "{managerId}-content-controller")
|
||||||
|
*/
|
||||||
|
function updateTabs(controllerId) {
|
||||||
|
const controller = document.getElementById(controllerId);
|
||||||
|
if (!controller) {
|
||||||
|
console.warn(`Controller ${controllerId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTabId = controller.dataset.activeTab;
|
||||||
|
if (!activeTabId) {
|
||||||
|
console.warn('No active tab ID found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract manager ID from controller ID (remove '-content-controller' suffix)
|
||||||
|
const managerId = controllerId.replace('-content-controller', '');
|
||||||
|
|
||||||
|
// Hide all tab contents for this manager
|
||||||
|
const contentWrapper = document.getElementById(`${managerId}-content-wrapper`);
|
||||||
|
if (contentWrapper) {
|
||||||
|
contentWrapper.querySelectorAll('.mf-tab-content').forEach(content => {
|
||||||
|
content.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show the active tab content
|
||||||
|
const activeContent = document.getElementById(`${managerId}-${activeTabId}-content`);
|
||||||
|
if (activeContent) {
|
||||||
|
activeContent.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update active tab button styling
|
||||||
|
const header = document.getElementById(`${managerId}-header`);
|
||||||
|
if (header) {
|
||||||
|
// Remove active class from all tabs
|
||||||
|
header.querySelectorAll('.mf-tab-button').forEach(btn => {
|
||||||
|
btn.classList.remove('mf-tab-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add active class to current tab
|
||||||
|
const activeButton = header.querySelector(`[data-tab-id="${activeTabId}"]`);
|
||||||
|
if (activeButton) {
|
||||||
|
activeButton.classList.add('mf-tab-active');
|
||||||
|
|
||||||
|
// Scroll to make active tab visible if needed
|
||||||
|
activeButton.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest',
|
||||||
|
inline: 'nearest'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/myfasthtml/assets/vis-network.min.js
vendored
Normal file
34
src/myfasthtml/assets/vis-network.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,17 +1,39 @@
|
|||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fasthtml.common import Div, Span
|
from fasthtml.common import Div, Span
|
||||||
|
from fasthtml.xtend import Script
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import Ids, mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
||||||
|
from myfasthtml.core.instances_helper import InstancesHelper
|
||||||
from myfasthtml.icons.fluent_p1 import tabs24_regular
|
from myfasthtml.icons.fluent_p1 import tabs24_regular
|
||||||
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular, tab_add24_regular
|
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular, tab_add24_regular
|
||||||
|
|
||||||
|
logger = logging.getLogger("TabsManager")
|
||||||
|
|
||||||
|
vis_nodes = [
|
||||||
|
{"id": 1, "label": "Node 1"},
|
||||||
|
{"id": 2, "label": "Node 2"},
|
||||||
|
{"id": 3, "label": "Node 3"},
|
||||||
|
{"id": 4, "label": "Node 4"},
|
||||||
|
{"id": 5, "label": "Node 5"}
|
||||||
|
]
|
||||||
|
|
||||||
|
vis_edges = [
|
||||||
|
{"from": 1, "to": 3},
|
||||||
|
{"from": 1, "to": 2},
|
||||||
|
{"from": 2, "to": 4},
|
||||||
|
{"from": 2, "to": 5},
|
||||||
|
{"from": 3, "to": 3}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Boundaries:
|
class Boundaries:
|
||||||
@@ -37,7 +59,7 @@ class Commands(BaseCommands):
|
|||||||
def show_tab(self, tab_id):
|
def show_tab(self, tab_id):
|
||||||
return Command(f"{self._prefix}ShowTab",
|
return Command(f"{self._prefix}ShowTab",
|
||||||
"Activate or show a specific tab",
|
"Activate or show a specific tab",
|
||||||
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
|
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}-content-controller", swap="outerHTML")
|
||||||
|
|
||||||
def close_tab(self, tab_id):
|
def close_tab(self, tab_id):
|
||||||
return Command(f"{self._prefix}CloseTab",
|
return Command(f"{self._prefix}CloseTab",
|
||||||
@@ -45,9 +67,10 @@ class Commands(BaseCommands):
|
|||||||
self._owner.close_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
|
self._owner.close_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
|
||||||
|
|
||||||
def add_tab(self, label: str, component: Any, auto_increment=False):
|
def add_tab(self, label: str, component: Any, auto_increment=False):
|
||||||
return Command(f"{self._prefix}AddTab",
|
return (Command(f"{self._prefix}AddTab",
|
||||||
"Add a new tab",
|
"Add a new tab",
|
||||||
self._owner.on_new_tab, label, component, auto_increment).htmx(target=f"#{self._id}")
|
self._owner.on_new_tab, label, component, auto_increment).
|
||||||
|
htmx(target=f"#{self._id}-content-controller"))
|
||||||
|
|
||||||
def update_boundaries(self):
|
def update_boundaries(self):
|
||||||
return Command(f"{self._prefix}UpdateBoundaries",
|
return Command(f"{self._prefix}UpdateBoundaries",
|
||||||
@@ -73,107 +96,23 @@ class TabsManager(MultipleInstance):
|
|||||||
self._state = TabsManagerState(self)
|
self._state = TabsManagerState(self)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self._boundaries = Boundaries()
|
self._boundaries = Boundaries()
|
||||||
|
logger.debug(f"TabsManager created with id: {self._id}")
|
||||||
|
logger.debug(f" tabs : {self._get_ordered_tabs()}")
|
||||||
|
logger.debug(f" active tab : {self._state.active_tab}")
|
||||||
|
|
||||||
def get_state(self):
|
def get_state(self):
|
||||||
return self._state
|
return self._state
|
||||||
|
|
||||||
def _estimate_tab_width(self, label: str) -> int:
|
def _get_ordered_tabs(self):
|
||||||
"""
|
return {tab_id: self._state.tabs.get(tab_id, None) for tab_id in self._state.tabs_order}
|
||||||
Estimate the width of a tab based on its label.
|
|
||||||
|
|
||||||
Args:
|
def _get_tab_content(self, tab_id):
|
||||||
label: Tab label text
|
if tab_id not in self._state.tabs:
|
||||||
|
return None
|
||||||
Returns:
|
tab_config = self._state.tabs[tab_id]
|
||||||
Estimated width in pixels
|
if tab_config["component_type"] is None:
|
||||||
"""
|
return None
|
||||||
char_width = len(label) * self.TAB_CHAR_WIDTH
|
return InstancesHelper.dynamic_get(self._session, tab_config["component_type"], tab_config["component_id"])
|
||||||
total_width = char_width + self.TAB_PADDING
|
|
||||||
return max(total_width, self.TAB_MIN_WIDTH)
|
|
||||||
|
|
||||||
def _calculate_visible_tabs(self) -> tuple[list[str], list[str]]:
|
|
||||||
"""
|
|
||||||
Calculate which tabs should be visible and which should be in dropdown.
|
|
||||||
Priority: active tab + adjacent tabs, then others.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (visible_tab_ids, hidden_tab_ids)
|
|
||||||
"""
|
|
||||||
if self._boundaries.width == 0:
|
|
||||||
# No width info yet, show all tabs
|
|
||||||
return self._state.tabs_order.copy(), []
|
|
||||||
|
|
||||||
available_width = self._boundaries.width - self.DROPDOWN_BTN_WIDTH
|
|
||||||
visible_tabs = []
|
|
||||||
hidden_tabs = []
|
|
||||||
|
|
||||||
# Find active tab index
|
|
||||||
active_index = -1
|
|
||||||
if self._state.active_tab in self._state.tabs_order:
|
|
||||||
active_index = self._state.tabs_order.index(self._state.active_tab)
|
|
||||||
|
|
||||||
# Calculate widths for all tabs
|
|
||||||
tab_widths = {}
|
|
||||||
for tab_id in self._state.tabs_order:
|
|
||||||
label = self._state.tabs[tab_id].get('label', 'Untitled')
|
|
||||||
tab_widths[tab_id] = self._estimate_tab_width(label)
|
|
||||||
|
|
||||||
# Strategy: Start with active tab, then add adjacent tabs alternating left/right
|
|
||||||
used_width = 0
|
|
||||||
added_indices = set()
|
|
||||||
|
|
||||||
# Always add active tab first if it exists
|
|
||||||
if active_index >= 0:
|
|
||||||
active_tab_id = self._state.tabs_order[active_index]
|
|
||||||
used_width += tab_widths[active_tab_id]
|
|
||||||
if used_width <= available_width:
|
|
||||||
visible_tabs.append(active_tab_id)
|
|
||||||
added_indices.add(active_index)
|
|
||||||
else:
|
|
||||||
# Even active tab doesn't fit, add to hidden
|
|
||||||
hidden_tabs.append(active_tab_id)
|
|
||||||
|
|
||||||
# Add adjacent tabs alternating left and right
|
|
||||||
left_offset = 1
|
|
||||||
right_offset = 1
|
|
||||||
|
|
||||||
while used_width < available_width and len(added_indices) < len(self._state.tabs_order):
|
|
||||||
added_this_round = False
|
|
||||||
|
|
||||||
# Try to add tab to the right
|
|
||||||
right_index = active_index + right_offset
|
|
||||||
if right_index < len(self._state.tabs_order) and right_index not in added_indices:
|
|
||||||
tab_id = self._state.tabs_order[right_index]
|
|
||||||
tab_width = tab_widths[tab_id]
|
|
||||||
if used_width + tab_width <= available_width:
|
|
||||||
visible_tabs.append(tab_id)
|
|
||||||
added_indices.add(right_index)
|
|
||||||
used_width += tab_width
|
|
||||||
added_this_round = True
|
|
||||||
right_offset += 1
|
|
||||||
|
|
||||||
# Try to add tab to the left
|
|
||||||
left_index = active_index - left_offset
|
|
||||||
if left_index >= 0 and left_index not in added_indices:
|
|
||||||
tab_id = self._state.tabs_order[left_index]
|
|
||||||
tab_width = tab_widths[tab_id]
|
|
||||||
if used_width + tab_width <= available_width:
|
|
||||||
visible_tabs.insert(0, tab_id) # Insert at beginning to maintain order
|
|
||||||
added_indices.add(left_index)
|
|
||||||
used_width += tab_width
|
|
||||||
added_this_round = True
|
|
||||||
left_offset += 1
|
|
||||||
|
|
||||||
# If we couldn't add any tab this round, we're done
|
|
||||||
if not added_this_round:
|
|
||||||
break
|
|
||||||
|
|
||||||
# All remaining tabs go to hidden
|
|
||||||
for i, tab_id in enumerate(self._state.tabs_order):
|
|
||||||
if i not in added_indices:
|
|
||||||
hidden_tabs.append(tab_id)
|
|
||||||
|
|
||||||
return visible_tabs, hidden_tabs
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_tab_count():
|
def _get_tab_count():
|
||||||
@@ -182,10 +121,16 @@ class TabsManager(MultipleInstance):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
def on_new_tab(self, label: str, component: Any, auto_increment=False):
|
def on_new_tab(self, label: str, component: Any, auto_increment=False):
|
||||||
|
logger.debug(f"on_new_tab {label=}, {component=}, {auto_increment=}")
|
||||||
if auto_increment:
|
if auto_increment:
|
||||||
label = f"{label}_{self._get_tab_count()}"
|
label = f"{label}_{self._get_tab_count()}"
|
||||||
self.add_tab(label, component)
|
tab_id = self.add_tab(label, component)
|
||||||
return self
|
component = component or VisNetwork(self._session, nodes=vis_nodes, edges=vis_edges)
|
||||||
|
return (
|
||||||
|
self._mk_tabs_controller(),
|
||||||
|
self._wrap_tab_content(self._mk_tab_content(tab_id, component)),
|
||||||
|
self._mk_tabs_header(True),
|
||||||
|
)
|
||||||
|
|
||||||
def add_tab(self, label: str, component: Any, activate: bool = True) -> str:
|
def add_tab(self, label: str, component: Any, activate: bool = True) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -199,13 +144,14 @@ class TabsManager(MultipleInstance):
|
|||||||
Returns:
|
Returns:
|
||||||
tab_id: The UUID of the tab (new or existing)
|
tab_id: The UUID of the tab (new or existing)
|
||||||
"""
|
"""
|
||||||
|
logger.debug(f"add_tab {label=}, component={component}, activate={activate}")
|
||||||
# copy the state to avoid multiple database call
|
# copy the state to avoid multiple database call
|
||||||
state = self._state.copy()
|
state = self._state.copy()
|
||||||
|
|
||||||
# Extract component ID if the component has a get_id() method
|
# Extract component ID if the component has a get_id() method
|
||||||
component_type, component_id = None, None
|
component_type, component_id = None, None
|
||||||
if isinstance(component, BaseInstance):
|
if isinstance(component, BaseInstance):
|
||||||
component_type = type(component).__name__
|
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
|
||||||
component_id = component.get_id()
|
component_id = component.get_id()
|
||||||
|
|
||||||
# Check if a tab with the same component_type, component_id AND label already exists
|
# Check if a tab with the same component_type, component_id AND label already exists
|
||||||
@@ -249,11 +195,23 @@ class TabsManager(MultipleInstance):
|
|||||||
return tab_id
|
return tab_id
|
||||||
|
|
||||||
def show_tab(self, tab_id):
|
def show_tab(self, tab_id):
|
||||||
|
logger.debug(f"show_tab {tab_id=}")
|
||||||
if tab_id not in self._state.tabs:
|
if tab_id not in self._state.tabs:
|
||||||
|
logger.debug(f" Tab not found.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
logger.debug(f" Tab label is: {self._state.tabs[tab_id]['label']}")
|
||||||
self._state.active_tab = tab_id
|
self._state.active_tab = tab_id
|
||||||
return self
|
|
||||||
|
if tab_id not in self._state._tabs_content:
|
||||||
|
logger.debug(f" Content does not exist. Creating it.")
|
||||||
|
content = self._get_tab_content(tab_id)
|
||||||
|
tab_content = self._mk_tab_content(tab_id, content)
|
||||||
|
self._state._tabs_content[tab_id] = tab_content
|
||||||
|
return self._mk_tabs_controller(), self._wrap_tab_content(tab_content)
|
||||||
|
else:
|
||||||
|
logger.debug(f" Content already exists. Just switch.")
|
||||||
|
return self._mk_tabs_controller()
|
||||||
|
|
||||||
def close_tab(self, tab_id: str):
|
def close_tab(self, tab_id: str):
|
||||||
"""
|
"""
|
||||||
@@ -265,6 +223,7 @@ class TabsManager(MultipleInstance):
|
|||||||
Returns:
|
Returns:
|
||||||
Self for chaining
|
Self for chaining
|
||||||
"""
|
"""
|
||||||
|
logger.debug(f"close_tab {tab_id=}")
|
||||||
if tab_id not in self._state.tabs:
|
if tab_id not in self._state.tabs:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -309,7 +268,9 @@ class TabsManager(MultipleInstance):
|
|||||||
return mk.icon(tab_add24_regular,
|
return mk.icon(tab_add24_regular,
|
||||||
id=f"{self._id}-add-tab-btn",
|
id=f"{self._id}-add-tab-btn",
|
||||||
cls="mf-tab-btn",
|
cls="mf-tab-btn",
|
||||||
command=self.commands.add_tab(f"Untitled", None, True))
|
command=self.commands.add_tab(f"Untitled",
|
||||||
|
None,
|
||||||
|
True))
|
||||||
|
|
||||||
def _mk_tab_button(self, tab_id: str, tab_data: dict, in_dropdown: bool = False):
|
def _mk_tab_button(self, tab_id: str, tab_data: dict, in_dropdown: bool = False):
|
||||||
"""
|
"""
|
||||||
@@ -345,15 +306,7 @@ class TabsManager(MultipleInstance):
|
|||||||
data_manager_id=self._id
|
data_manager_id=self._id
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mk_tabs_header(self):
|
def _mk_tabs_header(self, oob=False):
|
||||||
"""
|
|
||||||
Create the tabs header containing visible tab buttons and dropdown.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Div element containing visible tabs and dropdown button
|
|
||||||
"""
|
|
||||||
visible_tabs, hidden_tabs = self._calculate_visible_tabs()
|
|
||||||
|
|
||||||
# Create visible tab buttons
|
# Create visible tab buttons
|
||||||
visible_tab_buttons = [
|
visible_tab_buttons = [
|
||||||
self._mk_tab_button(tab_id, self._state.tabs[tab_id])
|
self._mk_tab_button(tab_id, self._state.tabs[tab_id])
|
||||||
@@ -364,14 +317,28 @@ class TabsManager(MultipleInstance):
|
|||||||
header_content = [*visible_tab_buttons]
|
header_content = [*visible_tab_buttons]
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
Div(
|
|
||||||
Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"),
|
Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"),
|
||||||
self._mk_show_tabs_menu(),
|
self._mk_show_tabs_menu(),
|
||||||
cls="mf-tabs-header-wrapper"
|
id=f"{self._id}-header-wrapper",
|
||||||
|
cls="mf-tabs-header-wrapper",
|
||||||
|
hx_swap_oob="true" if oob else None
|
||||||
),
|
),
|
||||||
|
|
||||||
|
def _mk_tabs_controller(self):
|
||||||
|
return Div(
|
||||||
|
Div(id=f"{self._id}-content-controller", data_active_tab=f"{self._state.active_tab}"),
|
||||||
|
Script(f'updateTabs("{self._id}-content-controller");'),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mk_tab_content(self):
|
def _mk_tab_content(self, tab_id: str, content):
|
||||||
|
is_active = tab_id == self._state.active_tab
|
||||||
|
return Div(
|
||||||
|
content if content else Div("No Content", cls="mf-empty-content"),
|
||||||
|
cls=f"mf-tab-content {'hidden' if not is_active else ''}", # ← ici
|
||||||
|
id=f"{self._id}-{tab_id}-content",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mk_tab_content_wrapper(self):
|
||||||
"""
|
"""
|
||||||
Create the active tab content area.
|
Create the active tab content area.
|
||||||
|
|
||||||
@@ -385,9 +352,9 @@ class TabsManager(MultipleInstance):
|
|||||||
content = component
|
content = component
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
content if content else Div("No Content", cls="mf-empty-content"),
|
self._mk_tab_content(self._state.active_tab, content),
|
||||||
cls="mf-tab-content",
|
cls="mf-tab-content-wrapper",
|
||||||
id=f"{self._id}-content"
|
id=f"{self._id}-content-wrapper",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mk_show_tabs_menu(self):
|
def _mk_show_tabs_menu(self):
|
||||||
@@ -405,6 +372,15 @@ class TabsManager(MultipleInstance):
|
|||||||
cls="dropdown dropdown-end"
|
cls="dropdown dropdown-end"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _wrap_tab_content(self, tab_content):
|
||||||
|
return Div(
|
||||||
|
tab_content,
|
||||||
|
hx_swap_oob=f"beforeend:#{self._id}-content-wrapper",
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_boundaries(self):
|
||||||
|
return Script(f"updateBoundaries('{self._id}');")
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
"""
|
"""
|
||||||
Render the complete TabsManager component.
|
Render the complete TabsManager component.
|
||||||
@@ -413,10 +389,11 @@ class TabsManager(MultipleInstance):
|
|||||||
Div element containing tabs header, content area, and resize script
|
Div element containing tabs header, content area, and resize script
|
||||||
"""
|
"""
|
||||||
return Div(
|
return Div(
|
||||||
|
self._mk_tabs_controller(),
|
||||||
self._mk_tabs_header(),
|
self._mk_tabs_header(),
|
||||||
self._mk_tab_content(),
|
self._mk_tab_content_wrapper(),
|
||||||
cls="mf-tabs-manager",
|
cls="mf-tabs-manager",
|
||||||
id=self._id
|
id=self._id,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __ft__(self):
|
def __ft__(self):
|
||||||
|
|||||||
71
src/myfasthtml/controls/VisNetwork.py
Normal file
71
src/myfasthtml/controls/VisNetwork.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from fasthtml.components import Script, Div
|
||||||
|
|
||||||
|
from myfasthtml.controls.helpers import Ids
|
||||||
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
|
logger = logging.getLogger("VisNetwork")
|
||||||
|
|
||||||
|
class VisNetwork(MultipleInstance):
|
||||||
|
def __init__(self, session, _id=None, nodes=None, edges=None, options=None):
|
||||||
|
super().__init__(session, Ids.VisNetwork, _id=_id)
|
||||||
|
logger.debug(f"VisNetwork created with id: {self._id}")
|
||||||
|
|
||||||
|
# Default values
|
||||||
|
self.nodes = nodes or []
|
||||||
|
self.edges = edges or []
|
||||||
|
self.options = options or {
|
||||||
|
"autoResize": True,
|
||||||
|
"interaction": {
|
||||||
|
"dragNodes": True,
|
||||||
|
"zoomView": True,
|
||||||
|
"dragView": True,
|
||||||
|
},
|
||||||
|
"physics": {"enabled": True}
|
||||||
|
}
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
# Prepare JS arrays (no JSON library needed)
|
||||||
|
js_nodes = ",\n ".join(
|
||||||
|
f'{{ id: {n["id"]}, label: "{n.get("label", "")}" }}'
|
||||||
|
for n in self.nodes
|
||||||
|
)
|
||||||
|
js_edges = ",\n ".join(
|
||||||
|
f'{{ from: {e["from"]}, to: {e["to"]} }}'
|
||||||
|
for e in self.edges
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert Python options to JS
|
||||||
|
import json
|
||||||
|
js_options = json.dumps(self.options, indent=2)
|
||||||
|
|
||||||
|
return (
|
||||||
|
Div(
|
||||||
|
id=self._id,
|
||||||
|
cls="mf-vis",
|
||||||
|
#style="width:100%; height:100%;", # Let parent control the layout
|
||||||
|
),
|
||||||
|
|
||||||
|
# The script initializing Vis.js
|
||||||
|
Script(f"""
|
||||||
|
(function() {{
|
||||||
|
const container = document.getElementById("{self._id}");
|
||||||
|
const nodes = new vis.DataSet([
|
||||||
|
{js_nodes}
|
||||||
|
]);
|
||||||
|
const edges = new vis.DataSet([
|
||||||
|
{js_edges}
|
||||||
|
]);
|
||||||
|
const data = {{
|
||||||
|
nodes: nodes,
|
||||||
|
edges: edges
|
||||||
|
}};
|
||||||
|
const options = {js_options};
|
||||||
|
const network = new vis.Network(container, data, options);
|
||||||
|
}})();
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
return self.render()
|
||||||
@@ -13,6 +13,7 @@ class Ids:
|
|||||||
Layout = "mf-layout"
|
Layout = "mf-layout"
|
||||||
TabsManager = "mf-tabs-manager"
|
TabsManager = "mf-tabs-manager"
|
||||||
UserProfile = "mf-user-profile"
|
UserProfile = "mf-user-profile"
|
||||||
|
VisNetwork = "mf-vis-network"
|
||||||
|
|
||||||
|
|
||||||
class mk:
|
class mk:
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ class Command(BaseCommand):
|
|||||||
if isinstance(ret, (list, tuple)):
|
if isinstance(ret, (list, tuple)):
|
||||||
for r in ret[1:]:
|
for r in ret[1:]:
|
||||||
if hasattr(r, 'attrs'):
|
if hasattr(r, 'attrs'):
|
||||||
r.attrs["hx-swap-oob"] = "true"
|
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
|
||||||
|
|
||||||
if not ret_from_bindings:
|
if not ret_from_bindings:
|
||||||
return ret
|
return ret
|
||||||
|
|||||||
11
src/myfasthtml/core/instances_helper.py
Normal file
11
src/myfasthtml/core/instances_helper.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
|
from myfasthtml.controls.helpers import Ids
|
||||||
|
|
||||||
|
|
||||||
|
class InstancesHelper:
|
||||||
|
@staticmethod
|
||||||
|
def dynamic_get(session, component_type: str, instance_id: str):
|
||||||
|
if component_type == Ids.VisNetwork:
|
||||||
|
return VisNetwork(session, _id=instance_id)
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -31,6 +31,7 @@ def get_asset_content(filename):
|
|||||||
|
|
||||||
|
|
||||||
def create_app(daisyui: Optional[bool] = True,
|
def create_app(daisyui: Optional[bool] = True,
|
||||||
|
vis: Optional[bool] = True,
|
||||||
protect_routes: Optional[bool] = True,
|
protect_routes: Optional[bool] = True,
|
||||||
mount_auth_app: Optional[bool] = False,
|
mount_auth_app: Optional[bool] = False,
|
||||||
base_url: Optional[str] = None,
|
base_url: Optional[str] = None,
|
||||||
@@ -63,6 +64,11 @@ def create_app(daisyui: Optional[bool] = True,
|
|||||||
Script(src="/myfasthtml/tailwindcss-browser@4.js"),
|
Script(src="/myfasthtml/tailwindcss-browser@4.js"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if vis:
|
||||||
|
hdrs += [
|
||||||
|
Script(src="/myfasthtml/vis-network.min.js"),
|
||||||
|
]
|
||||||
|
|
||||||
beforeware = create_auth_beforeware() if protect_routes else None
|
beforeware = create_auth_beforeware() if protect_routes else None
|
||||||
app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs)
|
app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user