19 Commits

Author SHA1 Message Date
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
8 changed files with 375 additions and 166 deletions

View File

@@ -40,6 +40,13 @@ 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.
- `--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:
@@ -48,13 +55,19 @@ Store and load from dotenv files located at:
example .env:
```bash
```env
Q3RCON_TUI_HOST=localhost
Q3RCON_TUI_PORT=28960
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
`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

@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2026-present onyx-and-iris <code@onyxandiris.online>
#
# SPDX-License-Identifier: MIT
__version__ = '0.3.0'
__version__ = '0.5.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

@@ -44,43 +44,123 @@
Button {
width: 100%;
height: 3;
height: 4;
margin: 0 1;
background: #5e81ac;
color: #eceff4;
border: none;
text-style: bold;
border: solid #5e81ac;
}
Button:hover {
background: #81a1c1;
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: #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 {
background: #ebcb8b;
color: #2e3440;
border: solid #ebcb8b;
}
Button.warning:hover {
background: #d08770;
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: #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 {
@@ -90,25 +170,16 @@ Button.error:hover {
#quit:hover {
background: #d08770;
border: solid #ebcb8b;
color: #eceff4;
text-style: bold;
border: none;
}
#send {
background: #a3be8c;
}
#config {
background: #ebcb8b;
color: #2e3440;
}
#config:hover {
#quit:focus {
background: #d08770;
color: #2e3440;
}
#send:hover {
background: #8fbcbb;
color: #eceff4;
text-style: bold;
border: none;
}
/* Configuration Dialog Styles */

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,139 +1,19 @@
import re
from pathlib import Path
from typing import Annotated, Type
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.containers import Grid, Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label, RichLog, Static
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' / 'q3rcon-tui' / 'config.env',
),
env_file_encoding='utf-8',
env_prefix='Q3RCON_TUI_',
cli_prefix='',
cli_parse_args=True,
cli_implicit_flags=True,
# Allow field assignment for runtime updates
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)
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)
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(
@@ -146,6 +26,10 @@ class RconApp(App):
)
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:
case 'enter' if self.query_one('#command', Input).has_focus:
self.query_one('#send', Button).press()
@@ -168,30 +52,34 @@ class RconApp(App):
if event.button.id != 'send':
return
if settings.refresh_output:
if not settings.append:
self.query_one('#response', RichLog).clear()
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(
self.query_one('#command', Input).value
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'))
)
except RCONError as e:
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 = ''
@staticmethod
def remove_color_codes(s: str) -> str:
return RconApp.RE_COLOR_CODES.sub('', s)
def main():
app = RconApp()

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

@@ -0,0 +1,106 @@
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_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, style=None) -> Renderable:
response = 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(self.remove_color_codes(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')
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