Added Keyboard.py + started find test method

This commit is contained in:
2025-11-22 20:40:33 +01:00
parent 4199427c71
commit 97247f824c
13 changed files with 1205 additions and 27 deletions

View File

@@ -0,0 +1,294 @@
# Keyboard Support - Test Instructions
## ⚠️ Breaking Change
**Version 2.0** uses HTMX configuration objects instead of simple URL strings. The old format is **not supported**.
**Old format (no longer supported)**:
```javascript
{"A": "/url"}
```
**New format (required)**:
```javascript
{"A": {"hx-post": "/url"}}
```
## Files
- `keyboard_support.js` - Main keyboard support library with smart timeout logic
- `test_keyboard_support.html` - Test page to verify functionality
## Key Features
### Multiple Simultaneous Triggers
**IMPORTANT**: If multiple elements listen to the same combination, **ALL** of them will be triggered:
```javascript
add_keyboard_support('modal', '{"esc": "/close-modal"}');
add_keyboard_support('editor', '{"esc": "/cancel-edit"}');
add_keyboard_support('sidebar', '{"esc": "/hide-sidebar"}');
// Pressing ESC will trigger all 3 URLs simultaneously
```
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.).
### Smart Timeout Logic (Longest Match)
The library uses **a single global timeout** based on the sequence state, not on individual elements:
**Key principle**: If **any element** has a longer sequence possible, **all matching elements wait**.
Examples:
**Example 1**: Three elements, same combination
```javascript
add_keyboard_support('elem1', '{"esc": "/url1"}');
add_keyboard_support('elem2', '{"esc": "/url2"}');
add_keyboard_support('elem3', '{"esc": "/url3"}');
// Press ESC → ALL 3 trigger immediately (no longer sequences exist)
```
**Example 2**: Mixed - one has longer sequence
```javascript
add_keyboard_support('elem1', '{"A": "/url1"}');
add_keyboard_support('elem2', '{"A": "/url2"}');
add_keyboard_support('elem3', '{"A": "/url3", "A B": "/url3b"}');
// Press A:
// - elem3 has "A B" possible → EVERYONE WAITS 500ms
// - If B arrives: only elem3 triggers with "A B"
// - If timeout expires: elem1, elem2, elem3 ALL trigger with "A"
```
**Example 3**: Different combinations
```javascript
add_keyboard_support('elem1', '{"A B": "/url1"}');
add_keyboard_support('elem2', '{"C D": "/url2"}');
// Press A: elem1 waits for B, elem2 not affected
// Press C: elem2 waits for D, elem1 not affected
```
The timeout is tied to the **sequence being typed**, not to individual elements.
### Smart Timeout Logic (Longest Match)
Keyboard shortcuts are **disabled** when typing in input fields:
- `<input>` elements
- `<textarea>` elements
- Any `contenteditable` element
This ensures normal typing (Ctrl+C, Ctrl+A, etc.) works as expected in forms.
## How to Test
1. **Download both files** to the same directory
2. **Open `test_keyboard_support.html`** in a web browser
3. **Try the configured shortcuts**:
- `a` - Simple key (waits if "A B" might follow)
- `Ctrl+S` - Save combination (immediate)
- `Ctrl+C` - Copy combination (waits because "Ctrl+C C" exists)
- `A B` - Sequence (waits because "A B C" exists)
- `A B C` - Triple sequence (triggers immediately)
- `Ctrl+C C` - Press Ctrl+C together, release, then press C alone
- `Ctrl+C Ctrl+C` - Press Ctrl+C, keep Ctrl, release C, press C again
- `shift shift` - Press Shift twice in sequence
- `esc` - Escape key (immediate)
4. **Test focus behavior**:
- Click on the test element to focus it (turns blue)
- Try shortcuts with focus
- Click outside to remove focus
- Try shortcuts without focus
- The log shows whether the element had focus when triggered
5. **Test input protection**:
- Try typing in the input field
- Use Ctrl+C, Ctrl+A, etc. - should work normally
- Shortcuts should NOT trigger while typing in input
## Expected Behavior
### Smart Timeout Examples
**Scenario 1**: Only "A" is configured (no element has "A B")
- Press A → Triggers **immediately**
**Scenario 2**: At least one element has "A B"
- Press A → **ALL elements with "A" wait 500ms**
- If B pressed within 500ms → Only elements with "A B" trigger
- If timeout expires → ALL elements with "A" trigger
**Scenario 3**: "A", "A B", and "A B C" all configured (same or different elements)
- Press A → Waits (because "A B" exists)
- Press B → Waits (because "A B C" exists)
- Press C → Triggers "A B C" **immediately**
**Scenario 4**: Multiple elements, ESC on all
```javascript
add_keyboard_support('modal', '{"esc": "/close"}');
add_keyboard_support('editor', '{"esc": "/cancel"}');
```
- Press ESC → **Both trigger simultaneously** (no longer sequences)
## Integration in Your Project
## Integration in Your Project
### Configuration Format
The library now uses **HTMX configuration objects** instead of simple URL strings:
```python
# New format with HTMX configuration
combinations = {
"Ctrl+S": {
"hx-post": "/save-url",
"hx-target": "#result",
"hx-swap": "innerHTML"
},
"A B": {
"hx-post": "/sequence-url",
"hx-vals": {"extra": "data"}
},
"esc": {
"hx-get": "/cancel-url"
}
}
# This will generate the JavaScript call
f"add_keyboard_support('{element_id}', '{json.dumps(combinations)}')"
```
### Supported HTMX Attributes
You can use any HTMX attribute in the configuration object:
**HTTP Methods** (one required):
- `hx-post` - POST request
- `hx-get` - GET request
- `hx-put` - PUT request
- `hx-delete` - DELETE request
- `hx-patch` - PATCH request
**Common Options**:
- `hx-target` - Target element selector
- `hx-swap` - Swap strategy (innerHTML, outerHTML, etc.)
- `hx-vals` - Additional values to send (object)
- `hx-headers` - Custom headers (object)
- `hx-select` - Select specific content from response
- `hx-confirm` - Confirmation message
All other `hx-*` attributes are supported and will be converted to the appropriate htmx.ajax() parameters.
### Automatic Parameters
The library automatically adds these parameters to every request:
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
- `has_focus` - Boolean indicating if the element had focus
Example final request:
```javascript
htmx.ajax('POST', '/save-url', {
target: '#result',
swap: 'innerHTML',
values: {
extra: "data", // from hx-vals
combination: "Ctrl+S", // automatic
has_focus: true // automatic
}
})
```
## Integration Examples
### Basic Usage
```python
combinations = {
"Ctrl+S": {
"hx-post": "/save"
}
}
```
### With Target and Swap
```python
combinations = {
"Ctrl+D": {
"hx-delete": "/item",
"hx-target": "#item-list",
"hx-swap": "outerHTML"
}
}
```
### With Extra Values
```python
combinations = {
"Ctrl+N": {
"hx-post": "/create",
"hx-vals": json.dumps({"type": "quick", "mode": "keyboard"})
}
}
```
### Multiple Elements Example
```python
# Modal close
modal_combinations = {
"esc": {
"hx-post": "/modal/close",
"hx-target": "#modal",
"hx-swap": "outerHTML"
}
}
# Editor cancel
editor_combinations = {
"esc": {
"hx-post": "/editor/cancel",
"hx-target": "#editor",
"hx-swap": "innerHTML"
}
}
# Both will trigger when ESC is pressed
f"add_keyboard_support('modal', '{json.dumps(modal_combinations)}')"
f"add_keyboard_support('editor', '{json.dumps(editor_combinations)}')"
```
## Technical Details
### Trie-based Matching
The library uses a prefix tree (trie) data structure:
- Each node represents a keyboard snapshot (set of pressed keys)
- Leaf nodes contain the HTMX configuration object
- Intermediate nodes indicate longer sequences exist
- Enables efficient O(n) matching where n is sequence length
### HTMX Integration
Configuration objects are mapped to htmx.ajax() calls:
- `hx-*` attributes are converted to camelCase parameters
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
- `combination` and `has_focus` are automatically added to values
- All standard HTMX options are supported
### Key Normalization
- Case-insensitive: "ctrl" = "Ctrl" = "CTRL"
- Mapped keys: "Control" → "ctrl", "Escape" → "esc", "Delete" → "del"
- Simultaneous keys represented as sorted sets
## Notes
- The test page mocks `htmx.ajax` to display results in the console
- In production, real AJAX calls will be made to your backend
- Sequence timeout is 500ms between keys
- Maximum 10 snapshots kept in history to prevent memory issues

