Color-code install preview: red for errors, yellow for warnings, green for ready (#4512)
* Color-code install preview: red for errors, yellow for warnings, green for ready Add preview_markup opt-in field to MenuItem with automatic Rich markup escaping for all existing previews. Show missing configs and bootloader errors in red, network warning in yellow, "Ready to install" in green. Move network warning from confirmation dialog to install preview so it is visible earlier. * Fix Rich markup parsing error on JSON preview strings Text.from_markup() replaces Label(markup=True) to avoid MarkupError on strings containing [" * Replace raw Rich markup with PreviewResult dataclass for typed preview levels * Add missing translatable strings to base.pot * Move get_install_warnings() from ConfigurationOutput to GlobalMenu The method is only used by GlobalMenu._prev_install_invalid_config(), so it belongs there rather than on the serialization class. * Move level-to-style mapping into MsgLevelType.style() method Replace the module-level _LEVEL_STYLE dict in components.py with a style() method on the MsgLevelType enum, following the project convention of encapsulating type-bound logic on the type itself. * Change PreviewResult to hold a list of message-level pairs PreviewResult.messages is now list[tuple[str, MsgLevelType]], allowing a single result to carry multiple sections with different levels. The preview_action signature drops list[PreviewResult] since the dataclass itself handles multiple sections. Existing str-returning previews still work and will be converted in follow-up PRs. * Make MsgLevelType.style() return a typed MsgLevelStyle enum Replace the bare style strings returned by style() with a MsgLevelStyle StrEnum so the values are type-checked. Being a StrEnum, the members stay plain strings and pass straight into Text.append(style=...), so no call sites change.
This commit is contained in:
parent
1048f7eef0
commit
0f16801448
|
|
@ -11,7 +11,6 @@ from archinstall.lib.crypt import encrypt
|
|||
from archinstall.lib.log import debug, logger, warn
|
||||
from archinstall.lib.menu.helpers import Confirmation, Selection
|
||||
from archinstall.lib.menu.util import get_password, prompt_dir
|
||||
from archinstall.lib.models.network import NetworkConfiguration
|
||||
from archinstall.lib.translationhandler import tr
|
||||
from archinstall.lib.utils.format import as_key_value_pair
|
||||
from archinstall.tui.menu_item import MenuItem, MenuItemGroup
|
||||
|
|
@ -83,13 +82,10 @@ class ConfigurationOutput:
|
|||
|
||||
return simple_summary
|
||||
|
||||
async def confirm_config(self, show_install_warnings: bool = False) -> bool:
|
||||
async def confirm_config(self) -> bool:
|
||||
header = f'{tr("The specified configuration will be applied")}. '
|
||||
header += tr('Would you like to continue?') + '\n'
|
||||
|
||||
if show_install_warnings:
|
||||
header += self._render_install_warnings()
|
||||
|
||||
group = MenuItemGroup.yes_no()
|
||||
group.set_preview_for_all(lambda x: self.user_config_to_json())
|
||||
|
||||
|
|
@ -107,22 +103,6 @@ class ConfigurationOutput:
|
|||
|
||||
return True
|
||||
|
||||
def get_install_warnings(self) -> list[str]:
|
||||
warnings: list[str] = []
|
||||
|
||||
if not isinstance(self._config.network_config, NetworkConfiguration):
|
||||
warnings.append(tr('Warning: no network configuration selected. Network will need to be set up manually on the installed system.'))
|
||||
|
||||
return warnings
|
||||
|
||||
def _render_install_warnings(self) -> str:
|
||||
warnings = self.get_install_warnings()
|
||||
|
||||
if not warnings:
|
||||
return ''
|
||||
|
||||
return '\n' + '\n'.join(f'[yellow]{w}[/]' for w in warnings) + '\n'
|
||||
|
||||
def _is_valid_path(self, dest_path: Path) -> bool:
|
||||
dest_path_ok = dest_path.exists() and dest_path.is_dir()
|
||||
if not dest_path_ok:
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ from archinstall.lib.pacman.pacman_menu import PacmanMenu
|
|||
from archinstall.lib.translationhandler import Language, tr, translation_handler
|
||||
from archinstall.lib.utils.format import as_table
|
||||
from archinstall.tui.components import tui
|
||||
from archinstall.tui.menu_item import MenuItem, MenuItemGroup
|
||||
from archinstall.tui.menu_item import MenuItem, MenuItemGroup, MsgLevelType, PreviewResult
|
||||
|
||||
|
||||
class GlobalMenu(AbstractMenu[None]):
|
||||
|
|
@ -494,21 +494,48 @@ class GlobalMenu(AbstractMenu[None]):
|
|||
|
||||
return None
|
||||
|
||||
def _prev_install_invalid_config(self, item: MenuItem) -> str | None:
|
||||
if missing := self._missing_configs():
|
||||
text = tr('Missing configurations:\n')
|
||||
for m in missing:
|
||||
text += f'- {m}\n'
|
||||
return text[:-1] # remove last new line
|
||||
def _get_install_warnings(self) -> list[str]:
|
||||
warnings: list[str] = []
|
||||
|
||||
if error := self._validate_bootloader():
|
||||
return tr('Invalid configuration: {}').format(error)
|
||||
if not isinstance(self._arch_config.network_config, NetworkConfiguration):
|
||||
warnings.append(tr('No network configuration selected. Network will need to be set up manually on the installed system.'))
|
||||
|
||||
return warnings
|
||||
|
||||
def _prev_install_invalid_config(self, item: MenuItem) -> PreviewResult | None:
|
||||
self.sync_all_to_config()
|
||||
summary = ConfigurationOutput(self._arch_config).as_summary()
|
||||
if summary:
|
||||
return f'{tr("Ready to install")}\n\n{summary}'
|
||||
return tr('Ready to install')
|
||||
config_output = ConfigurationOutput(self._arch_config)
|
||||
|
||||
warnings = self._get_install_warnings()
|
||||
messages: list[tuple[str, MsgLevelType]] = []
|
||||
|
||||
errors = ''
|
||||
if missing := self._missing_configs():
|
||||
errors += f'{tr("Missing configurations:")}\n'
|
||||
errors += '\n'.join(f'- {m}' for m in missing)
|
||||
|
||||
disk_item = self._item_group.find_by_key('disk_config')
|
||||
if disk_item.has_value():
|
||||
if error := self._validate_bootloader():
|
||||
if errors:
|
||||
errors += '\n\n'
|
||||
errors += f'{tr("Invalid configuration:")}\n- {error}'
|
||||
|
||||
if errors:
|
||||
messages.append((errors, MsgLevelType.MsgError))
|
||||
else:
|
||||
messages.append((tr('Ready to install'), MsgLevelType.MsgInfo))
|
||||
|
||||
if warnings:
|
||||
text = f'{tr("Warnings:")}\n' + '\n'.join(f'- {w}' for w in warnings)
|
||||
messages.append((text, MsgLevelType.MsgWarning))
|
||||
|
||||
if not errors:
|
||||
summary = config_output.as_summary()
|
||||
if summary:
|
||||
messages.append((summary, MsgLevelType.MsgNone))
|
||||
|
||||
return PreviewResult(messages)
|
||||
|
||||
def _prev_profile(self, item: MenuItem) -> str | None:
|
||||
profile_config: ProfileConfiguration | None = item.value
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ from pathlib import Path
|
|||
from archinstall.lib.menu.helpers import Confirmation, Input
|
||||
from archinstall.lib.models.users import Password, PasswordStrength
|
||||
from archinstall.lib.translationhandler import tr
|
||||
from archinstall.tui.components import InputInfo, InputInfoType, tui
|
||||
from archinstall.tui.components import InputInfo, tui
|
||||
from archinstall.tui.menu_item import MsgLevelType
|
||||
from archinstall.tui.result import ResultType
|
||||
|
||||
|
||||
|
|
@ -20,11 +21,11 @@ async def get_password(
|
|||
return None
|
||||
strength = PasswordStrength.strength(value)
|
||||
if strength in (PasswordStrength.VERY_WEAK, PasswordStrength.WEAK):
|
||||
return InputInfo(message=tr('Password strength: Weak'), info_type=InputInfoType.MsgError)
|
||||
return InputInfo(message=tr('Password strength: Weak'), msg_level=MsgLevelType.MsgError)
|
||||
elif strength == PasswordStrength.MODERATE:
|
||||
return InputInfo(message=tr('Password strength: Moderate'), info_type=InputInfoType.MsgWarning)
|
||||
return InputInfo(message=tr('Password strength: Moderate'), msg_level=MsgLevelType.MsgWarning)
|
||||
elif strength == PasswordStrength.STRONG:
|
||||
return InputInfo(message=tr('Password strength: Strong'), info_type=InputInfoType.MsgInfo)
|
||||
return InputInfo(message=tr('Password strength: Strong'), msg_level=MsgLevelType.MsgInfo)
|
||||
return None
|
||||
|
||||
while True:
|
||||
|
|
|
|||
|
|
@ -1208,3 +1208,17 @@ msgstr ""
|
|||
|
||||
msgid " (default)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid configuration:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Missing configurations:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Warnings:"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"No network configuration selected. Network will need to be set up manually "
|
||||
"on the installed system."
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ def main(arch_config_handler: ArchConfigHandler | None = None) -> None:
|
|||
|
||||
if not arch_config_handler.args.silent:
|
||||
aborted = False
|
||||
res: bool = tui.run(lambda: config.confirm_config(show_install_warnings=True))
|
||||
res: bool = tui.run(config.confirm_config)
|
||||
|
||||
if not res:
|
||||
debug('Installation aborted')
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ async def main(arch_config_handler: ArchConfigHandler | None = None) -> None:
|
|||
|
||||
if not arch_config_handler.args.silent:
|
||||
aborted = False
|
||||
res: bool = tui.run(lambda: config.confirm_config(show_install_warnings=True))
|
||||
res: bool = tui.run(config.confirm_config)
|
||||
|
||||
if not res:
|
||||
debug('Installation aborted')
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ 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 rich.text import Text
|
||||
from textual import work
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding, BindingsMap
|
||||
|
|
@ -21,12 +21,28 @@ from textual.worker import WorkerCancelled
|
|||
|
||||
from archinstall.lib.log import debug
|
||||
from archinstall.lib.translationhandler import tr
|
||||
from archinstall.tui.menu_item import MenuItem, MenuItemGroup
|
||||
from archinstall.tui.menu_item import MenuItem, MenuItemGroup, MsgLevelType, PreviewResult
|
||||
from archinstall.tui.result import Result, ResultType
|
||||
|
||||
ValueT = TypeVar('ValueT')
|
||||
|
||||
|
||||
def _update_preview(widget: Label, result: str | PreviewResult | None) -> None:
|
||||
if result is None:
|
||||
widget.update('')
|
||||
return
|
||||
|
||||
if isinstance(result, str):
|
||||
widget.update(result)
|
||||
else:
|
||||
text = Text()
|
||||
for i, (message, level) in enumerate(result.messages):
|
||||
if i > 0:
|
||||
text.append('\n\n')
|
||||
text.append(message, style=level.style())
|
||||
widget.update(text)
|
||||
|
||||
|
||||
def _translate_bindings(source: BindingsMap | None, target: BindingsMap) -> None:
|
||||
"""Translate binding descriptions from source to target.
|
||||
|
||||
|
|
@ -361,13 +377,9 @@ class OptionListScreen(BaseScreen[ValueT]):
|
|||
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('')
|
||||
_update_preview(preview_widget, item.preview_action(item))
|
||||
else:
|
||||
_update_preview(preview_widget, None)
|
||||
|
||||
|
||||
class _SelectionList(SelectionList[ValueT]):
|
||||
|
|
@ -614,12 +626,9 @@ class SelectListScreen(BaseScreen[ValueT]):
|
|||
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('')
|
||||
_update_preview(preview_widget, item.preview_action(item))
|
||||
else:
|
||||
_update_preview(preview_widget, None)
|
||||
|
||||
|
||||
# DEPRECATED: Removed when switching to async
|
||||
|
|
@ -735,13 +744,8 @@ class ConfirmationScreen(BaseScreen[ValueT]):
|
|||
|
||||
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)
|
||||
result = focused.preview_action(focused) if focused.preview_action else None
|
||||
_update_preview(preview, result)
|
||||
else:
|
||||
button.remove_class('-active')
|
||||
|
||||
|
|
@ -768,16 +772,10 @@ class NotifyScreen(ConfirmationScreen[ValueT]):
|
|||
super().__init__(group, header)
|
||||
|
||||
|
||||
class InputInfoType(Enum):
|
||||
MsgInfo = auto()
|
||||
MsgWarning = auto()
|
||||
MsgError = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class InputInfo:
|
||||
message: str
|
||||
info_type: InputInfoType
|
||||
msg_level: MsgLevelType
|
||||
|
||||
|
||||
class InputScreen(BaseScreen[str]):
|
||||
|
|
@ -889,11 +887,11 @@ class InputScreen(BaseScreen[str]):
|
|||
result = self._info_callback(event.value)
|
||||
if result:
|
||||
css_class = ''
|
||||
if result.info_type == InputInfoType.MsgError:
|
||||
if result.msg_level == MsgLevelType.MsgError:
|
||||
css_class = 'input-hint-msg-error'
|
||||
elif result.info_type == InputInfoType.MsgWarning:
|
||||
elif result.msg_level == MsgLevelType.MsgWarning:
|
||||
css_class = 'input-hint-msg-warning'
|
||||
elif result.info_type == InputInfoType.MsgInfo:
|
||||
elif result.msg_level == MsgLevelType.MsgInfo:
|
||||
css_class = 'input-hint-msg-info'
|
||||
info_label.update(result.message)
|
||||
info_label.set_classes(css_class)
|
||||
|
|
@ -1138,13 +1136,7 @@ class TableSelectionScreen(BaseScreen[ValueT]):
|
|||
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('')
|
||||
_update_preview(preview_widget, item.preview_action(item))
|
||||
|
||||
def _set_cursor(self, row_index: int) -> None:
|
||||
data_table = self.query_one(DataTable)
|
||||
|
|
@ -1279,6 +1271,7 @@ class _AppInstance(App[ValueT]):
|
|||
background: black;
|
||||
border-left: vkey white 20%;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, main: InstanceRunnable[ValueT] | Callable[[], Awaitable[ValueT]]) -> None:
|
||||
|
|
|
|||
|
|
@ -1,12 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from enum import Enum, StrEnum, auto
|
||||
from functools import cached_property
|
||||
from typing import Any, ClassVar, Self, override
|
||||
|
||||
from archinstall.lib.translationhandler import tr
|
||||
|
||||
|
||||
class MsgLevelStyle(StrEnum):
|
||||
White = 'white'
|
||||
Green = 'green'
|
||||
Yellow = 'bright_yellow'
|
||||
Red = 'red'
|
||||
|
||||
|
||||
class MsgLevelType(Enum):
|
||||
MsgNone = auto()
|
||||
MsgInfo = auto()
|
||||
MsgWarning = auto()
|
||||
MsgError = auto()
|
||||
|
||||
def style(self) -> MsgLevelStyle:
|
||||
match self:
|
||||
case MsgLevelType.MsgNone:
|
||||
return MsgLevelStyle.White
|
||||
case MsgLevelType.MsgInfo:
|
||||
return MsgLevelStyle.Green
|
||||
case MsgLevelType.MsgWarning:
|
||||
return MsgLevelStyle.Yellow
|
||||
case MsgLevelType.MsgError:
|
||||
return MsgLevelStyle.Red
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreviewResult:
|
||||
messages: list[tuple[str, MsgLevelType]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuItem:
|
||||
text: str
|
||||
|
|
@ -18,7 +50,7 @@ class MenuItem:
|
|||
dependencies: list[str | Callable[[], bool]] = field(default_factory=list)
|
||||
dependencies_not: list[str] = field(default_factory=list)
|
||||
display_action: Callable[[Any], str] | None = None
|
||||
preview_action: Callable[[Self], str | None] | None = None
|
||||
preview_action: Callable[[Self], str | PreviewResult | None] | None = None
|
||||
key: str | None = None
|
||||
|
||||
_id: str = ''
|
||||
|
|
|
|||
Loading…
Reference in New Issue