mirror of
https://github.com/onyx-and-iris/q3rcon-tui.git
synced 2026-04-09 05:33:31 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6677ce56a7 | |||
| b3bc7c687c | |||
| ebe7437974 | |||
| 74393f5fb3 | |||
| 316fa2bd5f | |||
| e87ca1d0e5 | |||
| 3fbac04756 | |||
| 637597c95a |
@@ -1,5 +1,7 @@
|
|||||||
# q3rcon tui
|
# q3rcon tui
|
||||||
|
|
||||||
|
[](https://github.com/pypa/hatch)
|
||||||
|
[](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,7 +44,7 @@ q3rcon-tui --host=localhost --port=28960 --password=rconpassword
|
|||||||
|
|
||||||
Store and load from dotenv files located at:
|
Store and load from dotenv files located at:
|
||||||
- .env in the cwd
|
- .env in the cwd
|
||||||
- user home directory / .config / rcon-tui / config.env
|
- user home directory / .config / q3rcon-tui / config.env
|
||||||
|
|
||||||
example .env:
|
example .env:
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
|
|||||||
[project]
|
[project]
|
||||||
name = "q3rcon-tui"
|
name = "q3rcon-tui"
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
description = ''
|
description = 'A terminal user interface for managing Q3 compatible servers using RCON.'
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.1.0'
|
__version__ = '0.3.0'
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
border-title-color: #88c0d0;
|
border-title-color: #88c0d0;
|
||||||
border-title-style: bold;
|
border-title-style: bold;
|
||||||
padding: 1 3;
|
padding: 1 3;
|
||||||
grid-size: 2 3;
|
grid-size: 3 3;
|
||||||
grid-gutter: 1 2;
|
grid-gutter: 1 2;
|
||||||
grid-rows: auto 1fr auto;
|
grid-rows: auto 1fr auto;
|
||||||
align: center middle;
|
align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
#command {
|
#command {
|
||||||
column-span: 2;
|
column-span: 3;
|
||||||
height: auto;
|
height: auto;
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
content-align: center middle;
|
content-align: center middle;
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#response {
|
#response {
|
||||||
column-span: 2;
|
column-span: 3;
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
background: #2e3440;
|
background: #2e3440;
|
||||||
border: solid #4c566a;
|
border: solid #4c566a;
|
||||||
@@ -57,6 +57,24 @@ Button:hover {
|
|||||||
text-style: bold;
|
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 {
|
Button.error {
|
||||||
background: #bf616a;
|
background: #bf616a;
|
||||||
}
|
}
|
||||||
@@ -79,6 +97,75 @@ Button.error:hover {
|
|||||||
background: #a3be8c;
|
background: #a3be8c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#config {
|
||||||
|
background: #ebcb8b;
|
||||||
|
color: #2e3440;
|
||||||
|
}
|
||||||
|
|
||||||
|
#config:hover {
|
||||||
|
background: #d08770;
|
||||||
|
color: #2e3440;
|
||||||
|
}
|
||||||
|
|
||||||
#send:hover {
|
#send:hover {
|
||||||
background: #8fbcbb;
|
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;
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,14 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Type
|
from typing import Annotated, Type
|
||||||
|
|
||||||
from aioq3rcon import Client
|
from aioq3rcon import Client, RCONError
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import AfterValidator, BeforeValidator
|
from pydantic import AfterValidator, BeforeValidator
|
||||||
from pydantic_settings import BaseSettings, CliSettingsSource, SettingsConfigDict
|
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, Horizontal, Vertical
|
||||||
from textual.widgets import Button, Input, RichLog
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import Button, Input, Label, RichLog, Static
|
||||||
|
|
||||||
from .__about__ import __version__ as version
|
from .__about__ import __version__ as version
|
||||||
|
|
||||||
@@ -36,13 +37,16 @@ class Settings(BaseSettings):
|
|||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=(
|
env_file=(
|
||||||
'.env',
|
'.env',
|
||||||
Path.home() / '.config' / 'rcon-tui' / 'config.env',
|
Path.home() / '.config' / 'q3rcon-tui' / 'config.env',
|
||||||
),
|
),
|
||||||
env_file_encoding='utf-8',
|
env_file_encoding='utf-8',
|
||||||
env_prefix='Q3RCON_TUI_',
|
env_prefix='Q3RCON_TUI_',
|
||||||
cli_prefix='',
|
cli_prefix='',
|
||||||
cli_parse_args=True,
|
cli_parse_args=True,
|
||||||
cli_implicit_flags=True,
|
cli_implicit_flags=True,
|
||||||
|
# Allow field assignment for runtime updates
|
||||||
|
validate_assignment=False,
|
||||||
|
frozen=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -69,6 +73,64 @@ except ValueError as e:
|
|||||||
raise SystemExit(1)
|
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)
|
||||||
|
|
||||||
|
|
||||||
class RconApp(App):
|
class RconApp(App):
|
||||||
RE_COLOR_CODES = re.compile(r'\^[0-9]')
|
RE_COLOR_CODES = re.compile(r'\^[0-9]')
|
||||||
CSS_PATH = 'rcon_tui.tcss'
|
CSS_PATH = 'rcon_tui.tcss'
|
||||||
@@ -77,29 +139,51 @@ class RconApp(App):
|
|||||||
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 event.button.id != 'send':
|
||||||
|
return
|
||||||
|
|
||||||
if settings.refresh_output:
|
if settings.refresh_output:
|
||||||
self.query_one('#response', RichLog).clear()
|
self.query_one('#response', RichLog).clear()
|
||||||
|
|
||||||
async with Client(settings.host, settings.port, settings.password) as client:
|
try:
|
||||||
response = await client.send_command(
|
async with Client(
|
||||||
self.query_one('#command', Input).value
|
settings.host, settings.port, settings.password
|
||||||
)
|
) as client:
|
||||||
|
response = await client.send_command(
|
||||||
|
self.query_one('#command', Input).value
|
||||||
|
)
|
||||||
|
self.query_one('#response', RichLog).write(
|
||||||
|
self.remove_color_codes(response.removeprefix('print\n'))
|
||||||
|
)
|
||||||
|
except RCONError as e:
|
||||||
self.query_one('#response', RichLog).write(
|
self.query_one('#response', RichLog).write(
|
||||||
self.remove_color_codes(response.removeprefix('print\n'))
|
f'{type(e).__name__}: Unable to connect to server: is the server running and are the host, port, and password correct? ({e})'
|
||||||
)
|
)
|
||||||
|
|
||||||
self.query_one('#command', Input).value = ''
|
self.query_one('#command', Input).value = ''
|
||||||
|
|||||||
Reference in New Issue
Block a user