View File

@@ -265,4 +265,403 @@ function updateTabs(controllerId) {
});
}
}
}
}
/**
* Create keyboard bindings
*/
(function() {
/**
* Global registry to store keyboard shortcuts for multiple elements
*/
const KeyboardRegistry = {
elements: new Map(), // elementId -> { trie, element }
listenerAttached: false,
currentKeys: new Set(),
snapshotHistory: [],
pendingTimeout: null,
pendingMatches: [], // Array of matches waiting for timeout
sequenceTimeout: 500 // 500ms timeout for sequences
};
/**
* Normalize key names to lowercase for case-insensitive comparison
* @param {string} key - The key to normalize
* @returns {string} - Normalized key name
*/
function normalizeKey(key) {
const keyMap = {
'control': 'ctrl',
'escape': 'esc',
'delete': 'del'
};
const normalized = key.toLowerCase();
return keyMap[normalized] || normalized;
}
/**
* Create a unique string key from a Set of keys for Map indexing
* @param {Set} keySet - Set of normalized keys
* @returns {string} - Sorted string representation
*/
function setToKey(keySet) {
return Array.from(keySet).sort().join('+');
}
/**
* Parse a single element (can be a single key or a simultaneous combination)
* @param {string} element - The element string (e.g., "a" or "Ctrl+C")
* @returns {Set} - Set of normalized keys
*/
function parseElement(element) {
if (element.includes('+')) {
// Simultaneous combination
return new Set(element.split('+').map(k => normalizeKey(k.trim())));
}
// Single key
return new Set([normalizeKey(element.trim())]);
}
/**
* Parse a combination string into sequence elements
* @param {string} combination - The combination string (e.g., "Ctrl+C C" or "A B C")
* @returns {Array} - Array of Sets representing the sequence
*/
function parseCombination(combination) {
// Check if it's a sequence (contains space)
if (combination.includes(' ')) {
return combination.split(' ').map(el => parseElement(el.trim()));
}
// Single element (can be a key or simultaneous combination)
return [parseElement(combination)];
}
/**
* Create a new trie node
* @returns {Object} - New trie node
*/
function createTrieNode() {
return {
config: null,
combinationStr: null,
children: new Map()
};
}
/**
* Build a trie from combinations
* @param {Object} combinations - Map of combination strings to HTMX config objects
* @returns {Object} - Root trie node
*/
function buildTrie(combinations) {
const root = createTrieNode();
for (const [combinationStr, config] of Object.entries(combinations)) {
const sequence = parseCombination(combinationStr);
let currentNode = root;
for (const keySet of sequence) {
const key = setToKey(keySet);
if (!currentNode.children.has(key)) {
currentNode.children.set(key, createTrieNode());
}
currentNode = currentNode.children.get(key);
}
// Mark as end of sequence and store config
currentNode.config = config;
currentNode.combinationStr = combinationStr;
}
return root;
}
/**
* Traverse the trie with the current snapshot history
* @param {Object} trieRoot - Root of the trie
* @param {Array} snapshotHistory - Array of Sets representing pressed keys
* @returns {Object|null} - Current node or null if no match
*/
function traverseTrie(trieRoot, snapshotHistory) {
let currentNode = trieRoot;
for (const snapshot of snapshotHistory) {
const key = setToKey(snapshot);
if (!currentNode.children.has(key)) {
return null;
}
currentNode = currentNode.children.get(key);
}
return currentNode;
}
/**
* Check if we're inside an input element where typing should work normally
* @returns {boolean} - True if inside an input-like element
*/
function isInInputContext() {
const activeElement = document.activeElement;
if (!activeElement) return false;
const tagName = activeElement.tagName.toLowerCase();
// Check for input/textarea
if (tagName === 'input' || tagName === 'textarea') {
return true;
}
// Check for contenteditable
if (activeElement.isContentEditable) {
return true;
}
return false;
}
/**
* Trigger an action for a matched combination
* @param {string} elementId - ID of the element
* @param {Object} config - HTMX configuration object
* @param {string} combinationStr - The matched combination string
*/
function triggerAction(elementId, config, combinationStr) {
const element = document.getElementById(elementId);
if (!element) return;
const hasFocus = document.activeElement === element;
// Extract HTTP method and URL from hx-* attributes
let method = 'POST'; // default
let url = null;
const methodMap = {
'hx-post': 'POST',
'hx-get': 'GET',
'hx-put': 'PUT',
'hx-delete': 'DELETE',
'hx-patch': 'PATCH'
};
for (const [attr, httpMethod] of Object.entries(methodMap)) {
if (config[attr]) {
method = httpMethod;
url = config[attr];
break;
}
}
if (!url) {
console.error('No HTTP method attribute found in config:', config);
return;
}
// Build htmx.ajax options
const htmxOptions = {};
// Map hx-target to target
if (config['hx-target']) {
htmxOptions.target = config['hx-target'];
}
// Map hx-swap to swap
if (config['hx-swap']) {
htmxOptions.swap = config['hx-swap'];
}
// Map hx-vals to values and add combination and has_focus
const values = {};
if (config['hx-vals']) {
Object.assign(values, config['hx-vals']);
}
values.combination = combinationStr;
values.has_focus = hasFocus;
htmxOptions.values = values;
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
for (const [key, value] of Object.entries(config)) {
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
// Remove 'hx-' prefix and convert to camelCase
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
htmxOptions[optionKey] = value;
}
}
// Make AJAX call with htmx
htmx.ajax(method, url, htmxOptions);
}
/**
* Handle keyboard events and trigger matching combinations
* @param {KeyboardEvent} event - The keyboard event
*/
function handleKeyboardEvent(event) {
const key = normalizeKey(event.key);
// Add key to current pressed keys
KeyboardRegistry.currentKeys.add(key);
// Create a snapshot of current keyboard state
const snapshot = new Set(KeyboardRegistry.currentKeys);
// Add snapshot to history
KeyboardRegistry.snapshotHistory.push(snapshot);
// Cancel any pending timeout
if (KeyboardRegistry.pendingTimeout) {
clearTimeout(KeyboardRegistry.pendingTimeout);
KeyboardRegistry.pendingTimeout = null;
KeyboardRegistry.pendingMatches = [];
}
// Collect match information for all elements
const currentMatches = [];
let anyHasLongerSequence = false;
let foundAnyMatch = false;
// Check all registered elements for matching combinations
for (const [elementId, data] of KeyboardRegistry.elements) {
const element = document.getElementById(elementId);
if (!element) continue;
const trieRoot = data.trie;
// Traverse the trie with current snapshot history
const currentNode = traverseTrie(trieRoot, KeyboardRegistry.snapshotHistory);
if (!currentNode) {
// No match in this trie, continue to next element
continue;
}
// We found at least a partial match
foundAnyMatch = true;
// Check if we have a match (node has a URL)
const hasMatch = currentNode.config !== null;
// Check if there are longer sequences possible (node has children)
const hasLongerSequences = currentNode.children.size > 0;
// Track if ANY element has longer sequences possible
if (hasLongerSequences) {
anyHasLongerSequence = true;
}
// Collect matches
if (hasMatch) {
currentMatches.push({
elementId: elementId,
config: currentNode.config,
combinationStr: currentNode.combinationStr
});
}
}
// Prevent default if we found any match and not in input context
if (currentMatches.length > 0 && !isInInputContext()) {
event.preventDefault();
}
// Decision logic based on matches and longer sequences
if (currentMatches.length > 0 && !anyHasLongerSequence) {
// We have matches and NO element has longer sequences possible
// Trigger ALL matches immediately
for (const match of currentMatches) {
triggerAction(match.elementId, match.config, match.combinationStr);
}
// Clear history after triggering
KeyboardRegistry.snapshotHistory = [];
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
// We have matches but AT LEAST ONE element has longer sequences possible
// Wait for timeout - ALL current matches will be triggered if timeout expires
KeyboardRegistry.pendingMatches = currentMatches;
KeyboardRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches
for (const match of KeyboardRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr);
}
// Clear state
KeyboardRegistry.snapshotHistory = [];
KeyboardRegistry.pendingMatches = [];
KeyboardRegistry.pendingTimeout = null;
}, KeyboardRegistry.sequenceTimeout);
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
// No matches yet but longer sequences are possible
// Just wait, don't trigger anything
} else {
// No matches and no longer sequences possible
// This is an invalid sequence - clear history
KeyboardRegistry.snapshotHistory = [];
}
// If we found no match at all, clear the history
// This handles invalid sequences like "A C" when only "A B" exists
if (!foundAnyMatch) {
KeyboardRegistry.snapshotHistory = [];
}
// Also clear history if it gets too long (prevent memory issues)
if (KeyboardRegistry.snapshotHistory.length > 10) {
KeyboardRegistry.snapshotHistory = [];
}
}
/**
* Handle keyup event to remove keys from current pressed keys
* @param {KeyboardEvent} event - The keyboard event
*/
function handleKeyUp(event) {
const key = normalizeKey(event.key);
KeyboardRegistry.currentKeys.delete(key);
}
/**
* Attach the global keyboard event listener if not already attached
*/
function attachGlobalListener() {
if (!KeyboardRegistry.listenerAttached) {
document.addEventListener('keydown', handleKeyboardEvent);
document.addEventListener('keyup', handleKeyUp);
KeyboardRegistry.listenerAttached = true;
}
}
/**
* Add keyboard support to an element
* @param {string} elementId - The ID of the element
* @param {string} combinationsJson - JSON string of combinations mapping
*/
window.add_keyboard_support = function(elementId, combinationsJson) {
// Parse the combinations JSON
const combinations = JSON.parse(combinationsJson);
// Build trie for this element
const trie = buildTrie(combinations);
// Get element reference
const element = document.getElementById(elementId);
// Add to registry
KeyboardRegistry.elements.set(elementId, {
trie: trie,
element: element
});
// Attach global listener if not already attached
attachGlobalListener();
};
})();

