mirror of
https://github.com/onyx-and-iris/q3rcon-tui.git
synced 2026-04-09 05:33:31 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee45bfd03f | |||
| e10bec03ed | |||
| 02ded9fb46 | |||
| fee3678f40 | |||
| ba91b2d8be | |||
| 437b76ab13 | |||
| 004f1d0880 | |||
| 71401c32f9 | |||
| 1cee478197 | |||
| fcc91b7e34 | |||
| 9b3ae629f3 | |||
| 74ed189ca5 | |||
| 6e50e0861f | |||
| ab4898dac3 | |||
| 086eeba916 | |||
| 2075e98c17 | |||
| 923faa67ec | |||
| 76483a24b9 | |||
| b3a3a4759a | |||
| fc6c2e99a5 | |||
| 97458682ea | |||
| 3181377c18 |
16
README.md
16
README.md
@@ -1,6 +1,6 @@
|
|||||||
# q3rcon tui
|
# q3rcon tui
|
||||||
|
|
||||||
[](https://github.com/pypa/hatch)
|
[](https://github.com/pypa/hatch)
|
||||||
[](https://github.com/astral-sh/ruff)
|
[](https://github.com/astral-sh/ruff)
|
||||||
[](https://pypi.org/project/q3rcon-tui)
|
[](https://pypi.org/project/q3rcon-tui)
|
||||||
[](https://pypi.org/project/q3rcon-tui)
|
[](https://pypi.org/project/q3rcon-tui)
|
||||||
@@ -42,8 +42,12 @@ q3rcon-tui --host=localhost --port=28960 --password=rconpassword
|
|||||||
|
|
||||||
Additional flags:
|
Additional flags:
|
||||||
|
|
||||||
- `--raw`: Boolean flag, if set the RichLog will print raw responses withouth rendering tables.
|
- `--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.
|
- `--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
|
#### Environment Variables
|
||||||
|
|
||||||
@@ -57,10 +61,16 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -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.4.0'
|
__version__ = '0.6.1'
|
||||||
|
|||||||
@@ -44,43 +44,123 @@
|
|||||||
|
|
||||||
Button {
|
Button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 3;
|
height: 4;
|
||||||
margin: 0 1;
|
margin: 0 1;
|
||||||
background: #5e81ac;
|
background: #5e81ac;
|
||||||
color: #eceff4;
|
color: #eceff4;
|
||||||
border: none;
|
|
||||||
text-style: bold;
|
text-style: bold;
|
||||||
|
border: solid #5e81ac;
|
||||||
}
|
}
|
||||||
|
|
||||||
Button:hover {
|
Button:hover {
|
||||||
background: #81a1c1;
|
background: #88c0d0;
|
||||||
|
color: #2e3440;
|
||||||
text-style: bold;
|
text-style: bold;
|
||||||
|
border: solid #88c0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button:focus {
|
||||||
|
background: #88c0d0;
|
||||||
|
color: #2e3440;
|
||||||
|
text-style: bold;
|
||||||
|
border: solid #88c0d0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Button.success {
|
Button.success {
|
||||||
background: #a3be8c;
|
background: #a3be8c;
|
||||||
|
border: solid #a3be8c;
|
||||||
}
|
}
|
||||||
|
|
||||||
Button.success:hover {
|
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 {
|
Button.warning {
|
||||||
background: #ebcb8b;
|
background: #ebcb8b;
|
||||||
color: #2e3440;
|
color: #2e3440;
|
||||||
|
border: solid #ebcb8b;
|
||||||
}
|
}
|
||||||
|
|
||||||
Button.warning:hover {
|
Button.warning:hover {
|
||||||
background: #d08770;
|
background: #88c0d0;
|
||||||
color: #2e3440;
|
color: #2e3440;
|
||||||
|
text-style: bold;
|
||||||
|
border: solid #88c0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button.warning:focus {
|
||||||
|
background: #88c0d0;
|
||||||
|
color: #2e3440;
|
||||||
|
text-style: bold;
|
||||||
|
border: solid #88c0d0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Button.error {
|
Button.error {
|
||||||
background: #bf616a;
|
background: #bf616a;
|
||||||
|
border: solid #bf616a;
|
||||||
}
|
}
|
||||||
|
|
||||||
Button.error:hover {
|
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 {
|
#quit {
|
||||||
@@ -90,25 +170,16 @@ Button.error:hover {
|
|||||||
|
|
||||||
#quit:hover {
|
#quit:hover {
|
||||||
background: #d08770;
|
background: #d08770;
|
||||||
border: solid #ebcb8b;
|
color: #eceff4;
|
||||||
|
text-style: bold;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#send {
|
#quit:focus {
|
||||||
background: #a3be8c;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config {
|
|
||||||
background: #ebcb8b;
|
|
||||||
color: #2e3440;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config:hover {
|
|
||||||
background: #d08770;
|
background: #d08770;
|
||||||
color: #2e3440;
|
color: #eceff4;
|
||||||
}
|
text-style: bold;
|
||||||
|
border: none;
|
||||||
#send:hover {
|
|
||||||
background: #8fbcbb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Configuration Dialog Styles */
|
/* Configuration Dialog Styles */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ class RconApp(App):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def on_key(self, event) -> None:
|
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:
|
match event.key:
|
||||||
case 'enter' 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()
|
||||||
@@ -33,39 +37,51 @@ 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:
|
||||||
self.app.exit()
|
case 'quit':
|
||||||
elif event.button.id == 'config':
|
self._quit_button_handler()
|
||||||
result = await self.push_screen(
|
case 'config':
|
||||||
ConfigScreen(settings.host, settings.port, settings.password)
|
await self._config_button_handler()
|
||||||
|
case 'send':
|
||||||
|
await self._send_button_handler()
|
||||||
|
|
||||||
|
def _quit_button_handler(self):
|
||||||
|
self.app.exit()
|
||||||
|
|
||||||
|
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}'
|
||||||
)
|
)
|
||||||
if result:
|
|
||||||
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:
|
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:
|
||||||
cmd = self.query_one('#command', Input).value.strip()
|
|
||||||
if not cmd:
|
|
||||||
self.app.bell()
|
|
||||||
return
|
|
||||||
response = await client.send_command(cmd)
|
response = await client.send_command(cmd)
|
||||||
self.query_one('#response', RichLog).write(
|
self.query_one('#response', RichLog).write(
|
||||||
self.writable.parse(cmd, response)
|
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(
|
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 = ''
|
self.query_one('#command', Input).value = ''
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
|
|
||||||
|
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+'
|
||||||
@@ -23,79 +27,106 @@ 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
|
||||||
def remove_color_codes(s: str) -> str:
|
def remove_color_codes(s: str) -> str:
|
||||||
return Writable.RE_COLOR_CODES.sub('', s)
|
return Writable.RE_COLOR_CODES.sub('', s)
|
||||||
|
|
||||||
def parse(self, cmd, response: str) -> str:
|
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 response
|
return Text(response, style=style)
|
||||||
|
|
||||||
match cmd:
|
match cmd:
|
||||||
case 'status':
|
case 'status':
|
||||||
return self.status_table(response)
|
return self.status_table(response)
|
||||||
case _:
|
case _:
|
||||||
match self.RE_CVAR.match(response):
|
if m := self.RE_CVAR.match(response):
|
||||||
case None:
|
return self.cvar_table(m)
|
||||||
return self.remove_color_codes(response)
|
else:
|
||||||
case m:
|
return Text(response, style=style)
|
||||||
return self.cvar_table(m)
|
|
||||||
|
def error(self, message: str) -> Text:
|
||||||
|
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'),
|
||||||
table.add_column('Last', justify='center')
|
('Name', 'center'),
|
||||||
table.add_column('IP', justify='center')
|
]
|
||||||
table.add_column('Port', justify='center')
|
for column, justify in columns:
|
||||||
table.add_column('QPort', justify='center')
|
table.add_column(column, justify=justify)
|
||||||
table.add_column('Rate', justify='center')
|
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():
|
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')
|
||||||
return table
|
else:
|
||||||
|
table.title = out
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user