diff --git a/src/q3rcon_tui/__about__.py b/src/q3rcon_tui/__about__.py index 32fb64f..784b33b 100644 --- a/src/q3rcon_tui/__about__.py +++ b/src/q3rcon_tui/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2026-present onyx-and-iris # # SPDX-License-Identifier: MIT -__version__ = '0.6.1' +__version__ = '0.7.0' diff --git a/src/q3rcon_tui/configscreen.py b/src/q3rcon_tui/configscreen.py index edbdd66..a0bcb47 100644 --- a/src/q3rcon_tui/configscreen.py +++ b/src/q3rcon_tui/configscreen.py @@ -1,19 +1,17 @@ +from pydantic import ValidationError from textual.app import ComposeResult from textual.containers import Horizontal, Vertical from textual.screen import ModalScreen from textual.widgets import Button, Input, Label, Static -from .settings import settings - class ConfigScreen(ModalScreen[bool]): """Modal dialog for configuring connection settings.""" - def __init__(self, current_host: str, current_port: int, current_password: str): + def __init__(self, tui): super().__init__() - self.current_host = current_host - self.current_port = current_port - self.current_password = current_password + self._settings = tui._settings + self._original_settings = self._settings.model_copy() def compose(self) -> ComposeResult: with Vertical(id='config-dialog'): @@ -21,15 +19,19 @@ class ConfigScreen(ModalScreen[bool]): with Vertical(id='config-form'): yield Label('Host:') yield Input( - value=self.current_host, placeholder='localhost', id='host-input' + value=self._original_settings.host, + placeholder='localhost', + id='host-input', ) yield Label('Port:') yield Input( - value=str(self.current_port), placeholder='28960', id='port-input' + value=str(self._original_settings.port), + placeholder='28960', + id='port-input', ) yield Label('Password:') yield Input( - value=self.current_password, + value=self._original_settings.password, placeholder='Enter password', password=True, id='password-input', @@ -39,26 +41,71 @@ class ConfigScreen(ModalScreen[bool]): yield Button('Cancel', variant='error', id='config-cancel') def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == 'config-save': + match event.button.id: + case 'config-save': + self._save_button_handler() + case 'config-cancel': + self._cancel_button_handler() + + def _save_button_handler(self): + self._clear_field_errors() + + try: + new_host = self.query_one('#host-input', Input).value.strip() or 'localhost' + new_port = self.query_one('#port-input', Input).value + new_password = self.query_one('#password-input', Input).value + try: - new_host = ( - self.query_one('#host-input', Input).value.strip() or 'localhost' - ) - new_port = int(self.query_one('#port-input', Input).value or '28960') - new_password = self.query_one('#password-input', Input).value - - if new_port < 1 or new_port > 65535: - raise ValueError('Port must be between 1 and 65535') - - if len(new_password) < 8: - raise ValueError('Password must be at least 8 characters long') - - settings.host = new_host - settings.port = new_port - settings.password = new_password - - self.dismiss(True) + new_port = int(new_port or '28960') except ValueError: - self.app.bell() - elif event.button.id == 'config-cancel': - self.dismiss(False) + self._show_field_error('port-input', 'Port must be a valid number') + return + + self._settings.host = new_host + self._settings.port = new_port + self._settings.password = new_password + + self.dismiss(True) + except ValidationError as e: + for error in e.errors(): + field_name = error.get('loc', ()) + error_msg = error.get('msg', str(error)) + + match field_name: + case ('host',): + self._show_field_error('host-input', error_msg) + case ('port',): + self._show_field_error('port-input', error_msg) + case ('password',): + self._show_field_error('password-input', error_msg) + break # Only show the first error + + def _show_field_error(self, field_id: str, error_message: str): + """Show error message in the specified input field.""" + self._clear_field_errors() + + field = self.query_one(f'#{field_id}', Input) + field.add_class('error') + field.value = '' + + field.placeholder = error_message + field.focus() + + self.app.bell() + + def _clear_field_errors(self): + """Clear error styling from all input fields.""" + for field_id in ['host-input', 'port-input', 'password-input']: + field = self.query_one(f'#{field_id}', Input) + field.remove_class('error') + + match field_id: + case 'host-input': + field.placeholder = 'localhost' + case 'port-input': + field.placeholder = '28960' + case 'password-input': + field.placeholder = 'Enter password' + + def _cancel_button_handler(self): + self.dismiss(False) diff --git a/src/q3rcon_tui/q3rcon_tui.tcss b/src/q3rcon_tui/q3rcon_tui.tcss index 627df33..eecdcda 100644 --- a/src/q3rcon_tui/q3rcon_tui.tcss +++ b/src/q3rcon_tui/q3rcon_tui.tcss @@ -230,6 +230,29 @@ Button.error:focus { background: #434c5e; } +#config-form Input.error { + border: solid #bf616a; + background: #3c2a2a; + color: #d08770; +} + +#config-form Input.error:focus { + border: solid #d08770; + background: #4a2c2c; +} + +.error-message { + height: 0; + color: #bf616a; + text-style: bold; + margin-top: 0; + margin-bottom: 1; +} + +.error-message.visible { + height: 1; +} + #config-buttons { height: 3; width: 100%; diff --git a/src/q3rcon_tui/settings.py b/src/q3rcon_tui/settings.py index 5747480..7f571d6 100644 --- a/src/q3rcon_tui/settings.py +++ b/src/q3rcon_tui/settings.py @@ -1,7 +1,6 @@ from pathlib import Path from typing import Annotated, Type -from loguru import logger from pydantic import ( AfterValidator, AliasChoices, @@ -20,6 +19,18 @@ def version_callback(value: bool) -> bool | None: return False +def is_valid_host(host: str) -> str | None: + if not host: + raise ValueError('Host cannot be empty') + return host + + +def is_valid_port(port: int) -> int | None: + if port < 1 or port > 65535: + raise ValueError('Port must be between 1 and 65535') + return port + + def is_valid_password(password: str) -> str | None: if len(password) < 8: raise ValueError('Password must be at least 8 characters long') @@ -27,8 +38,8 @@ def is_valid_password(password: str) -> str | None: class Settings(BaseSettings): - host: str = 'localhost' - port: int = 28960 + host: Annotated[str, AfterValidator(is_valid_host)] = 'localhost' + port: Annotated[int, AfterValidator(is_valid_port)] = 28960 password: Annotated[str, AfterValidator(is_valid_password)] = '' append: bool = False min_status: bool = Field( @@ -52,7 +63,7 @@ class Settings(BaseSettings): cli_prefix='', cli_parse_args=True, cli_implicit_flags=True, - validate_assignment=False, + validate_assignment=True, frozen=False, ) @@ -71,10 +82,3 @@ class Settings(BaseSettings): dotenv_settings, init_settings, ) - - -try: - settings = Settings() -except ValueError as e: - logger.error(e) - raise SystemExit(1) diff --git a/src/q3rcon_tui/tui.py b/src/q3rcon_tui/tui.py index 57a8eab..d3698bd 100644 --- a/src/q3rcon_tui/tui.py +++ b/src/q3rcon_tui/tui.py @@ -4,7 +4,7 @@ from textual.containers import Grid from textual.widgets import Button, Input, RichLog from .configscreen import ConfigScreen -from .settings import settings +from .settings import Settings from .writable import Writable @@ -13,7 +13,8 @@ class RconApp(App): def __init__(self): super().__init__() - self.writable = Writable() + self._settings = Settings() + self.writable = Writable(self) def compose(self) -> ComposeResult: yield Grid( @@ -49,16 +50,14 @@ class RconApp(App): self.app.exit() async def _config_button_handler(self): - result = await self.push_screen( - ConfigScreen(settings.host, settings.port, settings.password) - ) + result = await self.push_screen(ConfigScreen(self)) if result: self.query_one('#response', RichLog).write( - f'Configuration updated: {settings.host}:{settings.port}' + f'Configuration updated: {self._settings.host}:{self._settings.port}' ) async def _send_button_handler(self): - if not settings.append: + if not self._settings.append: self.query_one('#response', RichLog).clear() cmd = self.query_one('#command', Input).value.strip() @@ -68,7 +67,7 @@ class RconApp(App): try: async with Client( - settings.host, settings.port, settings.password + self._settings.host, self._settings.port, self._settings.password ) as client: response = await client.send_command(cmd) self.query_one('#response', RichLog).write( diff --git a/src/q3rcon_tui/writable.py b/src/q3rcon_tui/writable.py index 88ac2a3..e2e2e88 100644 --- a/src/q3rcon_tui/writable.py +++ b/src/q3rcon_tui/writable.py @@ -3,8 +3,6 @@ import re from rich.table import Table from rich.text import Text -from .settings import settings - Renderable = Text | Table | str @@ -34,15 +32,21 @@ class Writable: r'["](?P.*?)["]$' ) + def __init__(self, tui): + self._settings = tui._settings + @staticmethod def remove_color_codes(s: str) -> str: return Writable.RE_COLOR_CODES.sub('', s) def parse(self, cmd, response: str, style=None) -> Renderable: response = self.remove_color_codes(response.removeprefix('print\n')) - if settings.raw: + if self._settings.raw: return Text(response, style=style) + if response in ['Bad rcon']: + return Text('Incorrect RCON password', style='#c73d4b') + match cmd: case 'status': return self.status_table(response) @@ -66,9 +70,9 @@ class Writable: ] for column, justify in columns: table.add_column(column, justify=justify) - if not settings.min_status: + if not self._settings.min_status: table.add_column('Last', justify='center') - if settings.min_status: + if self._settings.min_status: table.add_column('IP', justify='center') else: table.add_column('IP:Port', justify='center') @@ -76,7 +80,7 @@ class Writable: ('QPort', 'center'), ('Rate', 'center'), ] - if not settings.min_status: + if not self._settings.min_status: for column, justify in columns: table.add_column(column, justify=justify) @@ -93,7 +97,7 @@ class Writable: m.group('guid'), name, ] - if settings.min_status: + if self._settings.min_status: row.append(m.group('ip')) else: row.append(f'{m.group("ip")}:{m.group("port")}')