Implemented enable/disable for keyboard support

This commit is contained in:
2026-03-15 08:33:39 +01:00
parent f773fd1611
commit feb9da50b2
7 changed files with 151 additions and 54 deletions

View File

@@ -100,6 +100,57 @@ add_keyboard_support('elem2', '{"C D": "/url2"}');
The timeout is tied to the **sequence being typed**, not to individual elements. The timeout is tied to the **sequence being typed**, not to individual elements.
### Enabling and Disabling Combinations
Each combination can be enabled or disabled independently. A disabled combination is registered and tracked, but its action is never triggered.
| State | Behavior |
|-------|----------|
| `enabled=True` (default) | Combination triggers normally |
| `enabled=False` | Combination is silently ignored when pressed |
**Setting the initial state at registration:**
```python
# Enabled by default
keyboard.add("ctrl+s", self.commands.save())
# Disabled at startup
keyboard.add("ctrl+d", self.commands.delete(), enabled=False)
```
**Toggling dynamically at runtime:**
Use `mk_enable()` and `mk_disable()` to change the state from a server response. Both methods return an out-of-band HTMX element (`hx-swap-oob`) that updates the DOM without a full page reload.
```python
# Enable a combination (e.g., once an item is selected)
def handle_select(self):
item = ...
return item.render(), self.keyboard.mk_enable("ctrl+d")
# Disable a combination (e.g., when nothing is selected)
def handle_deselect(self):
return self.keyboard.mk_disable("ctrl+d")
```
The enabled state is stored in a hidden control `<div>` rendered alongside the keyboard script. The JavaScript reads this state before triggering any action.
**State at a glance:**
```
┌─────────────────────────────────────┐
│ Keyboard control div (hidden) │
│ ┌──────────────────────────────┐ │
│ │ div [data-combination="esc"] │ │
│ │ [data-enabled="true"] │ │
│ ├──────────────────────────────┤ │
│ │ div [data-combination="ctrl+d"] │ │
│ │ [data-enabled="false"] │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────┘
```
### Smart Timeout Logic (Longest Match) ### Smart Timeout Logic (Longest Match)
Keyboard shortcuts are **disabled** when typing in input fields: Keyboard shortcuts are **disabled** when typing in input fields:
@@ -364,16 +415,56 @@ remove_keyboard_support('modal');
## API Reference ## API Reference
### add_keyboard_support(elementId, combinationsJson) ### add_keyboard_support(elementId, controlDivId, combinationsJson)
Adds keyboard support to an element. Adds keyboard support to an element.
**Parameters**: **Parameters**:
- `elementId` (string): ID of the HTML element - `elementId` (string): ID of the HTML element to watch for key events
- `controlDivId` (string): ID of the keyboard control div rendered by `Keyboard.render()`, used to look up enabled/disabled state at runtime
- `combinationsJson` (string): JSON string of combinations with HTMX configs - `combinationsJson` (string): JSON string of combinations with HTMX configs
**Returns**: void **Returns**: void
### Python Component Methods
These methods are available on the `Keyboard` instance.
#### add(sequence, command, require_inside=True, enabled=True)
Registers a key combination.
| Parameter | Type | Description | Default |
|-----------|------|-------------|---------|
| `sequence` | `str` | Key combination string, e.g. `"ctrl+s"`, `"esc"`, `"a b"` | — |
| `command` | `Command` | Command to execute when the combination is triggered | — |
| `require_inside` | `bool` | If `True`, only triggers when focus is inside the element | `True` |
| `enabled` | `bool` | Whether the combination is active at render time | `True` |
**Returns**: `self` (chainable)
#### mk_enable(sequence)
Returns an out-of-band HTMX element that enables a combination at runtime.
| Parameter | Type | Description |
|-----------|------|-------------|
| `sequence` | `str` | Key combination to enable, must match exactly what was passed to `add()` |
**Returns**: `Div` with `hx-swap-oob="true"`
#### mk_disable(sequence)
Returns an out-of-band HTMX element that disables a combination at runtime.
| Parameter | Type | Description |
|-----------|------|-------------|
| `sequence` | `str` | Key combination to disable, must match exactly what was passed to `add()` |
**Returns**: `Div` with `hx-swap-oob="true"`
---
### remove_keyboard_support(elementId) ### remove_keyboard_support(elementId)
Removes keyboard support from an element. Removes keyboard support from an element.

View File

