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.
This commit is contained in:
Softer 2026-05-02 01:21:55 +03:00
parent 76629ecc15
commit 0b235cc8f8
6 changed files with 38 additions and 26 deletions

View File

@ -124,13 +124,10 @@ class ConfigurationOutput:
label_width = max(len(label) for label, _ in rows) + 2
return '\n'.join(f'{label:<{label_width}}{value}' for label, value in rows)
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())
@ -156,14 +153,6 @@ class ConfigurationOutput:
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

@ -1,5 +1,7 @@
from typing import override
from rich.markup import escape as _escape_markup
from archinstall.default_profiles.profile import GreeterType
from archinstall.lib.applications.application_menu import ApplicationMenu
from archinstall.lib.args import ArchConfig
@ -183,6 +185,7 @@ class GlobalMenu(AbstractMenu[None]):
MenuItem(
text=tr('Install'),
preview_action=self._prev_install_invalid_config,
preview_markup=True,
key=SpecialMenuKey.INSTALL.value,
),
MenuItem(
@ -495,20 +498,30 @@ class GlobalMenu(AbstractMenu[None]):
return None
def _prev_install_invalid_config(self, item: MenuItem) -> str | None:
self.sync_all_to_config()
config_output = ConfigurationOutput(self._arch_config)
warnings = config_output.get_install_warnings()
warnings_text = ''
if warnings:
warnings_text = f'\n\n[yellow]{_escape_markup(tr("Warnings:"))}\n'
for w in warnings:
warnings_text += f'- {_escape_markup(w)}\n'
warnings_text = warnings_text.rstrip('\n') + '[/yellow]'
if missing := self._missing_configs():
text = tr('Missing configurations:\n')
text = f'[red]{_escape_markup(tr("Missing configurations:"))}\n'
for m in missing:
text += f'- {m}\n'
return text[:-1] # remove last new line
text += f'- {_escape_markup(m)}\n'
return text.rstrip('\n') + '[/red]' + warnings_text
if error := self._validate_bootloader():
return tr(f'Invalid configuration: {error}')
return f'[red]{_escape_markup(tr(f"Invalid configuration: {error}"))}[/red]' + warnings_text
self.sync_all_to_config()
summary = ConfigurationOutput(self._arch_config).as_summary()
summary = config_output.as_summary()
if summary:
return f'{tr("Ready to install")}\n\n{summary}'
return tr('Ready to install')
return f'[green]{_escape_markup(tr("Ready to install"))}[/green]{warnings_text}\n\n{_escape_markup(summary)}'
return f'[green]{_escape_markup(tr("Ready to install"))}[/green]{warnings_text}'
def _prev_profile(self, item: MenuItem) -> str | None:
profile_config: ProfileConfiguration | None = item.value

View File

@ -226,7 +226,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

@ -5,6 +5,7 @@ from dataclasses import dataclass, replace
from enum import Enum, auto
from typing import Any, ClassVar, Literal, TypeVar, cast, override
from rich.markup import escape as _escape_markup
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding, BindingsMap
@ -280,7 +281,7 @@ class OptionListScreen(BaseScreen[ValueT]):
with Container():
yield option_list
yield Rule(orientation=rule_orientation)
yield ScrollableContainer(Label('', id='preview_content', markup=False))
yield ScrollableContainer(Label('', id='preview_content', markup=True))
if self._filter:
yield Input(placeholder='/filter', id='filter-input')
@ -359,6 +360,8 @@ class OptionListScreen(BaseScreen[ValueT]):
maybe_preview = item.preview_action(item)
if maybe_preview is not None:
if not item.preview_markup:
maybe_preview = _escape_markup(maybe_preview)
preview_widget.update(maybe_preview)
return
@ -510,7 +513,7 @@ class SelectListScreen(BaseScreen[ValueT]):
with Container():
yield selection_list
yield Rule(orientation=rule_orientation)
yield ScrollableContainer(Label('', id='preview_content', markup=False))
yield ScrollableContainer(Label('', id='preview_content', markup=True))
if self._filter:
yield Input(placeholder='/filter', id='filter-input')
@ -601,6 +604,8 @@ class SelectListScreen(BaseScreen[ValueT]):
if item.preview_action is not None:
maybe_preview = item.preview_action(item)
if maybe_preview is not None:
if not item.preview_markup:
maybe_preview = _escape_markup(maybe_preview)
preview_widget.update(maybe_preview)
return
@ -688,7 +693,7 @@ class ConfirmationScreen(BaseScreen[ValueT]):
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 ScrollableContainer(Label('', id='preview_content', markup=True))
yield Footer()
@ -726,6 +731,8 @@ class ConfirmationScreen(BaseScreen[ValueT]):
else:
text = focused.preview_action(focused)
if text is not None:
if not focused.preview_markup:
text = _escape_markup(text)
preview.update(text)
else:
button.remove_class('-active')
@ -1016,7 +1023,7 @@ class TableSelectionScreen(BaseScreen[ValueT]):
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 ScrollableContainer(Label('', id='preview_content', markup=True))
yield Footer()
@ -1126,6 +1133,8 @@ class TableSelectionScreen(BaseScreen[ValueT]):
maybe_preview = item.preview_action(item)
if maybe_preview is not None:
if not item.preview_markup:
maybe_preview = _escape_markup(maybe_preview)
preview_widget.update(maybe_preview)
return

View File

@ -19,6 +19,7 @@ class MenuItem:
dependencies_not: list[str] = field(default_factory=list)
display_action: Callable[[Any], str] | None = None
preview_action: Callable[[Self], str | None] | None = None
preview_markup: bool = False
key: str | None = None
_id: str = ''