20 Commits

Author SHA1 Message Date
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
6 changed files with 213 additions and 78 deletions

View File

@@ -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.

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.4.0' __version__ = '0.6.0'

View File

@@ -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 */

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

@@ -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()
@@ -51,21 +55,27 @@ class RconApp(App):
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 = ''

View File

@@ -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