@@ -181,13 +181,13 @@ Users can rename nodes via the edit button:
```python ```python
# Programmatically start rename # Programmatically start rename
tree._start_rename("node-id") tree.handle_start_rename("node-id")
# Save rename # Save rename
tree._save_rename("node-id", "New Label") tree.handle_save_rename("node-id", "New Label")
# Cancel rename # Cancel rename
tree._cancel_rename() tree.handle_cancel_rename()
``` ```
### Deleting Nodes ### Deleting Nodes
@@ -201,7 +201,7 @@ Users can delete nodes via the delete button:
```python ```python
# Programmatically delete node # Programmatically delete node
tree._delete_node("node-id") # Raises ValueError if node has children tree.handle_delete_node("node-id") # Raises ValueError if node has children
``` ```
## Content System ## Content System
@@ -449,11 +449,12 @@ tree = TreeView(parent=root_instance, _id="dynamic-tree")
root = TreeNode(id="root", label="Tasks", type="folder") root = TreeNode(id="root", label="Tasks", type="folder")
tree.add_node(root) tree.add_node(root)
# Function to handle selection # Function to handle selection
def on_node_selected(node_id): def on_node_selected(node_id):
# Custom logic when node is selected # Custom logic when node is selected
node = tree._state.items[node_id] node = tree._state.items[node_id]
tree._select_node(node_id) tree.handle_select_node(node_id)
# Update a detail panel elsewhere in the UI # Update a detail panel elsewhere in the UI
return Div( return Div(
@@ -462,6 +463,7 @@ def on_node_selected(node_id):
P(f"Children: {len(node.children)}") P(f"Children: {len(node.children)}")
) )
# Override select command with custom handler # Override select command with custom handler
# (In practice, you'd extend the Commands class or use event callbacks) # (In practice, you'd extend the Commands class or use event callbacks)

View File

@@ -294,7 +294,7 @@ class DataGrid(MultipleInstance):
} }
self._key_support = { self._key_support = {
"esc": {"command": self.commands.on_key_pressed(), "require_inside": True}, "esc": {"command": self.commands.on_key_pressed(), "require_inside": False},
} }
logger.debug(f"DataGrid '{self.get_table_name()}' with id='{self._id}' created.") logger.debug(f"DataGrid '{self.get_table_name()}' with id='{self._id}' created.")

View File

@@ -211,7 +211,7 @@ class DataGridsManager(SingleInstance):
if parent_id not in self._tree.get_state().opened: if parent_id not in self._tree.get_state().opened:
self._tree.get_state().opened.append(parent_id) self._tree.get_state().opened.append(parent_id)
self._tree.get_state().selected = document.document_id self._tree.get_state().selected = document.document_id
self._tree._start_rename(document.document_id) self._tree.handle_start_rename(document.document_id)
return self._tree, self._tabs_manager.show_tab(tab_id) return self._tree, self._tabs_manager.show_tab(tab_id)

View File

