Developer Guide for hyper2kvm Terminal User Interface
This guide documents the patterns, practices, and architecture of the hyper2kvm TUI built with Textual v0.87.1.
The hyper2kvm TUI follows a component-based architecture with clear separation of concerns:
┌─────────────────────────────────────────────────────┐
│ Main Application (main_app.py) │
│ ┌──────────────────────────────────────────────┐ │
│ │ TabbedContent (Tab Container) │ │
│ │ ┌────────────┬──────────┬──────────────┐ │ │
│ │ │ Welcome │ Wizard │ Browser │ │ │
│ │ │ Panel │ Panel │ Panel │ │ │
│ │ ├────────────┼──────────┼──────────────┤ │ │
│ │ │ Migrations │ Batch │ Settings │ │ │
│ │ │ Panel │ Manager │ Panel │ │ │
│ │ └────────────┴──────────┴──────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ Backend Integration: │
│ ├── MigrationTracker (persistent history) │
│ ├── MigrationController (process control) │
│ ├── TUIConfig (settings persistence) │
│ └── HelpDialog (context-sensitive help) │
└─────────────────────────────────────────────────────┘
hyper2kvm/tui/
├── main_app.py # Main application and tab management
├── batch_manager.py # Batch migration manager panel
├── migrations_panel.py # Active migrations monitoring panel
├── vm_browser.py # VM selection and browsing panel
├── wizard.py # Migration wizard panel
├── settings_panel.py # Settings configuration panel
├── help_dialog.py # Modal help dialog (Screen)
├── migration_tracker.py # Backend: Migration history tracking
├── migration_controller.py # Backend: Process control (SIGSTOP/SIGCONT/SIGTERM)
├── tui_config.py # Backend: Settings persistence
└── README.md # TUI feature documentation
*_panel.py: UI panels that are tabs in the main TabbedContent*_manager.py: Complex UI components with business logic*_dialog.py: Modal screens that overlay the main UI*_tracker.py: Backend state management (persistent)*_controller.py: Backend process/business logic*_config.py: Configuration and settings managementUse Case: Dynamically update Static widgets with new values (e.g., statistics counters)
Pattern:
def update_stats_display(self, stats: Dict[str, Any]) -> None:
"""Update statistics widgets with new values."""
# Define widget ID to display text mapping
stat_widgets = {
"stat_active": f"Active: {stats.get('active_migrations', 0)}",
"stat_completed": f"Completed: {stats.get('total_completed', 0)}",
"stat_failed": f"Failed: {stats.get('total_failed', 0)}",
}
# Update each widget safely
for widget_id, text in stat_widgets.items():
try:
widget = self.query_one(f"#{widget_id}", Static)
widget.update(text)
except Exception:
# Widget might not exist yet during initialization
pass
Key Points:
try/except to handle widgets that don’t exist during initialization#widget_id selector.update(text) to change widget contentExample Usage (from batch_manager.py:306-327):
stats = self.migration_tracker.get_statistics()
self.update_stats_display(stats)
Use Case: Track selected items in a DataTable with checkboxes
Pattern:
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection in table."""
table = event.data_table
row_key = event.row_key
try:
# Get current row data
row = table.get_row(row_key)
checkbox = row[0] # First column is the checkbox
# Toggle selection state
if checkbox == "[ ]":
# Select: Add to selection list
item_info = {
"name": row[1], # Assuming name is in column 1
"row_key": row_key,
"size_gb": 50.0, # Parse from actual data
}
self.selected_items.append(item_info)
table.update_cell(row_key, "Select", "[X]")
else:
# Deselect: Remove from selection list
self.selected_items = [
item for item in self.selected_items
if item.get("row_key") != row_key
]
table.update_cell(row_key, "Select", "[ ]")
except Exception as e:
self.notify(f"Selection error: {e}", severity="error")
# Update selection info display
self.update_selection_info()
def update_selection_info(self) -> None:
"""Update selection counter and size display."""
total_size = sum(item.get("size_gb", 0) for item in self.selected_items)
try:
count_widget = self.query_one("#selection_count", Static)
count_widget.update(f"Selected: {len(self.selected_items)} items")
except Exception:
pass
try:
size_widget = self.query_one("#selection_size", Static)
size_widget.update(f"Total size: {total_size:.1f} GB")
except Exception:
pass
Key Points:
self.selected_items)table.update_cell(row_key, column_key, new_value)update_selection_info() after changing selectionExample Usage (from vm_browser.py:266-300):
self.selected_vms = [] # Initialize in __init__
def on_data_table_row_selected(self, event):
# Toggle checkbox and manage self.selected_vms list
...
Use Case: Display help, confirmation dialogs, or detailed information overlays
Pattern:
from textual.screen import Screen
from textual.containers import Container
from textual.widgets import Static, Button
class CustomDialog(Screen):
"""Modal dialog for displaying information."""
DEFAULT_CSS = """
CustomDialog {
align: center middle;
}
.dialog-container {
width: 80;
height: 35;
border: heavy #DE7356; /* Coral brand color */
background: $surface;
}
.dialog-header {
height: 3;
background: #DE7356;
color: white;
padding: 1 2;
text-style: bold;
}
.dialog-body {
height: 1fr;
padding: 1 2;
overflow-y: scroll;
}
.dialog-footer {
height: 3;
background: $surface-darken-1;
padding: 1 2;
}
"""
def __init__(self, title: str, content: str, **kwargs):
super().__init__(**kwargs)
self.title = title
self.content = content
def compose(self) -> ComposeResult:
with Container(classes="dialog-container"):
# Header
with Container(classes="dialog-header"):
yield Static(self.title)
# Body
with Container(classes="dialog-body"):
yield Static(self.content)
# Footer with close button
with Container(classes="dialog-footer"):
yield Button("Close", id="btn_close", variant="primary")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button press."""
if event.button.id == "btn_close":
self.app.pop_screen()
# Usage in main app:
def action_show_dialog(self) -> None:
"""Show custom dialog."""
self.push_screen(CustomDialog(
title="🔔 Information",
content="This is a modal dialog example."
))
Key Points:
Screen class for modal dialogsself.app.push_screen() to displayself.app.pop_screen() to closeoverflow-y: scrollExample Usage (from help_dialog.py:1-340):
class HelpDialog(Screen):
def __init__(self, topic: str = "general", **kwargs):
...
# In main_app.py:
def action_help(self) -> None:
self.push_screen(HelpDialog(topic="general"))
Use Case: Periodically refresh data without blocking the UI
Pattern:
from textual.worker import work
import asyncio
class MyPanel(Container):
def on_mount(self) -> None:
"""Called when panel is mounted."""
# Start background worker
self.update_worker = self.update_stats()
@work(exclusive=True)
async def update_stats(self) -> None:
"""Update statistics in the background."""
while True:
await asyncio.sleep(5) # 5 second interval
try:
# Reload data from backend
self.tracker.load()
stats = self.tracker.get_statistics()
# Update UI widgets
self.update_stats_display(stats)
except Exception as e:
self.logger.debug(f"Failed to update stats: {e}")
Key Points:
@work(exclusive=True) decorator for background tasksawait asyncio.sleep() for non-blocking delaysexclusive=True to prevent multiple instancesExample Usage (from main_app.py:354-372):
@work(exclusive=True)
async def update_stats(self) -> None:
while True:
await asyncio.sleep(5)
self.migration_tracker.load()
self.stats = self.migration_tracker.get_statistics()
self.refresh_welcome_stats()
Use Case: Persist data and manage application state
from hyper2kvm.tui.migration_tracker import (
MigrationTracker,
MigrationRecord,
MigrationStatus,
create_migration_id,
)
# Initialize tracker
tracker = MigrationTracker(logger=logger)
tracker.load() # Load from ~/.config/hyper2kvm/migration_history.json
# Create new migration record
migration_id = create_migration_id("my-vm")
record = MigrationRecord(
id=migration_id,
vm_name="my-vm",
source_type="vsphere",
status=MigrationStatus.RUNNING,
start_time=datetime.now().isoformat(),
progress=0.0,
size_mb=10240.0,
)
tracker.add_migration(record)
# Update migration
tracker.update_migration(migration_id, progress=50.0)
# Get statistics
stats = tracker.get_statistics()
# Returns: total_migrations, active_migrations, completed_today, success_rate, etc.
# Save to disk
tracker.save()
from hyper2kvm.tui.migration_controller import MigrationController
# Initialize controller with tracker
controller = MigrationController(tracker, logger=logger)
# Register migration process
controller.register_process(migration_id, pid=12345)
# Control operations
controller.pause_migration(migration_id) # Sends SIGSTOP
controller.resume_migration(migration_id) # Sends SIGCONT
controller.cancel_migration(migration_id) # Sends SIGTERM
# Check status
is_running = controller.is_process_running(migration_id)
# Cleanup finished processes
controller.cleanup_finished_processes()
from hyper2kvm.tui.tui_config import (
TUIConfig,
load_tui_settings,
save_tui_settings,
)
# Load settings
settings = load_tui_settings(logger=logger)
# Loads from ~/.config/hyper2kvm/tui.json
# Access nested settings with dot notation
log_level = settings.get("general.log_level", "info")
disk_format = settings.get("migration.default_format", "qcow2")
# Modify settings
settings["general"]["log_level"] = "debug"
settings["migration"]["enable_compression"] = True
# Save settings
save_tui_settings(settings, logger=logger)
# Brand Colors
CORAL_BRAND = "#DE7356" # RGB: 222, 115, 86 (Pantone 7416 C)
# Status Colors (Textual defaults)
$success = "#00A000" # Green for completed/success
$error = "#FF0000" # Red for failed/error
$warning = "#FFA500" # Orange for warnings
$text-muted = "#808080" # Gray for queued/disabled
# Surface Colors (Textual defaults)
$surface = "#1E1E1E" # Main background
$surface-darken-1 = "#151515" # Slightly darker
DEFAULT_CSS = """
ComponentName {
height: 100%;
border: heavy #DE7356;
background: $surface;
}
.component-header {
height: 5;
background: #DE7356;
color: white;
padding: 1 2;
text-style: bold;
}
.component-toolbar {
height: 4;
background: $surface-darken-1;
padding: 0 2;
}
.component-body {
height: 1fr;
padding: 1 2;
}
.component-footer {
height: 5;
background: $surface-darken-1;
padding: 1 2;
}
"""
Standard shortcuts across all panels:
Ctrl+Q: Quit applicationF1: Show help dialogF2: Open migration wizardF3: Browse VMsF5: Refresh current viewCtrl+S: Open settingsEsc: Close modals/dialogs# File: tests/unit/test_tui/test_component.py
from hyper2kvm.tui.component import MyComponent
class TestMyComponent:
"""Test suite for MyComponent."""
def test_component_creation(self):
"""Test component can be instantiated."""
component = MyComponent()
assert component is not None
def test_component_with_params(self):
"""Test component with custom parameters."""
component = MyComponent(custom_param="value")
assert component.custom_param == "value"
def test_method_behavior(self):
"""Test specific method behavior."""
component = MyComponent()
result = component.some_method()
assert result == expected_value
tests/unit/test_tui/
├── test_dashboard.py # Dashboard tests (11 tests)
├── test_migration_tracker.py # Migration tracking tests (28 tests)
├── test_tui_availability.py # TUI import tests (7 tests)
├── test_tui_config.py # Configuration tests (35 tests)
├── test_tui_fallback.py # Fallback mode tests (18 tests)
└── test_widgets.py # Widget tests (21 tests)
# Run all TUI tests
python3 -m pytest tests/unit/test_tui/ -v
# Run specific test file
python3 -m pytest tests/unit/test_tui/test_migration_tracker.py -v
# Run with coverage
python3 -m pytest tests/unit/test_tui/ --cov=hyper2kvm.tui --cov-report=html
The following features have implementation notes in the code but are not yet implemented:
vm_browser.py)
vm_browser.py:318-332wizard.py)
wizard.py:468-482migrations_panel.py)
migrations_panel.py:287-298batch_manager.py)
batch_manager.py:250-260batch_manager.py)
batch_manager.py:301-316vm_browser.py)
vm_browser.py:302-307main_app.py)
main_app.py:339-352wizard.py)
wizard.py:458-466wizard.py)
wizard.py:432-439All future enhancements include:
Search for # Note: comments in TUI source files for implementation details.
hyper2kvm/tui/my_panel.pyContainer classDEFAULT_CSS with coral brandingcompose() methodon_button_pressed, etc.)main_app.py TabbedContent__init__ methodScreencompose() with header/body/footerself.app.push_screen() to displayWidgets not updating:
Background worker not running:
@work(exclusive=True) decorator is presenton_mount()await asyncio.sleep() not time.sleep()Selection state out of sync:
update_selection_info() after changing selected_items# Enable debug logging
import logging
logging.basicConfig(level=logging.DEBUG)
# Use Textual's inspector
# Run with: textual run --dev hyper2kvm-tui
# Add debug notifications
self.notify(f"Debug: {variable_value}", severity="information")
# Log to file
logger.debug(f"Component state: {self.__dict__}")
hyper2kvm/tui/tests/unit/test_tui/hyper2kvm/tui/README.mdexamples/tui/migration_demo.pyLast Updated: January 26, 2026 Version: 1.0 Status: Production-Ready