36 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
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
30ed9e9e6c minor bump 2026-02-20 15:25:05 +00:00
8139ecf21f upd screenshot 2026-02-20 15:24:52 +00:00
f74ce3e17c add --raw and --append to Flags section 2026-02-20 15:24:37 +00:00
82fb9b9fc4 add Writable class for creating RenderableTypes 2026-02-20 15:22:21 +00:00
8d11f60201 move settings and configscreen into their own files
rename tcss file
2026-02-20 15:21:14 +00:00
c62d18289b upd screenshot 2026-02-20 00:40:41 +00:00
6677ce56a7 minor bump 2026-02-20 00:27:20 +00:00
b3bc7c687c fix ruff py version 2026-02-20 00:26:47 +00:00
ebe7437974 add a config popup window for updating connection settings from within the TUI. 2026-02-20 00:26:39 +00:00
74393f5fb3 minor bump 2026-02-19 23:45:01 +00:00
316fa2bd5f write an error message to the user if a command fails. 2026-02-19 23:44:39 +00:00
e87ca1d0e5 upd dotenv file path 2026-02-19 23:36:42 +00:00
3fbac04756 add hatch and ruff badges 2026-02-19 22:43:13 +00:00
637597c95a add description 2026-02-19 22:34:37 +00:00
11 changed files with 602 additions and 172 deletions

View File

@@ -1,5 +1,7 @@
# 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)
[![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)
@@ -38,21 +40,37 @@ Pass `--host`, `--port` and `--password` as flags:
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.
#### Environment Variables
Store and load from dotenv files located at:
- .env in the cwd
- user home directory / .config / rcon-tui / config.env
- user home directory / .config / q3rcon-tui / config.env
example .env:
```bash
```env
Q3RCON_TUI_HOST=localhost
Q3RCON_TUI_PORT=28960
Q3RCON_TUI_PASSWORD=rconpassword
Q3RCON_TUI_REFRESH_OUTPUT=true
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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "q3rcon-tui"
dynamic = ["version"]
description = ''
description = 'A terminal user interface for managing Q3 compatible servers using RCON.'
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"

View File

@@ -32,8 +32,8 @@ exclude = [
line-length = 88
indent-width = 4
# Assume Python 3.9
target-version = "py39"
# Assume Python 3.10
target-version = "py310"
[lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.

View File

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

View File

@@ -0,0 +1,64 @@
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):
super().__init__()
self.current_host = current_host
self.current_port = current_port
self.current_password = current_password
def compose(self) -> ComposeResult:
with Vertical(id='config-dialog'):
yield Static('Connection Configuration', id='config-title')
with Vertical(id='config-form'):
yield Label('Host:')
yield Input(
value=self.current_host, placeholder='localhost', id='host-input'
)
yield Label('Port:')
yield Input(
value=str(self.current_port), placeholder='28960', id='port-input'
)
yield Label('Password:')
yield Input(
value=self.current_password,
placeholder='Enter password',
password=True,
id='password-input',
)
with Horizontal(id='config-buttons'):
yield Button('Save', variant='success', id='config-save')
yield Button('Cancel', variant='error', id='config-cancel')
def on_button_pressed(self, event: Button.Pressed) -> None:
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
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.dismiss(True)
except ValueError:
self.app.bell()
elif event.button.id == 'config-cancel':
self.dismiss(False)

View File

@@ -0,0 +1,242 @@
#dialog {
height: 30;
margin: 4 8;
background: #1e1e2f;
color: #e2e8f0;
border: heavy #3b4252;
border-title-color: #88c0d0;
border-title-style: bold;
padding: 1 3;
grid-size: 3 3;
grid-gutter: 1 2;
grid-rows: auto 1fr auto;
align: center middle;
}
#command {
column-span: 3;
height: auto;
width: 1fr;
content-align: center middle;
background: #2e3440;
border: solid #4c566a;
border-title-color: #81a1c1;
padding: 0 1;
margin: 0 1;
}
#command:focus {
border: solid #88c0d0;
background: #3b4252;
}
#response {
column-span: 3;
height: 1fr;
background: #2e3440;
border: solid #4c566a;
border-title-color: #a3be8c;
padding: 1;
margin: 0 1;
scrollbar-background: #3b4252;
scrollbar-color: #5e81ac;
}
Button {
width: 100%;
height: 4;
margin: 0 1;
background: #5e81ac;
color: #eceff4;
text-style: bold;
border: solid #5e81ac;
}
Button:hover {
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: #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: #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: #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 {
background: #bf616a;
border: solid #bf616a;
}
#quit:hover {
background: #d08770;
color: #eceff4;
text-style: bold;
border: none;
}
#quit:focus {
background: #d08770;
color: #eceff4;
text-style: bold;
border: none;
}
/* Configuration Dialog Styles */
#config-dialog {
background: #2e3440;
border: heavy #4c566a;
border-title-color: #ebcb8b;
border-title-style: bold;
padding: 1 2;
width: 60;
height: 30;
align: center middle;
}
#config-title {
content-align: center middle;
text-style: bold;
color: #88c0d0;
height: 3;
width: 100%;
background: #3b4252;
margin-bottom: 1;
}
#config-form {
height: auto;
width: 100%;
margin-bottom: 1;
}
#config-form Label {
margin-bottom: 1;
color: #d8dee9;
text-style: bold;
}
#config-form Input {
height: 3;
width: 100%;
margin-bottom: 1;
background: #3b4252;
border: solid #4c566a;
padding: 0 1;
}
#config-form Input:focus {
border: solid #88c0d0;
background: #434c5e;
}
#config-buttons {
height: 3;
width: 100%;
align: center middle;
}
#config-buttons Button {
width: 1fr;
margin: 0 1;
}