@@ -9,7 +9,7 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
from fasthtml.components import Div, Input, Span from fasthtml.components import Div, Input
from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.IconsHelper import IconsHelper from myfasthtml.controls.IconsHelper import IconsHelper
@@ -115,7 +115,7 @@ class Commands(BaseCommands):
return Command("StartRename", return Command("StartRename",
f"Start renaming {node_id}", f"Start renaming {node_id}",
self._owner, self._owner,
self._owner._start_rename, self._owner.handle_start_rename,
kwargs={"node_id": node_id}, kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-StartRename" key=f"{self._owner.get_safe_parent_key()}-StartRename"
).htmx(target=f"#{self._owner.get_id()}") ).htmx(target=f"#{self._owner.get_id()}")
@@ -125,7 +125,7 @@ class Commands(BaseCommands):
return Command("SaveRename", return Command("SaveRename",
f"Save rename for {node_id}", f"Save rename for {node_id}",
self._owner, self._owner,
self._owner._save_rename, self._owner.handle_save_rename,
kwargs={"node_id": node_id}, kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-SaveRename" key=f"{self._owner.get_safe_parent_key()}-SaveRename"
).htmx(target=f"#{self._owner.get_id()}") ).htmx(target=f"#{self._owner.get_id()}")
@@ -135,7 +135,7 @@ class Commands(BaseCommands):
return Command("CancelRename", return Command("CancelRename",
"Cancel rename", "Cancel rename",
self._owner, self._owner,
self._owner._cancel_rename, self._owner.handle_cancel_rename,
key=f"{self._owner.get_safe_parent_key()}-CancelRename" key=f"{self._owner.get_safe_parent_key()}-CancelRename"
).htmx(target=f"#{self._owner.get_id()}") ).htmx(target=f"#{self._owner.get_id()}")
@@ -144,7 +144,7 @@ class Commands(BaseCommands):
return Command("DeleteNode", return Command("DeleteNode",
f"Delete node {node_id}", f"Delete node {node_id}",
self._owner, self._owner,
self._owner._delete_node, self._owner.handle_delete_node,
kwargs={"node_id": node_id}, kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-DeleteNode" key=f"{self._owner.get_safe_parent_key()}-DeleteNode"
).htmx(target=f"#{self._owner.get_id()}") ).htmx(target=f"#{self._owner.get_id()}")
@@ -154,7 +154,7 @@ class Commands(BaseCommands):
return Command("SelectNode", return Command("SelectNode",
f"Select node {node_id}", f"Select node {node_id}",
self._owner, self._owner,
self._owner._select_node, self._owner.handle_select_node,
kwargs={"node_id": node_id}, kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-SelectNode" key=f"{self._owner.get_safe_parent_key()}-SelectNode"
).htmx(target=f"#{self._owner.get_id()}") ).htmx(target=f"#{self._owner.get_id()}")
@@ -185,6 +185,10 @@ class TreeView(MultipleInstance):
self._state = TreeViewState(self) self._state = TreeViewState(self)
self.conf = conf or TreeViewConf() self.conf = conf or TreeViewConf()
self.commands = Commands(self) self.commands = Commands(self)
self._keyboard = Keyboard(self, {"esc":
{"command": self.commands.cancel_rename(),
"require_inside": False}},
_id="-keyboard"),
if items: if items:
self._state.items = items self._state.items = items
@@ -346,7 +350,7 @@ class TreeView(MultipleInstance):
return self return self
def _start_rename(self, node_id: str): def handle_start_rename(self, node_id: str):
"""Start renaming a node (sets editing state and selection).""" """Start renaming a node (sets editing state and selection)."""
if node_id not in self._state.items: if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist") raise ValueError(f"Node {node_id} does not exist")
@@ -355,7 +359,7 @@ class TreeView(MultipleInstance):
self._state.editing = node_id self._state.editing = node_id
return self return self
def _save_rename(self, node_id: str, node_label: str): def handle_save_rename(self, node_id: str, node_label: str):
"""Save renamed node with new label.""" """Save renamed node with new label."""
logger.debug(f"_save_rename {node_id=}, {node_label=}") logger.debug(f"_save_rename {node_id=}, {node_label=}")
if node_id not in self._state.items: if node_id not in self._state.items:
@@ -365,13 +369,13 @@ class TreeView(MultipleInstance):
self._state.editing = None self._state.editing = None
return self return self
def _cancel_rename(self): def handle_cancel_rename(self):
"""Cancel renaming operation.""" """Cancel renaming operation."""
logger.debug("_cancel_rename") logger.debug("_cancel_rename")
self._state.editing = None self._state.editing = None
return self return self
def _delete_node(self, node_id: str): def handle_delete_node(self, node_id: str):
"""Delete a node (only if it has no children).""" """Delete a node (only if it has no children)."""
if node_id not in self._state.items: if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist") raise ValueError(f"Node {node_id} does not exist")
@@ -397,7 +401,7 @@ class TreeView(MultipleInstance):
return self return self
def _select_node(self, node_id: str): def handle_select_node(self, node_id: str):
"""Select a node.""" """Select a node."""
if node_id not in self._state.items: if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist") raise ValueError(f"Node {node_id} does not exist")
@@ -511,7 +515,7 @@ class TreeView(MultipleInstance):
return Div( return Div(
*[self._render_node(node_id) for node_id in root_nodes], *[self._render_node(node_id) for node_id in root_nodes],
Keyboard(self, {"esc": {"command": self.commands.cancel_rename(), "require_inside": False}}, _id="-keyboard"), self._keyboard,
id=self._id, id=self._id,
cls="mf-treeview" cls="mf-treeview"
) )

View File

@@ -72,7 +72,7 @@ class TestDataGridsManagerBehaviour:
""" """
# Create a folder and select it # Create a folder and select it
folder_id = datagrids_manager._tree.ensure_path("MyFolder") folder_id = datagrids_manager._tree.ensure_path("MyFolder")
datagrids_manager._tree._select_node(folder_id) datagrids_manager._tree.handle_select_node(folder_id)
result = datagrids_manager.handle_new_grid() result = datagrids_manager.handle_new_grid()
@@ -101,7 +101,7 @@ class TestDataGridsManagerBehaviour:
datagrids_manager._tree.add_node(leaf, parent_id=folder_id) datagrids_manager._tree.add_node(leaf, parent_id=folder_id)
# Select the leaf # Select the leaf
datagrids_manager._tree._select_node(leaf.id) datagrids_manager._tree.handle_select_node(leaf.id)
result = datagrids_manager.handle_new_grid() result = datagrids_manager.handle_new_grid()

