mirror of
https://github.com/onyx-and-iris/q3rcon-tui.git
synced 2026-04-08 21:23:30 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 225685de63 | |||
| 5178b0066b | |||
| 68926fa67b | |||
| d273ca57ca | |||
| ce660fb6c8 | |||
| 514dda463a | |||
| cac241a910 | |||
| 4606990edb | |||
| 15fd6a1914 | |||
| f59076f0a6 | |||
| 6bf16f5d50 | |||
| ee45bfd03f | |||
| e10bec03ed |
7
.pre-commit-config.yaml
Normal file
7
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v2.3.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
@@ -1,6 +1,6 @@
|
||||
# q3rcon tui
|
||||
|
||||
[](https://github.com/pypa/hatch)
|
||||
[](https://github.com/pypa/hatch)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
[](https://pypi.org/project/q3rcon-tui)
|
||||
[](https://pypi.org/project/q3rcon-tui)
|
||||
@@ -66,6 +66,12 @@ Q3RCON_TUI_RAW=false
|
||||
Q3RCON_TUI_APPEND=false
|
||||
```
|
||||
|
||||
## Use
|
||||
|
||||
Type in your Rcon command and press ENTER.
|
||||
|
||||
Press `Ctrl+q` to exit from the application.
|
||||
|
||||
## Special Thanks
|
||||
|
||||
- [lapetus-11](https://github.com/Iapetus-11) for writing the [aio-q3-rcon](https://github.com/Iapetus-11/aio-q3-rcon) package.
|
||||
|
||||
@@ -17,6 +17,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SPDX-FileCopyrightText: 2026-present onyx-and-iris <code@onyxandiris.online>
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
__version__ = '0.6.0'
|
||||
__version__ = '0.8.0'
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import ValidationError
|
||||
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):
|
||||
def __init__(self, tui):
|
||||
super().__init__()
|
||||
self.current_host = current_host
|
||||
self.current_port = current_port
|
||||
self.current_password = current_password
|
||||
self._settings = tui._settings
|
||||
self._original_settings = self._settings.model_copy()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id='config-dialog'):
|
||||
@@ -21,15 +21,19 @@ class ConfigScreen(ModalScreen[bool]):
|
||||
with Vertical(id='config-form'):
|
||||
yield Label('Host:')
|
||||
yield Input(
|
||||
value=self.current_host, placeholder='localhost', id='host-input'
|
||||
value=self._original_settings.host,
|
||||
placeholder='localhost',
|
||||
id='host-input',
|
||||
)
|
||||
yield Label('Port:')
|
||||
yield Input(
|
||||
value=str(self.current_port), placeholder='28960', id='port-input'
|
||||
value=str(self._original_settings.port),
|
||||
placeholder='28960',
|
||||
id='port-input',
|
||||
)
|
||||
yield Label('Password:')
|
||||
yield Input(
|
||||
value=self.current_password,
|
||||
value=self._original_settings.password,
|
||||
placeholder='Enter password',
|
||||
password=True,
|
||||
id='password-input',
|
||||
@@ -39,26 +43,69 @@ class ConfigScreen(ModalScreen[bool]):
|
||||
yield Button('Cancel', variant='error', id='config-cancel')
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == 'config-save':
|
||||
match event.button.id:
|
||||
case 'config-save':
|
||||
self._save_button_handler()
|
||||
case 'config-cancel':
|
||||
self._cancel_button_handler()
|
||||
|
||||
def _save_button_handler(self):
|
||||
self._clear_field_errors()
|
||||
|
||||
try:
|
||||
new_host = (
|
||||
new_host: str = (
|
||||
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
|
||||
new_port: int | Literal[28960] = (
|
||||
self.query_one('#port-input', Input).value or 28960
|
||||
)
|
||||
new_password: str = 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._settings.host = new_host
|
||||
self._settings.port = new_port
|
||||
self._settings.password = new_password
|
||||
|
||||
self.dismiss(True)
|
||||
except ValueError:
|
||||
except ValidationError as e:
|
||||
for error in e.errors():
|
||||
field_name = error.get('loc', ())
|
||||
error_msg = error.get('msg', str(error))
|
||||
|
||||
match field_name:
|
||||
case ('host',):
|
||||
self._show_field_error('host-input', error_msg)
|
||||
case ('port',):
|
||||
self._show_field_error('port-input', error_msg)
|
||||
case ('password',):
|
||||
self._show_field_error('password-input', error_msg)
|
||||
break # Only show the first error
|
||||
|
||||
def _show_field_error(self, field_id: str, error_message: str):
|
||||
"""Show error message in the specified input field."""
|
||||
self._clear_field_errors()
|
||||
|
||||
field = self.query_one(f'#{field_id}', Input)
|
||||
field.add_class('error')
|
||||
field.value = ''
|
||||
|
||||
field.placeholder = error_message
|
||||
field.focus()
|
||||
|
||||
self.app.bell()
|
||||
elif event.button.id == 'config-cancel':
|
||||
|
||||
def _clear_field_errors(self):
|
||||
"""Clear error styling from all input fields."""
|
||||
for field_id in ['host-input', 'port-input', 'password-input']:
|
||||
field = self.query_one(f'#{field_id}', Input)
|
||||
field.remove_class('error')
|
||||
|
||||
match field_id:
|
||||
case 'host-input':
|
||||
field.placeholder = 'localhost'
|
||||
case 'port-input':
|
||||
field.placeholder = '28960'
|
||||
case 'password-input':
|
||||
field.placeholder = 'Enter password'
|
||||
|
||||
def _cancel_button_handler(self):
|
||||
self.dismiss(False)
|
||||
|
||||
23
src/q3rcon_tui/history.py
Normal file
23
src/q3rcon_tui/history.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from collections import UserList
|
||||
|
||||
|
||||
class CommandHistory(UserList):
|
||||
"""A simple list to store command history."""
|
||||
|
||||
def add(self, command: str):
|
||||
"""Add a command to the history if it's not empty and not a duplicate of the last."""
|
||||
command = command.strip()
|
||||
if command and (not self.data or command != self.data[-1]):
|
||||
self.data.append(command)
|
||||
|
||||
def get_previous(self, index: int) -> str:
|
||||
"""Get the previous command based on the current index."""
|
||||
if 0 <= index < len(self.data):
|
||||
return self.data[index]
|
||||
return ''
|
||||
|
||||
def get_next(self, index: int) -> str:
|
||||
"""Get the next command based on the current index."""
|
||||
if 0 <= index < len(self.data):
|
||||
return self.data[index]
|
||||
return ''
|
||||
@@ -230,6 +230,29 @@ Button.error:focus {
|
||||
background: #434c5e;
|
||||
}
|
||||
|
||||
#config-form Input.error {
|
||||
border: solid #bf616a;
|
||||
background: #3c2a2a;
|
||||
color: #d08770;
|
||||
}
|
||||
|
||||
#config-form Input.error:focus {
|
||||
border: solid #d08770;
|
||||
background: #4a2c2c;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
height: 0;
|
||||
color: #bf616a;
|
||||
text-style: bold;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.error-message.visible {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#config-buttons {
|
||||
height: 3;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Type
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import (
|
||||
AfterValidator,
|
||||
AliasChoices,
|
||||
@@ -20,6 +19,18 @@ def version_callback(value: bool) -> bool | None:
|
||||
return False
|
||||
|
||||
|
||||
def is_valid_host(host: str) -> str | None:
|
||||
if not host:
|
||||
raise ValueError('Host cannot be empty')
|
||||
return host
|
||||
|
||||
|
||||
def is_valid_port(port: int) -> int | None:
|
||||
if port < 1 or port > 65535:
|
||||
raise ValueError('Port must be between 1 and 65535')
|
||||
return port
|
||||
|
||||
|
||||
def is_valid_password(password: str) -> str | None:
|
||||
if len(password) < 8:
|
||||
raise ValueError('Password must be at least 8 characters long')
|
||||
@@ -27,8 +38,8 @@ def is_valid_password(password: str) -> str | None:
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
host: str = 'localhost'
|
||||
port: int = 28960
|
||||
host: Annotated[str, AfterValidator(is_valid_host)] = 'localhost'
|
||||
port: Annotated[int, AfterValidator(is_valid_port)] = 28960
|
||||
password: Annotated[str, AfterValidator(is_valid_password)] = ''
|
||||
append: bool = False
|
||||
min_status: bool = Field(
|
||||
@@ -52,7 +63,7 @@ class Settings(BaseSettings):
|
||||
cli_prefix='',
|
||||
cli_parse_args=True,
|
||||
cli_implicit_flags=True,
|
||||
validate_assignment=False,
|
||||
validate_assignment=True,
|
||||
frozen=False,
|
||||
)
|
||||
|
||||
@@ -71,10 +82,3 @@ class Settings(BaseSettings):
|
||||
dotenv_settings,
|
||||
init_settings,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
settings = Settings()
|
||||
except ValueError as e:
|
||||
logger.error(e)
|
||||
raise SystemExit(1)
|
||||
|
||||
@@ -4,16 +4,29 @@ from textual.containers import Grid
|
||||
from textual.widgets import Button, Input, RichLog
|
||||
|
||||
from .configscreen import ConfigScreen
|
||||
from .settings import settings
|
||||
from .history import CommandHistory
|
||||
from .settings import Settings
|
||||
from .writable import Writable
|
||||
|
||||
|
||||
class RconApp(App):
|
||||
class Q3RconTUI(App):
|
||||
CSS_PATH = 'q3rcon_tui.tcss'
|
||||
CMD_CONFIG = {
|
||||
'status': (2, 0.25, False),
|
||||
'fast_restart': (3, 1, True),
|
||||
'map_restart': (3, 1, True),
|
||||
'map': (3, 1, True),
|
||||
'map_rotate': (3, 1, True),
|
||||
}
|
||||
DEFAULT_TIMEOUT = 2
|
||||
DEFAULT_FRAGMENT_READ_TIMEOUT = 0.25
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.writable = Writable()
|
||||
self._settings = Settings()
|
||||
self.writable = Writable(self)
|
||||
self._command_history = CommandHistory()
|
||||
self._history_index = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Grid(
|
||||
@@ -30,29 +43,57 @@ class RconApp(App):
|
||||
if self.screen and isinstance(self.screen, ConfigScreen):
|
||||
return
|
||||
|
||||
command_input = self.query_one('#command', Input)
|
||||
match event.key:
|
||||
case 'enter' if self.query_one('#command', Input).has_focus:
|
||||
case 'enter' if command_input.has_focus:
|
||||
value = command_input.value.strip()
|
||||
if value:
|
||||
self._command_history.add(value)
|
||||
self._history_index = None
|
||||
self.query_one('#send', Button).press()
|
||||
case 'up' if command_input.has_focus:
|
||||
if self._command_history:
|
||||
if self._history_index is None:
|
||||
self._history_index = len(self._command_history) - 1
|
||||
elif self._history_index > 0:
|
||||
self._history_index -= 1
|
||||
command_input.value = self._command_history.get_previous(
|
||||
self._history_index
|
||||
)
|
||||
case 'down' if command_input.has_focus:
|
||||
if self._command_history and self._history_index is not None:
|
||||
if self._history_index < len(self._command_history) - 1:
|
||||
self._history_index += 1
|
||||
command_input.value = self._command_history.get_next(
|
||||
self._history_index
|
||||
)
|
||||
else:
|
||||
self._history_index = None
|
||||
command_input.value = ''
|
||||
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()
|
||||
elif event.button.id == 'config':
|
||||
result = await self.push_screen(
|
||||
ConfigScreen(settings.host, settings.port, settings.password)
|
||||
)
|
||||
|
||||
async def _config_button_handler(self):
|
||||
result = await self.push_screen(ConfigScreen(self))
|
||||
if result:
|
||||
self.query_one('#response', RichLog).write(
|
||||
f'Configuration updated: {settings.host}:{settings.port}'
|
||||
f'Configuration updated: {self._settings.host}:{self._settings.port}'
|
||||
)
|
||||
return
|
||||
|
||||
if event.button.id != 'send':
|
||||
return
|
||||
|
||||
if not settings.append:
|
||||
async def _send_button_handler(self):
|
||||
if not self._settings.append:
|
||||
self.query_one('#response', RichLog).clear()
|
||||
|
||||
cmd = self.query_one('#command', Input).value.strip()
|
||||
@@ -60,11 +101,20 @@ class RconApp(App):
|
||||
self.app.bell()
|
||||
return
|
||||
|
||||
timeout, fragment_read_timeout, interpret = Q3RconTUI.CMD_CONFIG.get(
|
||||
cmd.split()[0].lower(),
|
||||
(Q3RconTUI.DEFAULT_TIMEOUT, Q3RconTUI.DEFAULT_FRAGMENT_READ_TIMEOUT, False),
|
||||
)
|
||||
|
||||
try:
|
||||
async with Client(
|
||||
settings.host, settings.port, settings.password
|
||||
self._settings.host,
|
||||
self._settings.port,
|
||||
self._settings.password,
|
||||
timeout=timeout,
|
||||
fragment_read_timeout=fragment_read_timeout,
|
||||
) as client:
|
||||
response = await client.send_command(cmd)
|
||||
response = await client.send_command(cmd, interpret=interpret)
|
||||
self.query_one('#response', RichLog).write(
|
||||
self.writable.parse(cmd, response)
|
||||
)
|
||||
@@ -82,5 +132,5 @@ class RconApp(App):
|
||||
|
||||
|
||||
def main():
|
||||
app = RconApp()
|
||||
app = Q3RconTUI()
|
||||
app.run()
|
||||
|
||||
@@ -3,8 +3,6 @@ import re
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from .settings import settings
|
||||
|
||||
Renderable = Text | Table | str
|
||||
|
||||
|
||||
@@ -34,15 +32,21 @@ class Writable:
|
||||
r'["](?P<info>.*?)["]$'
|
||||
)
|
||||
|
||||
def __init__(self, tui):
|
||||
self._settings = tui._settings
|
||||
|
||||
@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:
|
||||
if self._settings.raw:
|
||||
return Text(response, style=style)
|
||||
|
||||
if response in ['Bad rcon']:
|
||||
return Text('Incorrect RCON password', style='#c73d4b')
|
||||
|
||||
match cmd:
|
||||
case 'status':
|
||||
return self.status_table(response)
|
||||
@@ -66,9 +70,9 @@ class Writable:
|
||||
]
|
||||
for column, justify in columns:
|
||||
table.add_column(column, justify=justify)
|
||||
if not settings.min_status:
|
||||
if not self._settings.min_status:
|
||||
table.add_column('Last', justify='center')
|
||||
if settings.min_status:
|
||||
if self._settings.min_status:
|
||||
table.add_column('IP', justify='center')
|
||||
else:
|
||||
table.add_column('IP:Port', justify='center')
|
||||
@@ -76,7 +80,7 @@ class Writable:
|
||||
('QPort', 'center'),
|
||||
('Rate', 'center'),
|
||||
]
|
||||
if not settings.min_status:
|
||||
if not self._settings.min_status:
|
||||
for column, justify in columns:
|
||||
table.add_column(column, justify=justify)
|
||||
|
||||
@@ -93,7 +97,7 @@ class Writable:
|
||||
m.group('guid'),
|
||||
name,
|
||||
]
|
||||
if settings.min_status:
|
||||
if self._settings.min_status:
|
||||
row.append(m.group('ip'))
|
||||
else:
|
||||
row.append(f'{m.group("ip")}:{m.group("port")}')
|
||||
|
||||
Reference in New Issue
Block a user