13 Commits

10 changed files with 455 additions and 167 deletions

View File

@@ -40,6 +40,13 @@ Pass `--host`, `--port` and `--password` as flags:
q3rcon-tui --host=localhost --port=28960 --password=rconpassword q3rcon-tui --host=localhost --port=28960 --password=rconpassword
``` ```
Additional flags:
- `--raw`: Boolean flag, if set the RichLog will print raw responses without rendering tables.
- `--append`: Boolean flag, if set the RichLog output with append each response continuously.
- `--version`: Print the version of the TUI.
- `--help`: Print the help message.
#### Environment Variables #### Environment Variables
Store and load from dotenv files located at: Store and load from dotenv files located at:
@@ -48,13 +55,19 @@ Store and load from dotenv files located at:
example .env: example .env:
```bash ```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_REFRESH_OUTPUT=true 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 ## License
`q3rcon-tui` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) 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

@@ -32,8 +32,8 @@ exclude = [
line-length = 88 line-length = 88
indent-width = 4 indent-width = 4
# Assume Python 3.9 # Assume Python 3.10
target-version = "py39" target-version = "py310"
[lint] [lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. # 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-FileCopyrightText: 2026-present onyx-and-iris <code@onyxandiris.online>
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
__version__ = '0.2.0' __version__ = '0.4.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,171 @@
#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: 3;
margin: 0 1;
background: #5e81ac;
color: #eceff4;
border: none;
text-style: bold;
}
Button:hover {
background: #81a1c1;
text-style: bold;
}
Button.success {
background: #a3be8c;
}
Button.success:hover {
background: #8fbcbb;
}
Button.warning {
background: #ebcb8b;
color: #2e3440;
}
Button.warning:hover {
background: #d08770;
color: #2e3440;
}
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;
}
#config {
background: #ebcb8b;
color: #2e3440;
}
#config:hover {
background: #d08770;
color: #2e3440;
}
#send:hover {
background: #8fbcbb;
}
/* 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,67 @@
from pathlib import Path
from typing import Annotated, Type
from loguru import logger
from pydantic import AfterValidator, BeforeValidator
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
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,108 +1,68 @@
import re
from pathlib import Path
from typing import Annotated, Type
from aioq3rcon import Client, RCONError from aioq3rcon import Client, RCONError
from loguru import logger
from pydantic import AfterValidator, BeforeValidator
from pydantic_settings import BaseSettings, CliSettingsSource, SettingsConfigDict
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Grid from textual.containers import Grid
from textual.widgets import Button, Input, RichLog from textual.widgets import Button, Input, RichLog
from .__about__ import __version__ as version from .configscreen import ConfigScreen
from .settings import settings
from .writable import Writable
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' / 'q3rcon-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)
class RconApp(App): class RconApp(App):
RE_COLOR_CODES = re.compile(r'\^[0-9]') CSS_PATH = 'q3rcon_tui.tcss'
CSS_PATH = 'rcon_tui.tcss'
def __init__(self):
super().__init__()
self.writable = Writable()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Grid( yield Grid(
Input('status', placeholder='Enter a rcon command', id='command'), Input('status', placeholder='Enter a rcon command', id='command'),
RichLog(id='response'), 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'), Button('Quit', variant='primary', id='quit'),
id='dialog', id='dialog',
) )
async def on_key(self, event) -> None: async def on_key(self, event) -> None:
if event.key == 'enter': match event.key:
if self.query_one('#command', Input).has_focus: case 'enter' if self.query_one('#command', Input).has_focus:
self.query_one('#send', Button).press() self.query_one('#send', Button).press()
case 'f2':
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': if event.button.id == 'quit':
self.app.exit() self.app.exit()
elif event.button.id == 'config':
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}'
)
return
if settings.refresh_output: if event.button.id != 'send':
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()
if not cmd:
self.app.bell()
return
try: try:
async with Client( async with Client(
settings.host, settings.port, settings.password settings.host, settings.port, settings.password
) as client: ) as client:
response = await client.send_command( response = await client.send_command(cmd)
self.query_one('#command', Input).value
)
self.query_one('#response', RichLog).write( self.query_one('#response', RichLog).write(
self.remove_color_codes(response.removeprefix('print\n')) self.writable.parse(cmd, response)
) )
except RCONError as e: except RCONError as e:
self.query_one('#response', RichLog).write( self.query_one('#response', RichLog).write(
@@ -111,10 +71,6 @@ class RconApp(App):
self.query_one('#command', Input).value = '' self.query_one('#command', Input).value = ''
@staticmethod
def remove_color_codes(s: str) -> str:
return RconApp.RE_COLOR_CODES.sub('', s)
def main(): def main():
app = RconApp() app = RconApp()

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

@@ -0,0 +1,101 @@
import re
from rich.table import Table
from .settings import settings
class Writable:
RE_COLOR_CODES = re.compile(r'\^[0-9]')
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>.*?)\^7["]\s'
r'default[:]\s'
r'["](?P<default>.*?)\^7["]\s'
r'info[:]\s'
r'["](?P<info>.*?)\^7["]$'
)
@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
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:
return self.cvar_table(m)
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')
table.add_column('Last', justify='center')
table.add_column('IP', justify='center')
table.add_column('Port', justify='center')
table.add_column('QPort', justify='center')
table.add_column('Rate', justify='center')
for line in status_response.splitlines():
match self.RE_PLAYER_FROM_STATUS.match(line):
case None:
continue
case m:
table.add_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'),
)
if len(table.rows) == 0:
return 'No players connected.'
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')
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')),
)
return table