View File

@@ -145,7 +145,7 @@ class TestTreeviewBehaviour:
node = TreeNode(label="Node", type="folder") node = TreeNode(label="Node", type="folder")
tree_view.add_node(node) tree_view.add_node(node)
tree_view._select_node(node.id) tree_view.handle_select_node(node.id)
assert tree_view._state.selected == node.id assert tree_view._state.selected == node.id
@@ -155,7 +155,7 @@ class TestTreeviewBehaviour:
node = TreeNode(label="Old Name", type="folder") node = TreeNode(label="Old Name", type="folder")
tree_view.add_node(node) tree_view.add_node(node)
tree_view._start_rename(node.id) tree_view.handle_start_rename(node.id)
assert tree_view._state.editing == node.id assert tree_view._state.editing == node.id
@@ -164,9 +164,9 @@ class TestTreeviewBehaviour:
tree_view = TreeView(root_instance) tree_view = TreeView(root_instance)
node = TreeNode(label="Old Name", type="folder") node = TreeNode(label="Old Name", type="folder")
tree_view.add_node(node) tree_view.add_node(node)
tree_view._start_rename(node.id) tree_view.handle_start_rename(node.id)
tree_view._save_rename(node.id, "New Name") tree_view.handle_save_rename(node.id, "New Name")
assert tree_view._state.items[node.id].label == "New Name" assert tree_view._state.items[node.id].label == "New Name"
assert tree_view._state.editing is None assert tree_view._state.editing is None
@@ -176,9 +176,9 @@ class TestTreeviewBehaviour:
tree_view = TreeView(root_instance) tree_view = TreeView(root_instance)
node = TreeNode(label="Name", type="folder") node = TreeNode(label="Name", type="folder")
tree_view.add_node(node) tree_view.add_node(node)
tree_view._start_rename(node.id) tree_view.handle_start_rename(node.id)
tree_view._cancel_rename() tree_view.handle_cancel_rename()
assert tree_view._state.editing is None assert tree_view._state.editing is None
assert tree_view._state.items[node.id].label == "Name" assert tree_view._state.items[node.id].label == "Name"
@@ -193,7 +193,7 @@ class TestTreeviewBehaviour:
tree_view.add_node(child, parent_id=parent.id) tree_view.add_node(child, parent_id=parent.id)
# Delete child (leaf node) # Delete child (leaf node)
tree_view._delete_node(child.id) tree_view.handle_delete_node(child.id)
assert child.id not in tree_view._state.items assert child.id not in tree_view._state.items
assert child.id not in parent.children assert child.id not in parent.children
@@ -225,7 +225,7 @@ class TestTreeviewBehaviour:
# Try to delete parent (has children) # Try to delete parent (has children)
with pytest.raises(ValueError, match="Cannot delete node.*with children"): with pytest.raises(ValueError, match="Cannot delete node.*with children"):
tree_view._delete_node(parent.id) tree_view.handle_delete_node(parent.id)
def test_i_cannot_add_sibling_to_root(self, root_instance): def test_i_cannot_add_sibling_to_root(self, root_instance):
"""Test that adding sibling to root node raises an error.""" """Test that adding sibling to root node raises an error."""
@@ -243,7 +243,7 @@ class TestTreeviewBehaviour:
# Try to select node that doesn't exist # Try to select node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"): with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._select_node("nonexistent_id") tree_view.handle_select_node("nonexistent_id")
def test_add_node_prevents_duplicate_children(self, root_instance): def test_add_node_prevents_duplicate_children(self, root_instance):
"""Test that add_node prevents adding duplicate child IDs.""" """Test that add_node prevents adding duplicate child IDs."""
@@ -317,11 +317,11 @@ class TestTreeviewBehaviour:
tree_view.add_node(child, parent_id=parent.id) tree_view.add_node(child, parent_id=parent.id)
# Select the child # Select the child
tree_view._select_node(child.id) tree_view.handle_select_node(child.id)
assert tree_view._state.selected == child.id assert tree_view._state.selected == child.id
# Delete the selected child # Delete the selected child
tree_view._delete_node(child.id) tree_view.handle_delete_node(child.id)
# Selection should be cleared # Selection should be cleared
assert tree_view._state.selected is None assert tree_view._state.selected is None
@@ -340,7 +340,7 @@ class TestTreeviewBehaviour:
assert parent.id in tree_view._state.opened assert parent.id in tree_view._state.opened
# Delete the child (making parent a leaf) # Delete the child (making parent a leaf)
tree_view._delete_node(child.id) tree_view.handle_delete_node(child.id)
# Now delete the parent (now a leaf node) # Now delete the parent (now a leaf node)
# First remove it from root by creating a grandparent # First remove it from root by creating a grandparent
@@ -349,7 +349,7 @@ class TestTreeviewBehaviour:
parent.parent = grandparent.id parent.parent = grandparent.id
grandparent.children.append(parent.id) grandparent.children.append(parent.id)
tree_view._delete_node(parent.id) tree_view.handle_delete_node(parent.id)
# Parent should be removed from opened list # Parent should be removed from opened list
assert parent.id not in tree_view._state.opened assert parent.id not in tree_view._state.opened
@@ -360,7 +360,7 @@ class TestTreeviewBehaviour:
# Try to start rename on node that doesn't exist # Try to start rename on node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"): with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._start_rename("nonexistent_id") tree_view.handle_start_rename("nonexistent_id")
def test_i_cannot_save_rename_nonexistent_node(self, root_instance): def test_i_cannot_save_rename_nonexistent_node(self, root_instance):
"""Test that saving rename for nonexistent node raises error.""" """Test that saving rename for nonexistent node raises error."""
@@ -368,7 +368,7 @@ class TestTreeviewBehaviour:
# Try to save rename for node that doesn't exist # Try to save rename for node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"): with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._save_rename("nonexistent_id", "New Name") tree_view.handle_save_rename("nonexistent_id", "New Name")
def test_i_cannot_add_sibling_to_nonexistent_node(self, root_instance): def test_i_cannot_add_sibling_to_nonexistent_node(self, root_instance):
"""Test that adding sibling to nonexistent node raises error.""" """Test that adding sibling to nonexistent node raises error."""
@@ -597,11 +597,11 @@ class TestTreeviewBehaviour:
tree_view.add_node(node2) tree_view.add_node(node2)
# Start editing node1 # Start editing node1
tree_view._start_rename(node1.id) tree_view.handle_start_rename(node1.id)
assert tree_view._state.editing == node1.id assert tree_view._state.editing == node1.id
# Select node2 # Select node2
tree_view._select_node(node2.id) tree_view.handle_select_node(node2.id)
# Edit mode should be cancelled # Edit mode should be cancelled
assert tree_view._state.editing is None assert tree_view._state.editing is None
@@ -615,11 +615,11 @@ class TestTreeviewBehaviour:
tree_view.add_node(node) tree_view.add_node(node)
# Start editing the node # Start editing the node
tree_view._start_rename(node.id) tree_view.handle_start_rename(node.id)
assert tree_view._state.editing == node.id assert tree_view._state.editing == node.id
# Select the same node # Select the same node
tree_view._select_node(node.id) tree_view.handle_select_node(node.id)
# Edit mode should be cancelled # Edit mode should be cancelled
assert tree_view._state.editing is None assert tree_view._state.editing is None
@@ -784,7 +784,7 @@ class TestTreeViewRender:
""" """
node = TreeNode(label="Selected Node", type="file") node = TreeNode(label="Selected Node", type="file")
tree_view.add_node(node) tree_view.add_node(node)
tree_view._select_node(node.id) tree_view.handle_select_node(node.id)
rendered = tree_view.render() rendered = tree_view.render()
selected_container = find_one(rendered, Div(data_node_id=node.id)) selected_container = find_one(rendered, Div(data_node_id=node.id))
@@ -814,7 +814,7 @@ class TestTreeViewRender:
""" """
node = TreeNode(label="Edit Me", type="file") node = TreeNode(label="Edit Me", type="file")
tree_view.add_node(node) tree_view.add_node(node)
tree_view._start_rename(node.id) tree_view.handle_start_rename(node.id)
rendered = tree_view.render() rendered = tree_view.render()
editing_container = find_one(rendered, Div(data_node_id=node.id)) editing_container = find_one(rendered, Div(data_node_id=node.id))
@@ -1009,7 +1009,7 @@ class TestTreeViewRender:
""" """
node = TreeNode(label="Edit Me", type="file") node = TreeNode(label="Edit Me", type="file")
tree_view.add_node(node) tree_view.add_node(node)
tree_view._start_rename(node.id) tree_view.handle_start_rename(node.id)
# Step 1: Extract the input element # Step 1: Extract the input element
rendered = tree_view.render() rendered = tree_view.render()