diff --git a/docs/Keyboard Support.md b/docs/Keyboard Support.md index 2a2e747..d361973 100644 --- a/docs/Keyboard Support.md +++ b/docs/Keyboard Support.md @@ -21,19 +21,47 @@ ## Key Features -### Multiple Simultaneous Triggers +### Scope Control with `require_inside` -**IMPORTANT**: If multiple elements listen to the same combination, **ALL** of them will be triggered: +Each combination can declare whether it should only trigger when the focus is **inside** the registered element, or fire **globally** regardless of focus. + +| `require_inside` | Behavior | +|-----------------|----------| +| `true` (default) | Triggers only if focus is inside the element or one of its children | +| `false` | Triggers regardless of where the focus is (global shortcut) | ```javascript -add_keyboard_support('modal', '{"esc": "/close-modal"}'); -add_keyboard_support('editor', '{"esc": "/cancel-edit"}'); -add_keyboard_support('sidebar', '{"esc": "/hide-sidebar"}'); +// Only fires when focus is inside #tree-panel +add_keyboard_support('tree-panel', '{"esc": {"hx-post": "/cancel", "require_inside": true}}'); -// Pressing ESC will trigger all 3 URLs simultaneously +// Fires anywhere on the page +add_keyboard_support('app', '{"ctrl+n": {"hx-post": "/new", "require_inside": false}}'); ``` -This is crucial for use cases like the ESC key, which often needs to cancel multiple actions at once (close modal, cancel edit, hide panels, etc.). +**Python usage (`Keyboard` component):** + +```python +# Default: require_inside=True — fires only when inside the element +Keyboard(self, _id="-kb").add("esc", self.commands.cancel()) + +# Explicit global shortcut +Keyboard(self, _id="-kb").add("ctrl+n", self.commands.new_item(), require_inside=False) +``` + +### Multiple Simultaneous Triggers + +**IMPORTANT**: If multiple elements listen to the same combination, all of them whose `require_inside` condition is satisfied will be triggered simultaneously: + +```javascript +add_keyboard_support('modal', '{"esc": {"hx-post": "/close-modal", "require_inside": true}}'); +add_keyboard_support('editor', '{"esc": {"hx-post": "/cancel-edit", "require_inside": true}}'); +add_keyboard_support('sidebar', '{"esc": {"hx-post": "/hide-sidebar", "require_inside": false}}'); + +// Pressing ESC while focus is inside 'editor': +// - 'modal' → skipped (require_inside: true, focus not inside) +// - 'editor' → triggered ✓ +// - 'sidebar' → triggered ✓ (require_inside: false) +``` ### Smart Timeout Logic (Longest Match) @@ -232,6 +260,8 @@ The library automatically adds these parameters to every request: - `has_focus` - Boolean indicating if the element had focus - `is_inside` - Boolean indicating if the focus is inside the element (element itself or any child) +Note: `require_inside` controls **whether** the action fires; `is_inside` is an informational parameter sent **with** the request after it fires. + Example final request: ```javascript htmx.ajax('POST', '/save-url', { diff --git a/src/myfasthtml/assets/core/keyboard.js b/src/myfasthtml/assets/core/keyboard.js index ca85a1f..c032043 100644 --- a/src/myfasthtml/assets/core/keyboard.js +++ b/src/myfasthtml/assets/core/keyboard.js @@ -165,7 +165,6 @@ // Add key to current pressed keys KeyboardRegistry.currentKeys.add(key); - // console.debug("Received key", key); // Create a snapshot of current keyboard state const snapshot = new Set(KeyboardRegistry.currentKeys); @@ -218,14 +217,17 @@ anyHasLongerSequence = true; } - // Collect matches + // Collect matches, respecting require_inside flag if (hasMatch) { - currentMatches.push({ - elementId: elementId, - config: currentNode.config, - combinationStr: currentNode.combinationStr, - isInside: isInside - }); + const requireInside = currentNode.config["require_inside"] === true; + if (!requireInside || isInside) { + currentMatches.push({ + elementId: elementId, + config: currentNode.config, + combinationStr: currentNode.combinationStr, + isInside: isInside + }); + } } } diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 90b4608..8ec725d 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -16,12 +16,13 @@ from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER from myfasthtml.controls.DslEditor import DslEditorConf +from myfasthtml.controls.IconsHelper import IconsHelper from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.controls.Mouse import Mouse from myfasthtml.controls.Panel import Panel, PanelConf from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState -from myfasthtml.controls.helpers import mk, icons, column_type_defaults +from myfasthtml.controls.helpers import mk, column_type_defaults from myfasthtml.core.commands import Command from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID from myfasthtml.core.dbmanager import DbObject @@ -261,7 +262,7 @@ class DataGrid(MultipleInstance): } self._key_support = { - "esc": self.commands.on_key_pressed(), + "esc": {"command": self.commands.on_key_pressed(), "require_inside": True}, } logger.debug(f"DataGrid '{self.get_table_name()}' with id='{self._id}' created.") @@ -647,7 +648,7 @@ class DataGrid(MultipleInstance): def _mk_header_name(col_def: DataGridColumnState): return Div( - mk.label(col_def.title, icon=icons.get(col_def.type, None)), + mk.label(col_def.title, icon=IconsHelper.get(col_def.type)), # make room for sort and filter indicators cls="flex truncate cursor-default", data_tooltip=col_def.title, diff --git a/src/myfasthtml/controls/DataGridColumnsManager.py b/src/myfasthtml/controls/DataGridColumnsManager.py index fb85a1c..b2797ff 100644 --- a/src/myfasthtml/controls/DataGridColumnsManager.py +++ b/src/myfasthtml/controls/DataGridColumnsManager.py @@ -5,9 +5,10 @@ from fasthtml.components import * from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.DataGridFormulaEditor import DataGridFormulaEditor from myfasthtml.controls.DslEditor import DslEditorConf +from myfasthtml.controls.IconsHelper import IconsHelper from myfasthtml.controls.Search import Search from myfasthtml.controls.datagrid_objects import DataGridColumnState -from myfasthtml.controls.helpers import icons, mk +from myfasthtml.controls.helpers import mk from myfasthtml.core.commands import Command from myfasthtml.core.constants import ColumnType from myfasthtml.core.dsls import DslsManager @@ -179,7 +180,7 @@ class DataGridColumnsManager(MultipleInstance): ), mk.mk( Div( - Div(mk.label(col_def.col_id, icon=icons.get(col_def.type, None), cls="ml-2")), + Div(mk.label(col_def.col_id, icon=IconsHelper.get(col_def.type), cls="ml-2")), Div(mk.icon(chevron_right20_regular), cls="mr-2"), cls="dt2-column-manager-label" ), diff --git a/src/myfasthtml/controls/Dropdown.py b/src/myfasthtml/controls/Dropdown.py index 4323e2f..b27caea 100644 --- a/src/myfasthtml/controls/Dropdown.py +++ b/src/myfasthtml/controls/Dropdown.py @@ -97,7 +97,7 @@ class Dropdown(MultipleInstance): self._mk_content(), cls="mf-dropdown-wrapper" ), - Keyboard(self, _id="-keyboard").add("esc", self.commands.close()), + Keyboard(self, _id="-keyboard").add("esc", self.commands.close(), require_inside=True), Mouse(self, "-mouse").add("click", self.commands.click(), hx_vals="js:getDropdownExtra()"), id=self._id ) diff --git a/src/myfasthtml/controls/IconsHelper.py b/src/myfasthtml/controls/IconsHelper.py new file mode 100644 index 0000000..d83e7a2 --- /dev/null +++ b/src/myfasthtml/controls/IconsHelper.py @@ -0,0 +1,71 @@ +from myfasthtml.core.constants import ColumnType +from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \ + number_row20_regular +from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \ + checkbox_checked20_filled, math_formula16_regular +from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular +from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular + +default_icons = { + None: question20_regular, + True: checkbox_checked20_regular, + False: checkbox_unchecked20_regular, + + "Brain": brain_circuit20_regular, + + ColumnType.RowIndex: number_symbol20_regular, + ColumnType.Text: text_field20_regular, + ColumnType.Number: number_row20_regular, + ColumnType.Datetime: calendar_ltr20_regular, + ColumnType.Bool: checkbox_checked20_filled, + ColumnType.Enum: text_bullet_list_square20_regular, + ColumnType.Formula: math_formula16_regular, +} + + +class IconsHelper: + _icons = default_icons.copy() + + @staticmethod + def get(name, package=None): + """ + Fetches and returns an icon resource based on the provided name and optional package. If the icon is not already + cached, it will attempt to dynamically load the icon from the available modules under the `myfasthtml.icons` package. + This method uses an internal caching mechanism to store previously fetched icons for future quick lookups. + + :param name: The name of the requested icon. + :param package: The optional sub-package to limit the search for the requested icon. If not provided, the method will + iterate through all available modules within the `myfasthtml.icons` package. + :return: The requested icon resource if found; otherwise, returns None. + :rtype: object or None + """ + if name in IconsHelper._icons: + return IconsHelper._icons[name] + + import importlib + import pkgutil + import myfasthtml.icons as icons_pkg + + _UTILITY_MODULES = {'manage_icons', 'update_icons'} + + if package: + module = importlib.import_module(f"myfasthtml.icons.{package}") + icon = getattr(module, name, None) + if icon is not None: + IconsHelper._icons[name] = icon + return icon + + for _, modname, _ in pkgutil.iter_modules(icons_pkg.__path__): + if modname in _UTILITY_MODULES: + continue + module = importlib.import_module(f"myfasthtml.icons.{modname}") + icon = getattr(module, name, None) + if icon is not None: + IconsHelper._icons[name] = icon + return icon + + return None + + @staticmethod + def reset(): + IconsHelper._icons = default_icons.copy() diff --git a/src/myfasthtml/controls/Keyboard.py b/src/myfasthtml/controls/Keyboard.py index 854b071..c8386cd 100644 --- a/src/myfasthtml/controls/Keyboard.py +++ b/src/myfasthtml/controls/Keyboard.py @@ -17,12 +17,16 @@ class Keyboard(MultipleInstance): super().__init__(parent, _id=_id) self.combinations = combinations or {} - def add(self, sequence: str, command: Command): - self.combinations[sequence] = command + def add(self, sequence: str, command: Command, require_inside: bool = True): + self.combinations[sequence] = {"command": command, "require_inside": require_inside} return self - + def render(self): - str_combinations = {sequence: command.get_htmx_params() for sequence, command in self.combinations.items()} + str_combinations = {} + for sequence, value in self.combinations.items(): + params = value["command"].get_htmx_params() + params["require_inside"] = value.get("require_inside", True) + str_combinations[sequence] = params return Script(f"add_keyboard_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')") def __ft__(self): diff --git a/src/myfasthtml/controls/TreeView.py b/src/myfasthtml/controls/TreeView.py index 0a7aeda..3dc8417 100644 --- a/src/myfasthtml/controls/TreeView.py +++ b/src/myfasthtml/controls/TreeView.py @@ -4,6 +4,7 @@ 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 logging import uuid from dataclasses import dataclass, field from typing import Optional @@ -19,6 +20,19 @@ 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 +logger = logging.getLogger("TreeView") + + +@dataclass +class TreeViewConf: + edit_node: bool = True + add_node: bool = True + delete_node: bool = True + edit_leaf: bool = True + add_leaf: bool = True + delete_leaf: bool = True + icons: dict = None + @dataclass class TreeNode: @@ -157,7 +171,7 @@ class TreeView(MultipleInstance): - Node selection """ - def __init__(self, parent, items: Optional[dict] = None, _id: Optional[str] = None): + def __init__(self, parent, items: Optional[dict] = None, conf: TreeViewConf = None, _id: Optional[str] = None): """ Initialize TreeView component. @@ -168,10 +182,14 @@ class TreeView(MultipleInstance): """ super().__init__(parent, _id=_id) self._state = TreeViewState(self) + self.conf = conf or TreeViewConf() self.commands = Commands(self) - + if items: self._state.items = items + + if self.conf.icons: + self._state.icon_config = self.conf.icons def set_icon_config(self, config: dict[str, str]): """ @@ -207,7 +225,7 @@ class TreeView(MultipleInstance): else: parent.children.append(node.id) - def ensure_path(self, path: str): + def ensure_path(self, path: str, node_type="folder"): """Add a node to the tree based on a path string. Args: @@ -237,7 +255,7 @@ class TreeView(MultipleInstance): node = [node for node in current_nodes if node.label == part] if len(node) == 0: # create the node - node = TreeNode(label=part, type="folder") + node = TreeNode(label=part, type=node_type) self.add_node(node, parent_id=parent_id) else: node = node[0] @@ -341,6 +359,7 @@ class TreeView(MultipleInstance): def _cancel_rename(self): """Cancel renaming operation.""" + logger.debug("_cancel_rename") self._state.editing = None return self @@ -380,12 +399,22 @@ class TreeView(MultipleInstance): 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" - ) + is_leaf = len(self._state.items[node_id].children) == 0 + conf = self.conf + + add_visible = conf.add_leaf if is_leaf else conf.add_node + edit_visible = conf.edit_leaf if is_leaf else conf.edit_node + delete_visible = conf.delete_leaf if is_leaf else conf.delete_node + + buttons = [] + if add_visible: + buttons.append(mk.icon(add_circle20_regular, command=self.commands.add_child(node_id))) + if edit_visible: + buttons.append(mk.icon(edit20_regular, command=self.commands.start_rename(node_id))) + if delete_visible: + buttons.append(mk.icon(delete20_regular, command=self.commands.delete_node(node_id))) + + return Div(*buttons, cls="mf-treenode-actions") def _render_node(self, node_id: str, level: int = 0): """ @@ -404,10 +433,13 @@ class TreeView(MultipleInstance): 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 None, - command=self.commands.toggle_node(node_id)) + # Toggle icon (only for nodes with children) + if has_children: + toggle = mk.icon( + chevron_down20_regular if is_expanded else chevron_right20_regular, + command=self.commands.toggle_node(node_id)) + else: + toggle = None # Label or input for editing if is_editing: @@ -426,7 +458,7 @@ class TreeView(MultipleInstance): node_element = Div( toggle, label_element, - self._render_action_buttons(node_id), + *([self._render_action_buttons(node_id)] if not is_editing else []), cls=f"mf-treenode flex {'selected' if is_selected and not is_editing else ''}", style=f"padding-left: {level * 20}px" ) @@ -461,7 +493,7 @@ class TreeView(MultipleInstance): return Div( *[self._render_node(node_id) for node_id in root_nodes], - Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="-keyboard"), + Keyboard(self, {"esc": {"command": self.commands.cancel_rename(), "require_inside": False}}, _id="-keyboard"), id=self._id, cls="mf-treeview" ) diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py index 8c14e4c..420e170 100644 --- a/src/myfasthtml/controls/helpers.py +++ b/src/myfasthtml/controls/helpers.py @@ -5,12 +5,6 @@ from myfasthtml.core.bindings import Binding from myfasthtml.core.commands import Command, CommandTemplate from myfasthtml.core.constants import ColumnType from myfasthtml.core.utils import merge_classes -from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_row20_regular, \ - number_symbol20_regular -from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \ - checkbox_checked20_filled, math_formula16_regular -from myfasthtml.icons.fluent_p2 import text_bullet_list_square20_regular, text_field20_regular -from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular class Ids: @@ -148,26 +142,9 @@ class mk: return ft -icons = { - None: question20_regular, - True: checkbox_checked20_regular, - False: checkbox_unchecked20_regular, - - "Brain": brain_circuit20_regular, - - ColumnType.RowIndex: number_symbol20_regular, - ColumnType.Text: text_field20_regular, - ColumnType.Number: number_row20_regular, - ColumnType.Datetime: calendar_ltr20_regular, - ColumnType.Bool: checkbox_checked20_filled, - ColumnType.Enum: text_bullet_list_square20_regular, - ColumnType.Formula: math_formula16_regular, -} - column_type_defaults = { ColumnType.Number: 0, ColumnType.Text: "", ColumnType.Bool: False, ColumnType.Datetime: pd.NaT, } - \ No newline at end of file diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index 9705229..756d2f7 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -88,10 +88,10 @@ class Command: if auto_register: if self._key is not None: if self._key in CommandsManager.commands_by_key: - logger.debug(f"Command {self.name} with key={self._key} will not be registered.") + #logger.debug(f"Command {self.name} with key={self._key} will not be registered.") self.id = CommandsManager.commands_by_key[self._key].id else: - logger.debug(f"Command {self.name} with key={self._key} will be registered.") + #logger.debug(f"Command {self.name} with key={self._key} will be registered.") CommandsManager.register(self) else: logger.warning(f"Command {self.name} has no key, it will not be registered.") diff --git a/tests/controls/test_icons_helper.py b/tests/controls/test_icons_helper.py new file mode 100644 index 0000000..e2d2b1a --- /dev/null +++ b/tests/controls/test_icons_helper.py @@ -0,0 +1,23 @@ +from myfasthtml.controls.IconsHelper import IconsHelper + + +def test_existing_icon(): + IconsHelper.reset() + assert IconsHelper.get(True) is not None + + +def test_dynamic_icon(): + IconsHelper.reset() + assert IconsHelper.get("add20_filled") is not None + + +def test_unknown_icon(): + IconsHelper.reset() + assert IconsHelper.get("does_not_exist") is None + + + +def test_dynamic_icon_by_package(): + IconsHelper.reset() + assert IconsHelper.get("add20_filled", "fa") is None + assert IconsHelper.get("add20_filled", "fluent") is not None diff --git a/tests/controls/test_treeview.py b/tests/controls/test_treeview.py index 9c7569e..90af53c 100644 --- a/tests/controls/test_treeview.py +++ b/tests/controls/test_treeview.py @@ -600,7 +600,7 @@ class TestTreeViewRender: - cls "mf-treeview": Root CSS class for TreeView styling """ expected = Div( - TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}), + TestObject(Keyboard, combinations={"esc": {"command": TestCommand("CancelRename"), "require_inside": False}}), _id=tree_view.get_id(), cls="mf-treeview" ) @@ -693,7 +693,7 @@ class TestTreeViewRender: child_container = find_one(rendered, Div(data_node_id=child1.id)) expected_child_container = Div( Div( - Div(None), # No icon, the div is empty + None, # No icon for leaf nodes Span("Child1"), Div(), # action buttons cls=Contains("mf-treenode") @@ -721,7 +721,7 @@ class TestTreeViewRender: # Step 2: Define expected structure expected = Div( Div( - Div(None), # No icon, the div is empty + None, # No icon for leaf nodes Span("Leaf Node"), # Label Div(), # Action buttons still present ), @@ -749,7 +749,7 @@ class TestTreeViewRender: expected = Div( Div( - Div(None), # No icon, leaf node + None, # No icon for leaf nodes Span("Selected Node"), Div(), # Action buttons cls=Contains("mf-treenode", "selected") @@ -779,13 +779,13 @@ class TestTreeViewRender: expected = Div( Div( - Div(None), # No icon, leaf node + None, # No icon for leaf nodes Input( name="node_label", value="Edit Me", cls=Contains("mf-treenode-input") ), - Div(), # Action buttons + # Div(), # Action buttons cls=Contains("mf-treenode") ), cls="mf-treenode-container", @@ -859,7 +859,7 @@ class TestTreeViewRender: grandchild_container = find_one(rendered, Div(data_node_id=grandchild.id)) grandchild_expected = Div( Div( - Div(None), # No icon, leaf node + None, # No icon for leaf nodes Span("Grandchild"), Div(), # Action buttons cls=Contains("mf-treenode"), @@ -997,7 +997,9 @@ class TestTreeViewRender: keyboard = find_one(rendered, TestObject(Keyboard)) # Step 2: Define expected structure - expected = TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}) + expected = TestObject(Keyboard, combinations={"esc": + {"command": TestCommand("CancelRename"), + "require_inside": False}}) # Step 3: Compare assert matches(keyboard, expected) @@ -1026,7 +1028,7 @@ class TestTreeViewRender: expected_root1 = Div( Div( - Div(None), # No icon, leaf node + None, # No icon for leaf nodes Span("Root 1"), Div(), # Action buttons cls=Contains("mf-treenode") @@ -1037,7 +1039,7 @@ class TestTreeViewRender: expected_root2 = Div( Div( - Div(None), # No icon, leaf node + None, # No icon for leaf nodes Span("Root 2"), Div(), # Action buttons cls=Contains("mf-treenode")