View File

@@ -1,84 +0,0 @@
#dialog {
height: 30;
margin: 4 8;
background: #1e1e2f;
color: #e2e8f0;
border: heavy #3b4252;
border-title-color: #88c0d0;
border-title-style: bold;
padding: 1 3;
grid-size: 2 3;
grid-gutter: 1 2;
grid-rows: auto 1fr auto;
align: center middle;
}
#command {
column-span: 2;
height: auto;
width: 1fr;
content-align: center middle;
background: #2e3440;
border: solid #4c566a;
border-title-color: #81a1c1;
padding: 0 1;
margin: 0 1;
}
#command:focus {
border: solid #88c0d0;
background: #3b4252;
}
#response {
column-span: 2;
height: 1fr;
background: #2e3440;
border: solid #4c566a;
border-title-color: #a3be8c;
padding: 1;
margin: 0 1;
scrollbar-background: #3b4252;
scrollbar-color: #5e81ac;
}
Button {
width: 100%;
height: 3;
margin: 0 1;
background: #5e81ac;
color: #eceff4;
border: none;
text-style: bold;
}
Button:hover {
background: #81a1c1;
text-style: bold;
}
Button.error {
background: #bf616a;
}
Button.error:hover {
background: #d08770;
}
#quit {
background: #bf616a;
border: solid #bf616a;
}
#quit:hover {
background: #d08770;
border: solid #ebcb8b;
}
#send {
background: #a3be8c;
}
#send:hover {
background: #8fbcbb;
}

View File

@@ -0,0 +1,80 @@
from pathlib import Path
from typing import Annotated, Type
from loguru import logger
from pydantic import (
AfterValidator,
AliasChoices,
BeforeValidator,
Field,
)
from pydantic_settings import BaseSettings, CliSettingsSource, SettingsConfigDict
from .__about__ import __version__ as version
def version_callback(value: bool) -> bool | None:
if value:
print(f'q3rcon-tui version: {version}')
raise SystemExit(0)
return False
def is_valid_password(password: str) -> str | None:
if len(password) < 8:
raise ValueError('Password must be at least 8 characters long')
return password
class Settings(BaseSettings):
host: str = 'localhost'
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
model_config = SettingsConfigDict(
env_file=(
'.env',
Path.home() / '.config' / 'q3rcon-tui' / 'config.env',
),
env_file_encoding='utf-8',
env_prefix='Q3RCON_TUI_',
cli_prefix='',
cli_parse_args=True,
cli_implicit_flags=True,
validate_assignment=False,
frozen=False,
)
@classmethod
def settings_customise_sources(
cls,
settings_cls: Type[BaseSettings],
init_settings: ...,
env_settings: ...,
dotenv_settings: ...,
file_secret_settings: ...,
) -> tuple:
return (
CliSettingsSource(settings_cls),
env_settings,
dotenv_settings,
init_settings,
)
try:
settings = Settings()
except ValueError as e:
logger.error(e)
raise SystemExit(1)

View File

