Compare commits

...

27 Commits
v0.4.0 ... main

Author SHA1 Message Date
cac241a910 remove new_port validation, leave it to pydantic
patch bump
2026-02-25 23:47:05 +00:00
4606990edb temporary fix for hatch build, see https://github.com/pypa/hatch/issues/2193 2026-02-25 21:52:11 +00:00
15fd6a1914 rename RconApp to Q3RconTUI 2026-02-25 21:43:18 +00:00
f59076f0a6 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
2026-02-25 21:41:19 +00:00
6bf16f5d50 add py13 2026-02-23 20:40:19 +00:00
ee45bfd03f implement separate button handlers
patch bump
2026-02-22 15:04:50 +00:00
e10bec03ed upd hatch badge 2026-02-22 11:34:23 +00:00
02ded9fb46 minor version bump 2026-02-21 16:07:48 +00:00
fee3678f40 upd README with --min-status/Q3RCON_TUI_MIN_STATUS 2026-02-21 16:07:29 +00:00
ba91b2d8be add --min-status to Settings 2026-02-21 16:04:52 +00:00
437b76ab13 use tuples as records to build the tables.
build status table according to --min-status value

clean the response strings of colour codes early.
2026-02-21 16:04:34 +00:00
004f1d0880 give mapname a default value, although failing to parse the mapname is never expected to happen. 2026-02-21 13:55:30 +00:00
71401c32f9 include the mapname in the status output
patch bump
2026-02-21 12:37:10 +00:00
1cee478197 remove trailing space 2026-02-20 18:21:04 +00:00
fcc91b7e34 patch bump 2026-02-20 18:20:11 +00:00
9b3ae629f3 include the command in the error message 2026-02-20 18:19:57 +00:00
74ed189ca5 minor bump 2026-02-20 18:17:05 +00:00
6e50e0861f add Renderable type annotation
add {Writable}.error() for displaying error messages in red.
2026-02-20 18:16:47 +00:00
ab4898dac3 prevent keypresses from ConfigScreen propogating to the mainframe
improve the error message should a command execution fail.
2026-02-20 18:16:15 +00:00
086eeba916 make hover and focus more consistent with one another.
reorganise css.
2026-02-20 17:55:17 +00:00
2075e98c17 typo 2026-02-20 17:11:20 +00:00
923faa67ec patch bump 2026-02-20 16:18:43 +00:00
76483a24b9 improve the hovering and focus effects for the buttons. 2026-02-20 16:18:29 +00:00
b3a3a4759a add --version and --help to additional flags 2026-02-20 15:41:58 +00:00
fc6c2e99a5 add Special Thanks
patch bump
2026-02-20 15:39:48 +00:00
97458682ea move guard clause out of context block 2026-02-20 15:36:20 +00:00
3181377c18 typo 2026-02-20 15:28:05 +00:00
9 changed files with 359 additions and 140 deletions

View File

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

View File

