Compare commits

..

No commits in common. "main" and "v0.6.1" have entirely different histories.
main ... v0.6.1

8 changed files with 58 additions and 136 deletions

View File

@ -30,7 +30,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install "virtualenv<21" hatch
pip install hatch
- name: Build package
run: hatch build

View File

@ -17,7 +17,6 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]

View File

@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2026-present onyx-and-iris <code@onyxandiris.online>
#
# SPDX-License-Identifier: MIT
__version__ = '0.7.1'
__version__ = '0.6.1'

View File

@ -1,19 +1,19 @@
from typing import Literal
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, tui):
def __init__(self, current_host: str, current_port: int, current_password: str):
super().__init__()
self._settings = tui._settings
self._original_settings = self._settings.model_copy()
self.current_host = current_host
self.current_port = current_port
self.current_password = current_password
def compose(self) -> ComposeResult:
with Vertical(id='config-dialog'):
@ -21,19 +21,15 @@ class ConfigScreen(ModalScreen[bool]):
with Vertical(id='config-form'):
yield Label('Host:')
yield Input(
value=self._original_settings.host,
placeholder='localhost',
id='host-input',
value=self.current_host, placeholder='localhost', id='host-input'
)
yield Label('Port:')
yield Input(
value=str(self._original_settings.port),
placeholder='28960',
id='port-input',
value=str(self.current_port), placeholder='28960', id='port-input'
)
yield Label('Password:')
yield Input(
value=self._original_settings.password,
value=self.current_password,
placeholder='Enter password',
password=True,
id='password-input',
@ -43,69 +39,26 @@ class ConfigScreen(ModalScreen[bool]):
yield Button('Cancel', variant='error', id='config-cancel')
def on_button_pressed(self, event: Button.Pressed) -> None:
match event.button.id:
case 'config-save':
self._save_button_handler()
case 'config-cancel':
self._cancel_button_handler()
if event.button.id == 'config-save':
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
def _save_button_handler(self):
self._clear_field_errors()
if new_port < 1 or new_port > 65535:
raise ValueError('Port must be between 1 and 65535')
try:
new_host: str = (
self.query_one('#host-input', Input).value.strip() or 'localhost'
)
new_port: int | Literal[28960] = (
self.query_one('#port-input', Input).value or 28960
)
new_password: str = self.query_one('#password-input', Input).value
if len(new_password) < 8:
raise ValueError('Password must be at least 8 characters long')
self._settings.host = new_host
self._settings.port = new_port
self._settings.password = new_password
settings.host = new_host
settings.port = new_port
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)
self.dismiss(True)
except ValueError:
self.app.bell()
elif event.button.id == 'config-cancel':
self.dismiss(False)

View File

@ -230,29 +230,6 @@ 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%;

View File

@ -1,6 +1,7 @@
from pathlib import Path
from typing import Annotated, Type
from loguru import logger
from pydantic import (
AfterValidator,
AliasChoices,
@ -19,18 +20,6 @@ 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')
@ -38,8 +27,8 @@ def is_valid_password(password: str) -> str | None:
class Settings(BaseSettings):
host: Annotated[str, AfterValidator(is_valid_host)] = 'localhost'
port: Annotated[int, AfterValidator(is_valid_port)] = 28960
host: str = 'localhost'
port: int = 28960
password: Annotated[str, AfterValidator(is_valid_password)] = ''
append: bool = False
min_status: bool = Field(
@ -63,7 +52,7 @@ class Settings(BaseSettings):
cli_prefix='',
cli_parse_args=True,
cli_implicit_flags=True,
validate_assignment=True,
validate_assignment=False,
frozen=False,
)
@ -82,3 +71,10 @@ class Settings(BaseSettings):
dotenv_settings,
init_settings,
)
try:
settings = Settings()
except ValueError as e:
logger.error(e)
raise SystemExit(1)

View File

@ -4,17 +4,16 @@ 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
class Q3RconTUI(App):
class RconApp(App):
CSS_PATH = 'q3rcon_tui.tcss'
def __init__(self):
super().__init__()
self._settings = Settings()
self.writable = Writable(self)
self.writable = Writable()
def compose(self) -> ComposeResult:
yield Grid(
@ -50,14 +49,16 @@ class Q3RconTUI(App):
self.app.exit()
async def _config_button_handler(self):
result = await self.push_screen(ConfigScreen(self))
result = await self.push_screen(
ConfigScreen(settings.host, settings.port, settings.password)
)
if result:
self.query_one('#response', RichLog).write(
f'Configuration updated: {self._settings.host}:{self._settings.port}'
f'Configuration updated: {settings.host}:{settings.port}'
)
async def _send_button_handler(self):
if not self._settings.append:
if not settings.append:
self.query_one('#response', RichLog).clear()
cmd = self.query_one('#command', Input).value.strip()
@ -67,7 +68,7 @@ class Q3RconTUI(App):
try:
async with Client(
self._settings.host, self._settings.port, self._settings.password
settings.host, settings.port, settings.password
) as client:
response = await client.send_command(cmd)
self.query_one('#response', RichLog).write(
@ -87,5 +88,5 @@ class Q3RconTUI(App):
def main():
app = Q3RconTUI()
app = RconApp()
app.run()

View File

@ -3,6 +3,8 @@ import re
from rich.table import Table
from rich.text import Text
from .settings import settings
Renderable = Text | Table | str
@ -32,21 +34,15 @@ class Writable:
r'["](?P<info>.*?)["]$'
)
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 self._settings.raw:
if 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)
@ -70,9 +66,9 @@ class Writable:
]
for column, justify in columns:
table.add_column(column, justify=justify)
if not self._settings.min_status:
if not settings.min_status:
table.add_column('Last', justify='center')
if self._settings.min_status:
if settings.min_status:
table.add_column('IP', justify='center')
else:
table.add_column('IP:Port', justify='center')
@ -80,7 +76,7 @@ class Writable:
('QPort', 'center'),
('Rate', 'center'),
]
if not self._settings.min_status:
if not settings.min_status:
for column, justify in columns:
table.add_column(column, justify=justify)
@ -97,7 +93,7 @@ class Writable:
m.group('guid'),
name,
]
if self._settings.min_status:
if settings.min_status:
row.append(m.group('ip'))
else:
row.append(f'{m.group("ip")}:{m.group("port")}')