Compare commits

..

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

9 changed files with 106 additions and 232 deletions

View File

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

View File

@ -1,6 +1,6 @@
# q3rcon tui # q3rcon tui
[![Hatch project](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/pypa/hatch/master/docs/assets/badge/v0.json)](https://github.com/pypa/hatch) [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![PyPI - Version](https://img.shields.io/pypi/v/q3rcon-tui.svg)](https://pypi.org/project/q3rcon-tui) [![PyPI - Version](https://img.shields.io/pypi/v/q3rcon-tui.svg)](https://pypi.org/project/q3rcon-tui)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/q3rcon-tui.svg)](https://pypi.org/project/q3rcon-tui) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/q3rcon-tui.svg)](https://pypi.org/project/q3rcon-tui)
@ -43,8 +43,6 @@ q3rcon-tui --host=localhost --port=28960 --password=rconpassword
Additional flags: Additional flags:
- `--raw`: Boolean flag, if set the RichLog will print raw responses without rendering tables. - `--raw`: Boolean flag, if set the RichLog will print raw responses without rendering tables.
- `--min-status`: Boolean flag, if set the status command will print a minified table.
- note, this will have no effect if in *raw* mode.
- `--append`: Boolean flag, if set the RichLog output will append each response continuously. - `--append`: Boolean flag, if set the RichLog output will append each response continuously.
- `--version`: Print the version of the TUI. - `--version`: Print the version of the TUI.
- `--help`: Print the help message. - `--help`: Print the help message.
@ -61,7 +59,6 @@ example .env:
Q3RCON_TUI_HOST=localhost Q3RCON_TUI_HOST=localhost
Q3RCON_TUI_PORT=28960 Q3RCON_TUI_PORT=28960
Q3RCON_TUI_PASSWORD=rconpassword Q3RCON_TUI_PASSWORD=rconpassword
Q3RCON_TUI_MIN_STATUS=false
Q3RCON_TUI_RAW=false Q3RCON_TUI_RAW=false
Q3RCON_TUI_APPEND=false Q3RCON_TUI_APPEND=false
``` ```

View File

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

View File

@ -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.7.1' __version__ = '0.5.1'

View File

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

View File

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

View File

@ -1,12 +1,8 @@
from pathlib import Path from pathlib import Path
from typing import Annotated, Type from typing import Annotated, Type
from pydantic import ( from loguru import logger
AfterValidator, from pydantic import AfterValidator, BeforeValidator
AliasChoices,
BeforeValidator,
Field,
)
from pydantic_settings import BaseSettings, CliSettingsSource, SettingsConfigDict from pydantic_settings import BaseSettings, CliSettingsSource, SettingsConfigDict
from .__about__ import __version__ as version from .__about__ import __version__ as version
@ -19,18 +15,6 @@ 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')
@ -38,18 +22,10 @@ def is_valid_password(password: str) -> str | None:
class Settings(BaseSettings): class Settings(BaseSettings):
host: Annotated[str, AfterValidator(is_valid_host)] = 'localhost' host: str = 'localhost'
port: Annotated[int, AfterValidator(is_valid_port)] = 28960 port: int = 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(
default=False,
alias='min-status',
validation_alias=AliasChoices(
'min-status',
'Q3RCON_TUI_MIN_STATUS',
),
)
raw: bool = False raw: bool = False
version: Annotated[bool, BeforeValidator(version_callback)] = False version: Annotated[bool, BeforeValidator(version_callback)] = False
@ -63,7 +39,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=True, validate_assignment=False,
frozen=False, frozen=False,
) )
@ -82,3 +58,10 @@ class Settings(BaseSettings):
dotenv_settings, dotenv_settings,
init_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 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
class Q3RconTUI(App): class RconApp(App):
CSS_PATH = 'q3rcon_tui.tcss' CSS_PATH = 'q3rcon_tui.tcss'
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._settings = Settings() self.writable = Writable()
self.writable = Writable(self)
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Grid( yield Grid(
@ -38,26 +37,22 @@ class Q3RconTUI(App):
self.query_one('#config', Button).press() self.query_one('#config', Button).press()
async def on_button_pressed(self, event: Button.Pressed) -> None: async def on_button_pressed(self, event: Button.Pressed) -> None:
match event.button.id: if event.button.id == 'quit':
case 'quit': self.app.exit()
self._quit_button_handler() elif event.button.id == 'config':
case 'config': result = await self.push_screen(
await self._config_button_handler() ConfigScreen(settings.host, settings.port, settings.password)
case 'send':
await self._send_button_handler()
def _quit_button_handler(self):
self.app.exit()
async def _config_button_handler(self):
result = await self.push_screen(ConfigScreen(self))
if result:
self.query_one('#response', RichLog).write(
f'Configuration updated: {self._settings.host}:{self._settings.port}'
) )
if result:
self.query_one('#response', RichLog).write(
f'Configuration updated: {settings.host}:{settings.port}'
)
return
async def _send_button_handler(self): if event.button.id != 'send':
if not self._settings.append: return
if not 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()
@ -67,7 +62,7 @@ class Q3RconTUI(App):
try: try:
async with Client( async with Client(
self._settings.host, self._settings.port, self._settings.password settings.host, settings.port, 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(
@ -75,7 +70,7 @@ class Q3RconTUI(App):
) )
except RCONError: except RCONError:
output = ( output = (
f'Unable to execute command {cmd}.', f'Unable to execute command {cmd}. ',
'It may be due to a map change or a server restart.', 'It may be due to a map change or a server restart.',
'If the problem persists, please check your connection settings and ensure the server is running.', 'If the problem persists, please check your connection settings and ensure the server is running.',
) )
@ -87,5 +82,5 @@ class Q3RconTUI(App):
def main(): def main():
app = Q3RconTUI() app = RconApp()
app.run() app.run()

View File

@ -3,12 +3,13 @@ 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
class Writable: class Writable:
RE_COLOR_CODES = re.compile(r'\^[0-9]') RE_COLOR_CODES = re.compile(r'\^[0-9]')
RE_MAP_FROM_STATUS = re.compile(r'^map: (?P<mapname>mp_[a-z_]+)$')
RE_PLAYER_FROM_STATUS = re.compile( RE_PLAYER_FROM_STATUS = re.compile(
r'^\s*(?P<slot>[0-9]+)\s+' r'^\s*(?P<slot>[0-9]+)\s+'
r'(?P<score>[0-9-]+)\s+' r'(?P<score>[0-9-]+)\s+'
@ -25,28 +26,22 @@ class Writable:
) )
RE_CVAR = re.compile( RE_CVAR = re.compile(
r'^["](?P<name>[a-z_]+)["]\sis[:]\s' r'^["](?P<name>[a-z_]+)["]\sis[:]\s'
r'["](?P<value>.*?)["]\s' r'["](?P<value>.*?)\^7["]\s'
r'default[:]\s' r'default[:]\s'
r'["](?P<default>.*?)["]\s' r'["](?P<default>.*?)\^7["]\s'
r'info[:]\s' r'info[:]\s'
r'["](?P<info>.*?)["]$' r'["](?P<info>.*?)\^7["]$'
) )
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 = response.removeprefix('print\n')
if self._settings.raw: if 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)
@ -54,83 +49,58 @@ class Writable:
if m := self.RE_CVAR.match(response): if m := self.RE_CVAR.match(response):
return self.cvar_table(m) return self.cvar_table(m)
else: else:
return Text(response, style=style) return Text(self.remove_color_codes(response), style=style)
def error(self, message: str) -> Text: def error(self, message: str) -> Text:
return Text(message, style='#c73d4b') return Text(message, style='#c73d4b')
def status_table(self, status_response: str) -> Table | str: def status_table(self, status_response: str) -> Table | str:
table = Table(show_header=True, header_style='bold #88c0d0') table = Table(show_header=True, header_style='bold #88c0d0')
columns = [ table.add_column('Slot', justify='center')
('Slot', 'center'), table.add_column('Score', justify='center')
('Score', 'center'), table.add_column('Ping', justify='center')
('Ping', 'center'), table.add_column('GUID', justify='center')
('GUID', 'center'), table.add_column('Name', justify='center')
('Name', 'center'), table.add_column('Last', justify='center')
] table.add_column('IP', justify='center')
for column, justify in columns: table.add_column('Port', justify='center')
table.add_column(column, justify=justify) table.add_column('QPort', justify='center')
if not self._settings.min_status: table.add_column('Rate', justify='center')
table.add_column('Last', justify='center')
if self._settings.min_status:
table.add_column('IP', justify='center')
else:
table.add_column('IP:Port', justify='center')
columns = [
('QPort', 'center'),
('Rate', 'center'),
]
if not self._settings.min_status:
for column, justify in columns:
table.add_column(column, justify=justify)
mapname = 'unable to parse map name'
for line in status_response.splitlines(): for line in status_response.splitlines():
if m := self.RE_PLAYER_FROM_STATUS.match(line): match self.RE_PLAYER_FROM_STATUS.match(line):
name = m.group('name') case None:
if name == '': continue
name = '[no name]' case m:
row = [ table.add_row(
m.group('slot'), m.group('slot'),
m.group('score'), m.group('score'),
m.group('ping'), m.group('ping'),
m.group('guid'), m.group('guid'),
name, self.remove_color_codes(m.group('name')),
] m.group('last'),
if self._settings.min_status: m.group('ip'),
row.append(m.group('ip')) m.group('port'),
else: m.group('qport'),
row.append(f'{m.group("ip")}:{m.group("port")}') m.group('rate'),
row.append(m.group('last')) )
row.append(m.group('qport'))
row.append(m.group('rate'))
table.add_row(*row)
elif m := self.RE_MAP_FROM_STATUS.match(line):
mapname = m.group('mapname')
out = Text(f'Map: {mapname}\n', style='bold #88c0d0')
if len(table.rows) == 0: if len(table.rows) == 0:
return out.append('No players connected', style='#c73d4b') return 'No players connected.'
else: return table
table.title = out
return table
def cvar_table(self, m: re.Match) -> Table: def cvar_table(self, m: re.Match) -> Table:
table = Table(show_header=True, header_style='bold #88c0d0') table = Table(show_header=True, header_style='bold #88c0d0')
columns = [ table.add_column('Name', justify='center')
('Name', 'center'), table.add_column('Value', justify='center')
('Value', 'center'), table.add_column('Default', justify='center')
('Default', 'center'), table.add_column('Info', justify='center')
('Info', 'center'),
]
for column, justify in columns:
table.add_column(column, justify=justify)
table.add_row( table.add_row(
m.group('name'), m.group('name'),
m.group('value'), self.remove_color_codes(m.group('value')),
m.group('default'), self.remove_color_codes(m.group('default')),
m.group('info'), self.remove_color_codes(m.group('info')),
) )
return table return table