@ -1,6 +1,6 @@
# q3rcon tui
[![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch)
[![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)
[![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 - Python Version](https://img.shields.io/pypi/pyversions/q3rcon-tui.svg)](https://pypi.org/project/q3rcon-tui)
@ -42,8 +42,12 @@ q3rcon-tui --host=localhost --port=28960 --password=rconpassword
Additional flags:
- `--raw`: Boolean flag, if set the RichLog will print raw responses withouth rendering tables.
- `--append`: Boolean flag, if set the RichLog output with append each response continuously.
- `--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.
- `--version`: Print the version of the TUI.
- `--help`: Print the help message.
#### Environment Variables
@ -57,10 +61,16 @@ example .env:
Q3RCON_TUI_HOST=localhost
Q3RCON_TUI_PORT=28960
Q3RCON_TUI_PASSWORD=rconpassword
Q3RCON_TUI_MIN_STATUS=false
Q3RCON_TUI_RAW=false
Q3RCON_TUI_APPEND=false
```
## Special Thanks
- [lapetus-11](https://github.com/Iapetus-11) for writing the [aio-q3-rcon](https://github.com/Iapetus-11/aio-q3-rcon) package.
- The developers at [Textualize](https://github.com/Textualize) for writing the [textual](https://github.com/Textualize/textual) package.
## License
`q3rcon-tui` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.

View File

@ -17,6 +17,7 @@ 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.4.0'
__version__ = '0.7.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, 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 +21,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 +43,69 @@ 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 = (
new_host: str = (
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
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 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._settings.host = new_host
self._settings.port = new_port
self._settings.password = new_password
self.dismiss(True)
except ValueError:
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()
elif event.button.id == 'config-cancel':
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

@ -44,43 +44,123 @@
Button {
width: 100%;
height: 3;
height: 4;
margin: 0 1;
background: #5e81ac;
color: #eceff4;
border: none;
text-style: bold;
border: solid #5e81ac;
}
Button:hover {
background: #81a1c1;
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
Button:focus {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
Button.success {
background: #a3be8c;
border: solid #a3be8c;
}
Button.success:hover {
background: #8fbcbb;
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
Button.success:focus {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
Button.warning {
background: #ebcb8b;
color: #2e3440;
border: solid #ebcb8b;
}
Button.warning:hover {
background: #d08770;
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
Button.warning:focus {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
Button.error {
background: #bf616a;
border: solid #bf616a;
}
Button.error:hover {
background: #d08770;
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
Button.error:focus {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
#send {
background: #a3be8c;
border: solid #a3be8c;
}
#send:focus {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
#send:hover {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
#config {
background: #ebcb8b;
color: #2e3440;
border: solid #ebcb8b;
}
#config:hover {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
#config:focus {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
#quit {
@ -90,25 +170,16 @@ Button.error:hover {
#quit:hover {
background: #d08770;
border: solid #ebcb8b;
color: #eceff4;
text-style: bold;
border: none;
}
#send {
background: #a3be8c;
}
#config {
background: #ebcb8b;
color: #2e3440;
}
#config:hover {
#quit:focus {
background: #d08770;
color: #2e3440;
}
#send:hover {
background: #8fbcbb;
color: #eceff4;
text-style: bold;
border: none;
}
/* Configuration Dialog Styles */
@ -159,6 +230,29 @@ Button.error:hover {
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,8 +1,12 @@
from pathlib import Path
from typing import Annotated, Type
from loguru import logger
from pydantic import AfterValidator, BeforeValidator
from pydantic import (
AfterValidator,
AliasChoices,
BeforeValidator,
Field,
)
from pydantic_settings import BaseSettings, CliSettingsSource, SettingsConfigDict
from .__about__ import __version__ as version
@ -15,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')
@ -22,10 +38,18 @@ 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(
default=False,
alias='min-status',
validation_alias=AliasChoices(
'min-status',
'Q3RCON_TUI_MIN_STATUS',
),
)
raw: bool = False
version: Annotated[bool, BeforeValidator(version_callback)] = False
@ -39,7 +63,7 @@ class Settings(BaseSettings):
cli_prefix='',
cli_parse_args=True,
cli_implicit_flags=True,
validate_assignment=False,
validate_assignment=True,
frozen=False,
)
@ -58,10 +82,3 @@ class Settings(BaseSettings):
dotenv_settings,
init_settings,
)
try:
settings = Settings()
except ValueError as e:
logger.error(e)
raise SystemExit(1)

View File

@ -4,16 +4,17 @@ 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 RconApp(App):
class Q3RconTUI(App):
CSS_PATH = 'q3rcon_tui.tcss'
def __init__(self):
super().__init__()
self.writable = Writable()
self._settings = Settings()
self.writable = Writable(self)
def compose(self) -> ComposeResult:
yield Grid(
@ -26,6 +27,10 @@ class RconApp(App):
)
async def on_key(self, event) -> None:
# prevent keypresses from ConfigScreen from triggering actions in RconApp
if self.screen and isinstance(self.screen, ConfigScreen):
return
match event.key:
case 'enter' if self.query_one('#command', Input).has_focus:
self.query_one('#send', Button).press()
@ -33,44 +38,54 @@ class RconApp(App):
self.query_one('#config', Button).press()
async def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == 'quit':
match event.button.id:
case 'quit':
self._quit_button_handler()
case 'config':
await self._config_button_handler()
case 'send':
await self._send_button_handler()
def _quit_button_handler(self):
self.app.exit()
elif event.button.id == 'config':
result = await self.push_screen(
ConfigScreen(settings.host, settings.port, settings.password)
)
async def _config_button_handler(self):
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}'
)
return
if event.button.id != 'send':
return
if not settings.append:
async def _send_button_handler(self):
if not self._settings.append:
self.query_one('#response', RichLog).clear()
try:
async with Client(
settings.host, settings.port, settings.password
) as client:
cmd = self.query_one('#command', Input).value.strip()
if not cmd:
self.app.bell()
return
try:
async with Client(
self._settings.host, self._settings.port, self._settings.password
) as client:
response = await client.send_command(cmd)
self.query_one('#response', RichLog).write(
self.writable.parse(cmd, response)
)
except RCONError as e:
except RCONError:
output = (
f'Unable to execute command {cmd}.',
'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.',
)
self.query_one('#response', RichLog).write(
f'{type(e).__name__}: Unable to connect to server: is the server running and are the host, port, and password correct? ({e})'
self.writable.error('\n'.join(output))
)
self.query_one('#command', Input).value = ''
def main():
app = RconApp()
app = Q3RconTUI()
app.run()

View File

@ -1,12 +1,14 @@
import re
from rich.table import Table
from rich.text import Text
from .settings import settings
Renderable = Text | Table | str
class Writable:
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(
r'^\s*(?P<slot>[0-9]+)\s+'
r'(?P<score>[0-9-]+)\s+'
@ -23,79 +25,112 @@ class Writable:
)
RE_CVAR = re.compile(
r'^["](?P<name>[a-z_]+)["]\sis[:]\s'
r'["](?P<value>.*?)\^7["]\s'
r'["](?P<value>.*?)["]\s'
r'default[:]\s'
r'["](?P<default>.*?)\^7["]\s'
r'["](?P<default>.*?)["]\s'
r'info[:]\s'
r'["](?P<info>.*?)\^7["]$'
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) -> str:
response = response.removeprefix('print\n')
if settings.raw:
return response
def parse(self, cmd, response: str, style=None) -> Renderable:
response = self.remove_color_codes(response.removeprefix('print\n'))
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)
case _:
match self.RE_CVAR.match(response):
case None:
return self.remove_color_codes(response)
case m:
if m := self.RE_CVAR.match(response):
return self.cvar_table(m)
else:
return Text(response, style=style)
def error(self, message: str) -> Text:
return Text(message, style='#c73d4b')
def status_table(self, status_response: str) -> Table | str:
table = Table(show_header=True, header_style='bold #88c0d0')
table.add_column('Slot', justify='center')
table.add_column('Score', justify='center')
table.add_column('Ping', justify='center')
table.add_column('GUID', justify='center')
table.add_column('Name', justify='center')
columns = [
('Slot', 'center'),
('Score', 'center'),
('Ping', 'center'),
('GUID', 'center'),
('Name', 'center'),
]
for column, justify in columns:
table.add_column(column, justify=justify)
if not self._settings.min_status:
table.add_column('Last', justify='center')
if self._settings.min_status:
table.add_column('IP', justify='center')
table.add_column('Port', justify='center')
table.add_column('QPort', justify='center')
table.add_column('Rate', 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():
match self.RE_PLAYER_FROM_STATUS.match(line):
case None:
continue
case m:
table.add_row(
if m := self.RE_PLAYER_FROM_STATUS.match(line):
name = m.group('name')
if name == '':
name = '[no name]'
row = [
m.group('slot'),
m.group('score'),
m.group('ping'),
m.group('guid'),
self.remove_color_codes(m.group('name')),
m.group('last'),
m.group('ip'),
m.group('port'),
m.group('qport'),
m.group('rate'),
)
name,
]
if self._settings.min_status:
row.append(m.group('ip'))
else:
row.append(f'{m.group("ip")}:{m.group("port")}')
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:
return 'No players connected.'
return out.append('No players connected', style='#c73d4b')
else:
table.title = out
return table
def cvar_table(self, m: re.Match) -> Table:
table = Table(show_header=True, header_style='bold #88c0d0')
table.add_column('Name', justify='center')
table.add_column('Value', justify='center')
table.add_column('Default', justify='center')
table.add_column('Info', justify='center')
columns = [
('Name', 'center'),
('Value', 'center'),
('Default', 'center'),
('Info', 'center'),
]
for column, justify in columns:
table.add_column(column, justify=justify)
table.add_row(
m.group('name'),
self.remove_color_codes(m.group('value')),
self.remove_color_codes(m.group('default')),
self.remove_color_codes(m.group('info')),
m.group('value'),
m.group('default'),
m.group('info'),
)
return table