Added IconsHelper and updated Keyboard to support require_inside flag

This commit is contained in:
2026-02-20 20:35:09 +01:00
parent b09763b1eb
commit 13f292fc9d
12 changed files with 219 additions and 76 deletions

View File

@@ -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', {

View File

@@ -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
});
}
}
}

View File

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

View File

@@ -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"
),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.")

View File

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

View File

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