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:
Softer 2026-06-08 09:22:34 +03:00 committed by GitHub
parent 1048f7eef0
commit 0f16801448
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 128 additions and 81 deletions

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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 ""

View File

@ -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')

View File

@ -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')

View File

@ -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:

View File

@ -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 = ''