@@ -1,113 +1,91 @@
import re
from pathlib import Path
from typing import Annotated, Type
from aioq3rcon import Client
from loguru import logger
from pydantic import AfterValidator, BeforeValidator
from pydantic_settings import BaseSettings, CliSettingsSource, SettingsConfigDict
from aioq3rcon import Client, RCONError
from textual.app import App, ComposeResult
from textual.containers import Grid
from textual.widgets import Button, Input, RichLog
from .__about__ import __version__ as version
def is_valid_password(password: str) -> str | None:
if len(password) < 8:
raise ValueError('Password must be at least 8 characters long')
return password
def version_callback(value: bool) -> bool | None:
if value:
print(f'q3rcon-tui version: {version}')
raise SystemExit(0)
return False
class Settings(BaseSettings):
host: str = 'localhost'
port: int = 28960
password: Annotated[str, AfterValidator(is_valid_password)] = ''
refresh_output: bool = False
version: Annotated[bool, BeforeValidator(version_callback)] = False
model_config = SettingsConfigDict(
env_file=(
'.env',
Path.home() / '.config' / 'rcon-tui' / 'config.env',
),
env_file_encoding='utf-8',
env_prefix='Q3RCON_TUI_',
cli_prefix='',
cli_parse_args=True,
cli_implicit_flags=True,
)
@classmethod
def settings_customise_sources(
cls,
settings_cls: Type[BaseSettings],
init_settings: ...,
env_settings: ...,
dotenv_settings: ...,
file_secret_settings: ...,
) -> tuple:
return (
CliSettingsSource(settings_cls),
env_settings,
dotenv_settings,
init_settings,
)
try:
settings = Settings()
except ValueError as e:
logger.error(e)
raise SystemExit(1)
from .configscreen import ConfigScreen
from .settings import settings
from .writable import Writable
class RconApp(App):
RE_COLOR_CODES = re.compile(r'\^[0-9]')
CSS_PATH = 'rcon_tui.tcss'
CSS_PATH = 'q3rcon_tui.tcss'
def __init__(self):
super().__init__()
self.writable = Writable()
def compose(self) -> ComposeResult:
yield Grid(
Input('status', placeholder='Enter a rcon command', id='command'),
RichLog(id='response'),
Button('Send', variant='error', id='send'),
Button('Send', variant='success', id='send'),
Button('Config', variant='warning', id='config'),
Button('Quit', variant='primary', id='quit'),
id='dialog',
)
async def on_key(self, event) -> None:
if event.key == 'enter':
if self.query_one('#command', Input).has_focus:
# 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()
case 'f2':
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()
if settings.refresh_output:
async def _config_button_handler(self):
result = await self.push_screen(
ConfigScreen(settings.host, settings.port, settings.password)
)
if result:
self.query_one('#response', RichLog).write(
f'Configuration updated: {settings.host}:{settings.port}'
)
async def _send_button_handler(self):
if not settings.append:
self.query_one('#response', RichLog).clear()
async with Client(settings.host, settings.port, settings.password) as client:
response = await client.send_command(
self.query_one('#command', Input).value
cmd = self.query_one('#command', Input).value.strip()
if not cmd:
self.app.bell()
return
try:
async with Client(
settings.host, settings.port, settings.password
) as client:
response = await client.send_command(cmd)
self.query_one('#response', RichLog).write(
self.writable.parse(cmd, response)
)
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(
self.remove_color_codes(response.removeprefix('print\n'))
self.writable.error('\n'.join(output))
)
self.query_one('#command', Input).value = ''
@staticmethod
def remove_color_codes(s: str) -> str:
return RconApp.RE_COLOR_CODES.sub('', s)
def main():
app = RconApp()

132
src/q3rcon_tui/writable.py Normal file
View File

@@ -0,0 +1,132 @@
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+'
r'(?P<ping>[0-9]+)\s+'
r'(?P<guid>[0-9a-f]+)\s+'
r'(?P<name>.*?)\s+'
r'(?P<last>[0-9]+?)\s*'
r'(?P<ip>(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}'
r'(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])):?'
r'(?P<port>-?[0-9]{1,5})\s*'
r'(?P<qport>-?[0-9]{1,5})\s+'
r'(?P<rate>[0-9]+)$',
re.IGNORECASE | re.VERBOSE,
)
RE_CVAR = re.compile(
r'^["](?P<name>[a-z_]+)["]\sis[:]\s'
r'["](?P<value>.*?)["]\s'
r'default[:]\s'
r'["](?P<default>.*?)["]\s'
r'info[:]\s'
r'["](?P<info>.*?)["]$'
)
@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 settings.raw:
return Text(response, style=style)
match cmd:
case 'status':
return self.status_table(response)
case _:
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')
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')
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():
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'),
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 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')
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'),
m.group('value'),
m.group('default'),
m.group('info'),
)
return table