archinstall/archinstall/tui/components.py

1353 lines
33 KiB
Python

import sys
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, replace
from enum import Enum, auto
from typing import Any, ClassVar, Literal, TypeVar, cast, override
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding, BindingsMap
from textual.containers import Center, Horizontal, ScrollableContainer, Vertical
from textual.events import Key
from textual.geometry import Offset
from textual.screen import Screen
from textual.validation import Validator
from textual.widgets import Button, DataTable, Footer, Input, Label, LoadingIndicator, OptionList, Rule, SelectionList
from textual.widgets._data_table import RowKey
from textual.widgets.option_list import Option
from textual.widgets.selection_list import Selection
from textual.worker import WorkerCancelled
from archinstall.lib.output import debug
from archinstall.lib.translationhandler import tr
from archinstall.tui.menu_item import MenuItem, MenuItemGroup
from archinstall.tui.result import Result, ResultType
ValueT = TypeVar('ValueT')
def _translate_bindings(source: BindingsMap | None, target: BindingsMap) -> None:
"""Translate binding descriptions from source to target.
Uses source (original, immutable class-level cache) to avoid
double-translation on repeated calls (e.g. language switch).
"""
if source is None:
return
for key, bindings in source.key_to_bindings.items():
target.key_to_bindings[key] = [replace(b, description=tr(b.description)) if b.description else b for b in bindings]
class BaseScreen(Screen[Result[ValueT]]):
BINDINGS: ClassVar = [
Binding('escape', 'cancel_operation', 'Cancel', show=True),
Binding('ctrl+c', 'reset_operation', 'Reset', show=True),
]
def __init__(self, allow_skip: bool = False, allow_reset: bool = False):
super().__init__()
self._allow_skip = allow_skip
self._allow_reset = allow_reset
def action_cancel_operation(self) -> None:
if self._allow_skip:
_ = self.dismiss(Result(ResultType.Skip))
async def action_reset_operation(self) -> None:
if self._allow_reset:
_ = self.dismiss(Result(ResultType.Reset))
class LoadingScreen(BaseScreen[ValueT]):
CSS = """
LoadingScreen {
align: center middle;
background: transparent;
}
.content-container {
width: 1fr;
height: 1fr;
max-height: 100%;
margin-top: 2;
margin-bottom: 2;
background: transparent;
}
LoadingIndicator {
align: center middle;
}
"""
def __init__(
self,
timer: int = 3,
data_callback: Callable[[], Any] | None = None,
header: str | None = None,
):
super().__init__()
self._timer = timer
self._header = header
self._data_callback = data_callback
async def run(self) -> Result[ValueT]:
assert TApp.app
return await TApp.app.show(self)
@override
def compose(self) -> ComposeResult:
with Vertical(classes='content-container'):
if self._header:
with Center():
yield Label(self._header, classes='header', id='loading_header')
yield Center(LoadingIndicator())
yield Footer()
def on_mount(self) -> None:
_translate_bindings(self._merged_bindings, self._bindings)
if self._data_callback:
self._exec_callback()
else:
self.set_timer(self._timer, self.action_pop_screen)
self._set_cursor()
def _set_cursor(self) -> None:
label = self.query_one(Label)
self.app.cursor_position = Offset(label.region.x, label.region.y)
self.app.refresh()
@work(thread=True)
def _exec_callback(self) -> None:
assert self._data_callback
result = self._data_callback()
# cannot call self.dismiss directly from
# background thread (thread=true) as there's no event loop
self.app.call_from_thread(self.dismiss, Result(ResultType.Selection, _data=result))
def action_pop_screen(self) -> None:
_ = self.dismiss()
class _OptionList(OptionList):
BINDINGS: ClassVar = [
Binding('down', 'cursor_down', 'Down', show=True),
Binding('up', 'cursor_up', 'Up', show=True),
Binding('j', 'cursor_down', 'Down', show=False),
Binding('k', 'cursor_up', 'Up', show=False),
]
@override
def on_mount(self) -> None:
_translate_bindings(self._merged_bindings, self._bindings)
class OptionListScreen(BaseScreen[ValueT]):
"""
Single selection menu list
"""
BINDINGS: ClassVar = [
Binding('/', 'search', 'Search', show=True),
]
CSS = """
OptionListScreen {
align-horizontal: center;
align-vertical: middle;
background: transparent;
}
.content-container {
width: 1fr;
height: 1fr;
max-height: 100%;
margin-top: 2;
margin-left: 2;
background: transparent;
}
.list-container {
width: auto;
height: auto;
max-height: 100%;
padding-bottom: 3;
background: transparent;
}
OptionList {
width: auto;
height: auto;
min-width: 15%;
max-height: 1fr;
padding-bottom: 3;
background: transparent;
}
OptionList > .option-list--option-highlighted {
background: blue;
color: white;
text-style: bold;
}
"""
def __init__(
self,
group: MenuItemGroup,
header: str | None = None,
title: str | None = None,
allow_skip: bool = False,
allow_reset: bool = False,
preview_location: Literal['right', 'bottom'] | None = None,
enable_filter: bool = False,
):
super().__init__(allow_skip, allow_reset)
self._group = group
self._header = header
self._title = title
self._preview_location = preview_location
self._filter = enable_filter
self._show_frame = False
self._options = self._get_options()
def action_search(self) -> None:
if self.query_one(OptionList).has_focus:
if self._filter:
self._handle_search_action()
@override
def action_cancel_operation(self) -> None:
if self._filter and self.query_one(Input).has_focus:
self._handle_search_action()
else:
super().action_cancel_operation()
def _handle_search_action(self) -> None:
search_input = self.query_one(Input)
if search_input.has_focus:
self.query_one(OptionList).focus()
else:
search_input.focus()
async def run(self) -> Result[ValueT]:
assert TApp.app
return await TApp.app.show(self)
def _get_options(self) -> list[Option]:
options = []
for item in self._group.get_enabled_items():
disabled = True if item.read_only else False
options.append(Option(item.text, id=item.get_id(), disabled=disabled))
return options
@override
def compose(self) -> ComposeResult:
if self._title:
yield Label(self._title, classes='app-header')
with Vertical(classes='content-container'):
if self._header:
yield Label(self._header, classes='header-text', id='header_text')
option_list = _OptionList(id='option_list_widget')
if not self._show_frame:
option_list.classes = 'no-border'
if self._preview_location is None:
with Center():
with Vertical(classes='list-container'):
yield option_list
else:
Container = Horizontal if self._preview_location == 'right' else Vertical
rule_orientation: Literal['horizontal', 'vertical'] = 'vertical' if self._preview_location == 'right' else 'horizontal'
with Container():
yield option_list
yield Rule(orientation=rule_orientation)
yield ScrollableContainer(Label('', id='preview_content', markup=False))
if self._filter:
yield Input(placeholder='/filter', id='filter-input')
yield Footer()
def on_mount(self) -> None:
_translate_bindings(self._merged_bindings, self._bindings)
self._update_options(self._options)
self.query_one(OptionList).focus()
def on_input_changed(self, event: Input.Changed) -> None:
search_term = event.value.lower()
self._group.set_filter_pattern(search_term)
filtered_options = self._get_options()
self._update_options(filtered_options)
def _update_options(self, options: list[Option]) -> None:
option_list = self.query_one(OptionList)
option_list.clear_options()
option_list.add_options(options)
option_list.highlighted = self._group.get_focused_index()
if focus_item := self._group.focus_item:
self._set_preview(focus_item.get_id())
def on_input_submitted(self, event: Input.Submitted) -> None:
if self.query_one(Input).has_focus:
self._handle_search_action()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
selected_option = event.option
if selected_option.id is not None:
item = self._group.find_by_id(selected_option.id)
_ = self.dismiss(Result(ResultType.Selection, _item=item))
def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
if event.option.id:
self._set_preview(event.option.id)
self._set_cursor()
def _set_cursor(self) -> None:
option_list = self.query_one(OptionList)
index = option_list.highlighted
if index is None:
return
target_y = sum(
[
1 if self._show_frame else 0, # add top buffer for the frame
option_list.region.y, # padding/margin offset of the option list
index, # index of the highlighted option
-option_list.scroll_offset.y, # scroll offset
]
)
# debug(f'Index: {index}')
# debug(f'Region: {option_list.region}')
# debug(f'Scroll offset: {option_list.scroll_offset}')
# debug(f'Target_Y: {target_y}')
self.app.cursor_position = Offset(option_list.region.x, target_y)
self.app.refresh()
def _set_preview(self, item_id: str) -> None:
if self._preview_location is None:
return
preview_widget = self.query_one('#preview_content', Label)
item = self._group.find_by_id(item_id)
if item.preview_action is not None:
maybe_preview = item.preview_action(item)
if maybe_preview is not None:
preview_widget.update(maybe_preview)
return
preview_widget.update('')
class _SelectionList(SelectionList[ValueT]):
BINDINGS: ClassVar = [
Binding('down', 'cursor_down', 'Down', show=True),
Binding('up', 'cursor_up', 'Up', show=True),
Binding('j', 'cursor_down', 'Down', show=False),
Binding('k', 'cursor_up', 'Up', show=False),
Binding('space', 'select', 'Toggle', show=True),
]
@override
def on_mount(self) -> None:
_translate_bindings(self._merged_bindings, self._bindings)
class SelectListScreen(BaseScreen[ValueT]):
"""
Multi selection menu
"""
BINDINGS: ClassVar = [
Binding('/', 'search', 'Search', show=True),
Binding('enter', '', 'Confirm', show=True),
]
CSS = """
SelectListScreen {
align-horizontal: center;
align-vertical: middle;
background: transparent;
}
.content-container {
width: 1fr;
height: 1fr;
max-height: 100%;
margin-top: 2;
margin-left: 2;
background: transparent;
}
.list-container {
width: auto;
height: auto;
min-width: 15%;
max-height: 1fr;
padding-bottom: 3;
background: transparent;
}
SelectionList {
width: auto;
height: auto;
max-height: 1fr;
padding-bottom: 3;
background: transparent;
}
SelectionList > .option-list--option-highlighted {
background: blue;
color: white;
text-style: bold;
}
#preview_content {
width: 100%;
height: auto;
}
"""
def __init__(
self,
group: MenuItemGroup,
header: str | None = None,
allow_skip: bool = False,
allow_reset: bool = False,
preview_location: Literal['right', 'bottom'] | None = None,
enable_filter: bool = False,
):
super().__init__(allow_skip, allow_reset)
self._group = group
self._header = header
self._preview_location = preview_location
self._show_frame = False
self._filter = enable_filter
self._selected_items: list[MenuItem] = self._group.selected_items
self._options: list[Selection[MenuItem]] = self._get_selections()
def action_search(self) -> None:
if self.query_one(OptionList).has_focus:
if self._filter:
self._handle_search_action()
@override
def action_cancel_operation(self) -> None:
if self._filter and self.query_one(Input).has_focus:
self._handle_search_action()
else:
super().action_cancel_operation()
def _handle_search_action(self) -> None:
search_input = self.query_one(Input)
if search_input.has_focus:
self.query_one(SelectionList).focus()
else:
search_input.focus()
async def run(self) -> Result[ValueT]:
assert TApp.app
return await TApp.app.show(self)
def _get_selections(self) -> list[Selection[MenuItem]]:
selections = []
for item in self._group.get_enabled_items():
is_selected = item in self._selected_items
selection = Selection(item.text, item, is_selected)
selections.append(selection)
return selections
@override
def compose(self) -> ComposeResult:
with Vertical(classes='content-container'):
if self._header:
yield Label(self._header, classes='header-text', id='header_text')
selection_list = _SelectionList[MenuItem](id='select_list_widget')
if not self._show_frame:
selection_list.classes = 'no-border'
if self._preview_location is None:
with Center():
with Vertical(classes='list-container'):
yield selection_list
else:
Container = Horizontal if self._preview_location == 'right' else Vertical
rule_orientation: Literal['horizontal', 'vertical'] = 'vertical' if self._preview_location == 'right' else 'horizontal'
with Container():
yield selection_list
yield Rule(orientation=rule_orientation)
yield ScrollableContainer(Label('', id='preview_content', markup=False))
if self._filter:
yield Input(placeholder='/filter', id='filter-input')
yield Footer()
def on_input_submitted(self, event: Input.Submitted) -> None:
if self.query_one(Input).has_focus:
self._handle_search_action()
def on_mount(self) -> None:
_translate_bindings(self._merged_bindings, self._bindings)
self._update_options(self._options)
self.query_one(SelectionList).focus()
def on_key(self, event: Key) -> None:
selection_list = self.query_one(SelectionList)
if not selection_list.has_focus or event.key != 'enter':
return
if len(self._selected_items) < 1:
index = selection_list.highlighted
if index is not None:
selection = selection_list.get_option_at_index(index)
self._selected_items.append(selection.value)
_ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items))
def on_input_changed(self, event: Input.Changed) -> None:
search_term = event.value.lower()
self._group.set_filter_pattern(search_term)
filtered_options = self._get_selections()
self._update_options(filtered_options)
def _update_options(self, options: list[Selection[MenuItem]]) -> None:
selection_list = self.query_one(SelectionList)
selection_list.clear_options()
selection_list.add_options(options)
selection_list.highlighted = self._group.get_focused_index()
if focus_item := self._group.focus_item:
self._set_preview(focus_item)
self._set_cursor()
def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[MenuItem]) -> None:
if self._preview_location is not None:
item: MenuItem = event.selection.value
self._set_preview(item)
self._set_cursor()
def _set_cursor(self) -> None:
selection_list = self.query_one(SelectionList)
index = selection_list.highlighted
if index is None:
return
target_y = sum(
[
1 if self._show_frame else 0, # add top buffer for the frame
selection_list.region.y, # padding/margin offset of the option list
index, # index of the highlighted option
-selection_list.scroll_offset.y, # scroll offset
]
)
self.app.cursor_position = Offset(selection_list.region.x, target_y)
self.app.refresh()
def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled[MenuItem]) -> None:
item: MenuItem = event.selection.value
if item not in self._selected_items:
self._selected_items.append(item)
else:
self._selected_items.remove(item)
def _set_preview(self, item: MenuItem) -> None:
if self._preview_location is None:
return
preview_widget = self.query_one('#preview_content', Label)
if item.preview_action is not None:
maybe_preview = item.preview_action(item)
if maybe_preview is not None:
preview_widget.update(maybe_preview)
return
preview_widget.update('')
# DEPRECATED: Removed when switching to async
class ConfirmationScreen(BaseScreen[ValueT]):
BINDINGS: ClassVar = [
Binding('l', 'focus_right', 'Focus right', show=False),
Binding('h', 'focus_left', 'Focus left', show=False),
Binding('right', 'focus_right', 'Focus right', show=True),
Binding('left', 'focus_left', 'Focus left', show=True),
]
CSS = """
ConfirmationScreen {
align: center top;
}
.content-container {
width: 1fr;
height: 1fr;
max-height: 100%;
border: none;
background: transparent;
}
.buttons-container {
align: center top;
height: 3;
background: transparent;
}
Button {
width: 4;
height: 3;
background: transparent;
margin: 0 1;
}
Button.-active {
background: blue;
color: white;
border: none;
text-style: none;
}
"""
def __init__(
self,
group: MenuItemGroup,
header: str,
allow_skip: bool = False,
allow_reset: bool = False,
preview_location: Literal['bottom'] | None = None,
preview_header: str | None = None,
):
super().__init__(allow_skip, allow_reset)
self._group = group
self._header = header
self._preview_location = preview_location
self._preview_header = preview_header
async def run(self) -> Result[ValueT]:
assert TApp.app
return await TApp.app.show(self)
@override
def compose(self) -> ComposeResult:
yield Label(self._header, classes='header-text', id='header_text')
if self._preview_location is None:
with Vertical(classes='content-container'):
with Horizontal(classes='buttons-container'):
for item in self._group.items:
yield Button(item.text, id=item.key)
else:
with Vertical():
with Horizontal(classes='buttons-container'):
for item in self._group.items:
yield Button(item.text, id=item.key)
yield Rule(orientation='horizontal')
if self._preview_header is not None:
yield Label(self._preview_header, classes='preview-header', id='preview_header')
yield ScrollableContainer(Label('', id='preview_content', markup=False))
yield Footer()
def on_mount(self) -> None:
_translate_bindings(self._merged_bindings, self._bindings)
self._update_selection()
def action_focus_right(self) -> None:
if self._is_btn_focus():
self._group.focus_next()
self._update_selection()
def action_focus_left(self) -> None:
if self._is_btn_focus():
self._group.focus_prev()
self._update_selection()
def _update_selection(self) -> None:
focused = self._group.focus_item
buttons = self.query(Button)
if not focused:
return
for button in buttons:
if button.id == focused.key:
button.add_class('-active')
button.focus()
if self._preview_header is not None:
preview = self.query_one('#preview_content', Label)
if focused.preview_action is None:
preview.update('')
else:
text = focused.preview_action(focused)
if text is not None:
preview.update(text)
else:
button.remove_class('-active')
def _is_btn_focus(self) -> bool:
buttons = self.query(Button)
for button in buttons:
if button.has_focus:
return True
return False
def on_key(self, event: Key) -> None:
if event.key == 'enter':
if self._is_btn_focus():
item = self._group.focus_item
if not item:
return
_ = self.dismiss(Result(ResultType.Selection, _item=item))
class NotifyScreen(ConfirmationScreen[ValueT]):
def __init__(self, header: str):
group = MenuItemGroup([MenuItem(tr('Ok'))])
super().__init__(group, header)
class InputInfoType(Enum):
MsgInfo = auto()
MsgWarning = auto()
MsgError = auto()
@dataclass
class InputInfo:
message: str
info_type: InputInfoType
class InputScreen(BaseScreen[str]):
CSS = """
InputScreen {
align: center middle;
}
.container-wrapper {
align: center top;
width: 100%;
height: 1fr;
}
.input-content {
width: 60;
height: 10;
}
.input-failure {
color: red;
text-align: center;
}
#input-info {
text-align: center;
}
.input-hint-msg-error {
color: red;
}
.input-hint-msg-warning {
color: yellow;
}
.input-hint-msg-info {
color: green;
}
"""
def __init__(
self,
header: str | None = None,
placeholder: str | None = None,
password: bool = False,
default_value: str | None = None,
allow_reset: bool = False,
allow_skip: bool = False,
validator: Validator | None = None,
info_callback: Callable[[str], InputInfo | None] | None = None,
):
super().__init__(allow_skip, allow_reset)
self._header = header or ''
self._placeholder = placeholder or ''
self._password = password
self._default_value = default_value or ''
self._allow_reset = allow_reset
self._allow_skip = allow_skip
self._validator = validator
self._info_callback = info_callback
async def run(self) -> Result[str]:
assert TApp.app
return await TApp.app.show(self)
@override
def compose(self) -> ComposeResult:
yield Label(self._header, classes='header-text', id='header_text')
with Center(classes='container-wrapper'):
with Vertical(classes='input-content'):
yield Input(
placeholder=self._placeholder,
password=self._password,
value=self._default_value,
id='main_input',
validators=self._validator,
validate_on=['submitted'],
)
yield Label('', classes='input-failure', id='input-failure')
yield Label('', id='input-info')
yield Footer()
def on_mount(self) -> None:
_translate_bindings(self._merged_bindings, self._bindings)
input_field = self.query_one('#main_input', Input)
input_field.focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.validation_result and not event.validation_result.is_valid:
failures = [failure.description for failure in event.validation_result.failures if failure.description]
failure_out = ', '.join(failures)
self.query_one('#input-failure', Label).update(failure_out)
else:
input_value = event.value
if not input_value and not self._allow_skip:
self.query_one('#input-failure', Label).update(tr('Input cannot be empty'))
return
_ = self.dismiss(Result(ResultType.Selection, _data=event.value))
def on_input_changed(self, event: Input.Changed) -> None:
info_label = self.query_one('#input-info', Label)
if self._info_callback:
result = self._info_callback(event.value)
if result:
css_class = ''
if result.info_type == InputInfoType.MsgError:
css_class = 'input-hint-msg-error'
elif result.info_type == InputInfoType.MsgWarning:
css_class = 'input-hint-msg-warning'
elif result.info_type == InputInfoType.MsgInfo:
css_class = 'input-hint-msg-info'
info_label.update(result.message)
info_label.set_classes(css_class)
else:
info_label.update('')
info_label.set_classes('')
class _DataTable(DataTable[ValueT]):
BINDINGS: ClassVar = [
Binding('down', 'cursor_down', 'Down', show=True),
Binding('up', 'cursor_up', 'Up', show=True),
Binding('j', 'cursor_down', 'Down', show=False),
Binding('k', 'cursor_up', 'Up', show=False),
Binding('space', 'select', 'Toggle', show=True),
Binding('enter', 'select_cursor', 'Confirm', show=True),
]
@override
def on_mount(self) -> None:
_translate_bindings(self._merged_bindings, self._bindings)
class TableSelectionScreen(BaseScreen[ValueT]):
BINDINGS: ClassVar = [
Binding('space', 'toggle_selection', 'Toggle', show=True), # expclit handling of space in multi-selection mode
]
CSS = """
TableSelectionScreen {
align: center top;
background: transparent;
}
.content-container {
width: 1fr;
height: 1fr;
max-height: 100%;
margin-top: 2;
margin-bottom: 2;
background: transparent;
}
.table-container {
align: center top;
width: 1fr;
height: 1fr;
background: transparent;
}
.table-container ScrollableContainer {
align: center top;
height: auto;
background: transparent;
}
DataTable {
width: auto;
height: auto;
padding-bottom: 2;
border: none;
background: transparent;
}
DataTable .datatable--header {
background: transparent;
border: solid;
}
LoadingIndicator {
height: auto;
padding-top: 2;
background: transparent;
}
"""
def __init__(
self,
header: str | None = None,
group: MenuItemGroup | None = None,
group_callback: Callable[[], Awaitable[MenuItemGroup]] | None = None,
allow_reset: bool = False,
allow_skip: bool = False,
loading_header: str | None = None,
multi: bool = False,
preview_location: Literal['bottom'] | None = None,
preview_header: str | None = None,
):
super().__init__(allow_skip, allow_reset)
self._header = header
self._group = group
self._group_callback = group_callback
self._loading_header = loading_header
self._multi = multi
self._preview_location = preview_location
self._preview_header = preview_header
self._selected_keys: set[RowKey] = set()
self._current_row_key: RowKey | None = None
if self._group is None and self._group_callback is None:
raise ValueError('Either data or data_callback must be provided')
async def run(self) -> Result[ValueT]:
assert TApp.app
return await TApp.app.show(self)
@override
def compose(self) -> ComposeResult:
if self._header:
yield Label(self._header, classes='header-text', id='header_text')
with Vertical(classes='content-container'):
if self._loading_header:
with Center():
yield Label(self._loading_header, classes='header', id='loading_header')
yield LoadingIndicator(id='loader')
if self._preview_location is None:
with Center():
with Vertical(classes='table-container'):
yield ScrollableContainer(_DataTable(id='data_table'))
else:
with Vertical(classes='table-container'):
yield ScrollableContainer(_DataTable(id='data_table'))
yield Rule(orientation='horizontal')
if self._preview_header is not None:
yield Label(self._preview_header, classes='preview-header', id='preview-header')
yield ScrollableContainer(Label('', id='preview_content', markup=False))
yield Footer()
def on_mount(self) -> None:
_translate_bindings(self._merged_bindings, self._bindings)
self._display_header(True)
data_table = self.query_one(DataTable)
data_table.cell_padding = 2
if self._group:
self._put_data_to_table(data_table, self._group)
else:
self._load_data(data_table)
@work
async def _load_data(self, table: DataTable[ValueT]) -> None:
assert self._group_callback is not None
group = await self._group_callback()
self._put_data_to_table(table, group)
def _display_header(self, is_loading: bool) -> None:
if self._loading_header:
loading_header = self.query_one('#loading_header', Label)
loading_header.display = is_loading
if self._header:
header = self.query_one('#header_text', Label)
header.display = not is_loading
def _get_column_keys(self, items: list[MenuItem]) -> list[str]:
all_keys: list[str] = []
for item in items:
if item.value:
all_keys.extend(item.value.table_data().keys())
# Create unique list while preserving order
unique_keys: list[str] = list(dict.fromkeys(all_keys))
if self._multi:
unique_keys.insert(0, ' ')
return unique_keys
def _put_data_to_table(self, table: DataTable[ValueT], group: MenuItemGroup) -> None:
items = group.items
selected = group.selected_items
if not items:
_ = self.dismiss(Result(ResultType.Selection))
return
cols = self._get_column_keys(items)
table.add_columns(*cols)
for item in items:
if not item.value:
continue
row_values = list(item.value.table_data().values())
if self._multi:
if item in selected:
row_values.insert(0, '[X]')
else:
row_values.insert(0, '[ ]')
row_key = table.add_row(*row_values, key=item) # type: ignore[arg-type]
if item in selected:
self._selected_keys.add(row_key)
table.cursor_type = 'row'
table.display = True
loader = self.query_one('#loader')
loader.display = False
self._display_header(False)
table.focus()
def action_toggle_selection(self) -> None:
if not self._multi:
return
if not self._current_row_key:
return
table = self.query_one(DataTable)
cell_key = table.coordinate_to_cell_key(table.cursor_coordinate)
if self._current_row_key in self._selected_keys:
self._selected_keys.remove(self._current_row_key)
table.update_cell(self._current_row_key, cell_key.column_key, '[ ]')
else:
self._selected_keys.add(self._current_row_key)
table.update_cell(self._current_row_key, cell_key.column_key, '[X]')
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
self._set_cursor(event.cursor_row)
self._current_row_key = event.row_key
item: MenuItem = event.row_key.value # type: ignore[assignment]
if not item.preview_action:
return
preview_widget = self.query_one('#preview_content', Label)
maybe_preview = item.preview_action(item)
if maybe_preview is not None:
preview_widget.update(maybe_preview)
return
preview_widget.update('')
def _set_cursor(self, row_index: int) -> None:
data_table = self.query_one(DataTable)
target_y = sum(
[
data_table.region.y, # padding/margin offset of the option list
1, # table header
row_index, # index of the highlighted row
-data_table.scroll_offset.y, # scroll offset
]
)
debug(f'Setting cursor to target_y: {target_y}')
self.app.cursor_position = Offset(data_table.region.x, target_y)
self.app.refresh()
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
if self._multi:
if len(self._selected_keys) == 0:
selection = [event.row_key.value]
else:
selection = [row_key.value for row_key in self._selected_keys]
else:
selection = event.row_key.value # type: ignore[assignment]
_ = self.dismiss(
Result[ValueT](
ResultType.Selection,
_item=selection, # type: ignore[arg-type]
)
)
class InstanceRunnable[ValueT](ABC):
@abstractmethod
async def run(self) -> ValueT | None:
pass
class _AppInstance(App[ValueT]):
ENABLE_COMMAND_PALETTE = False
BINDINGS: ClassVar = [
Binding('f1', 'trigger_help', 'Show/Hide help', show=True),
Binding('ctrl+q', 'quit', 'Quit', show=True, priority=True),
]
CSS = """
Screen {
color: white;
}
* {
scrollbar-size: 1 1;
/* Use high contrast colors */
scrollbar-color: white;
scrollbar-background: black;
}
.app-header {
dock: top;
height: auto;
width: 100%;
content-align: center middle;
background: blue;
color: white;
text-style: bold;
}
.header-text {
text-align: center;
width: 100%;
height: auto;
padding-top: 2;
padding-bottom: 2;
background: transparent;
}
.preview-header {
text-align: center;
color: white;
text-style: bold;
width: 100%;
padding-bottom: 1;
background: transparent;
}
.no-border {
border: none;
}
Input {
border: solid gray 50%;
background: transparent;
height: 3;
color: white;
}
Input .input--cursor {
color: white;
}
Input:focus {
border: solid blue;
}
Footer {
dock: bottom;
width: 100%;
background: transparent;
color: white;
height: 1;
}
.footer-key--key {
background: black;
color: white;
}
.footer-key--description {
background: black;
color: white;
padding-right: 2;
}
FooterKey.-command-palette {
background: black;
border-left: vkey white 20%;
}
"""
def __init__(self, main: InstanceRunnable[ValueT] | Callable[[], Awaitable[ValueT]]) -> None:
super().__init__(ansi_color=True)
self._main = main
@override
async def _on_exit_app(self) -> None:
from archinstall.lib.translationhandler import translation_handler
translation_handler.restore_console_font()
await super()._on_exit_app()
def action_trigger_help(self) -> None:
from textual.widgets import HelpPanel
if self.screen.query('HelpPanel'):
_ = self.screen.query('HelpPanel').remove()
else:
_ = self.screen.mount(HelpPanel())
def on_mount(self) -> None:
from archinstall.lib.translationhandler import translation_handler
translation_handler.apply_console_font()
_translate_bindings(self._merged_bindings, self._bindings)
self._run_worker()
@work
async def _run_worker(self) -> None:
try:
if isinstance(self._main, InstanceRunnable):
result: ValueT | None = await self._main.run()
else:
result = await self._main()
tui.exit(result)
except WorkerCancelled:
debug('Worker was cancelled')
except Exception as err:
debug(f'Error while running main app: {err}')
# this will terminate the textual app and return the exception
self.exit(cast(ValueT, err))
@work
async def _show_async(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]:
return await self.push_screen_wait(screen)
async def show(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]:
return await self._show_async(screen).wait()
class TApp:
app: _AppInstance[Any] | None = None
def run(self, main: InstanceRunnable[ValueT] | Callable[[], Awaitable[ValueT]]) -> ValueT:
TApp.app = _AppInstance(main)
result: ValueT | Exception | None = TApp.app.run()
if isinstance(result, Exception):
raise result
if result is None:
debug('App returned no result, assuming exit')
sys.exit(0)
return result
def exit(self, result: Any) -> None:
assert TApp.app
TApp.app.exit(result)
def translate_bindings(self) -> None:
"""Re-translate app-level binding descriptions after language change."""
if TApp.app is not None:
_translate_bindings(TApp.app._merged_bindings, TApp.app._bindings)
tui = TApp()