From f59076f0a6c2d43904cf20c260a17ef3faaa5984 Mon Sep 17 00:00:00 2001 From: onyx-and-iris Date: Wed, 25 Feb 2026 21:41:19 +0000 Subject: [PATCH] improve data encapsulation by initialising Settings in RconApp and passing it to child objects add host, port validation functions improve error handling for the ConfigScreen. Pydantic validations now occur on assignment as well as creation. minor version bump --- src/q3rcon_tui/__about__.py | 2 +- src/q3rcon_tui/configscreen.py | 107 ++++++++++++++++++++++++--------- src/q3rcon_tui/q3rcon_tui.tcss | 23 +++++++ src/q3rcon_tui/settings.py | 26 ++++---- src/q3rcon_tui/tui.py | 15 +++-- src/q3rcon_tui/writable.py | 18 +++--- 6 files changed, 134 insertions(+), 57 deletions(-) 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")}')