9 Commits

Author SHA1 Message Date
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
5 changed files with 105 additions and 57 deletions

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)
@@ -43,6 +43,8 @@ q3rcon-tui --host=localhost --port=28960 --password=rconpassword
Additional flags:
- `--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.
@@ -59,6 +61,7 @@ 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
```

View File

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

View File

@@ -2,7 +2,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
@@ -26,6 +31,14 @@ class Settings(BaseSettings):
port: int = 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

View File

@@ -37,9 +37,18 @@ 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':
async def _config_button_handler(self):
result = await self.push_screen(
ConfigScreen(settings.host, settings.port, settings.password)
)
@@ -47,11 +56,8 @@ class RconApp(App):
self.query_one('#response', RichLog).write(
f'Configuration updated: {settings.host}:{settings.port}'
)
return
if event.button.id != 'send':
return
async def _send_button_handler(self):
if not settings.append:
self.query_one('#response', RichLog).clear()

View File

@@ -10,6 +10,7 @@ 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+'
@@ -26,11 +27,11 @@ 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>.*?)["]$'
)
@staticmethod
@@ -38,7 +39,7 @@ class Writable:
return Writable.RE_COLOR_CODES.sub('', s)
def parse(self, cmd, response: str, style=None) -> Renderable:
response = response.removeprefix('print\n')
response = self.remove_color_codes(response.removeprefix('print\n'))
if settings.raw:
return Text(response, style=style)
@@ -49,58 +50,83 @@ class Writable:
if m := self.RE_CVAR.match(response):
return self.cvar_table(m)
else:
return Text(self.remove_color_codes(response), style=style)
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 settings.min_status:
table.add_column('Last', justify='center')
if 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 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 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