View File

@@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Keyboard Support Test</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
}
.test-container {
border: 2px solid #333;
padding: 20px;
margin: 20px 0;
border-radius: 5px;
}
.test-element {
background-color: #f0f0f0;
border: 2px solid #999;
padding: 30px;
text-align: center;
border-radius: 5px;
cursor: pointer;
margin: 10px 0;
}
.test-element:focus {
background-color: #e3f2fd;
border-color: #2196F3;
outline: none;
}
.log-container {
background-color: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 5px;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 14px;
margin-top: 20px;
}
.log-entry {
margin: 5px 0;
padding: 5px;
border-left: 3px solid #4CAF50;
padding-left: 10px;
}
.log-entry.focus {
border-left-color: #2196F3;
}
.log-entry.no-focus {
border-left-color: #FF9800;
}
.shortcuts-list {
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 15px;
border-radius: 5px;
margin: 10px 0;
}
.shortcuts-list h3 {
margin-top: 0;
}
.shortcuts-list ul {
margin: 10px 0;
padding-left: 20px;
}
.shortcuts-list code {
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.clear-button {
background-color: #f44336;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
}
.clear-button:hover {
background-color: #d32f2f;
}
h1, h2 {
color: #333;
}
</style>
</head>
<body>
<h1>Keyboard Support Test Page</h1>
<div class="shortcuts-list">
<h3>📋 Configured Shortcuts (with HTMX options)</h3>
<p><strong>Simple keys:</strong></p>
<ul>
<li><code>a</code> - Simple key A (POST to /test/key-a)</li>
<li><code>esc</code> - Escape key (POST)</li>
</ul>
<p><strong>Simultaneous combinations with HTMX options:</strong></p>
<ul>
<li><code>Ctrl+S</code> - Save (POST with swap: innerHTML)</li>
<li><code>Ctrl+C</code> - Copy (POST with target: #result, swap: outerHTML)</li>
</ul>
<p><strong>Sequences with various configs:</strong></p>
<ul>
<li><code>A B</code> - Sequence (POST with extra values: {"extra": "data"})</li>
<li><code>A B C</code> - Triple sequence (GET request)</li>
<li><code>shift shift</code> - Press Shift twice in sequence</li>
</ul>
<p><strong>Complex:</strong></p>
<ul>
<li><code>Ctrl+C C</code> - Ctrl+C then C alone</li>
<li><code>Ctrl+C Ctrl+C</code> - Ctrl+C twice</li>
</ul>
<p><strong>Tip:</strong> Check the log to see how HTMX options (target, swap, extra values) are passed!</p>
</div>
<div class="test-container">
<h2>Test Input (typing should work normally here)</h2>
<input type="text" placeholder="Try typing Ctrl+C, Ctrl+A here - should work normally" style="width: 100%; padding: 10px; font-size: 14px;">
</div>
<div class="test-container">
<h2>Test Element 1</h2>
<div id="test-element" class="test-element" tabindex="0">
Click me to focus, then try keyboard shortcuts
</div>
</div>
<div class="test-container">
<h2>Test Element 2 (also listens to ESC and Shift Shift)</h2>
<div id="test-element-2" class="test-element" tabindex="0">
This element also responds to ESC and Shift Shift
</div>
</div>
<div class="test-container">
<h2>Event Log</h2>
<button class="clear-button" onclick="clearLog()">Clear Log</button>
<div id="log" class="log-container"></div>
</div>
<!-- Include htmx -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Mock htmx.ajax for testing -->
<script>
// Store original htmx.ajax if it exists
const originalHtmxAjax = window.htmx && window.htmx.ajax;
// Override htmx.ajax for testing purposes
if (window.htmx) {
window.htmx.ajax = function(method, url, config) {
const timestamp = new Date().toLocaleTimeString();
const hasFocus = config.values.has_focus;
const combination = config.values.combination;
// Build details string with all config options
const details = [
`Combination: "${combination}"`,
`Element has focus: ${hasFocus}`
];
if (config.target) {
details.push(`Target: ${config.target}`);
}
if (config.swap) {
details.push(`Swap: ${config.swap}`);
}
if (config.values) {
const extraVals = Object.keys(config.values).filter(k => k !== 'combination' && k !== 'has_focus');
if (extraVals.length > 0) {
details.push(`Extra values: ${JSON.stringify(extraVals.reduce((obj, k) => ({...obj, [k]: config.values[k]}), {}))}`);
}
}
logEvent(
`[${timestamp}] ${method} ${url}`,
...details,
hasFocus
);
// Uncomment below to use real htmx.ajax if you have a backend
// if (originalHtmxAjax) {
// originalHtmxAjax.call(this, method, url, config);
// }
};
}
function logEvent(title, ...details) {
const log = document.getElementById('log');
const hasFocus = details[details.length - 1];
const entry = document.createElement('div');
entry.className = `log-entry ${hasFocus ? 'focus' : 'no-focus'}`;
entry.innerHTML = `
<strong>${title}</strong><br>
${details.slice(0, -1).join('<br>')}
`;
log.insertBefore(entry, log.firstChild);
}
function clearLog() {
document.getElementById('log').innerHTML = '';
}
</script>
<!-- Include keyboard support script -->
<script src="keyboard_support.js"></script>
<!-- Initialize keyboard support -->
<script>
const combinations = {
"a": {
"hx-post": "/test/key-a"
},
"Ctrl+S": {
"hx-post": "/test/save",
"hx-swap": "innerHTML"
},
"Ctrl+C": {
"hx-post": "/test/copy",
"hx-target": "#result",
"hx-swap": "outerHTML"
},
"A B": {
"hx-post": "/test/sequence-ab",
"hx-vals": {"extra": "data"}
},
"A B C": {
"hx-get": "/test/sequence-abc"
},
"Ctrl+C C": {
"hx-post": "/test/complex-ctrl-c-c"
},
"Ctrl+C Ctrl+C": {
"hx-post": "/test/complex-ctrl-c-twice"
},
"shift shift": {
"hx-post": "/test/shift-shift"
},
"esc": {
"hx-post": "/test/escape"
}
};
add_keyboard_support('test-element', JSON.stringify(combinations));
// Add second element that also listens to ESC and shift shift
const combinations2 = {
"esc": {
"hx-post": "/test/escape-element2"
},
"shift shift": {
"hx-post": "/test/shift-shift-element2"
}
};
add_keyboard_support('test-element-2', JSON.stringify(combinations2));
// Log initial state
logEvent('Keyboard support initialized',
'Element 1: All shortcuts configured with HTMX options',
'Element 2: ESC and Shift Shift (will trigger simultaneously with Element 1)',
'Smart timeout enabled: waits 500ms only if longer sequence exists', false);
</script>
</body>
</html>

View File

@@ -15,7 +15,7 @@ from fasthtml.common import RedirectResponse, Beforeware
from jose import jwt, JWTError
# Configuration
API_BASE_URL = "http://localhost:5001" # Base URL for FastAPI backend
API_BASE_URL = "http://localhost:5003" # Base URL for FastAPI backend
JWT_SECRET = "jwt-secret-to-change" # Must match FastAPI secret
JWT_ALGORITHM = "HS256"
TOKEN_REFRESH_THRESHOLD_MINUTES = 5 # Refresh token if expires in less than 5 minutes

View File

@@ -1,12 +1,18 @@
from dataclasses import dataclass
import logging
from io import BytesIO
import pandas as pd
from fastapi import UploadFile
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.bindings import Binding, LambdaConverter
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
logger = logging.getLogger("FileUpload")
class FileUploadState(DbObject):
def __init__(self, owner):
@@ -15,34 +21,77 @@ class FileUploadState(DbObject):
# persisted in DB
# must not be persisted in DB (prefix ns_ = no_saving_)
self.ns_file_name: str = ""
self.ns_file_name: str | None = None
self.ns_sheets_names: list | None = None
self.ns_selected_sheet_name: str | None = None
class Commands(BaseCommands):
def __init__(self, owner):
super().__init__(owner)
def upload_file(self):
return Command("UploadFile", "Upload file", self._owner.upload_file).htmx(target=f"#sn_{self._id}")
class FileUpload(MultipleInstance):
@dataclass
class BindingData:
filename: str = ""
def __init__(self, parent, _id=None):
super().__init__(Ids.FileUpload, parent, _id=_id)
self._binding = self.BindingData()
self.commands = Commands(self)
self._state = FileUploadState(self)
def upload_file(self, file: UploadFile):
logger.debug(f"upload_file: {file=}")
if file:
file_content = file.file.read()
self._state.ns_sheets_names = self.get_sheets_names(file_content)
self._state.ns_selected_sheet_name = self._state.ns_sheets_names[0] if len(self._state.ns_sheets_names) > 0 else 0
return self.mk_sheet_selector()
def mk_sheet_selector(self):
options = [Option("Choose a file...", selected=True, disabled=True)] if self._state.ns_sheets_names is None else \
[Option(
name,
selected=True if name == self._state.ns_selected_sheet_name else None,
) for name in self._state.ns_sheets_names]
return Select(
*options,
name="sheet_name",
id=f"sn_{self._id}", # sn stands for 'sheet name'
cls="select select-bordered select-sm w-full ml-2"
)
@staticmethod
def get_sheets_names(file_content):
try:
excel_file = pd.ExcelFile(BytesIO(file_content))
sheet_names = excel_file.sheet_names
except Exception as ex:
logger.error(f"get_sheets_names: {ex=}")
sheet_names = []
return sheet_names
def render(self):
return Div(
mk.mk(Input(type='file',
name='file',
id=f"fn_{self._id}", # fn stands for 'file name'
value=self._state.ns_file_name,
hx_preserve="true",
hx_encoding='multipart/form-data',
cls="file-input file-input-bordered file-input-sm w-full",
),
binding=Binding(self._binding, "filename")
),
mk.mk(Label(), binding=Binding(self._binding, "filename",
LambdaConverter(lambda x: x.filename if hasattr(x, "filename") else x))),
cls="flex"
Div(
mk.mk(Input(type='file',
name='file',
id=f"fi_{self._id}", # fn stands for 'file name'
value=self._state.ns_file_name,
hx_preserve="true",
hx_encoding='multipart/form-data',
cls="file-input file-input-bordered file-input-sm w-full",
),
command=self.commands.upload_file()
),
self.mk_sheet_selector(),
cls="flex"
),
mk.dialog_buttons(),
)
def __ft__(self):

View File

@@ -0,0 +1,24 @@
import json
from fasthtml.xtend import Script
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.commands import BaseCommand
from myfasthtml.core.instances import MultipleInstance
class Keyboard(MultipleInstance):
def __init__(self, parent, _id=None, combinations=None):
super().__init__(Ids.Keyboard, parent)
self.combinations = combinations or {}
def add(self, sequence: str, command: BaseCommand):
self.combinations[sequence] = command
return self
def render(self):
str_combinations = {sequence: command.get_htmx_params() for sequence, command in self.combinations.items()}
return Script(f"add_keyboard_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
def __ft__(self):
return self.render()

View File

@@ -13,6 +13,7 @@ class Ids:
DbManager = "mf-dbmanager"
FileUpload = "mf-file-upload"
InstancesDebugger = "mf-instances-debugger"
Keyboard = "mf-keyboard"
Layout = "mf-layout"
Root = "mf-root"
Search = "mf-search"
@@ -27,6 +28,21 @@ class mk:
def button(element, command: Command = None, binding: Binding = None, **kwargs):
return mk.mk(Button(element, **kwargs), command=command, binding=binding)
@staticmethod
def dialog_buttons(ok_title: str = "OK",
cancel_title: str = "Cancel",
on_ok: Command = None,
on_cancel: Command = None,
cls=None):
return Div(
Div(
mk.button(ok_title, cls="btn btn-primary btn-sm mr-2", command=on_ok),
mk.button(cancel_title, cls="btn btn-ghost btn-sm", command=on_cancel),
cls="flex justify-end"
),
cls=merge_classes("flex justify-end w-full mt-1", cls)
)
@staticmethod
def icon(icon, size=20,
can_select=True,
@@ -87,6 +103,6 @@ class mk:
@staticmethod
def mk(ft, command: Command = None, binding: Binding = None, init_binding=True):
ft = mk.manage_command(ft, command)
ft = mk.manage_binding(ft, binding, init_binding=init_binding)
ft = mk.manage_command(ft, command) if command else ft
ft = mk.manage_binding(ft, binding, init_binding=init_binding) if binding else ft
return ft

View File

@@ -39,7 +39,7 @@ class BaseCommand:
return {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"c_id": "{self.id}"}}',
"hx-vals": {"c_id": f"{self.id}"},
} | self._htmx_extra
def execute(self, client_response: dict = None):

View File

@@ -248,6 +248,7 @@ def post(session, c_id: str, client_response: dict = None):
from myfasthtml.core.commands import CommandsManager
command = CommandsManager.get_command(c_id)
if command:
logger.debug(f"Executing command {command.name}.")
return command.execute(client_response)
raise ValueError(f"Command with ID '{c_id}' not found.")

View File

@@ -410,3 +410,59 @@ def matches(actual, expected, path=""):
_expected=expected)
return True
def find(ft, expected):
res = []
def _type(x):
return type(x)
def _same(_ft, _expected):
if _ft == _expected:
return True
if _ft.tag != _expected.tag:
return False
for attr in _expected.attrs:
if attr not in _ft.attrs or _ft.attrs[attr] != _expected.attrs[attr]:
return False
for expected_child in _expected.children:
for ft_child in _ft.children:
if _same(ft_child, expected_child):
break
else:
return False
return True
def _find(current, current_expected):
if _type(current) != _type(current_expected):
return []
if not hasattr(current, "tag"):
return [current] if current == current_expected else []
_found = []
if _same(current, current_expected):
_found.append(current)
# look at the children
for child in current.children:
_found.extend(_find(child, current_expected))
return _found
ft_as_list = [ft] if not isinstance(ft, (list, tuple, set)) else ft
for current_ft in ft_as_list:
found = _find(current_ft, expected)
res.extend(found)
if len(res) == 0:
raise AssertionError(f"No element found for '{expected}'")
return res