From 0f16801448435afb5985d1b4b8d033c35e163072 Mon Sep 17 00:00:00 2001 From: Softer Date: Mon, 8 Jun 2026 09:22:34 +0300 Subject: [PATCH] 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. --- archinstall/lib/configuration.py | 22 +--------- archinstall/lib/global_menu.py | 53 ++++++++++++++++++------ archinstall/lib/menu/util.py | 9 ++-- archinstall/locales/base.pot | 14 +++++++ archinstall/scripts/guided.py | 2 +- archinstall/scripts/minimal.py | 2 +- archinstall/tui/components.py | 71 ++++++++++++++------------------ archinstall/tui/menu_item.py | 36 +++++++++++++++- 8 files changed, 128 insertions(+), 81 deletions(-) diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index f0096872..5754dfcf 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -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: diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 45a0e3b4..548ed522 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -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 diff --git a/archinstall/lib/menu/util.py b/archinstall/lib/menu/util.py index 10edfd49..5efef09d 100644 --- a/archinstall/lib/menu/util.py +++ b/archinstall/lib/menu/util.py @@ -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: diff --git a/archinstall/locales/base.pot b/archinstall/locales/base.pot index 94224c1f..32c6b6c4 100644 --- a/archinstall/locales/base.pot +++ b/archinstall/locales/base.pot @@ -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 "" diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 6f484910..29573a5f 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -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') diff --git a/archinstall/scripts/minimal.py b/archinstall/scripts/minimal.py index c6eda03d..a58963f2 100644 --- a/archinstall/scripts/minimal.py +++ b/archinstall/scripts/minimal.py @@ -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') diff --git a/archinstall/tui/components.py b/archinstall/tui/components.py index efbb64f9..a2e6b132 100644 --- a/archinstall/tui/components.py +++ b/archinstall/tui/components.py @@ -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: diff --git a/archinstall/tui/menu_item.py b/archinstall/tui/menu_item.py index 4c10e275..13f9f165 100644 --- a/archinstall/tui/menu_item.py +++ b/archinstall/tui/menu_item.py @@ -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 = ''