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 # 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) [![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,6 +43,8 @@ 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.
@@ -59,6 +61,7 @@ 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

@@ -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.5.1' __version__ = '0.6.1'

View File

@@ -2,7 +2,12 @@ from pathlib import Path
from typing import Annotated, Type from typing import Annotated, Type
from loguru import logger from loguru import logger
from pydantic import AfterValidator, BeforeValidator from pydantic import (
AfterValidator,
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
@@ -26,6 +31,14 @@ class Settings(BaseSettings):
port: int = 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

View File

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

View File

@@ -10,6 +10,7 @@ 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+'
@@ -26,11 +27,11 @@ 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>.*?)\^7["]\s' r'["](?P<value>.*?)["]\s'
r'default[:]\s' r'default[:]\s'
r'["](?P<default>.*?)\^7["]\s' r'["](?P<default>.*?)["]\s'
r'info[:]\s' r'info[:]\s'
r'["](?P<info>.*?)\^7["]$' r'["](?P<info>.*?)["]$'
) )
@staticmethod @staticmethod
@@ -38,7 +39,7 @@ class Writable:
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 = response.removeprefix('print\n') response = self.remove_color_codes(response.removeprefix('print\n'))
if settings.raw: if settings.raw:
return Text(response, style=style) return Text(response, style=style)
@@ -49,58 +50,83 @@ 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(self.remove_color_codes(response), style=style) return Text(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')
table.add_column('Slot', justify='center') columns = [
table.add_column('Score', justify='center') ('Slot', 'center'),
table.add_column('Ping', justify='center') ('Score', 'center'),
table.add_column('GUID', justify='center') ('Ping', 'center'),
table.add_column('Name', justify='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') table.add_column('Last', justify='center')
if settings.min_status:
table.add_column('IP', justify='center') table.add_column('IP', justify='center')
table.add_column('Port', justify='center') else:
table.add_column('QPort', justify='center') table.add_column('IP:Port', justify='center')
table.add_column('Rate', 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(): for line in status_response.splitlines():
match self.RE_PLAYER_FROM_STATUS.match(line): if m := self.RE_PLAYER_FROM_STATUS.match(line):
case None: name = m.group('name')
continue if name == '':
case m: name = '[no name]'
table.add_row( 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'),
self.remove_color_codes(m.group('name')), name,
m.group('last'), ]
m.group('ip'), if settings.min_status:
m.group('port'), row.append(m.group('ip'))
m.group('qport'), else:
m.group('rate'), 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: if len(table.rows) == 0:
return 'No players connected.' return out.append('No players connected', style='#c73d4b')
else:
table.title = out
return table 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')
table.add_column('Name', justify='center') columns = [
table.add_column('Value', justify='center') ('Name', 'center'),
table.add_column('Default', justify='center') ('Value', 'center'),
table.add_column('Info', justify='center') ('Default', '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'),
self.remove_color_codes(m.group('value')), m.group('value'),
self.remove_color_codes(m.group('default')), m.group('default'),
self.remove_color_codes(m.group('info')), m.group('info'),
) )
return table return table