init
This commit is contained in:
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
68
CLAUDE.md
Normal file
68
CLAUDE.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# NiceGUI Extensions (niceguiex) - Project Overview
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
This project is a collection of extensions for NiceGUI, organized as a modular package that can be extended with new components and utilities.
|
||||||
|
|
||||||
|
### Current Structure
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
niceguiex/
|
||||||
|
__init__.py
|
||||||
|
async_elements/
|
||||||
|
__init__.py
|
||||||
|
base.py # AsyncElement base class
|
||||||
|
elements.py # Pre-built async element types
|
||||||
|
chat_input.py # ChatInput component (to be moved into package)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
### 1. Async Elements (`niceguiex.async_elements`)
|
||||||
|
Provides async-aware UI elements that can perform async operations during initialization while maintaining full typing support.
|
||||||
|
|
||||||
|
**Base Class:** `AsyncElement[T]` - Generic base for creating async elements
|
||||||
|
**Pre-built Elements:**
|
||||||
|
- AsyncColumn, AsyncRow, AsyncCard
|
||||||
|
- AsyncDialog, AsyncTabs, AsyncScrollArea
|
||||||
|
- AsyncExpansion, AsyncCarousel, AsyncMenu
|
||||||
|
- And more...
|
||||||
|
|
||||||
|
### 2. ChatInput Component
|
||||||
|
A textarea component optimized for chat interfaces:
|
||||||
|
- Enter to send message
|
||||||
|
- Shift+Enter for new line
|
||||||
|
- Callback-based message handling
|
||||||
|
- Currently in `chat_input.py`, should be moved to package structure
|
||||||
|
|
||||||
|
## Import Convention
|
||||||
|
|
||||||
|
The package uses a submodule structure to organize different types of extensions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from niceguiex.async_elements import AsyncColumn, AsyncCard
|
||||||
|
from chat_input import ChatInput # To be: from niceguiex.inputs import ChatInput
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- **Async Module Name:** Originally planned as `niceguiex.async` but changed to `async_elements` because `async` is a Python reserved keyword
|
||||||
|
- **Type Safety:** All components maintain full type hints for IDE support
|
||||||
|
- **Inheritance Pattern:** Components can be used via generic `AsyncElement[T]` or by inheriting from specific types
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
The package structure is designed to accommodate:
|
||||||
|
- Input components (ChatInput, forms, etc.)
|
||||||
|
- Layout helpers
|
||||||
|
- Data visualization components
|
||||||
|
- Utility functions
|
||||||
|
- Theme and styling extensions
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
When adding new components:
|
||||||
|
1. Consider which submodule they belong in
|
||||||
|
2. Maintain full typing support
|
||||||
|
3. Provide both generic and inheritance-based usage patterns where applicable
|
||||||
|
4. Include proper async/await support where needed
|
||||||
126
README.md
126
README.md
@@ -1,2 +1,126 @@
|
|||||||
# NiceGUIEx
|
# NiceGUI Extensions (niceguiex)
|
||||||
|
|
||||||
|
Extensions for NiceGUI including async elements with proper typing support and more. Create powerful UI components while maintaining full type hints and IDE support.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install directly from GitHub using uv or pip:
|
||||||
|
|
||||||
|
### Using uv
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add git+https://github.com/yourusername/niceguiex.git
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using pip
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install git+https://github.com/yourusername/niceguiex.git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nicegui import ui
|
||||||
|
from niceguiex.async_elements import AsyncElement, AsyncColumn, AsyncCard
|
||||||
|
|
||||||
|
# Create an async element that fetches data
|
||||||
|
class UserCard(AsyncElement[ui.column]):
|
||||||
|
async def build(self, user_id: int) -> None:
|
||||||
|
# Simulate async data fetching
|
||||||
|
user_data = await fetch_user(user_id)
|
||||||
|
|
||||||
|
with self:
|
||||||
|
with ui.card():
|
||||||
|
ui.label(user_data['name'])
|
||||||
|
ui.label(user_data['email'])
|
||||||
|
|
||||||
|
# Use in your NiceGUI app
|
||||||
|
@ui.page('/')
|
||||||
|
async def main():
|
||||||
|
# Creates the element and returns a properly typed ui.column
|
||||||
|
user_card = await UserCard.create(user_id=123)
|
||||||
|
user_card.classes('w-full') # Full IDE support!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Inheritance
|
||||||
|
|
||||||
|
For even better typing, inherit from specific element types:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class DataTable(AsyncColumn):
|
||||||
|
async def build(self, api_endpoint: str) -> None:
|
||||||
|
# Fetch data asynchronously
|
||||||
|
data = await fetch_data(api_endpoint)
|
||||||
|
|
||||||
|
with self:
|
||||||
|
ui.table(columns=data['columns'], rows=data['rows'])
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
data_table = await DataTable.create(api_endpoint="/api/users")
|
||||||
|
# data_table is fully typed as DataTable/AsyncColumn
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Async Elements Module (`niceguiex.async_elements`)
|
||||||
|
- **Async Initialization**: Build UI elements that require async operations (API calls, database queries, etc.)
|
||||||
|
- **Full Type Support**: Maintains complete typing for IDE autocomplete and type checking
|
||||||
|
- **Context Manager Support**: Works seamlessly with NiceGUI's context manager pattern
|
||||||
|
- **Multiple Approaches**: Choose between generic `AsyncElement` or inherit from specific element types
|
||||||
|
- **Pre-built Async Components**: `AsyncColumn`, `AsyncRow`, `AsyncCard`, `AsyncDialog`, `AsyncTabs`, `AsyncScrollArea`, and more
|
||||||
|
|
||||||
|
### Additional Components
|
||||||
|
|
||||||
|
#### AutoScrollArea
|
||||||
|
Automatically scrolling area that follows content as it's added:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from niceguiex.async_elements import AsyncScrollArea
|
||||||
|
|
||||||
|
class AutoScrollArea(AsyncScrollArea):
|
||||||
|
async def build(self) -> None:
|
||||||
|
# Automatically scrolls to bottom when content is added
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
auto_scroll = await AutoScrollArea.create()
|
||||||
|
with auto_scroll:
|
||||||
|
for message in messages:
|
||||||
|
ui.label(message)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ChatInput
|
||||||
|
Enhanced textarea for chat interfaces with Enter to send, Shift+Enter for new line:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from chat_input import ChatInput
|
||||||
|
|
||||||
|
async def handle_message(message: str):
|
||||||
|
print(f"User sent: {message}")
|
||||||
|
|
||||||
|
chat = ChatInput(
|
||||||
|
placeholder='Type your message...',
|
||||||
|
on_enter=handle_message
|
||||||
|
).classes('w-full')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coming Soon
|
||||||
|
- Additional UI component extensions
|
||||||
|
- Helper utilities for common patterns
|
||||||
|
- Enhanced form handling
|
||||||
|
- And more!
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Run the example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python example.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
89
example_async.py
Normal file
89
example_async.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from nicegui import ui
|
||||||
|
from niceguiex.async_elements import AsyncElement, AsyncColumn, AsyncCard
|
||||||
|
|
||||||
|
|
||||||
|
# Example using the generic AsyncElement (returns proper NiceGUI element type)
|
||||||
|
class UserCard(AsyncElement[ui.column]):
|
||||||
|
async def build(self, user_id: int, *args, **kwargs) -> None:
|
||||||
|
user_data = {
|
||||||
|
'name': f'User {user_id}',
|
||||||
|
'email': f'user{user_id}@example.com',
|
||||||
|
'status': 'online' if user_id % 2 == 0 else 'offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
with self:
|
||||||
|
with ui.card().classes('w-full max-w-md'):
|
||||||
|
ui.label(user_data['name']).classes('text-h6')
|
||||||
|
ui.label(user_data['email']).classes('text-caption')
|
||||||
|
|
||||||
|
with ui.row().classes('w-full justify-between items-center'):
|
||||||
|
status_color = 'green' if user_data['status'] == 'online' else 'grey'
|
||||||
|
ui.badge(user_data['status']).props(f'color={status_color}')
|
||||||
|
ui.button('Edit', icon='edit').props('flat dense')
|
||||||
|
|
||||||
|
|
||||||
|
# Example using direct inheritance approach
|
||||||
|
class LoadingCard(AsyncCard):
|
||||||
|
async def build(self, title: str, delay: float = 1.0, *args, **kwargs) -> None:
|
||||||
|
with self:
|
||||||
|
ui.label(title).classes('text-h6 mb-2')
|
||||||
|
ui.label('Content loaded successfully!').classes('text-positive')
|
||||||
|
with ui.row().classes('gap-2'):
|
||||||
|
ui.button('Action 1', icon='star')
|
||||||
|
ui.button('Action 2', icon='favorite')
|
||||||
|
|
||||||
|
|
||||||
|
class DataColumn(AsyncColumn):
|
||||||
|
async def build(self, data_source: str, *args, **kwargs) -> None:
|
||||||
|
columns = [
|
||||||
|
{'name': 'name', 'label': 'Name', 'field': 'name', 'required': True, 'align': 'left'},
|
||||||
|
{'name': 'age', 'label': 'Age', 'field': 'age', 'sortable': True},
|
||||||
|
{'name': 'city', 'label': 'City', 'field': 'city'},
|
||||||
|
]
|
||||||
|
rows = [
|
||||||
|
{'id': 1, 'name': 'Alice', 'age': 25, 'city': 'New York'},
|
||||||
|
{'id': 2, 'name': 'Bob', 'age': 30, 'city': 'San Francisco'},
|
||||||
|
{'id': 3, 'name': 'Charlie', 'age': 35, 'city': 'London'},
|
||||||
|
]
|
||||||
|
|
||||||
|
with self:
|
||||||
|
ui.label(f'Data from {data_source}').classes('text-caption mb-2')
|
||||||
|
ui.table(columns=columns, rows=rows, row_key='id').classes('w-full')
|
||||||
|
|
||||||
|
|
||||||
|
@ui.page('/')
|
||||||
|
async def main_page():
|
||||||
|
ui.label('Typed Async Elements Demo').classes('text-h4 mb-4')
|
||||||
|
|
||||||
|
# Example 1: Generic AsyncElement - returns ui.column with proper typing
|
||||||
|
user_card: ui.column = await UserCard.create(element_type=ui.column, user_id=123)
|
||||||
|
user_card.classes('w-full mb-8')
|
||||||
|
|
||||||
|
# Example 2: Direct inheritance - perfect typing, returns the class instance
|
||||||
|
loading_card: LoadingCard = await LoadingCard.create(title="Dashboard Section", delay=0.8)
|
||||||
|
loading_card.classes('w-full p-4 mb-8')
|
||||||
|
|
||||||
|
# Example 3: Column inheritance
|
||||||
|
data_column: DataColumn = await DataColumn.create(data_source="users_api")
|
||||||
|
data_column.classes('w-full mb-8')
|
||||||
|
|
||||||
|
# Example 4: Multiple elements with proper typing
|
||||||
|
with ui.row().classes('w-full gap-4'):
|
||||||
|
for i in range(1, 4):
|
||||||
|
card: ui.column = await UserCard.create(element_type=ui.column, user_id=i)
|
||||||
|
card.classes('flex-1')
|
||||||
|
|
||||||
|
# Example 5: Type checker knows all methods are available
|
||||||
|
another_card = await LoadingCard.create(title="Another Section")
|
||||||
|
another_card.classes('w-full p-4')
|
||||||
|
another_card.style('border: 1px solid red') # Type checker knows this method exists
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ in {"__main__", "__mp_main__"}:
|
||||||
|
ui.run(
|
||||||
|
title='Typed AsyncElements',
|
||||||
|
favicon='🔒',
|
||||||
|
show=False,
|
||||||
|
dark=False,
|
||||||
|
port=8082
|
||||||
|
)
|
||||||
30
example_chat_input.py
Normal file
30
example_chat_input.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from nicegui import ui
|
||||||
|
from niceguiex.components import ChatInput
|
||||||
|
|
||||||
|
|
||||||
|
@ui.page('/')
|
||||||
|
async def main_page():
|
||||||
|
ui.label('Chat Input Demo').classes('text-h4 mb-4')
|
||||||
|
|
||||||
|
output = ui.column().classes('w-full p-4 bg-gray-100 rounded mb-4')
|
||||||
|
|
||||||
|
async def handle_message(message: str):
|
||||||
|
with output:
|
||||||
|
ui.label(f'Sent: {message}').classes('mb-2')
|
||||||
|
|
||||||
|
chat = ChatInput(
|
||||||
|
placeholder='Type your message... (Enter to send, Shift+Enter for new line)',
|
||||||
|
on_enter=handle_message
|
||||||
|
).classes('w-full')
|
||||||
|
|
||||||
|
ui.label('Try typing a message and press Enter to send, or Shift+Enter to add a new line').classes('text-caption text-gray-600')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ in {"__main__", "__mp_main__"}:
|
||||||
|
ui.run(
|
||||||
|
title='Chat Input Demo',
|
||||||
|
favicon='💬',
|
||||||
|
show=False,
|
||||||
|
dark=False,
|
||||||
|
port=8082
|
||||||
|
)
|
||||||
258
example_file_drop.py
Normal file
258
example_file_drop.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
from nicegui import ui
|
||||||
|
from typing import Optional, Callable, List, Dict, Any
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
|
class FileDrop(ui.element):
|
||||||
|
def __init__(self,
|
||||||
|
on_upload: Optional[Callable[[List[Dict[str, Any]]], None]] = None,
|
||||||
|
multiple: bool = True,
|
||||||
|
accept: Optional[str] = None,
|
||||||
|
*args, **kwargs):
|
||||||
|
|
||||||
|
super().__init__(tag='div', *args, **kwargs)
|
||||||
|
|
||||||
|
self.on_upload_callback = on_upload
|
||||||
|
self.multiple = multiple
|
||||||
|
self.accept = accept
|
||||||
|
|
||||||
|
# Create the drop zone with inline styling and JavaScript
|
||||||
|
self.classes('border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer transition-colors')
|
||||||
|
self.classes('hover:border-gray-400 hover:bg-gray-50')
|
||||||
|
self.style('min-height: 120px; display: flex; flex-direction: column; align-items: center; justify-content: center;')
|
||||||
|
|
||||||
|
with self:
|
||||||
|
ui.icon('cloud_upload', size='3em').classes('text-gray-400 mb-4')
|
||||||
|
ui.label('Drop files here or click to browse').classes('text-lg text-gray-600 mb-2')
|
||||||
|
ui.label('Drag and drop your files').classes('text-sm text-gray-400')
|
||||||
|
|
||||||
|
# Hidden file input for click functionality
|
||||||
|
self.file_input = ui.upload(
|
||||||
|
on_upload=self._handle_file_input,
|
||||||
|
multiple=multiple,
|
||||||
|
auto_upload=True,
|
||||||
|
label=''
|
||||||
|
).props('hidden').classes('hidden')
|
||||||
|
|
||||||
|
if accept:
|
||||||
|
self.file_input.props(f'accept="{accept}"')
|
||||||
|
|
||||||
|
# Setup drag and drop with JavaScript
|
||||||
|
self._setup_drag_drop()
|
||||||
|
self.on('click', lambda: self.file_input.run_method('pickFiles'))
|
||||||
|
# Register for the actual drop event
|
||||||
|
self.on('drop', self._handle_drop_event)
|
||||||
|
print(f"DEBUG: Registered drop handler for element {self.id}")
|
||||||
|
|
||||||
|
def _setup_drag_drop(self):
|
||||||
|
# Use timer to ensure element is rendered
|
||||||
|
ui.timer(0.1, lambda: self._add_drag_handlers(), once=True)
|
||||||
|
|
||||||
|
def _add_drag_handlers(self):
|
||||||
|
print(f"DEBUG: About to add drag handlers for element {self.id}")
|
||||||
|
ui.run_javascript(f'''
|
||||||
|
console.log("JavaScript executing for element {self.id}");
|
||||||
|
(() => {{
|
||||||
|
const element = document.getElementById("c{self.id}") || document.getElementById("{self.id}");
|
||||||
|
if (!element) {{
|
||||||
|
console.error("Element not found for drag setup: c{self.id} or {self.id}");
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
console.log("Setting up drag handlers for:", element.id);
|
||||||
|
|
||||||
|
let dragCounter = 0;
|
||||||
|
|
||||||
|
element.addEventListener('dragenter', (e) => {{
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter++;
|
||||||
|
element.classList.add('border-blue-500', 'bg-blue-50');
|
||||||
|
element.classList.remove('border-gray-300');
|
||||||
|
}});
|
||||||
|
|
||||||
|
element.addEventListener('dragleave', (e) => {{
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter--;
|
||||||
|
if (dragCounter === 0) {{
|
||||||
|
element.classList.remove('border-blue-500', 'bg-blue-50');
|
||||||
|
element.classList.add('border-gray-300');
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
element.addEventListener('dragover', (e) => {{
|
||||||
|
e.preventDefault();
|
||||||
|
}});
|
||||||
|
|
||||||
|
element.addEventListener('drop', async (e) => {{
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter = 0;
|
||||||
|
element.classList.remove('border-blue-500', 'bg-blue-50');
|
||||||
|
element.classList.add('border-gray-300');
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
console.log("Files dropped:", files.length);
|
||||||
|
|
||||||
|
// Process files
|
||||||
|
const maxFiles = {str(self.multiple).lower()} ? files.length : 1;
|
||||||
|
const filesToProcess = files.slice(0, maxFiles);
|
||||||
|
|
||||||
|
const fileData = [];
|
||||||
|
for (const file of filesToProcess) {{
|
||||||
|
const reader = new FileReader();
|
||||||
|
const content = await new Promise((resolve) => {{
|
||||||
|
reader.onload = () => resolve(reader.result.split(',')[1]);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}});
|
||||||
|
|
||||||
|
fileData.push({{
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
content: content
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Store data for Python to access
|
||||||
|
window.dropData_{self.id} = fileData;
|
||||||
|
console.log("Stored file data with", fileData.length, "files");
|
||||||
|
|
||||||
|
// Let the natural drop event bubble up to Python
|
||||||
|
}});
|
||||||
|
|
||||||
|
console.log("Drag handlers setup complete");
|
||||||
|
}})();
|
||||||
|
''')
|
||||||
|
|
||||||
|
def _handle_drop_event(self, e):
|
||||||
|
"""Handle the native drop event"""
|
||||||
|
print(f"DEBUG: Drop event received!")
|
||||||
|
print(f"DEBUG: Event type: {type(e)}")
|
||||||
|
print(f"DEBUG: Event args: {getattr(e, 'args', None)}")
|
||||||
|
|
||||||
|
async def get_and_process():
|
||||||
|
try:
|
||||||
|
# First check if data exists
|
||||||
|
has_data = await ui.run_javascript(f'return window.dropData_{self.id} !== undefined && window.dropData_{self.id} !== null', timeout=1.0)
|
||||||
|
print(f"DEBUG: Data exists: {has_data}")
|
||||||
|
|
||||||
|
if has_data:
|
||||||
|
# Try to get debug info about the data first
|
||||||
|
data_info = await ui.run_javascript(f'''
|
||||||
|
return {{
|
||||||
|
exists: window.dropData_{self.id} !== undefined,
|
||||||
|
type: typeof window.dropData_{self.id},
|
||||||
|
length: window.dropData_{self.id} ? window.dropData_{self.id}.length : 0
|
||||||
|
}}
|
||||||
|
''', timeout=1.0)
|
||||||
|
print(f"DEBUG: Data info: {data_info}")
|
||||||
|
|
||||||
|
# Get just the metadata first (without content)
|
||||||
|
metadata = await ui.run_javascript(f'''
|
||||||
|
return window.dropData_{self.id}.map(file => ({{
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type
|
||||||
|
}}))
|
||||||
|
''', timeout=2.0)
|
||||||
|
print(f"DEBUG: Retrieved metadata: {metadata}")
|
||||||
|
|
||||||
|
# Get the actual data with content
|
||||||
|
data = await ui.run_javascript(f'return window.dropData_{self.id}', timeout=5.0)
|
||||||
|
print(f"DEBUG: Retrieved full data: {len(data) if data else 0} files")
|
||||||
|
|
||||||
|
# Clear the data
|
||||||
|
await ui.run_javascript(f'window.dropData_{self.id} = null; return true;', timeout=1.0)
|
||||||
|
else:
|
||||||
|
print("DEBUG: No data found in JavaScript")
|
||||||
|
data = None
|
||||||
|
|
||||||
|
if data and self.on_upload_callback:
|
||||||
|
files = []
|
||||||
|
for file_data in data:
|
||||||
|
content = base64.b64decode(file_data['content'])
|
||||||
|
files.append({
|
||||||
|
'name': file_data['name'],
|
||||||
|
'size': file_data['size'],
|
||||||
|
'type': file_data.get('type', 'application/octet-stream'),
|
||||||
|
'content': content
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"DEBUG: Calling callback with {len(files)} dropped files")
|
||||||
|
self.on_upload_callback(files)
|
||||||
|
|
||||||
|
# Data already cleared in the retrieval
|
||||||
|
except Exception as ex:
|
||||||
|
print(f"DEBUG: Error processing dropped files: {ex}")
|
||||||
|
|
||||||
|
# Give JavaScript time to process files before retrieving data
|
||||||
|
ui.timer(0.1, get_and_process, once=True)
|
||||||
|
|
||||||
|
def _handle_file_input(self, e):
|
||||||
|
"""Handle files from the file picker"""
|
||||||
|
print(f"DEBUG: File input upload")
|
||||||
|
|
||||||
|
if self.on_upload_callback and hasattr(e, 'content'):
|
||||||
|
content = e.content.read()
|
||||||
|
files = [{
|
||||||
|
'name': e.name if hasattr(e, 'name') else 'unknown',
|
||||||
|
'size': len(content),
|
||||||
|
'type': e.type if hasattr(e, 'type') else 'application/octet-stream',
|
||||||
|
'content': content
|
||||||
|
}]
|
||||||
|
print(f"DEBUG: Calling callback with {len(files)} files from input")
|
||||||
|
self.on_upload_callback(files)
|
||||||
|
|
||||||
|
|
||||||
|
@ui.page('/')
|
||||||
|
async def main_page():
|
||||||
|
ui.label('File Drop Demo').classes('text-h4 mb-4')
|
||||||
|
|
||||||
|
uploaded_files = ui.column().classes('w-full mt-4')
|
||||||
|
|
||||||
|
def handle_files(files: List[Dict[str, Any]]):
|
||||||
|
with uploaded_files:
|
||||||
|
ui.label(f'Uploaded {len(files)} file(s):').classes('font-bold mb-2')
|
||||||
|
for file in files:
|
||||||
|
with ui.card().classes('p-2 mb-2'):
|
||||||
|
ui.label(f"📄 {file['name']}").classes('font-medium')
|
||||||
|
ui.label(f"Size: {file['size']:,} bytes").classes('text-sm text-gray-600')
|
||||||
|
ui.label(f"Type: {file['type']}").classes('text-sm text-gray-600')
|
||||||
|
|
||||||
|
FileDrop(
|
||||||
|
on_upload=handle_files,
|
||||||
|
multiple=True,
|
||||||
|
accept='.pdf,.docx,.txt,.jpg,.png'
|
||||||
|
).classes('w-full max-w-xl mx-auto')
|
||||||
|
|
||||||
|
ui.separator().classes('my-8')
|
||||||
|
|
||||||
|
ui.label('Single Image Drop Zone').classes('text-h6 mb-2')
|
||||||
|
|
||||||
|
image_preview = ui.column().classes('w-full mt-4')
|
||||||
|
|
||||||
|
def handle_image(files: List[Dict[str, Any]]):
|
||||||
|
image_preview.clear()
|
||||||
|
if files:
|
||||||
|
file = files[0]
|
||||||
|
with image_preview:
|
||||||
|
ui.label(f"Uploaded: {file['name']}").classes('mb-2')
|
||||||
|
if file['type'].startswith('image/'):
|
||||||
|
img_data = base64.b64encode(file['content']).decode()
|
||||||
|
ui.html(f'<img src="data:{file["type"]};base64,{img_data}" class="max-w-full rounded">')
|
||||||
|
|
||||||
|
FileDrop(
|
||||||
|
on_upload=handle_image,
|
||||||
|
multiple=False,
|
||||||
|
accept='image/*'
|
||||||
|
).classes('w-full max-w-xl mx-auto').style('min-height: 150px')
|
||||||
|
|
||||||
|
if __name__ in {"__main__", "__mp_main__"}:
|
||||||
|
ui.run(
|
||||||
|
title='File Drop Demo',
|
||||||
|
favicon='📁',
|
||||||
|
show=False,
|
||||||
|
dark=False,
|
||||||
|
port=8083
|
||||||
|
)
|
||||||
121
file_drop.js
Normal file
121
file_drop.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
export default {
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
class="file-drop-zone"
|
||||||
|
:class="{ 'dragging': isDragging }"
|
||||||
|
@click="openFileDialog"
|
||||||
|
@dragover.prevent="onDragOver"
|
||||||
|
@dragleave.prevent="onDragLeave"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
style="border: 2px dashed #d1d5db; border-radius: 8px; padding: 32px; text-align: center; cursor: pointer; transition: all 0.3s ease; min-height: 120px; display: flex; flex-direction: column; align-items: center; justify-content: center;"
|
||||||
|
>
|
||||||
|
<div style="color: #9ca3af; font-size: 48px; margin-bottom: 16px;">☁️</div>
|
||||||
|
<div style="font-size: 18px; color: #6b7280; margin-bottom: 8px;">Drop files here or click to browse</div>
|
||||||
|
<div style="font-size: 14px; color: #9ca3af;">Drag and drop your files</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
:multiple="multiple"
|
||||||
|
:accept="accept"
|
||||||
|
@change="onFileInputChange"
|
||||||
|
style="display: none;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
props: {
|
||||||
|
multiple: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
accept: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isDragging: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
openFileDialog() {
|
||||||
|
this.$refs.fileInput.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
onDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.isDragging = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
onDragLeave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.isDragging = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async onDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.isDragging = false;
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
await this.processFiles(files);
|
||||||
|
},
|
||||||
|
|
||||||
|
async onFileInputChange(e) {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
await this.processFiles(files);
|
||||||
|
|
||||||
|
// Clear the input
|
||||||
|
e.target.value = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
async processFiles(files) {
|
||||||
|
if (!this.multiple && files.length > 1) {
|
||||||
|
files = [files[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesData = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const content = await this.readFileAsBase64(file);
|
||||||
|
|
||||||
|
filesData.push({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
content: content
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('upload', filesData);
|
||||||
|
},
|
||||||
|
|
||||||
|
readFileAsBase64(file) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
// Remove the data URL prefix to get just the base64 content
|
||||||
|
const base64 = reader.result.split(',')[1];
|
||||||
|
resolve(base64);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
style: `
|
||||||
|
.file-drop-zone.dragging {
|
||||||
|
border-color: #3b82f6 !important;
|
||||||
|
background-color: #eff6ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop-zone:hover {
|
||||||
|
border-color: #9ca3af !important;
|
||||||
|
background-color: #f9fafb !important;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
};
|
||||||
36
pyproject.toml
Normal file
36
pyproject.toml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "niceguiex"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Extensions for NiceGUI including async elements and more"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [
|
||||||
|
{name = "Your Name", email = "your.email@example.com"}
|
||||||
|
]
|
||||||
|
keywords = ["nicegui", "async", "ui", "web"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"nicegui>=2.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/yourusername/niceguiex"
|
||||||
|
Issues = "https://github.com/yourusername/niceguiex/issues"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/niceguiex"]
|
||||||
3
src/niceguiex/__init__.py
Normal file
3
src/niceguiex/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""NiceGUI Extensions - Extensions for NiceGUI with proper typing support"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
10
src/niceguiex/async_elements/__init__.py
Normal file
10
src/niceguiex/async_elements/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from .base import AsyncElement
|
||||||
|
from .elements import (AsyncColumn, AsyncRow, AsyncCard, AsyncScrollArea)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'AsyncElement',
|
||||||
|
'AsyncColumn',
|
||||||
|
'AsyncRow',
|
||||||
|
'AsyncCard',
|
||||||
|
'AsyncScrollArea'
|
||||||
|
]
|
||||||
47
src/niceguiex/async_elements/base.py
Normal file
47
src/niceguiex/async_elements/base.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Type, Any, TypeVar, Generic
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
# Type variable for the wrapped element type
|
||||||
|
T = TypeVar('T', bound=ui.element)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncElement(ABC, Generic[T]):
|
||||||
|
"""Base class for UI elements with async initialization that behaves like a NiceGUI element"""
|
||||||
|
|
||||||
|
def __init__(self, element_type: Type[T] = ui.column, *element_args, **element_kwargs) -> None:
|
||||||
|
# Create the NiceGUI element
|
||||||
|
self._element: T = element_type(*element_args, **element_kwargs)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def build(self, *args, **kwargs) -> None:
|
||||||
|
"""Build/setup the element - must be implemented by subclasses"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create(cls, element_type: Type[T] = ui.column, *args, **kwargs) -> T:
|
||||||
|
"""Factory method to create and build an element instance, returns the NiceGUI element"""
|
||||||
|
# Separate element constructor args from build args
|
||||||
|
element_args = kwargs.pop('element_args', ())
|
||||||
|
element_kwargs = kwargs.pop('element_kwargs', {})
|
||||||
|
|
||||||
|
# Create and build the instance
|
||||||
|
instance = cls(element_type, *element_args, **element_kwargs)
|
||||||
|
await instance.build(*args, **kwargs)
|
||||||
|
|
||||||
|
# Add a reference to the async instance on the element for potential later access
|
||||||
|
instance._element._async_instance = instance # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
|
return instance._element # Return the NiceGUI element with proper typing
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
"""Delegate any missing attribute access to the wrapped element"""
|
||||||
|
return getattr(self._element, name)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""Support context manager protocol"""
|
||||||
|
return self._element.__enter__()
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
"""Support context manager protocol"""
|
||||||
|
return self._element.__exit__(*args)
|
||||||
79
src/niceguiex/async_elements/elements.py
Normal file
79
src/niceguiex/async_elements/elements.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Self
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncColumn(ui.column, ABC):
|
||||||
|
"""Async column that inherits from ui.column for perfect typing"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def build(self, *args, **kwargs) -> None:
|
||||||
|
"""Build/setup the element - must be implemented by subclasses"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create(cls, *args, **kwargs) -> Self:
|
||||||
|
"""Factory method to create and build a column instance"""
|
||||||
|
instance = cls()
|
||||||
|
await instance.build(*args, **kwargs)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncRow(ui.row, ABC):
|
||||||
|
"""Async row that inherits from ui.row for perfect typing"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def build(self, *args, **kwargs) -> None:
|
||||||
|
"""Build/setup the element - must be implemented by subclasses"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create(cls, *args, **kwargs) -> Self:
|
||||||
|
"""Factory method to create and build a column instance"""
|
||||||
|
instance = cls()
|
||||||
|
await instance.build(*args, **kwargs)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncCard(ui.card, ABC):
|
||||||
|
"""Async card that inherits from ui.card for perfect typing"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def build(self, *args, **kwargs) -> None:
|
||||||
|
"""Build/setup the element - must be implemented by subclasses"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create(cls, *args, **kwargs) -> Self:
|
||||||
|
"""Factory method to create and build a card instance"""
|
||||||
|
instance = cls()
|
||||||
|
await instance.build(*args, **kwargs)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncScrollArea(ui.scroll_area, ABC):
|
||||||
|
"""Async ScrollArea that inherits from ui.scrol_area for perfect typing"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def build(self, *args, **kwargs) -> None:
|
||||||
|
"""Build/setup the element - must be implemented by subclasses"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create(cls, *args, **kwargs) -> Self:
|
||||||
|
"""Factory method to create and build a column instance"""
|
||||||
|
instance = cls()
|
||||||
|
await instance.build(*args, **kwargs)
|
||||||
|
return instance
|
||||||
4
src/niceguiex/components/__init__.py
Normal file
4
src/niceguiex/components/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .auto_scroll_area import AutoScrollArea
|
||||||
|
from .chat_input import ChatInput
|
||||||
|
|
||||||
|
__all__ = ['AutoScrollArea', 'ChatInput']
|
||||||
72
src/niceguiex/components/auto_scroll_area.py
Normal file
72
src/niceguiex/components/auto_scroll_area.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
|
||||||
|
class AutoScrollArea(ui.scroll_area):
|
||||||
|
"""A scroll area that automatically scrolls to bottom when new content is added
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Auto-scrolls to bottom when at bottom and new content arrives
|
||||||
|
- Stops auto-scroll when user scrolls up manually
|
||||||
|
- Resumes auto-scroll when user scrolls back to bottom
|
||||||
|
"""
|
||||||
|
|
||||||
|
_auto_scroll_enabled: bool = True
|
||||||
|
_auto_scroll_timer: Optional[ui.timer] = None
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Set up scroll monitoring
|
||||||
|
# self._handle_scroll
|
||||||
|
self.on_scroll(self._handle_scroll_event)
|
||||||
|
self.on('wheel', self._handle_wheel, ['deltaY'])
|
||||||
|
|
||||||
|
# Create timer for auto-scrolling
|
||||||
|
self._auto_scroll_timer = ui.timer(0.1, lambda: self.scroll_to(percent=1))
|
||||||
|
self._auto_scroll_timer.activate()
|
||||||
|
|
||||||
|
def _scroll_event_test(self, e):
|
||||||
|
print(e.vertical_percentage)
|
||||||
|
|
||||||
|
def _handle_scroll_event(self, event_data):
|
||||||
|
"""Handle scroll events to detect when user is at bottom"""
|
||||||
|
|
||||||
|
if not self._auto_scroll_timer:
|
||||||
|
print('no timer instantiated.')
|
||||||
|
return
|
||||||
|
|
||||||
|
# If scrolled to bottom (100%), enable auto-scroll
|
||||||
|
if event_data.vertical_percentage > 0.99: # Using 0.99 for some tolerance
|
||||||
|
if not self._auto_scroll_timer.active:
|
||||||
|
self._auto_scroll_timer.activate()
|
||||||
|
|
||||||
|
def _handle_wheel(self, event_data):
|
||||||
|
"""Handle mouse wheel events to detect manual scrolling"""
|
||||||
|
delta_y = event_data.args['deltaY']
|
||||||
|
if not self._auto_scroll_timer:
|
||||||
|
print('no timer instantiated.')
|
||||||
|
return
|
||||||
|
# If scrolling up (negative delta), disable auto-scroll
|
||||||
|
if delta_y < 0:
|
||||||
|
if self._auto_scroll_timer.active:
|
||||||
|
self._auto_scroll_timer.deactivate()
|
||||||
|
|
||||||
|
def enable_auto_scroll(self):
|
||||||
|
"""Manually enable auto-scrolling"""
|
||||||
|
if self._auto_scroll_timer:
|
||||||
|
if not self._auto_scroll_timer.active:
|
||||||
|
self._auto_scroll_timer.activate()
|
||||||
|
|
||||||
|
def disable_auto_scroll(self):
|
||||||
|
"""Manually disable auto-scrolling"""
|
||||||
|
|
||||||
|
if self._auto_scroll_timer:
|
||||||
|
if self._auto_scroll_timer.active:
|
||||||
|
self._auto_scroll_timer.deactivate()
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Clean up timer when component is destroyed"""
|
||||||
|
if self._auto_scroll_timer:
|
||||||
|
self._auto_scroll_timer.deactivate()
|
||||||
|
self._auto_scroll_timer = None
|
||||||
27
src/niceguiex/components/chat_input.py
Normal file
27
src/niceguiex/components/chat_input.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from typing import Optional, Callable, Awaitable
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
|
||||||
|
class ChatInput(ui.textarea):
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
placeholder: str = 'Type your message...',
|
||||||
|
on_enter: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||||
|
*args, **kwargs) -> None:
|
||||||
|
super().__init__(placeholder=placeholder, *args, **kwargs)
|
||||||
|
self._on_enter_callback = on_enter
|
||||||
|
self.classes('flex-grow').props('outlined dense autogrow')
|
||||||
|
self.on('keydown', self._handle_keydown)
|
||||||
|
|
||||||
|
async def _handle_keydown(self, event) -> None:
|
||||||
|
"""Handle keyboard shortcuts for message input"""
|
||||||
|
if hasattr(event, 'args') and event.args:
|
||||||
|
key_event = event.args
|
||||||
|
if key_event.get('key') == 'Enter' and not key_event.get('shiftKey', False):
|
||||||
|
# Enter without Shift: Send message
|
||||||
|
event.args['preventDefault'] = True
|
||||||
|
if self._on_enter_callback and self.value:
|
||||||
|
await self._on_enter_callback(self.value)
|
||||||
|
self.value = ''
|
||||||
|
# Shift+Enter: Allow default behavior (new line)
|
||||||
Reference in New Issue
Block a user