mirror of
https://github.com/onyx-and-iris/q3rcon-tui.git
synced 2026-02-26 03:09:09 +00:00
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
This commit is contained in:
parent
6bf16f5d50
commit
f59076f0a6
@ -1,4 +1,4 @@
|
|||||||
# SPDX-FileCopyrightText: 2026-present onyx-and-iris <code@onyxandiris.online>
|
# SPDX-FileCopyrightText: 2026-present onyx-and-iris <code@onyxandiris.online>
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
__version__ = '0.6.1'
|
__version__ = '0.7.0'
|
||||||
|
|||||||
@ -1,19 +1,17 @@
|
|||||||
|
from pydantic import ValidationError
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Horizontal, Vertical
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.widgets import Button, Input, Label, Static
|
from textual.widgets import Button, Input, Label, Static
|
||||||
|
|
||||||
from .settings import settings
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigScreen(ModalScreen[bool]):
|
class ConfigScreen(ModalScreen[bool]):
|
||||||
"""Modal dialog for configuring connection settings."""
|
"""Modal dialog for configuring connection settings."""
|
||||||
|
|
||||||
def __init__(self, current_host: str, current_port: int, current_password: str):
|
def __init__(self, tui):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.current_host = current_host
|
self._settings = tui._settings
|
||||||
self.current_port = current_port
|
self._original_settings = self._settings.model_copy()
|
||||||
self.current_password = current_password
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Vertical(id='config-dialog'):
|
with Vertical(id='config-dialog'):
|
||||||
@ -21,15 +19,19 @@ class ConfigScreen(ModalScreen[bool]):
|
|||||||
with Vertical(id='config-form'):
|
with Vertical(id='config-form'):
|
||||||
yield Label('Host:')
|
yield Label('Host:')
|
||||||
yield Input(
|
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 Label('Port:')
|
||||||
yield Input(
|
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 Label('Password:')
|
||||||
yield Input(
|
yield Input(
|
||||||
value=self.current_password,
|
value=self._original_settings.password,
|
||||||
placeholder='Enter password',
|
placeholder='Enter password',
|
||||||
password=True,
|
password=True,
|
||||||
id='password-input',
|
id='password-input',
|
||||||
@ -39,26 +41,71 @@ class ConfigScreen(ModalScreen[bool]):
|
|||||||
yield Button('Cancel', variant='error', id='config-cancel')
|
yield Button('Cancel', variant='error', id='config-cancel')
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
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:
|
try:
|
||||||
new_host = (
|
new_port = int(new_port or '28960')
|
||||||
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)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.app.bell()
|
self._show_field_error('port-input', 'Port must be a valid number')
|
||||||
elif event.button.id == 'config-cancel':
|
return
|
||||||
self.dismiss(False)
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -230,6 +230,29 @@ Button.error:focus {
|
|||||||
background: #434c5e;
|
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 {
|
#config-buttons {
|
||||||
height: 3;
|
height: 3;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Type
|
from typing import Annotated, Type
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
AfterValidator,
|
AfterValidator,
|
||||||
AliasChoices,
|
AliasChoices,
|
||||||
@ -20,6 +19,18 @@ def version_callback(value: bool) -> bool | None:
|
|||||||
return False
|
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:
|
def is_valid_password(password: str) -> str | None:
|
||||||
if len(password) < 8:
|
if len(password) < 8:
|
||||||
raise ValueError('Password must be at least 8 characters long')
|
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):
|
class Settings(BaseSettings):
|
||||||
host: str = 'localhost'
|
host: Annotated[str, AfterValidator(is_valid_host)] = 'localhost'
|
||||||
port: int = 28960
|
port: Annotated[int, AfterValidator(is_valid_port)] = 28960
|
||||||
password: Annotated[str, AfterValidator(is_valid_password)] = ''
|
password: Annotated[str, AfterValidator(is_valid_password)] = ''
|
||||||
append: bool = False
|
append: bool = False
|
||||||
min_status: bool = Field(
|
min_status: bool = Field(
|
||||||
@ -52,7 +63,7 @@ class Settings(BaseSettings):
|
|||||||
cli_prefix='',
|
cli_prefix='',
|
||||||
cli_parse_args=True,
|
cli_parse_args=True,
|
||||||
cli_implicit_flags=True,
|
cli_implicit_flags=True,
|
||||||
validate_assignment=False,
|
validate_assignment=True,
|
||||||
frozen=False,
|
frozen=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -71,10 +82,3 @@ class Settings(BaseSettings):
|
|||||||
dotenv_settings,
|
dotenv_settings,
|
||||||
init_settings,
|
init_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings = Settings()
|
|
||||||
except ValueError as e:
|
|
||||||
logger.error(e)
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from textual.containers import Grid
|
|||||||
from textual.widgets import Button, Input, RichLog
|
from textual.widgets import Button, Input, RichLog
|
||||||
|
|
||||||
from .configscreen import ConfigScreen
|
from .configscreen import ConfigScreen
|
||||||
from .settings import settings
|
from .settings import Settings
|
||||||
from .writable import Writable
|
from .writable import Writable
|
||||||
|
|
||||||
|
|
||||||
@ -13,7 +13,8 @@ class RconApp(App):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.writable = Writable()
|
self._settings = Settings()
|
||||||
|
self.writable = Writable(self)
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Grid(
|
yield Grid(
|
||||||
@ -49,16 +50,14 @@ class RconApp(App):
|
|||||||
self.app.exit()
|
self.app.exit()
|
||||||
|
|
||||||
async def _config_button_handler(self):
|
async def _config_button_handler(self):
|
||||||
result = await self.push_screen(
|
result = await self.push_screen(ConfigScreen(self))
|
||||||
ConfigScreen(settings.host, settings.port, settings.password)
|
|
||||||
)
|
|
||||||
if result:
|
if result:
|
||||||
self.query_one('#response', RichLog).write(
|
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):
|
async def _send_button_handler(self):
|
||||||
if not settings.append:
|
if not self._settings.append:
|
||||||
self.query_one('#response', RichLog).clear()
|
self.query_one('#response', RichLog).clear()
|
||||||
|
|
||||||
cmd = self.query_one('#command', Input).value.strip()
|
cmd = self.query_one('#command', Input).value.strip()
|
||||||
@ -68,7 +67,7 @@ class RconApp(App):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with Client(
|
async with Client(
|
||||||
settings.host, settings.port, settings.password
|
self._settings.host, self._settings.port, self._settings.password
|
||||||
) as client:
|
) as client:
|
||||||
response = await client.send_command(cmd)
|
response = await client.send_command(cmd)
|
||||||
self.query_one('#response', RichLog).write(
|
self.query_one('#response', RichLog).write(
|
||||||
|
|||||||
@ -3,8 +3,6 @@ import re
|
|||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from .settings import settings
|
|
||||||
|
|
||||||
Renderable = Text | Table | str
|
Renderable = Text | Table | str
|
||||||
|
|
||||||
|
|
||||||
@ -34,15 +32,21 @@ class Writable:
|
|||||||
r'["](?P<info>.*?)["]$'
|
r'["](?P<info>.*?)["]$'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __init__(self, tui):
|
||||||
|
self._settings = tui._settings
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def remove_color_codes(s: str) -> str:
|
def remove_color_codes(s: str) -> str:
|
||||||
return Writable.RE_COLOR_CODES.sub('', s)
|
return Writable.RE_COLOR_CODES.sub('', s)
|
||||||
|
|
||||||
def parse(self, cmd, response: str, style=None) -> Renderable:
|
def parse(self, cmd, response: str, style=None) -> Renderable:
|
||||||
response = self.remove_color_codes(response.removeprefix('print\n'))
|
response = self.remove_color_codes(response.removeprefix('print\n'))
|
||||||
if settings.raw:
|
if self._settings.raw:
|
||||||
return Text(response, style=style)
|
return Text(response, style=style)
|
||||||
|
|
||||||
|
if response in ['Bad rcon']:
|
||||||
|
return Text('Incorrect RCON password', style='#c73d4b')
|
||||||
|
|
||||||
match cmd:
|
match cmd:
|
||||||
case 'status':
|
case 'status':
|
||||||
return self.status_table(response)
|
return self.status_table(response)
|
||||||
@ -66,9 +70,9 @@ class Writable:
|
|||||||
]
|
]
|
||||||
for column, justify in columns:
|
for column, justify in columns:
|
||||||
table.add_column(column, justify=justify)
|
table.add_column(column, justify=justify)
|
||||||
if not settings.min_status:
|
if not self._settings.min_status:
|
||||||
table.add_column('Last', justify='center')
|
table.add_column('Last', justify='center')
|
||||||
if settings.min_status:
|
if self._settings.min_status:
|
||||||
table.add_column('IP', justify='center')
|
table.add_column('IP', justify='center')
|
||||||
else:
|
else:
|
||||||
table.add_column('IP:Port', justify='center')
|
table.add_column('IP:Port', justify='center')
|
||||||
@ -76,7 +80,7 @@ class Writable:
|
|||||||
('QPort', 'center'),
|
('QPort', 'center'),
|
||||||
('Rate', 'center'),
|
('Rate', 'center'),
|
||||||
]
|
]
|
||||||
if not settings.min_status:
|
if not self._settings.min_status:
|
||||||
for column, justify in columns:
|
for column, justify in columns:
|
||||||
table.add_column(column, justify=justify)
|
table.add_column(column, justify=justify)
|
||||||
|
|
||||||
@ -93,7 +97,7 @@ class Writable:
|
|||||||
m.group('guid'),
|
m.group('guid'),
|
||||||
name,
|
name,
|
||||||
]
|
]
|
||||||
if settings.min_status:
|
if self._settings.min_status:
|
||||||
row.append(m.group('ip'))
|
row.append(m.group('ip'))
|
||||||
else:
|
else:
|
||||||
row.append(f'{m.group("ip")}:{m.group("port")}')
|
row.append(f'{m.group("ip")}:{m.group("port")}')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user